Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 1420
}
]
}
35 changes: 35 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"ask": [
"Bash(git push *)",
"Bash(gh pr merge *)",
"Bash(* --no-verify)",
"Bash(* --no-verify *)"
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/guard.mjs\" turn-start",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/guard.mjs\" turn-end",
"timeout": 30
}
]
}
]
}
}
34 changes: 34 additions & 0 deletions .claude/skills/build-feature/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
name: build-feature
description: Builds a new feature that fits the editor's existing shape, with a test for each behavior. Use when asked to "add", "implement", "support", "build a feature", or "make it possible to" do something new.
---

# Build a feature

1. **Read the closest feature** and follow its shape. For an editor command, that is its function
in `src/editor/commands.ts` or `src/editor/keys.ts`, its key in `src/editor/setup.ts`, its entry
in `src/palette/commands.ts`, and its tests.
2. **Settle the decisions in one round.** List the choices only the owner can make (behavior,
shortcut, names, file format), each with your recommendation, and ask them all at once. Done
when each one has an answer.
3. **Write done as behaviors** a test or a look at the app can check. For example: "⌘⇧D on a
paragraph with versions adds one more, and undo removes it."
4. **Build in thin slices, test first,** at the public interface: a command that returns a
`TransactionSpec`, tested on `EditorState`. For each slice: write the test, see it fail, make it
pass, and run `pnpm verify`.
5. **Update what the change touches,** from the table below.
6. Report with the `verify` skill.

| When you touch | Also update |
| --- | --- |
| A shortcut or command | The keymap in `src/editor/setup.ts`, or the keys in `src/App.tsx`; `src/palette/commands.ts`; the Shortcuts table in `README.md`; the context menu (`buildMenu` in `src/App.tsx`) if it belongs there |
| The file format | `src/editor/format.ts` and its round-trip tests; the File format section of `README.md`. Older files must still open. |
| Something a new user should learn | The welcome document, `src/welcome.ts` |
| A Rust command | `generate_handler!` in `src-tauri/src/lib.rs`; the `invoke` calls that use it, in `src/lib/files.ts`, `src/lib/links.ts`, or `src/ai/client.ts`; `src-tauri/capabilities/default.json` if it needs a permission; a Rust test |
| A new kind of layer | `src/editor/layers.ts`; `src/editor/format.ts` and the File format section of `README.md`; `src/editor/clipboard.ts`; `src/editor/ink.ts`; undo and redo tests |
| An AI task | `src/ai/tasks.ts`; a `mock` result for the browser preview; the AI section of `README.md` |
| How the editor is built | "How the editor works" in `CLAUDE.md` |
| A decision with reasons | "Decisions taken" in `docs/codemirror-plan.md` |

If the code you build on needs restructuring, do that first, as its own change, with the
`refactor` skill.
29 changes: 29 additions & 0 deletions .claude/skills/fix-bug/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
name: fix-bug
description: Fixes a bug so it stays fixed, with a regression test that proves the fix is needed. Use when something is "broken", "wrong", "a bug", "a regression", "doesn't work", or "used to work".
---

# Fix a bug

1. **Restate the symptom** in one line: what you did, what happened, what should happen. If the
report is vague, write your restatement and ask before you start.
2. **Reproduce it at the lowest layer that shows it.** A test on `EditorState` in
`src/editor/*.test.ts` beats one through React. A test in the Rust file's `mod tests` beats the
app. Run it: it must fail, for the reason in the report. Done when you have that failing test.
- Example, for "Enter in a list item leaves a stray space", in `src/editor/keys.test.ts`. This
bug is real and still open:
`expect(run(stateFor("1. one‸ two"), enter).text).toBe("1. one\n2. ‸two")`
- Some bugs show only in WebKit: caret, IME, dead keys. Reproduce those in `pnpm tauri dev` and
write the steps down. Still add a test for any part the editor state can show.
3. **Find the root cause.** Say why it happens and cite the line. Don't patch the symptom in a
caller.
4. **Make the smallest fix.** No restructuring in the same change. Note it for the `refactor`
skill instead.
5. **Prove the fix is needed:** undo it and see the test fail, then restore it and see it pass.
6. Run `pnpm verify`, then report with the `verify` skill.

