Skip to content

Work the review queue without the mouse - #1

Merged
MaXiMo000 merged 1 commit into
mainfrom
review-keyboard
Aug 25, 2026
Merged

MaXiMo000 merged 1 commit into
mainfrom
review-keyboard

Conversation

@MaXiMo000

Copy link
Copy Markdown
Owner

The review queue is the one screen somebody works down. Today every row costs
roughly eight tab stops — search field, each candidate button, confirm, reject —
and HANDOFF has been carrying the note for a while: "Confirm/reject/skip
without the mouse is the difference between reviewing forty-four items and
abandoning it."

The keys

j / k, ↓ / ↑ move the cursor
1–9 choose the nth candidate
Enter confirm and remember
x not a lab result
/ jump to the LOINC search, Escape to leave it

No skip key. Moving the cursor is skipping, and a second key for it would
only be a second way to lose your place.

The legend sits above the queue, and is hidden under (hover: none) along with
the candidate digits — a phone showing key caps it cannot press is describing
somebody else's screen.

The cursor advances by not moving

It is an index into a list that shrinks underneath it. Settle the row it points
at, the row after it takes that position, and the cursor is already on the next
thing to do. Clamped rather than corrected, so settling the last row lands on
the new last row instead of running off the end.

Focus follows it for real — tabIndex={-1} on the row — rather than as a class,
so a screen reader is told the cursor moved and the row scrolls into view without
asking. Only once a key has been pressed, though: moving focus on mount yanks the
page for somebody who arrived with a mouse. Settling a row also parks focus on
its replacement, whether a key or a click did it, instead of dropping focus to
<body> and putting a keyboard user back at the top of the document.

What is tested, and what deliberately is not

keyAction is pure and has its own suite; the listeners do not. Same split as
idleDelayMs / useIdleSignOut — two addEventListener calls and a cleanup
that mirrors them is what reading the file shows, while which keystrokes this
claims
is what can be quietly wrong.

The expensive failure is claiming one it should not have. This handler is on
window, so every keystroke on the screen passes through it, and typing "flux"
into a LOINC search must not reject the row on the x. Text fields,
contenteditable, every modifier combination, and Enter on an
already-focused candidate button are all returned as "not ours" — each with a
test.

Two bugs that surfaced while driving it

  • The tally counted rejections as rules learned. /reject answers
    alias_written: false and means it. The one counter whose job is to make
    convergence visible rather than claimed was overstating it.
  • A queue of one read "1 result need you".

Verification

npx vitest run — 18 passing, 7 of them new. oxlint clean on the touched
files, vite build clean.

The interaction itself was driven in a browser against the real component: the
cursor moves on j/k, 2 selects the second candidate, Enter confirms and
the cursor lands on the row that took its place, x rejects without touching
the tally, / reaches the search field, x/j/k/1 typed into that field
do nothing to the queue, and Escape returns focus to the row. Under
(hover: none) the legend and digits compute to display: none.

Forty-four rows at roughly eight tab stops each is where a queue stops
being reviewed and starts being dismissed. The whole loop now has keys:
j/k move a cursor, 1-9 choose a candidate, Enter confirms, x rejects,
/ reaches the search field and Escape leaves it.

No skip key. Moving the cursor is skipping, and a second key for it
would only be a second way to lose your place.

The cursor is an index into a list that shrinks underneath it, which is
how it advances: settle the row it points at, the row after it takes
that position, and the cursor is already on the next thing to do.
Clamped rather than corrected, so settling the last row lands on the
new last row instead of running off the end. Focus follows it for real
rather than as a class, so a screen reader is told the cursor moved and
the row scrolls into view without asking - but only once a key has been
pressed, because moving focus on mount yanks the page for somebody who
arrived with a mouse.

keyAction is pure and tested; the listeners are not. Same split as
idleDelayMs: two addEventListener calls and a cleanup that mirrors them
is what reading the file shows, while which keystrokes this claims is
what can be quietly wrong. A window handler sees every keystroke on the
screen, so typing "flux" into a LOINC search must not reject the row on
the x. Text fields, modifier combinations, and Enter on an already
focused button are all returned as not ours, each with a test.

Two bugs the screen had all along surfaced while driving it. The tally
counted rejections as rules learned, when /reject answers
alias_written: false and means it - the one counter whose job is to
make convergence visible rather than claimed was overstating it. And a
queue of one read "1 result need you".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MaXiMo000
MaXiMo000 merged commit d5cf3c6 into main Aug 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants