UI — Keyboard contract (L1)
UI domain — L1
The mail surface has a single keyboard owner: one window listener routes
every keystroke through a fixed precedence, so a key’s meaning never depends on
which component happens to be mounted. This contract specifies that order and the
full binding map for the navigation model in docs/ui/L0.
Scope note: this L1 covers the keyboard shortcuts section only; the rest of the UI domain’s L1 contract is not yet authored.
Keyboard shortcuts
Dispatch precedence
Keys are tested in this order; the first match consumes the event:
- Inert when a focused surface owns the screen. While a settings, message, or compose surface is open, the mail map does nothing — that surface owns input.
- Modifier chords. Tested before the typing guard, so they work even from inside a text field.
- Typing guard. With a text input focused, nothing below this point fires.
- Undo / redo, Escape,
?,/. These work on the bare surface; undo and Escape stand down while a lightweight overlay (palette, compose, tag editor, shortcut sheet) owns input. - Goto prefix machine (
g…). A pending prefix consumes the next key before any pane key can. - Pane rotation (
Shift+H/Shift+L).Tabis intentionally not bound — it keeps native focus traversal for accessibility. - The active pane’s handler (
j/kvertical,h/lhorizontal). - Selection-scoped actions (
e,#,t,o), available from any pane while a message is selected.
Lightweight overlays suppress steps 5–8 (so the reader never navigates a list behind a panel); a focused surface suppresses everything.
Binding map
Modifier chords (⌘ on macOS, Ctrl elsewhere):
| Keys | Action |
|---|---|
⌘/Ctrl + K | Open command palette |
⌘/Ctrl + , | Open settings |
⌘/Ctrl + N | Compose |
⌘/Ctrl + R | Reply |
⌘/Ctrl + Shift + R | Reply all |
⌘/Ctrl + Shift + L | Toggle flag (selected message) |
⌘/Ctrl + Z / Shift+Z | Undo / redo |
Single keys on the bare surface:
| Keys | Action |
|---|---|
? | Toggle the shortcut reference |
/ | Open command palette |
Escape | Clear the selected message, else the search query |
Shift + H / Shift + L | Rotate the active pane left / right (wraps) |
j / k (or ↓ / ↑) | Within the active pane: next / previous |
h / l (or ← / →) | Within the list’s conversation view: collapse / expand the focused thread |
Enter / Space | Activate the focused sidebar row |
e | Archive the selected message |
# / Backspace | Trash the selected message |
t | Edit tags on the selected message |
o | Open the selected message in a focused surface |
g … | Go to a mailbox or the current conversation (see below) |
Pane focus and within-pane keys
Shift+H/Shift+L rotate the active pane between sidebar and list
(wrapping at the ends); j/k (vertical) and h/l (horizontal) are routed
to whichever is active. The detail pane is not focusable — it only renders the
list’s selected message, so while a message is open the list stays active and
its j/k step the selection (the detail pane follows):
- list —
j/kstep to the next/previous message (stops at the ends). In conversation view,h/l(or←/→) collapse / expand the focused thread, keyed to that node; in the flat list they are no-ops. When the list becomes active with rows but no selection, it anchors to the first row so the current message is always highlighted (noj/kneeded to make focus visible). - sidebar —
j/kselect the next/previous visible row immediately (smart mailboxes, then each account’s folders, honoring every collapse state; stops at the ends), loading it into the list while focus stays in the sidebar for continued browsing. The selected row’s highlight is the cursor; there is no separate roving highlight orEnter-to-commit step.Shift+Lthen rotates to the list to read.
In both panes the selected item shows the accent only while its pane is the active one; when focus is elsewhere it greys out, so accent always means “focused here.”
e/#/t/o act on the selected message from any pane, not just the list.
The goto prefix machine
Goto is a small two-level prefix held in a ref (read and written synchronously
within a keystroke, so a fast gi cannot race a re-render) that self-clears
after a short pause and is dropped the moment a text input takes focus:
gtheni/a/t→ context-aware go to inbox / archive / trash.gthenc→ go to conversation: filter the list to the selected message’s conversation (a no-op when nothing is selected).gqtheni/a/t/d/s/j→ force-smart go to the smart mailbox for inbox / archive / trash / drafts / sent / junk.
Resolution follows docs/ui/L0: context-aware jumps prefer the current account’s
role folder and fall back to the matching smart mailbox; force-smart jumps always
select the smart mailbox, preferring the built-in default (ties broken by sidebar
display order). A key that does not continue a sequence drops the pending prefix
and is then handled normally, so a stray g never swallows the next keystroke.