The new test stays as the regression test. Never change an existing test's expected value to make
it pass, unless the owner says the old behavior was wrong.

After three fixes that didn't work, stop. Say what you tried and what you learned, then rethink the
cause before you try again.
22 changes: 22 additions & 0 deletions .claude/skills/learn/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: learn
description: Turns a correction or a repeated mistake into a check that catches it next time. Use when the owner says "don't do that again", "you keep doing this", "remember this", "this happened before", or corrects the same thing twice.
---

# Learn from a mistake

1. **Name the mistake** in one line, with the example that showed it.
2. **Pick the strongest structure that holds it,** in this order:
1. a type, in TypeScript or Rust
2. a test, in `src/`, `src-tauri/`, or `scripts/`
3. a script check: a step in `scripts/verify-steps.mjs`, or the gate in `scripts/guard.mjs`
4. a line in `CLAUDE.md` or a skill, only for what no code can check

A rule that is already written down and still broken goes straight to code.
3. **Prove it catches the mistake:** make the mistake on purpose and see the check fail.
4. **Delete the instructions it replaces** in `CLAUDE.md`, the skills, and `docs/`.
5. **Update the rules table** in `docs/agents.md`: the rule, what holds it, and how sure.
6. Make it its own small change. Commit it when the owner asks.

Claude's memory notes hold the owner's preferences, such as how they like commits. They are not
the place for a rule a check can hold.
25 changes: 25 additions & 0 deletions .claude/skills/refactor/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
name: refactor
description: Changes the structure of code without changing what it does, bugs included. Use when asked to "refactor", "clean up", "restructure", "move", "rename", "split", "extract", or "tidy" code.
---

# Refactor

The structure changes. The behavior doesn't, bugs included. A bug you find goes in a note, for a
separate fix with the `fix-bug` skill.

1. **Pin today's behavior** at the public interface, with tests that pass now. For UI, take
"before" screenshots in the browser preview. Done when the pins cover everything you will move.
2. **Delete proven-dead code** first: nothing calls it, and `pnpm verify` passes without it. Its
own tests go with it; name them in the report.
3. **Move one concept at a time,** in small steps. Run `pnpm verify` after each step. The pins stay
green.
4. **Migrate every caller,** then delete the old API. No shims left behind.
5. **Compare** "after" screenshots with "before".
6. **Keep it only if you can name what got easier to read.** If you can't, revert it.

Existing tests may change how they reach the code: paths, names, call sites. Never their inputs or
expected values. When the editor moved to CodeMirror, 1 of the 8 list tests was ported, and a fix
for stray spaces broke without any test noticing. Port every case.

Report with the `verify` skill. Name the test files whose expected values you left unchanged.
62 changes: 62 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
name: verify
description: Proves a change works, and reports what was and wasn't verified. Use when finishing any change, or when someone asks "does it work?", "did you test it?", "is it done?", or "check it".
---

# Verify a change

1. Run `pnpm verify` and fix every failure. Done when it exits 0: it prints "Passed", or says
nothing but docs changed since its last pass.
2. Get the proof the table asks for.
3. Check that the proof can fail: undo the change or break it on purpose, see the test or the app
show it, then restore it.
4. Write the report below.

