Triage the whole queue from the keyboard, and never type an n

The last of the UX review's item 8. Ctrl+. and Ctrl+, step through the
underlines from anywhere in the text; the card that opens takes focus and
answers Tab / Shift+Tab / Enter / Del / ? / Esc itself. Answering a card
advances to the next by itself, and the last one closes and puts the caret
back in the prose — so a document is triaged in five presses of Enter.

The item asked for bare Tab or n/p. Neither can exist in a text editor: an
unmodified letter is a letter. They work fine once a card holds focus, which
is where the item wanted them; getting there needs a chord that is safe to
press mid-sentence, and mid-composition, so the entry keys are IME-guarded
like every other binding.

The queue is the underlines read off the decoration DOM in document order,
not the suggestion list: a stop she cannot see is worse than one she never
visits, and it guarantees the card can anchor itself.

Escape is stopped at the card. Unhandled it would also have left
distraction-free mode, restoring the sidebar and — via the rail-follows-the-
mode fix — pulling the rail out from under her mid-triage.

The legend is bilingual and leads with the pair language, unlike the card's
English buttons: those name what she is learning, this is an instruction for
operating Petal, like the status bar. Key names are as printed on her
keyboard (Entrée, Suppr, Intro, Supr). The es pack's own punctuation test
caught the "?" and is right in general; the key cap is one named exemption.

