Skip to content

feat(web): make edit-field shortcuts discoverable with a which-key menu - #2762

Merged
tyler-dane merged 7 commits into
mainfrom
claude/form-field-edit-shortcuts-d9ec4b
Aug 12, 2026
Merged

feat(web): make edit-field shortcuts discoverable with a which-key menu#2762
tyler-dane merged 7 commits into
mainfrom
claude/form-field-edit-shortcuts-d9ec4b

Conversation

@tyler-dane

Copy link
Copy Markdown
Contributor

Summary

The e-then-letter field jumps existed but nothing taught them, and the one
place that documented them was broken.

Three defects, all fixed here:

  1. The legend hid the rows it exists to show. The seven e-sequence rows
    were gated when: { eventFocused: true }, and eventFocused came from live
    DOM focus. Opening the legend focuses its own search input, which fired
    focusout on the event and filtered the rows away in the same tick. They
    were unreachable in the UI.
  2. The leader was silent. Pressing e gave no feedback for 600ms, then
    disarmed. There was no way to learn the second keys by experiment.
  3. Two leaders, neither advertised. Mod+E looked like a "show hints"
    chord but only armed the in-form variant, and ? cannot open the legend
    from inside a form field (TanStack's ignoreInputs default), so those rows
    were invisible too.

What changed:

  • One leader. useEditSequenceShortcut now owns both bare e (bails on
    editable targets) and Mod+E (works anywhere, including inside TipTap).
    A second listener claiming Mod+E would fire sequences twice, so the
    near-duplicate useEventFormFieldJumpShortcuts is deleted. The form only
    ever renders inside Week and Day, both of which mount the leader.
  • A which-key menu. The 600ms fast path is unchanged, so muscle memory
    sees no UI. Past it, a menu of the seven second keys opens anchored to the
    focused card (falling back to the form panel, then the viewport) and stays
    armed until a key, Escape, pointerdown, or blur.
  • canArm, so a stray e with nothing to edit no longer swallows the
    next keystroke.
  • The fragile gate is deleted, not worked around. That also retires
    useIsGridEventFocused and its prop threading, removes eventFocused from
    ShortcutContext, and collapses seven duplicate form-jump-* rows into one
    Mod+E row.

Also included: a one-line .claude/launch.json fix so the dev server resolves
bun from PATH instead of a hardcoded /opt/homebrew path.

Simplicity

Net effect is two files deleted and seven duplicate registry rows removed
alongside the new menu. /simplify ran as its own commit (60b2c3f) and made
three reuse cuts:

  • Placement is @floating-ui/react's, already a dependency used the same
    way by ActionsMenu and the context menu. offset/flip/shift measure
    the real rendered box and autoUpdate tracks scroll, which deleted the
    hand-rolled rect math, a height estimate plus its measuring layout effect,
    a duplicated off-screen predicate, and a scroll/resize tick copy-pasted from
    ShiftHintOverlay.
  • One field list. The dispatch map, the menu, and the legend rows now derive
    from edit-sequence.fields.ts. They had already drifted: the legend said
    "Edit recurrence" while the menu said "Repeat".
  • Reused isBareLetterKey instead of a hand-rolled modifier check, matching
    the two sibling leader hooks; dropped an announcement field and two
    selectors nothing read.

Considered and skipped: a shared "keyboard mode" primitive across the three
leader hooks (their lifecycles genuinely differ, so it would be an abstraction
fitted to three shapes it does not share), and extracting the shared
suppressKeyUp logic (would mean editing useShiftHoldEventHints, outside
this diff).

Retained React state, per repo convention on justifying it: useRef for
getAnchor/onSequence/canArm because the listeners mount once and must
still see fresh callbacks — the same idiom useShiftHoldEventHints uses.

Automated validation

Browser session against the local dev server (http://localhost:9094,
frontend-only anonymous/IndexedDB mode), Week and Day views:

Scenario Result
Focus event, press e, wait past 600ms Menu opens anchored to the card
Card near the bottom edge Flips above: measured 142px box, menu bottom 669 vs card top 676, 0px overlap, fully inside viewport
Armed menu, press l Menu closes, form opens
Mod+E then R with caret in the title input Focus lands on the recurrence control (aria-label="Edit recurrence")
Press ? with nothing focused All seven edit rows listed while the search input holds focus — the exact condition that used to erase them
Press e with nothing focused, then t Does not arm; t not swallowed (defaultPrevented === false)
Armed, press Escape, then t Menu closes; t passes through, no keyup suppression leak
Armed, press unmapped j Menu closes; j passes through and navigates weeks
Armed, pointerdown Menu closes
App lock held (legend open) Leader correctly refuses to arm

Console: only ERR_CONNECTION_REFUSED against the backend on :3014, expected
with dev:web alone. No app or React errors; server logs clean.

Not covered in-browser: the grid path's focusFieldAfterPaint uses a double
requestAnimationFrame, and the preview tab reports document.hidden === true
where rAF never fires, so focus fell to the title's autoFocus instead of the
requested field there. That helper is unchanged from main; the behavior is
covered by unit tests and by the Mod+E path above, which takes no rAF.

Independent review

A fresh reviewer read the final diff without the implementation notes and
reported no confirmed findings. It explicitly verified: Escape handling
does not break closing the form, the module-global arm state cannot diverge
from the per-instance timers (Day and Week are never co-mounted and each
cleanup disarms), deleting the form-side hook is safe because the form only
renders under Week/Day, the floating-ui reference is seated without a stale
closure, and the tests assert behavior rather than tautologies.

It raised one asymmetry below its confidence bar, which I judged real and
fixed in 777d58c: event jump yields to an armed sequence, but not the
reverse, so a bare e during jump mode armed underneath the hints. Harmless
before (the arm expired silently in 600ms); with the menu it steals the next
day letter, since t and r are both jump letters and edit-sequence keys.

An earlier /code-review pass confirmed one finding, fixed before the review
above: the menu height was a hardcoded constant, so a flipped menu could cover
the event it points at. Superseded by the floating-ui move, which measures the
real box. One low-severity item is reported but not fixed: the menu's
aria-live region is inserted together with its content, so screen readers may
miss the announcement. Every existing indicator in the repo
(EventJumpIndicator, ShortcutTipIndicator) does the same, so changing only
this one would be inconsistent.

Test plan

bun run test:web      # 2171 pass, 0 fail, 291 files
bun run type-check    # clean
bun run lint          # clean

tyler-dane and others added 7 commits August 12, 2026 08:49
The `e`-then-letter field jumps existed but nothing taught them. Pressing
the leader was silent for 600ms then gave up, and the seven legend rows
were gated on live DOM focus, which the legend destroyed by focusing its
own search input, so they were unreachable in the UI.

- Unify the leader: `useEditSequenceShortcut` now owns both bare `e` and
  `Mod+E`. A second listener claiming `Mod+E` would have fired sequences
  twice, so `useEventFormFieldJumpShortcuts` (a near-duplicate) is gone.
- After the 600ms fast path elapses, open a which-key menu anchored to the
  focused card (falling back to the form, then the viewport) and stay armed
  until a key, Escape, pointerdown, or blur. Height is measured rather than
  assumed so a flipped menu always clears its anchor.
- Add `canArm` so a stray `e` with nothing to edit no longer swallows the
  next keystroke.
- Drop the `eventFocused` gate instead of working around it, which also
  retires `useIsGridEventFocused` and collapses seven duplicate
  `form-jump-*` rows into one `Mod+E` row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ence

No behavior change; verified the menu still flips above a bottom-edge event
with zero overlap, that `e`-then-letter and `Mod+E`-then-letter still land on
the right field, and that the legend rows are byte-identical.

- Delegate placement to @floating-ui/react, already a dependency used the same
  way by ActionsMenu and the context menu. offset/flip/shift measure the real
  rendered box and autoUpdate tracks scroll, which deletes the hand-rolled
  rect math, the height estimate and its measuring layout effect, the
  duplicated off-screen predicate, and the scroll/resize tick copied from
  ShiftHintOverlay.
- Derive the dispatch map, the which-key menu, and the seven legend rows from
  one `edit-sequence.fields` list. They had already drifted: the legend said
  "Edit recurrence" while the menu said "Repeat".
- Reuse the existing `isBareLetterKey` helper instead of a hand-rolled
  modifier check, matching the two sibling leader hooks.
- Drop the `announcement` field and two selectors that nothing read, hoist a
  `normalizeKey` helper, call `isModLeader` once, and drop a redundant guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Event jump already stands down for an armed sequence, but the reverse check
was missing, so a bare `e` during jump mode armed the edit sequence beneath
the jump hints. Harmless before, when the arm expired silently after 600ms;
now it opens the which-key menu over the hints and steals the next day letter
(`t` for Tuesday and `r` for Thursday are both edit-sequence keys).

Mirrors the existing yield with an `isEventJumpActive()` read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolved `useEditSequenceShortcut.ts`: kept main's `LEADER_KEY` derived from
`KEYMAP.editTitle.sequence.leader` and its exported `ARM_WINDOW_MS` (the
showcase practises the real cadence off it), on top of this branch's store-based
arm state and the field list moved out to `edit-sequence.fields.ts`.

`keymap.test.ts`'s parity check now reads `EDIT_SEQUENCE_FIELD_BY_KEY`, the
same key->field record under its new home.

The showcase holds the app lock while it is up, so the unified leader stays
inert during a lesson and the two cannot double-fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard avoids a store write and devtools entry on every grid click. An
earlier comment claimed it was load-bearing for drag-to-create; that was a
hypothesis about a pre-existing e2e failure and it was disproven, so the
comment now says only what is true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tyler-dane
tyler-dane merged commit ac0eb7e into main Aug 12, 2026
19 of 20 checks passed
@tyler-dane
tyler-dane deleted the claude/form-field-edit-shortcuts-d9ec4b branch August 12, 2026 15:33
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.

1 participant