| Kind of change | Proof |
| --- | --- |
| Editor command, key, layer, or Lab | A test on `EditorState` in `src/editor/*.test.ts` that fails without the change |
| File format (`src/editor/format.ts`) | A round-trip test in `src/editor/format.test.ts`, and an older file that still opens the same |
| Live preview or ink (`src/editor/preview.ts`, `src/editor/ink.ts`) | The browser preview, with the cursor on the text and away from it, in light and dark |
| React panel, palette, menu, or dialog | The browser preview: a screenshot, no console errors, at 640 px wide and full size |
| Caret, IME, dead keys, spellcheck, or drag | The desktop app. WebKit behaves differently from the browser preview. |
| Rust command (`src-tauri/src/`) | A test in that file's `mod tests`, then the desktop app |
| AI request or prompt | A test on the parsing in `src/ai/tasks.ts`. The real call only works in the app, with a key or a subscription. |
| Tooling in `scripts/` | A test in `scripts/*.test.mjs`. Tests that run git or write files use a throwaway repository from `scripts/test-repo.mjs`. |
| Instructions: `CLAUDE.md`, `README.md`, `docs/agents.md`, skills | `pnpm verify` runs `scripts/docs.test.mjs` on them. Also read what the words claim against the code. |
| Other docs | Nothing to run. |

## The browser preview

