Skip to content

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:

  1. 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.
  2. Modifier chords. Tested before the typing guard, so they work even from inside a text field.
  3. Typing guard. With a text input focused, nothing below this point fires.
  4. 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.
  5. Goto prefix machine (g …). A pending prefix consumes the next key before any pane key can.
  6. Pane rotation (Shift+H / Shift+L). Tab is intentionally not bound — it keeps native focus traversal for accessibility.
  7. The active pane’s handler (j / k vertical, h / l horizontal).
  8. 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):

KeysAction
⌘/Ctrl + KOpen command palette
⌘/Ctrl + ,Open settings
⌘/Ctrl + NCompose
⌘/Ctrl + RReply
⌘/Ctrl + Shift + RReply all
⌘/Ctrl + Shift + LToggle flag (selected message)
⌘/Ctrl + Z / Shift+ZUndo / redo

Single keys on the bare surface:

KeysAction
?Toggle the shortcut reference
/Open command palette
EscapeClear the selected message, else the search query
Shift + H / Shift + LRotate 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 / SpaceActivate the focused sidebar row
eArchive the selected message
# / BackspaceTrash the selected message
tEdit tags on the selected message
oOpen the selected message in a focused surface
gGo 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):

  • listj/k step 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 (no j/k needed to make focus visible).
  • sidebarj/k select 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 or Enter-to-commit step. Shift+L then 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:

  • g then i / a / tcontext-aware go to inbox / archive / trash.
  • g then cgo to conversation: filter the list to the selected message’s conversation (a no-op when nothing is selected).
  • g q then i / a / t / d / s / jforce-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.