Verified in a real browser at 1517x810 on a fresh database with no model,
over CDP — a keystroke feature deserves real keystrokes. Both layouts, wrap
in both directions, the accept/dismiss/advance loop, Ask Petal and back, the
full triage-to-empty criterion, and Accept-all clicked from a keyboard card.
The wiring has no unit test for the reason items 6, 7 and 8 recorded: jsdom
has no layout. triage.ts is pure and tested; browser-verified is written
down as browser-verified.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
prosolis
2026-07-28 21:11:42 -07:00
parent 178cb7ae67
commit c6bf36bddf
11 changed files with 639 additions and 16 deletions
+180 -8
View File
@@ -958,7 +958,146 @@ categories are the unit she can reason about — five article fixes are one
decision, but her whole queue is not — and a single button that rewrites the
document in one press is the opposite of a tool that teaches.
**Still open in item 8:** the keyboard triage flow.
### 8 — Keyboard triage DONE (eleventh session). The item's own keys were the one part that couldn't be built.
This is the last of item 8, and the last small item in the review. It arrives with
half of it already built by item 7: because the anchored popover is the primary
surface in *both* layouts, one keyboard flow covers rail and no-rail, and there
was never a question of driving two.
**The item asks for Tab/Shift+Tab "or n/p", and n/p cannot exist.** This is a
text editor. An unmodified letter is a letter, and `n` would type an n in the
middle of her sentence. Tab is nearly as bad while the caret is in the prose.
Both are fine *once a card is open and holding focus* — which is exactly where
the item asks for them — so the only real design question was how to get there,
and that needs a key that is safe to press mid-sentence. Mid-*composition*, even:
she writes Chinese, and a Chinese IME uses `,` and `.` to page its candidate
window, so the entry chord is guarded by `fromIME` like every other key Petal
binds.
**The shape, then:**
- `Ctrl/Cmd+.` and `Ctrl/Cmd+,` step to the next/previous underline from
anywhere in the text, which is both how triage is entered and how it is
continued. They join the existing `Ctrl+F` / `Ctrl+D` / `Ctrl+J` family in the
same handler.
- The card that opens **takes focus**, and from there the item's keys work as
written: `Tab`/`Shift+Tab` step, `Enter` accepts, `Del`/`Backspace` dismisses,
`Esc` leaves. `?` opens Ask Petal — the panel focuses its own input, and
`Esc` there steps back out to the card rather than out of triage, so the one
detour that matters to an ESL writer isn't a mouse-only feature.
- **Answering a card advances by itself.** Accept or dismiss and the next stop
opens focused; the last one closes the card and puts the caret back in the
text, just past the span she was reading about. That is the whole acceptance
criterion — a document triaged without the mouse is five presses of Enter.
**Implemented:**
- `triage.ts` — `stepId`, `entryId`, `idAfterRemoval`. Pure, and taking the queue
as an argument, so wrap-around, caret-relative entry and "where does an
answered card hand over to" are testable without a ProseMirror document or a
layout — the same split `acceptBatch.ts` used, for the same reason.
- **The queue is the underlines, not the suggestion list.** Read off the
decoration DOM in document order. A suggestion the editor couldn't anchor has
no underline, and a triage stop she cannot see is worse than one she never
visits; reading the DOM also guarantees every stop can be anchored, which is
what the card needs to position itself.
- `SuggestionCard.tsx` — `keyboard` mode: `tabIndex={-1}`, focus on mount *and on
every step* (stepping keeps the same component mounted and swaps the suggestion
inside it), `focus({ preventScroll: true })` for AskPetal's reason, an accent
border where a pointer would otherwise be saying "this one", and the legend.
- `EditorCore.tsx` — `orderedSpans` / `openTriageAt` / `stepTriage` / `exitTriage`,
and `queueTriageAfter`, which notes the next stop *before* the action, because
the queue has to be read while the answered card is still in it.
- An Accept-all pressed from a triage card resumes after **her** card, not after
whichever member of the batch happened to be last — the queue is in document
order and a category is scattered through it.
**Two things the code had to be told, and both are about other people's keys.**
- **Escape is overloaded.** App has a window listener where Escape leaves
distraction-free mode; unhandled, one press would have closed the card *and*
restored the sidebar *and* — via item 7's rail-follows-the-mode — pulled the
rail out from under her. In triage that key means "this card", never "the
writing mode", so the card stops the event.
- **The Spanish pack's own test caught the legend.** `?` in a Spanish `Line`
must open with `¿`, and the i18n suite says so for every native half in the
pack. It is right, and it is wrong here: this `?` is a key cap, no more Spanish
punctuation than `Esc`. The exemption is one named entry with the reason
written next to it, rather than a loosened rule.
**The legend is bilingual, against the card's own convention.** Accept, Dismiss
and Ask Petal stay English because they name the thing she is learning to talk
about (item 5's reasoning, and item 8's for the Accept-all label). This isn't
that: it is an instruction for operating Petal, like the status bar, so it is
bilingual and leads with the pair language. The key *names* are what is printed
on her keyboard, so fr says `Entrée`/`Suppr`/`Échap` and es says `Intro`/`Supr` —
a legend she has to translate back to find the key is not a legend.
**Verified in a real browser at the review's own 1517×810**, on a fresh database
with no model at all (the rule pack from item 3b needs none), against the served
bundle hash checked against `web/dist` first. Nineteen assertions on a clean run,
then the acceptance criterion itself:
- *Entry.* Five underlines from one typed paragraph. `Ctrl+.` opened the first
card after the caret, focused, accent-bordered, legend showing both halves —
and **did not type a period into her sentence**.
- *Walking.* Tab through all five to the last, once more to wrap to the first,
Shift+Tab to wrap backwards. Every step landed on the card it should.
- *Answering.* Enter accepted and the next card opened focused by itself
(5 → 4 underlines, text corrected); Del dismissed and advanced (4 → 3, text
untouched); `?` opened Ask Petal with its input focused, and Escape there came
back to the card rather than out of triage.
- *The criterion.* From `Ctrl+.`, **five presses of Enter and nothing else**:
zero underlines left, `I want an apple and an orange. She has three cats. He
walk to an office.`, card closed, caret back in the prose, no horizontal
overflow. No mouse after the initial click into the document.
- *Both layouts.* Escape out of distraction-free (rail gone, sidebar back), then
`Ctrl+,` — a card opened, focused, on the last underline before the caret,
with no rail anywhere.
- *The mixed path.* Accept-all **clicked** while a keyboard card was open: whole
category applied, triage ended cleanly with focus in the text. No page errors
in any run.
**No Chrome extension this session** — it wasn't connected — so the browser was
driven over CDP against a real headless Chrome instead. That turned out to be
the better tool for this item and is worth recording: `Input.dispatchKeyEvent`
produces genuine trusted keystrokes, which is the only honest way to test a
feature that *is* keystrokes. It also sidesteps the tenth session's rAF trap —
`document.visibilityState` reads `visible`, so `recomputeRail` runs. The driver
is ~70 lines (`connect` → `key`/`click`/`typeText`/`shot`/`ev`).
**A measurement trap, and a cheap one.** The first run reported zero of
everything because the click that focused the editor was at y=300 and the empty
document's prose box ends at y=231. Nothing errored; the text simply went
nowhere. The second reported five underlines becoming three, because it reused
the *previous run's document* — where two of those spans had already been
dismissed, and item 8's own settled-spans work was correctly refusing to raise
them again. **Reset the database between browser runs**, or the feature you
shipped last session will look like the bug you're chasing this one.
**Deliberately not done:**
- No keyboard binding for Accept-all. Every other triage key answers the card in
front of her; a key that rewrites parts of the document she cannot see is a
different kind of decision, and it is one worth the deliberate reach for a
button. The path still works if she clicks it, and is tested.
- No visual cue in the text beyond the existing active-span glow (which the rail
already drives), and no "3 of 5" counter on the card. The status bar already
counts the queue, and a position indicator turns walking one's own mistakes
into a progress bar — the pressure this review's non-goals rule out.
Coverage: `triage.test.ts` (wrap-around at both ends, entry from either
direction with the caret before/on/after a span, a current card that has left the
queue, single-item and empty queues, handover after one answer and after an
Accept-all swept several, never handing back the card just answered, and a card
that was never in the queue — the provisional rule-pack case from item 3b), and
an `i18n.test.ts` case that every pack names all five keys in both halves.
**The wiring itself has no unit test**, for the reason items 6, 7 and 8 all
recorded: jsdom has no layout, every rect is zero, and a test there would pass
whatever the code did. It is browser-verified only, and is written down as such.
**Item 8 is now complete.**
---
@@ -1071,6 +1210,21 @@ of it is stale until a screenshot forces a paint. Second, **the binary embeds
bundle-hash check will (rightly) fail. Untouched: item 8's keyboard flow, item 3's
incremental half.)*
*(Eleventh session: item 8's keyboard triage done — see the subsection under item
8. **Item 8 is finished, and item 3's incremental surfacing is the only thing
left in the whole review.** Note this session started from a `main` that had
moved on past the tenth session's note: the settled-spans and Accept-all work is
merged and pushed, alongside three later commits that were not review items (the
zh learner direction, the es pair, the IME composition guards). Two things to
carry forward. First, **a keystroke feature has to be tested with real
keystrokes**: with the Chrome extension unconnected, CDP's
`Input.dispatchKeyEvent` against a headless Chrome turned out to be the right
tool rather than a fallback — trusted events, real layout, and
`visibilityState: visible`, so the tenth session's rAF freeze doesn't apply.
Second, **reset the database between browser runs**: a second run against the
first run's document showed two underlines missing, which was not a bug but
item 8's own dismissal persistence working exactly as it should.)*
**Migration 0015 on the live database.** It rebuilds the suggestions table, so
unlike 0014 it could have dropped her rows. Backed up first — and the backup
had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in
@@ -1085,13 +1239,31 @@ CHECK, and every existing row still carrying the label she has already read (the
seven `clarity` rows include the mislabelled Chinese one — by design, only new
findings get the new type; if you want that card relabelled, edit the sentence).
**Suggested next (tenth session onward):** two things remain in the whole review.
**Keyboard triage** is the one to take: it is the last of item 8, and Accept All
just built half of what it needs — a category is now a thing the UI can act on in
one step, so "triage without the mouse" is mostly about driving the anchored
popover between spans. **Item 3's incremental surfacing** is the last item of any
size, and still needs a streaming `/check`. It remains the only one left that
changes how the app *feels* rather than what it can do.
**Suggested next (eleventh session onward): item 3's incremental surfacing is all
that is left of this review.** It is also the largest, and the only one that
changes how the app *feels* rather than what it can do. What it needs hasn't
changed: a streaming `/check`, which the current response shape doesn't do. What
*has* changed is that the expensive prerequisite is long since built — item 2's
chunking means the server already knows which sentences it is re-reading and
already returns cached rows for the rest, so "deliver per-chunk results as each
sentence finishes" is a transport change rather than an analysis one. The status
bar's running count ("Found 3 so far…") is the cheap half and can ship with it;
`petalsToPolish` in the packs is already the line to reuse.
A caution before starting it: **the 250 ms rule pass already covers the felt
latency for the errors it knows** (item 3b), so the honest scope of what remains
is the LLM's own findings arriving one sentence at a time. Measure what she
actually waits for now before designing streaming for a wait that may be
noticeably shorter than the review's original 815 s.
*(Superseded, kept for the reading list: the tenth session's advice.)* Two things
remained. **Keyboard triage** was the one to take: the last of item 8, with
Accept All having built half of what it needed — a category was now a thing the
UI could act on in one step, so "triage without the mouse" was mostly about
driving the anchored popover between spans. That reading was right about the
surface and wrong about the effort: the popover was ready, but the item's own key
choices (bare Tab, n/p) can't be bound in a text editor, and picking the entry
chord was the design work.
*(Superseded, kept for the reading list: the ninth session's advice.)* Three
things remained. **Accept All per category** was the one with real value left —