- Start the `web` entry in `.claude/launch.json` (`pnpm dev`, port 1420).
- It has no file access. AI calls return the `mock` result passed to `callClaude` in
`src/ai/client.ts`. There are no accounts to sign in to.
- Check: a screenshot, the console for errors, the window at 640 × 480 (the app's minimum) and at
full size, and both light and dark (the app follows the system theme). The UI is English only.

## The desktop app

- `pnpm tauri dev`. It needs `cargo` on `PATH` (see `CLAUDE.md`).
- Use it, with computer use if you can, for WebKit behavior, file dialogs, the keychain, and real
AI calls. If you can't run it, say so under "Not verified".

## The report

Rate each claim on this ladder:

1. **said:** you believe it
2. **cited:** you quote the line that shows it
3. **traced:** you followed the code path
4. **ran:** a test or script ran the code
5. **seen:** you saw it in the app

- "Fixed", "works", and "safe" need ran or seen. A fact visible in the code needs cited.
- A claim below the rung it needs goes under "Not verified", with the reason.
- When the change touches code others rely on (the file format, layers, Rust commands, or
`scripts/`), name the one fact that makes it safe, and how you proved it.

```
Ran: pnpm verify (passed, recorded <hash>); <other tests or app checks>
Showed: <what each one showed>
Safe because: <one fact> (<rung>)
Not verified: <claim>, because <reason>
```
36 changes: 36 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Runs `pnpm verify`, the same checks as on your Mac, on every push to main and every pull request.
# macOS because the app is built and tested there, and Tauri on Linux needs extra WebKit packages.
name: verify

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

# A newer push to the same branch cancels the older run. A cancelled run is not a failure.
concurrency:
group: verify-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10.28.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: rustup component add clippy rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: src-tauri
- run: pnpm install --frozen-lockfile
- run: pnpm verify
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.claude/settings.local.json

# Editor directories and files
.vscode/*
Expand Down
62 changes: 56 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,39 @@ What comes next, and the decisions behind the editor: the "Next" section of
pnpm install
pnpm dev # the editor in a plain browser: no files, fake AI results
pnpm tauri dev # the desktop app, with live reload
pnpm test # editor, format, Lab, and palette tests (vitest, no DOM)
pnpm exec tsc --noEmit -p . # type check
cd src-tauri && cargo test --lib # Rust tests
pnpm verify # every check CI runs, in about 10 seconds
pnpm test # only the vitest tests
pnpm tauri build --bundles app # src-tauri/target/release/bundle/macos/Undertext.app
```

`cargo` must be on `PATH`. With Homebrew's rustup it lives in `/opt/homebrew/opt/rustup/bin`.
`pnpm tauri` needs `cargo` on `PATH`. With Homebrew's rustup it lives in
`/opt/homebrew/opt/rustup/bin`. `pnpm verify` finds it there on its own.

## Done

- Done means proven: a test that failed before the change and passes after, or the change seen
in the app. Reading the code or a passing compile is not proof.
- Run `pnpm verify` after each change, and before you call anything done. It records the tree
when every check passes on it, or when only docs changed since its last pass.
- It can't see the app window (WebKit), real AI calls, file dialogs, or the keychain. When a
change depends on them, check it in `pnpm tauri dev`, or say it is not verified.
- Never weaken a check to pass it: no test skipped or deleted to get a pass, no loosened
assertions. If a check is wrong, stop and say why.
- At the end of a turn that changed code `pnpm verify` hasn't passed, a hook sends you back
once. Run it, or say what is not verified and stop. `git push` and `gh pr merge` ask the
owner first.
- Report what ran, what it showed, and what you did not verify. How this works:
`docs/agents.md`.

## Skills

Use these without being asked. They are in `.claude/skills/`.

- `verify`: before you call any change done.
- `fix-bug`: anything broken, wrong, or a regression.
- `build-feature`: anything new.
- `refactor`: structure only, always as its own change.
- `learn`: after the owner corrects you, or when a mistake repeats.

## How the editor works

Expand All @@ -36,9 +62,33 @@ pnpm tauri build --bundles app # src-tauri/target/release/bundle/macos/Undert
- `src-tauri/src/lib.rs`: file reads and writes (a save refuses to overwrite a change from another
app unless told to), and `open_url` for web and mail links.

## Tests

- Editor tests sit beside the code, in `src/editor/*.test.ts`, and run on `EditorState` without a
DOM. `src/editor/keys.test.ts` shows the `stateFor` and `run` helpers, with `‸` for the caret and
`« »` for a selection. Rust tests live in each file's `mod tests`. Tooling tests are
`scripts/*.test.mjs`; the ones that run git use `scripts/test-repo.mjs`.
- Each editor command gets a test. A command that changes layers also gets one that undoes and
redoes it and checks the saved Markdown.
- Never take an expected value from the code under test: write the text, positions, and messages
out, and above all any value another part relies on, such as an error code the TypeScript side
matches. A hash or id may come from the code when another test pins that code. Then assert equal
or not equal.
- Assert outcomes: the text, the saved Markdown, the layers, the file on disk. Check after each
step, not only at the end.
- Never fake code in `src/` that decides the result. Stubs the test never calls are fine. The
network is the one boundary: give the answer straight to the function that reads it, as the
`applyFixes` tests do.
- See each new test fail once, by breaking the code or undoing the fix. Say how it failed in your
report.
- `.only` fails `pnpm verify`. Skip a test only when it needs the network or an account, with a
comment saying how to run it, like the `#[ignore]` test in `src-tauri/src/claude_code.rs`.
- `expect(x).not.toBeNull()` alone proves little. It's fine as a guard before using `x`, or when
null is the failure you rule out and a nearby case pins the null, as for `matchScore`.
- Change a test's expected value only when the owner asked for the new behavior. A refactor may
change how a test reaches the code, never its inputs or expected values.

## Conventions

- Tests run on `EditorState`, without a DOM. Add one for each command, with undo when it touches
layers.
- Commands return a `TransactionSpec`, or null when they don't apply.
- Docs and UI text: plain words, short sentences, active voice.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,14 @@ limits. If `claude` isn't in `~/.local/bin`, `/opt/homebrew/bin`, or `/usr/local
## Development

```bash
pnpm test # editor and format tests
pnpm verify # every check CI runs: types, tests, web build, Rust format, lint, and tests
pnpm test # only the vitest tests
pnpm icons # rebuild the app icons from brand/ (needs ImageMagick)
cd src-tauri && cargo test
```

`pnpm verify` is also what coding agents run to prove their work. See
[docs/agents.md](docs/agents.md).

The editor is [CodeMirror](https://codemirror.net) inside a [Tauri](https://tauri.app) app. The page
text is plain Markdown; alternatives, ghosts, and versions are ranges kept beside it (`src/editor/layers.ts`).
The icon borrows the mark of [Klipstack](brand/README.md), with a handwritten u in place of its clip.
Expand Down
Loading
Loading