diff --git a/CLAUDE.md b/CLAUDE.md index 2c3e1a8..36a4f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,7 +266,11 @@ if/when one lands — `index-engine.md` §5.)* for the overlay layer), every overlay traps and restores it, `⇧F10` is the keyboard's right-click, and the Settings dialog (⌘,) is a tabbed surface whose rail follows the ARIA `tabs` pattern over `ui/src/settingstabs.ts` — General / Embedding / **Keyboard**, the last being the whole chord table - (`ui/src/shortcuts.ts`) that `?` jumps straight to. The four obligations a new surface owes are in + (`ui/src/shortcuts.ts`) that `?` jumps straight to. Chords themselves are declared once in + `ui/src/bindings.ts` — the keyboard registry — and the dispatcher, the editor's keymap and that + sheet all derive from it, so none of the three can drift; `conflicts()` fails the suite on two + commands sharing a keystroke in one scope, and `ui/src/editorkeys.ts` checks B2's chords against + CodeMirror's own ~100 stock bindings so an upgrade can't quietly take one. The four obligations a new surface owes are in [`crates/b2-desktop/CLAUDE.md`](crates/b2-desktop/CLAUDE.md). ### The `Vault` façade (`b2-core/src/vault.rs`) diff --git a/crates/b2-desktop/CLAUDE.md b/crates/b2-desktop/CLAUDE.md index 019c81c..55a28b1 100644 --- a/crates/b2-desktop/CLAUDE.md +++ b/crates/b2-desktop/CLAUDE.md @@ -126,10 +126,21 @@ Every new surface owes all four. They are cheap while you're building it and exp whatever opened it on close. `Escape` dismisses innermost-first; `Enter` confirms. All of this is one hook — `syncOverlayFocus()` in `main.ts`, called at the end of every `render()` and acting only on the open/close *edge*, so a toast timer's repaint never steals focus mid-⇥. -4. **Discoverable.** A chord nobody can find is not kept. Add the row to `ui/src/shortcuts.ts` (the - `?` sheet) **in the same change** that wires it, and put the chord in the control's `title` — and, - where the action lives in a menu, beside the menu item, which is where a keyboard user learns the - shortcut that lets them skip the menu next time. +4. **Discoverable.** A chord nobody can find is not kept. A chord is **declared once**, in + `ui/src/bindings.ts` — id, chord, scope — and everything else derives: the handler matches it with + `isBound(e, id)`, the editor's keymap takes it from `chordFor(id)`, and the `?` sheet + (`ui/src/shortcuts.ts`) names the id rather than spelling the chord. So add the binding, then add + its row to the sheet: `shortcuts.test.ts` fails on a binding no row documents, which is what makes + "in the same change" a rule the suite keeps rather than one you have to remember. Put the chord in + the control's `title` too — projected with `displayKeys([id])`, never typed out — and, where the + action lives in a menu, beside the menu item, which is where a keyboard user learns the shortcut + that lets them skip the menu next time. + + Two things the registry will tell you before a user does. `conflicts()` fails the suite if your + chord already means something else in the same scope, so pick the scope honestly — it's what + separates "⏎ commits *this* dialog" from a clash. And `editorkeys.test.ts` compares B2's chords + against CodeMirror's ~100 stock bindings, so if your chord needs to work while the note is being + edited, that check is what proves the editor isn't already using it. ### Where the pieces live diff --git a/ui/src/anomalies.ts b/ui/src/anomalies.ts index 66ceeca..12792ea 100644 --- a/ui/src/anomalies.ts +++ b/ui/src/anomalies.ts @@ -17,6 +17,7 @@ // index still say so, and it stops existing the moment a pass comes back clean. A panel // reopened after a restart shows nothing until the next pass re-derives the same anomaly. +import { displayKeys } from "./bindings.ts"; import type { B2idCollision, RestampedNote } from "./types.ts"; /** The slice of a `ProjectReport` the anomaly surfaces read (GH #81). */ @@ -26,9 +27,10 @@ export interface IndexAnomalies { } /** The chord that opens the review panel — quoted in the ping, so the toast tells you - * how to get the detail back after it clears. Its row in the `?` sheet - * (shortcuts.ts) and its wiring (main.ts) spell the same chord; keep the three in step. */ -export const REVIEW_CHORD = "⇧⌘A"; + * how to get the detail back after it clears. Projected from the keyboard registry + * (bindings.ts), which is what keeps it equal to the wiring and to the `?` sheet + * rather than being a third place the same chord is spelled by hand. */ +export const REVIEW_CHORD = displayKeys(["anomalies.toggle"]); /** * One path an anomaly row names, and what B2 can do with it. diff --git a/ui/src/bindings.test.ts b/ui/src/bindings.test.ts new file mode 100644 index 0000000..da841b3 --- /dev/null +++ b/ui/src/bindings.test.ts @@ -0,0 +1,359 @@ +// The keyboard registry (bindings.ts), pinned — and the collision gate itself. Pure — +// no DOM — so node runs it straight off the source: `npm test`. Dependency-free like the +// others. +// +// Two jobs. The first is that B2's own chords don't collide: `conflicts(BINDINGS)` must +// be empty, and because `npm test` runs inside both `just check` and `just ci`, that +// assertion *is* the gate. The second is proving the gate can fail — a checker that has +// only ever seen a clean table is indistinguishable from one that returns `[]` +// unconditionally, so the interesting cases below are synthetic tables built to collide. +// +// The matcher gets the same treatment. What a keyboard layer actually gets wrong is +// dull and invisible: a chord that never fires because the table spells a key the way +// the docs write it rather than the way `KeyboardEvent.key` reports it, a `?` that +// demands ⇧ on top of the ⇧ the browser already applied, a ⌘-chord that also answers to +// ⌥⌘ because nobody checked `altKey`. None of that shows up until a user reports that a +// shortcut "sometimes" doesn't work. +import { FORMATS } from "./format.ts"; +import { + type Binding, + BINDINGS, + type KeyEventLike, + allKeys, + canonicalKey, + chordFor, + chordMatches, + conflicts, + displayChord, + displayKeys, + isBound, + keystrokes, + parseChord, + scopeContains, + shadows, +} from "./bindings.ts"; + +let passed = 0; + +function assert(cond: boolean, msg: string): void { + if (!cond) throw new Error(`assertion failed: ${msg}`); +} +function assertEq(actual: unknown, expected: unknown, msg: string): void { + const [a, b] = [JSON.stringify(actual), JSON.stringify(expected)]; + if (a !== b) throw new Error(`assertion failed: ${msg}\n actual: ${a}\n expected: ${b}`); +} +function check(name: string, fn: () => void): void { + fn(); + passed++; + console.log(` ok ${name}`); +} + +/** A keydown, as the matcher sees it. Modifiers default to "not held". */ +function press(key: string, mods: Partial = {}): KeyEventLike { + return { key, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...mods }; +} + +// --- the table itself --------------------------------------------------------------- + +check("every chord in the table parses", () => { + // parseChord throws on an unknown key or modifier, so a typo — "Mod-Delete" for + // "Mod-Backspace", "Cmd+f" for "Mod-f" — fails here rather than shipping a dead chord + // that nothing reports because nothing was ever bound to notice. + for (const b of BINDINGS) { + for (const spec of allKeys(b)) parseChord(spec); + } +}); + +check("command ids are unique", () => { + // The lookup is a Map, so a duplicated id wouldn't error — the later row would quietly + // win and the earlier command would stop responding. + const seen = new Set(); + for (const b of BINDINGS) { + assert(!seen.has(b.id), `duplicate command id: ${b.id}`); + seen.add(b.id); + } +}); + +check("every format in FORMATS has a chord in the registry", () => { + // format.ts carries the marker; the chord lives here, and main.ts builds the editor's + // keymap by asking for `format.`. That lookup throws on a miss — at editor + // construction, in the app — so assert it now instead. + for (const f of FORMATS) chordFor(`format.${f.id}`); +}); + +// --- the gate ----------------------------------------------------------------------- + +check("B2's own chords do not collide", () => { + const found = conflicts(); + assertEq(found, [], `${found.length} colliding chord(s)`); +}); + +check("two commands on one chord in one scope is a conflict", () => { + // The gate above proves nothing on its own — this is what proves it can fail. + const table: Binding[] = [ + { id: "a", keys: ["Mod-k"], scope: "global" }, + { id: "b", keys: ["Mod-k"], scope: "global" }, + ]; + assertEq(conflicts(table), [{ a: "a", b: "b", form: "⌘k", scope: "global" }], "the clash"); +}); + +check("an alias collides as loudly as a listed chord", () => { + // ⌘← fires nav.back without appearing in the sheet. A chord nobody can *see* in the + // reference is exactly the one a new binding would land on unnoticed. + const table: Binding[] = [ + { id: "a", keys: ["Mod-["], aliases: ["Mod-ArrowLeft"], scope: "global" }, + { id: "b", keys: ["Mod-ArrowLeft"], scope: "global" }, + ]; + assertEq(conflicts(table).map((c) => c.form), ["⌘ArrowLeft"], "the alias clashes"); +}); + +check("an Any- chord collides with every strict chord over its key", () => { + // The subtle one, now that modifiers are compared literally. `Any-Escape` and + // `Mod-Escape` look like unrelated rows and are the same key press. + const table: Binding[] = [ + { id: "a", keys: ["Any-Escape"], scope: "global" }, + { id: "b", keys: ["Mod-Escape"], scope: "global" }, + ]; + assertEq(conflicts(table).map((c) => c.form), ["⌘Escape"], "⌘Esc is where they meet"); +}); + +check("the same chord in sibling scopes is not a conflict", () => { + // ⏎ commits the link dialog and the delete confirm. Only one can be open, so they are + // not competing — modelling that as two scopes is what keeps the gate from crying wolf. + const table: Binding[] = [ + { id: "a", keys: ["Enter"], scope: "overlay:link" }, + { id: "b", keys: ["Enter"], scope: "overlay:delete" }, + ]; + assertEq(conflicts(table), [], "siblings don't collide"); +}); + +check("an inner scope shadows the outer one, and that is reported, not failed", () => { + const table: Binding[] = [ + { id: "outer", keys: ["Escape"], scope: "global" }, + { id: "inner", keys: ["Escape"], scope: "textentry:rename" }, + ]; + assertEq(conflicts(table), [], "shadowing is legal"); + assertEq(shadows(table), [{ outer: "outer", inner: "inner", form: "Escape" }], "and reported"); +}); + +check("B2 shadows exactly these five, and each is an ordering the handler relies on", () => { + // A shadow is a scoped binding taking a keystroke the surface around it would + // otherwise get, so each one is a claim about branch order in main.ts's handler: + // + // - The three Escapes: an inline input's Esc backs *that* input out instead of running + // the overlay cascade, so each input's branch has to come before `dismiss`. + // - ⌃Tab and ⌃⇧Tab: the overlay's Tab trap is `Any-Tab` and swallows unconditionally, + // so the Settings rail's section chords only ever run because their branch is above + // it. That ordering is load-bearing and easy to undo by tidying; this is what + // notices. + // + // Pinned, so a sixth has to be argued for rather than accumulated. + assertEq( + shadows().map((s) => `${s.outer} > ${s.inner} (${s.form})`), + [ + "dismiss > create.cancel (Escape)", + "dismiss > rename.cancel (Escape)", + "dismiss > find.input.close (Escape)", + "overlay.focus.step > settings.section.next (⌃Tab)", + "overlay.focus.step > settings.section.prev (⌃⇧Tab)", + ], + "the shadow set", + ); +}); + +check("scope containment is global-over-all, then the : namespace", () => { + assert(scopeContains("global", "textentry:find"), "global contains everything"); + assert(scopeContains("overlay:link", "overlay:link"), "a scope contains itself"); + assert(scopeContains("overlay", "overlay:link"), "and the layer contains its members"); + assert(!scopeContains("overlay:link", "overlay:delete"), "siblings contain nothing"); + assert(!scopeContains("editor", "global"), "and containment doesn't run backwards"); +}); + +// --- matching ----------------------------------------------------------------------- + +check("a Mod chord answers to ⌘, and to nothing else", () => { + assert(isBound(press("f", { metaKey: true }), "find.open"), "⌘F opens find"); + assert(!isBound(press("f"), "find.open"), "bare F types an f"); + assert(!isBound(press("f", { metaKey: true, altKey: true }), "find.open"), "⌥⌘F is not ⌘F"); + assert(!isBound(press("f", { ctrlKey: true }), "find.open"), "and ⌃F is not ⌘F"); +}); + +check("⌃ is not a synonym for ⌘, anywhere in the table", () => { + // The regression guard for the alias B2 used to have. `(e.metaKey || e.ctrlKey)` is the + // right reflex on Windows and Linux and the wrong one on the only platform B2 ships on: + // macOS gives ⌃F/⌃B/⌃N/⌃P/⌃A/⌃E emacs meanings in every text field, CodeMirror + // implements them, and a CodeMirror binding calls `preventDefault` without + // `stopPropagation` — so the keystroke ran the caret move *and* B2's command. ⌃E went to + // end-of-line and left edit mode at once. + // + // Stated as a property rather than a list of six, so it holds for chords not yet + // written: a binding may claim a ⌃ keystroke only by asking for ⌃. + for (const b of BINDINGS) { + for (const spec of allKeys(b)) { + const declares = spec.includes("Ctrl-") || spec.includes("Any-"); + for (const form of keystrokes(spec)) { + assert( + !form.startsWith("⌃") || declares, + `${b.id} answers to ${form} but its chord (${spec}) never asks for ⌃`, + ); + } + } + } +}); + +check("⌃X and ⌘X are two different chords", () => { + // Which is what makes the Settings rail's ⌃Tab a chord of its own rather than a second + // spelling of something else. + assert(isBound(press("Tab", { ctrlKey: true }), "settings.section.next"), "⌃Tab"); + const table: Binding[] = [ + { id: "meta", keys: ["Mod-k"], scope: "global" }, + { id: "control", keys: ["Ctrl-k"], scope: "global" }, + ]; + assertEq(conflicts(table), [], "they no longer meet"); +}); + +check("⇧ separates two commands on one letter", () => { + const shiftF = press("F", { metaKey: true, shiftKey: true }); + assert(isBound(shiftF, "search.focus"), "⇧⌘F searches the vault"); + assert(!isBound(shiftF, "find.open"), "and is not ⌘F"); + assert(!isBound(press("f", { metaKey: true }), "search.focus"), "nor the reverse"); +}); + +check("a shifted letter arrives uppercase and still matches", () => { + // `KeyboardEvent.key` reports "A" when ⇧ is down. The table writes chords lowercase, + // so the canonical form has to fold the case or ⇧⌘A would never fire. + assertEq(canonicalKey("A"), "a", "the key folds"); + assert(isBound(press("A", { metaKey: true, shiftKey: true }), "anomalies.toggle"), "⇧⌘A"); +}); + +check("? asks for no ⇧ of its own, because the browser already applied it", () => { + // ⇧/ reports key "?" — the shift is *in* the character. A chord that also demanded + // shiftKey would be fine here but unfireable on a layout where ? is unshifted, and one + // that demanded !shiftKey would never fire at all. + assert(isBound(press("?", { shiftKey: true }), "help.keyboard"), "⇧/ opens the reference"); + assert(isBound(press("?"), "help.keyboard"), "and so does a ? that needed no shift"); + assert(!isBound(press("?", { metaKey: true }), "help.keyboard"), "but ⌘? is a different chord"); +}); + +check("⇧ does separate two commands on a named key", () => { + // The counterpart to the rule above: F10's identity doesn't change under ⇧, so there + // the modifier is real and has to be matched. + assert(isBound(press("F10", { shiftKey: true }), "menu.open"), "⇧F10 is the keyboard's right-click"); + assert(!isBound(press("F10"), "menu.open"), "bare F10 is not"); +}); + +check("the space bar is spelled, not written as a literal space", () => { + assertEq(canonicalKey(" "), "Space", "canonical"); + assert(isBound(press(" "), "graph.activate"), "Space opens a focused graph node"); + assert(isBound(press("Enter"), "graph.activate"), "so does ⏎"); +}); + +check("⌃Tab is literal Control, and ⌘Tab is not it", () => { + // The one chord in the app that asks for ⌃. ⌘Tab is macOS's app switcher and never + // reaches the webview at all, which is exactly why the rail wants the other one. + assert(!isBound(press("Tab", { metaKey: true }), "settings.section.next"), "⌘Tab is not ⌃Tab"); + assert( + isBound(press("Tab", { ctrlKey: true, shiftKey: true }), "settings.section.prev"), + "⌃⇧Tab steps back", + ); +}); + +check("an alias fires the command the sheet doesn't show it under", () => { + assert(isBound(press("[", { metaKey: true }), "nav.back"), "⌘["); + assert(isBound(press("ArrowLeft", { metaKey: true }), "nav.back"), "⌘← too"); + assert(isBound(press("s", { metaKey: true }), "fm.save"), "⌘S also saves the drawer"); +}); + +check("Esc gets you out with anything held down", () => { + // `Any-Escape`. The escape hatch must not be conditional on a modifier the user hasn't + // let go of yet — you press ⌘F, change your mind, and hit Escape with ⌘ still down. + for (const mods of [{}, { metaKey: true }, { shiftKey: true }, { altKey: true, ctrlKey: true }]) { + assert(isBound(press("Escape", mods), "dismiss"), `Esc with ${JSON.stringify(mods)}`); + } + assert(isBound(press("Tab", { metaKey: true }), "overlay.focus.step"), "and no Tab escapes"); + assert(!isBound(press("Enter"), "dismiss"), "but Any- is about modifiers, not keys"); +}); + +check("an Any- chord claims every keystroke over its key", () => { + // Which is what makes it shadow — and collide with — the bindings it would really take + // the key from. A strict ⌃Tab under a scope the trap covers is not a free chord. + const table: Binding[] = [ + { id: "trap", keys: ["Any-Tab"], scope: "overlay" }, + { id: "rail", keys: ["Ctrl-Tab"], scope: "overlay:settings" }, + { id: "elsewhere", keys: ["Ctrl-Tab"], scope: "editor" }, + ]; + assertEq( + shadows(table).map((s) => `${s.outer} > ${s.inner}`), + ["trap > rail"], + "the trap takes the rail's chord, and nothing outside the overlay layer", + ); +}); + +check("Any- and a named modifier is a contradiction", () => { + let threw = false; + try { + parseChord("Any-Shift-Escape"); + } catch { + threw = true; + } + assert(threw, "Any-Shift- asks for both 'any modifier' and 'this one'"); +}); + +check("an Any- chord prints as the bare key", () => { + assertEq(displayChord("Any-Escape"), "Esc", "not a list of twelve ways to hold it"); + assertEq(displayKeys(["overlay.focus.step"]), "Tab", "likewise"); +}); + +check("chordMatches reads the modifiers it is given, not the ones it isn't", () => { + const chord = parseChord("Mod-Backspace"); + assert(chordMatches(chord, press("Backspace", { metaKey: true })), "⌘⌫"); + assert(!chordMatches(chord, press("Backspace", { metaKey: true, shiftKey: true })), "⇧⌘⌫ is not"); + assert(!chordMatches(chord, press("Backspace")), "and a bare ⌫ deletes a character"); +}); + +check("parseChord refuses what it can't honour", () => { + // `Mod-Ctrl-x` is absent on purpose: ⌘⌃X is a real chord now that the two modifiers + // are compared separately, so the parser has nothing to object to. Nothing binds it. + const rejects = ["Cmd+f", "Mod-Meh", "Mod-Retrun", ""]; + for (const spec of rejects) { + let threw = false; + try { + parseChord(spec); + } catch { + threw = true; + } + assert(threw, `parseChord should refuse ${JSON.stringify(spec)}`); + } +}); + +// --- display ------------------------------------------------------------------------ + +check("modifiers print in Apple's order — ⌃⌥⇧⌘ — then the key", () => { + assertEq(displayChord("Mod-Shift-f"), "⇧⌘F", "shift before command"); + assertEq(displayChord("Ctrl-Shift-Tab"), "⌃⇧Tab", "control before shift"); + assertEq(displayChord("Mod-Shift-v"), "⇧⌘V", "paste as plain text"); +}); + +check("keys print as macOS writes them — glyphs, but words where macOS uses words", () => { + assertEq(displayChord("Mod-Backspace"), "⌘⌫", "delete is a glyph"); + assertEq(displayChord("Mod-Enter"), "⌘⏎", "so is return"); + assertEq(displayChord("Escape"), "Esc", "escape is a word — ⎋ exists, nobody reads it"); + assertEq(displayChord("Space"), "Space", "and so is space"); + assertEq(displayChord("ArrowUp"), "↑", "arrows are arrows"); + assertEq(displayChord("F2"), "F2", "function keys are themselves"); +}); + +check("a row prints its commands' chords, distinct ones only", () => { + assertEq(displayKeys(["find.next", "find.prev"]), "⌘G / ⇧⌘G", "two chords, two cells"); + assertEq(displayKeys(["link.commit", "delete.confirm"]), "⏎", "one chord, said once"); + assertEq(displayKeys(["graph.activate"]), "⏎ / Space", "one command, two chords"); +}); + +check("a row does not print the aliases the prose covers", () => { + // nav.back also answers to ⌘←; the row says so in words rather than listing it, which + // is the whole reason `aliases` is a separate field from `keys`. + assertEq(displayKeys(["nav.back", "nav.forward"]), "⌘[ / ⌘]", "brackets only"); + assertEq(displayKeys(["menu.open"]), "⇧F10", "not the Menu key"); +}); + +console.log(`bindings: ${passed} checks passed`); diff --git a/ui/src/bindings.ts b/ui/src/bindings.ts new file mode 100644 index 0000000..cb5eaba --- /dev/null +++ b/ui/src/bindings.ts @@ -0,0 +1,515 @@ +// The keyboard registry — the one machine-readable table of the chords B2 answers to, +// and the source both the dispatcher (main.ts) and the reference sheet (shortcuts.ts) +// read. Pure data + pure functions, no DOM, so node runs its test straight off the +// source (`npm test`), like format.ts / treenav.ts / settingstabs.ts. +// +// Why it exists. A chord used to be spelled twice — once as a modifier test in main.ts's +// keydown handler (`(e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "f"`) +// and once as a row of display text in shortcuts.ts — with nothing but discipline keeping +// them equal. K1 (docs/design/invariants.md) promises every mouse action has a keyboard +// path *and* that the path is findable, so a sheet free to fall behind the wiring is a +// promise waiting to break. The repo's habit everywhere else is to close that kind of gap +// by construction rather than by convention (sanitize.ts as marked's `postprocess` hook, +// sidenav.ts's row order reused by the paint, the ui suite's globbed test files). This is +// the keyboard's version: the chord is written once, here, and the other two derive. +// +// The second thing it buys is a suite. main.ts pulls in CodeMirror and the stylesheet, so +// node can't import it, and until now *nothing* about which chord is bound to what was +// testable at all — the app's whole keyboard contract sat in the one file the suite +// can't see. Everything in this module is data or a pure function over data. +// +// What lives here and what doesn't. The rule is **who owns the key → action mapping**: +// +// - Here: every chord the global keydown handler matches by hand. +// - Not here: the arrow-navigation families, whose key → move mapping is already owned +// and tested by a pure module of its own — treenav.ts (`arrowMove`), sidenav.ts +// (`sideArrowMove`), settingstabs.ts (`tabMove`). Copying their keys in would recreate +// exactly the two-sources-of-truth problem this module exists to end, so the sheet +// carries them as literal rows instead and shortcuts.ts says why. +// +// The chord syntax is CodeMirror's (`Mod-Shift-v`) on purpose: the editor's own bindings +// come out of this same table and are handed to `keymap.of` verbatim, so there is one +// spelling of a chord for both halves of the app. Sharing the syntax means sharing the +// *semantics* too — `Mod` is ⌘ here because `Mod` is ⌘ there (CodeMirror's +// `normalizeKeyName` resolves it to meta on mac). That agreement is load-bearing, and +// see `Chord.mod` for what it cost to notice. `Any-` is B2's one addition to the syntax, +// and the one thing that must not be handed to CodeMirror. + +/** Where a chord applies. `global` is the whole window; the rest are surfaces inside it, + * each named after the state that turns it on. `:` nests (`overlay:link` is inside + * `overlay`), which is what lets two ⏎ bindings coexist without ambiguity. */ +export type Scope = + | "global" + | "editor" // CodeMirror has the keyboard (state.editing) + | "fm" // the frontmatter drawer's mini-editor (state.fmEditing) + | "find" // the find bar is open (findOpen) + | "graph" // a graph node holds focus + // The overlay layer. `currentOverlay()` returns exactly one of these or null — the + // openers all run `dismissOverlays` first, so they can never stack — which is what + // makes them siblings, and what makes two ⏎ bindings unambiguous rather than a + // collision. They nest under `overlay` because the Tab trap is bound to the *layer*: + // it takes Tab from whichever one is up, so it shadows all of them. + | "overlay" + | "overlay:settings" + | "overlay:menu" + | "overlay:link" + | "overlay:delete" + | "textentry:create" + | "textentry:rename" + | "textentry:find"; + +/** One command and the chords that fire it. Deliberately *only* chord, scope and id — + * the conditions under which a chord applies stay ordinary code in main.ts's handler. + * A table rich enough to express "⌘G, but only while the find bar is open and not in a + * text field" is a `when`-clause expression language, which is a worse trade at this + * size than a guard you can read. */ +export interface Binding { + /** Stable command id. Referenced by main.ts's dispatcher and shortcuts.ts's rows. */ + readonly id: string; + /** The chords that fire it, in the order the sheet shows them. */ + readonly keys: readonly string[]; + /** Chords that also fire it but the sheet doesn't list, because the row's prose + * already mentions them ("Back / forward (⌘← / ⌘→ too)"). */ + readonly aliases?: readonly string[]; + readonly scope: Scope; +} + +// --- the table --------------------------------------------------------------------- + +export const BINDINGS = [ + // Global — the app's own chords, matched by the document-level keydown handler. + { id: "find.open", keys: ["Mod-f"], scope: "global" }, + { id: "search.focus", keys: ["Mod-Shift-f"], scope: "global" }, + { id: "tree.new-note", keys: ["Mod-n"], scope: "global" }, + { id: "tree.new-folder", keys: ["Mod-Shift-n"], scope: "global" }, + // The Menu key is the same gesture on a keyboard that has one; only ⇧F10 is listed. + { id: "menu.open", keys: ["Shift-F10"], aliases: ["ContextMenu"], scope: "global" }, + { id: "tree.rename", keys: ["F2"], scope: "global" }, + { id: "help.keyboard", keys: ["?"], scope: "global" }, + { id: "pane.tree", keys: ["Mod-1"], scope: "global" }, + { id: "pane.note", keys: ["Mod-2"], scope: "global" }, + { id: "pane.discovery", keys: ["Mod-3"], scope: "global" }, + { id: "delete.focused", keys: ["Mod-Backspace"], scope: "global" }, + { id: "settings.toggle", keys: ["Mod-,"], scope: "global" }, + { id: "anomalies.toggle", keys: ["Mod-Shift-a"], scope: "global" }, + { id: "edit.toggle", keys: ["Mod-e"], scope: "global" }, + // `Any-` because Escape is the way out and must not be conditional on what else is + // held down: a user who just pressed ⌘F and hasn't let go of ⌘ yet still gets out. + { id: "dismiss", keys: ["Any-Escape"], scope: "global" }, + { id: "nav.back", keys: ["Mod-["], aliases: ["Mod-ArrowLeft"], scope: "global" }, + { id: "nav.forward", keys: ["Mod-]"], aliases: ["Mod-ArrowRight"], scope: "global" }, + + // The find bar, once it's open. + { id: "find.next", keys: ["Mod-g"], scope: "find" }, + { id: "find.prev", keys: ["Mod-Shift-g"], scope: "find" }, + + // The editor. The first four are handed to CodeMirror's own keymap (ahead of its + // defaults, so they win); ⌘S is the document handler's, and reaches it only because + // CodeMirror leaves Mod-s unbound — editorkeys.test.ts is what keeps that true. + { id: "format.bold", keys: ["Mod-b"], scope: "editor" }, + { id: "format.italic", keys: ["Mod-i"], scope: "editor" }, + { id: "editor.table", keys: ["Mod-t"], scope: "editor" }, + { id: "editor.paste-plain", keys: ["Mod-Shift-v"], scope: "editor" }, + { id: "editor.save", keys: ["Mod-s"], scope: "editor" }, + + // The frontmatter drawer — a separate surface from the body editor, hence its own + // scope: ⌘S means "save this drawer" here and "flush the note" there. + { id: "fm.save", keys: ["Mod-Enter"], aliases: ["Mod-s"], scope: "fm" }, + + // The overlay layer. `Any-Tab` for the same reason as `Any-Escape`: the trap's contract + // is that *no* Tab walks the page behind the backdrop, whatever rode along with it. + { id: "overlay.focus.step", keys: ["Any-Tab"], scope: "overlay" }, + { id: "link.commit", keys: ["Enter"], scope: "overlay:link" }, + { id: "delete.confirm", keys: ["Enter"], scope: "overlay:delete" }, + { id: "menu.item.next", keys: ["ArrowDown"], scope: "overlay:menu" }, + { id: "menu.item.prev", keys: ["ArrowUp"], scope: "overlay:menu" }, + { id: "settings.section.next", keys: ["Ctrl-Tab"], scope: "overlay:settings" }, + { id: "settings.section.prev", keys: ["Ctrl-Shift-Tab"], scope: "overlay:settings" }, + + // SVG has no native button activation, so the graph binds what the platform would + // otherwise give a - @@ -3615,18 +3629,21 @@ function wireEvents(): void { } }); - // ⌘, toggles Settings (the macOS Preferences reflex); Escape closes whichever modal is - // up; Cmd/Ctrl+S forces an immediate flush while editing (autosave means it's never - // *required* — this is for the reflex); ⌘N / ⇧⌘N create a note / folder in the - // selection's folder (the tree-head icons' shortcuts). + // The app's chords. Every `isBound(e, …)` below asks the keyboard registry + // (bindings.ts) whether this keystroke is that command — the registry owns *what* the + // chord is, and the sheet in Settings → Keyboard is projected from the same table, so + // the two can't drift. What stays here is everything the table deliberately doesn't + // model: the order the surfaces get their turn (innermost first), and the guard beside + // each branch saying when its command applies at all — an overlay owns the keyboard, + // the tree has no focused row, ⌘⌫ must not hijack delete-to-line-start while editing. document.addEventListener("keydown", (e) => { // The tree's inline create input owns its keys first: Enter commits, Escape // cancels, and nothing else typed there leaks into the global chords below. if (state.treeCreate && (e.target as HTMLElement).id === "tree-create-input") { - if (e.key === "Enter") { + if (isBound(e, "create.commit")) { e.preventDefault(); void commitTreeCreate((e.target as HTMLInputElement).value, true); - } else if (e.key === "Escape") { + } else if (isBound(e, "create.cancel")) { e.preventDefault(); cancelTreeCreate(); } @@ -3634,10 +3651,10 @@ function wireEvents(): void { } // The rename input owns its keys the same way. if (state.treeRename && (e.target as HTMLElement).id === "tree-rename-input") { - if (e.key === "Enter") { + if (isBound(e, "rename.commit")) { e.preventDefault(); void commitTreeRename((e.target as HTMLInputElement).value); - } else if (e.key === "Escape") { + } else if (isBound(e, "rename.cancel")) { e.preventDefault(); cancelTreeRename(); } @@ -3653,9 +3670,10 @@ function wireEvents(): void { // those keys belong to the panel's own controls (and to the panel itself, which // scrolls) the moment focus leaves the rail. if (state.settingsOpen) { - if (e.key === "Tab" && e.ctrlKey && !e.metaKey && !e.altKey) { + const forward = isBound(e, "settings.section.next"); + if (forward || isBound(e, "settings.section.prev")) { e.preventDefault(); - selectSettingsTab(tabStep(state.settingsTab, e.shiftKey ? -1 : 1), true); + selectSettingsTab(tabStep(state.settingsTab, forward ? 1 : -1), true); return; } const onRail = @@ -3673,10 +3691,12 @@ function wireEvents(): void { // An open overlay owns Tab (K1): without a trap, Tab walks the page *behind* the // modal — focus vanishes under the backdrop and the overlay becomes dismissible // only with the mouse. Wrapping keeps every control in it reachable, forever. - if (e.key === "Tab" && currentOverlay() !== null) { - // Swallowed unconditionally — an overlay that somehow renders with no focusable - // control must still not let Tab walk the page behind it, which is the exact - // failure this block exists to prevent. + if (isBound(e, "overlay.focus.step") && currentOverlay() !== null) { + // The binding is `Any-Tab`: this branch's contract is that *no* Tab reaches the + // page, so a modifier the user is still holding must not defeat it. Swallowed + // unconditionally for the same reason — an overlay that somehow renders with no + // focusable control must still not let Tab walk the page behind it, which is the + // exact failure this block exists to prevent. e.preventDefault(); const items = overlayFocusables(); if (items.length > 0) { @@ -3690,57 +3710,65 @@ function wireEvents(): void { } // ↑/↓ walk an open context menu — the menu-pattern sibling of the tree's arrows. // (⏎ needs no binding: the items are buttons.) - if (state.contextMenu && (e.key === "ArrowDown" || e.key === "ArrowUp")) { - const items = overlayFocusables(); - if (items.length > 0) { - e.preventDefault(); - const i = items.indexOf(document.activeElement as HTMLElement); - const n = items.length; - const next = - i < 0 ? (e.key === "ArrowDown" ? 0 : n - 1) : (i + (e.key === "ArrowDown" ? 1 : -1) + n) % n; - items[next].focus(); + if (state.contextMenu) { + const down = isBound(e, "menu.item.next"); + if (down || isBound(e, "menu.item.prev")) { + const items = overlayFocusables(); + if (items.length > 0) { + e.preventDefault(); + const i = items.indexOf(document.activeElement as HTMLElement); + const n = items.length; + const next = i < 0 ? (down ? 0 : n - 1) : (i + (down ? 1 : -1) + n) % n; + items[next].focus(); + } + return; } - return; } // The find bar's input: Enter steps (⇧Enter back), Escape closes. Everything else // falls through so the global chords (⌘F itself, ⇧⌘F) still work from the bar. if (findOpen && (e.target as HTMLElement).id === "find-input") { - if (e.key === "Enter") { + const forward = isBound(e, "find.input.next"); + if (forward || isBound(e, "find.input.prev")) { e.preventDefault(); - findStep(e.shiftKey ? -1 : 1); + findStep(forward ? 1 : -1); return; } - if (e.key === "Escape") { + if (isBound(e, "find.input.close")) { e.preventDefault(); closeFind(); return; } } // ⌘F — find in the open note; ⇧⌘F — jump to the global vault-search box. - if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "f") { + const vaultSearch = isBound(e, "search.focus"); + if (vaultSearch || isBound(e, "find.open")) { if (currentOverlay() !== null) return; e.preventDefault(); - if (e.shiftKey) focusGlobalSearch(); + if (vaultSearch) focusGlobalSearch(); else openFind(); return; } // ⌘G / ⇧⌘G — the classic find-next/previous chords, live while the bar is open. - if (findOpen && (e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "g") { - e.preventDefault(); - findStep(e.shiftKey ? -1 : 1); - return; + if (findOpen) { + const forward = isBound(e, "find.next"); + if (forward || isBound(e, "find.prev")) { + e.preventDefault(); + findStep(forward ? 1 : -1); + return; + } } - if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "n") { + const newFolder = isBound(e, "tree.new-folder"); + if (newFolder || isBound(e, "tree.new-note")) { if (currentOverlay() !== null) return; // an overlay owns the keyboard e.preventDefault(); - startTreeCreate(e.shiftKey ? "folder" : "note", state.selectedDir); + startTreeCreate(newFolder ? "folder" : "note", state.selectedDir); return; } // ⇧F10 / the Menu key — the keyboard's right-click, and the entry point that makes // Rename / Move… / Link… reachable without a mouse at all (they live only in the // context menu). Opens the *same* menu the mouse does, anchored under whatever the // keyboard is on: a tree row, or a discovery card / graph ghost. - if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + if (isBound(e, "menu.open")) { if (currentOverlay() !== null || state.vaultRoot === null) return; const row = focusedTreeRow(); if (row) { @@ -3771,7 +3799,7 @@ function wireEvents(): void { } // F2 — rename the focused tree row. The platform rename chord, and the direct path // the context menu advertises next to the item. - if (e.key === "F2" && !e.shiftKey && !e.metaKey && !e.ctrlKey) { + if (isBound(e, "tree.rename")) { const row = focusedTreeRow(); if (!row) return; e.preventDefault(); @@ -3784,7 +3812,7 @@ function wireEvents(): void { // then ask again) — the sheet used to render over them, and folding it into Settings // is what makes this chord an ordinary one. A toggle, like ⌘,: pressing it while // already reading the section closes the dialog. - if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) { + if (isBound(e, "help.keyboard")) { if (state.editing || inTextEntry()) return; const overlay = currentOverlay(); if (overlay !== null && overlay !== "settings") return; @@ -3796,28 +3824,28 @@ function wireEvents(): void { // ⌘1 / ⌘2 / ⌘3 — put the keyboard in the files, the note, or discovery. Without // them, reaching the tree means Tab-ing through the whole top bar first, which is // "operable" only in the letter of K1, not its spirit. - if ( - (e.metaKey || e.ctrlKey) && - !e.altKey && - !e.shiftKey && - (e.key === "1" || e.key === "2" || e.key === "3") - ) { + const focusPane = isBound(e, "pane.tree") + ? focusTreePane + : isBound(e, "pane.note") + ? focusNotePane + : isBound(e, "pane.discovery") + ? focusSidePane + : null; + if (focusPane) { if (currentOverlay() !== null) return; e.preventDefault(); - if (e.key === "1") focusTreePane(); - else if (e.key === "2") focusNotePane(); - else focusSidePane(); + focusPane(); return; } // Enter commits the link modal from anywhere inside it — the keyboard sibling of // "Commit link" (the explanation field is a plain input, so ⏎ would do nothing). - if (state.linkTarget && e.key === "Enter" && !e.shiftKey) { + if (state.linkTarget && isBound(e, "link.commit")) { e.preventDefault(); void commitLink(); return; } // Enter commits the folder-delete confirm (its keyboard sibling of the button). - if (state.deleteTarget && e.key === "Enter") { + if (state.deleteTarget && isBound(e, "delete.confirm")) { e.preventDefault(); const node = state.deleteTarget; state.deleteTarget = null; @@ -3827,7 +3855,7 @@ function wireEvents(): void { // ⏎ / Space on a focused graph node. SVG has no native button activation, so the // key becomes the click the delegation above already answers — one activation path // for both hands, not two implementations to keep in step. - if (e.key === "Enter" || e.key === " ") { + if (isBound(e, "graph.activate")) { const active = document.activeElement; const node = active instanceof SVGElement || active instanceof HTMLElement @@ -3844,7 +3872,7 @@ function wireEvents(): void { // it falls back to the open document, the reader's expectation. Reading view only: // while editing — or in any text field — ⌘⌫ is the platform delete-to-line-start // and must not be hijacked. - if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key === "Backspace") { + if (isBound(e, "delete.focused")) { if (currentOverlay() !== null) return; if (state.editing || inTextEntry()) return; const row = focusedTreeRow(); @@ -3864,7 +3892,7 @@ function wireEvents(): void { requestDelete(node); return; } - if ((e.metaKey || e.ctrlKey) && e.key === ",") { + if (isBound(e, "settings.toggle")) { e.preventDefault(); if (state.settingsOpen) closeSettings(); else void openSettings(); @@ -3874,7 +3902,7 @@ function wireEvents(): void { // toggle like ⌘,, and reachable while editing for the same reason: it opens a modal // over the pane rather than touching the live buffer. Nothing to review is not a // reason to refuse — the panel then says so, which beats a chord that looks broken. - if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === "a") { + if (isBound(e, "anomalies.toggle")) { e.preventDefault(); if (state.anomaliesOpen) closeAnomalies(); else openAnomalies(); @@ -3883,7 +3911,7 @@ function wireEvents(): void { // ⌘E toggles edit mode — the keyboard sibling of the Edit / Done buttons. A modal // owns the keyboard first; a resource or empty pane has nothing to edit. Works while // editing (CodeMirror leaves Mod-e unbound, so the event bubbles here) to flip back. - if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "e") { + if (isBound(e, "edit.toggle")) { if (currentOverlay() !== null) return; if (state.editing) { e.preventDefault(); @@ -3894,7 +3922,7 @@ function wireEvents(): void { } return; } - if (e.key === "Escape") { + if (isBound(e, "dismiss")) { // Innermost first, always — and with the `?` sheet folded into Settings, the // overlay layer is flat, so this is simply the one overlay that is up. if (state.contextMenu) { @@ -3927,18 +3955,14 @@ function wireEvents(): void { if (state.graphOpen && state.current && !state.editing) toggleGraph(); return; } - if (state.editing && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { + if (state.editing && isBound(e, "editor.save")) { e.preventDefault(); void saveNow(); return; } // ⌘⏎ / ⌘S save the frontmatter mini-editor — its explicit-save chords (the // buttons' keyboard siblings, K1). Plain Enter stays a newline in the textarea. - if ( - state.fmEditing && - (e.metaKey || e.ctrlKey) && - (e.key === "Enter" || e.key.toLowerCase() === "s") - ) { + if (state.fmEditing && isBound(e, "fm.save")) { e.preventDefault(); void saveFmEdit(); return; @@ -3949,12 +3973,10 @@ function wireEvents(): void { // mean caret-to-edge, so the brackets still navigate (e.g. straight from the // search field). The buttons and mouse back/forward stay live everywhere — they // flush through navGo's edit-mode guard. - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && !state.editing) { + const back = isBound(e, "nav.back"); + if ((back || isBound(e, "nav.forward")) && !state.editing) { if (currentOverlay() !== null) return; - const back = e.key === "[" || e.key === "ArrowLeft"; - const forward = e.key === "]" || e.key === "ArrowRight"; - if (!back && !forward) return; - if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && inTextEntry()) return; + if (canonicalKey(e.key).startsWith("Arrow") && inTextEntry()) return; e.preventDefault(); void navGo(back ? -1 : 1); } diff --git a/ui/src/shortcuts.test.ts b/ui/src/shortcuts.test.ts index 70f7b3c..c91ca03 100644 --- a/ui/src/shortcuts.test.ts +++ b/ui/src/shortcuts.test.ts @@ -1,14 +1,24 @@ // The keyboard reference's own shape (shortcuts.ts), pinned. Pure data — no DOM — so // node runs it straight off the source: `npm test`. Dependency-free like the others. // -// These check the *sheet as an artifact*, not the wiring: whether a given chord is -// actually bound lives in main.ts's keydown handler, which pulls in CodeMirror and the -// stylesheet and so can't be imported here. What a table like this really gets wrong is -// smaller and duller — a half-filled row that renders as a blank line, one chord listed -// twice in the same group meaning two different things, or an entry spelled "Cmd+N" -// among a page of ⌘N. Those are exactly the drifts that make a reference stop reading -// as authoritative, and they're what's asserted below (K1, GH #78). -import { SHORTCUTS } from "./shortcuts.ts"; +// These check the *sheet as an artifact*. Since the sheet became a projection of the +// keyboard registry, one old worry is gone and a better one has replaced it. Gone: a row +// spelling a chord the wiring doesn't answer to, because rows no longer spell chords — +// they name commands, and `displayKeys` renders whatever bindings.ts says. Merely +// importing this module proves every id in it resolves, since SHORTCUTS is built at load +// and the lookup throws on a miss. +// +// The new worry is the other direction: a chord that exists and is documented *nowhere*. +// That's the K1 failure that matters (docs/design/invariants.md, GH #78) — an action +// reachable from the keyboard but discoverable only by reading main.ts. The coverage +// check below is what makes adding a binding without a row impossible. +// +// What's left is the dull editorial stuff a table like this really gets wrong: a +// half-filled row that renders as a blank line, one chord listed twice in the same group +// meaning two different things, an entry spelled "Cmd+N" among a page of ⌘N. Those are +// the drifts that make a reference stop reading as authoritative. +import { BINDINGS, allKeys } from "./bindings.ts"; +import { SHEET, SHORTCUTS } from "./shortcuts.ts"; let passed = 0; @@ -40,8 +50,8 @@ check("every row is a full pair — a chord and what it does", () => { check("no chord is listed twice within one group", () => { // Across groups is fine and deliberate — ⇧F10 opens a menu on a tree row *and* on a - // discovery card, ⏎ activates whatever is focused. Two meanings in one group is the - // contradiction: the reader has no way to tell which one applies. + // discovery card, Esc closes an overlay and closes Settings. Two meanings in one group + // is the contradiction: the reader has no way to tell which one applies. for (const g of SHORTCUTS) { const seen = new Set(); for (const s of g.items) { @@ -53,7 +63,9 @@ check("no chord is listed twice within one group", () => { check("modifiers are written as macOS glyphs, never spelled out", () => { // The sheet sits beside button tooltips that render ⌘/⇧, so a stray "Cmd+N" reads as - // a different app's documentation. Only the *modifiers* are held to this: macOS spells + // a different app's documentation. Projected rows get this from `displayChord`; the + // literal rows — the platform's own keys, the arrow families — are hand-written, and + // are what this still guards. Only the *modifiers* are held to it: macOS spells // Esc / Tab / Space / Home / End out in its own menus, and so does B2's existing // chrome ("Close (Esc)"), so those stay words — shortcuts.ts says why. const spelled = /\b(cmd|command|ctrl|control|shift|alt|option|enter|return|backspace)\b/i; @@ -65,4 +77,37 @@ check("modifiers are written as macOS glyphs, never spelled out", () => { } }); +check("every chord B2 binds is documented somewhere in the sheet", () => { + // The K1 guarantee, held by construction: you cannot add a binding without a row here. + // + // The one exemption is text entry, and it's a category rather than a list — ⏎ commits + // and Esc backs out of an inline input (rename, new note, the find field). Nobody + // needs telling, and spelling out six rows of it would bury the chords that genuinely + // have to be found. Anything else — including a chord that only applies inside a + // modal, or only while the find bar is open — has to earn a row. + const documented = new Set(); + for (const group of SHEET) { + for (const row of group.rows) { + if ("ids" in row) for (const id of row.ids) documented.add(id); + } + } + for (const b of BINDINGS) { + if (b.scope.startsWith("textentry")) continue; + assert(documented.has(b.id), `${b.id} (${allKeys(b).join(", ")}) is bound but undocumented`); + } +}); + +check("the sheet documents no command that isn't bound", () => { + // The mirror image, and the cheap half: SHORTCUTS is built at import, and resolving a + // row's ids throws on an unknown one, so a stale row can't survive to be rendered. + // Asserted anyway so the failure names the sheet rather than a module-load stack. + const ids = new Set(BINDINGS.map((b) => b.id)); + for (const group of SHEET) { + for (const row of group.rows) { + if (!("ids" in row)) continue; + for (const id of row.ids) assert(ids.has(id), `${id} is documented but not bound`); + } + } +}); + console.log(`shortcuts: ${passed} checks passed`); diff --git a/ui/src/shortcuts.ts b/ui/src/shortcuts.ts index a2de3f4..1e47911 100644 --- a/ui/src/shortcuts.ts +++ b/ui/src/shortcuts.ts @@ -1,21 +1,37 @@ -// The keyboard reference — the single source of truth for what the `?` cheat sheet -// shows. Pure data, no DOM, so node runs its test straight off the source +// The keyboard reference — the single source of truth for what Settings' Keyboard +// section shows. Pure data, no DOM, so node runs its test straight off the source // (`npm test`), like newentry.ts / move.ts / treenav.ts. // // Why a table rather than prose in a docs page: invariant K1 (docs/design/invariants.md, // GH #78) promises every mouse action has a keyboard path, and a promise nobody can // *find* is not kept. A shortcut that exists only in a button's `title` is discoverable // exactly once — by hovering the button you already knew about. This list is the app's -// answer to "what can I do without the mouse", and adding a chord means adding a row -// here in the same change, so the sheet can't quietly fall behind the wiring. +// answer to "what can I do without the mouse". +// +// What changed when bindings.ts arrived: a row no longer *spells* its chord, it **names +// the command** and the chord is projected from the registry. So the sheet can't drift +// from the wiring by a typo, and — because bindings.test.ts asserts every binding lands +// in some row — a new chord can't be added without a row here either. That's the K1 +// promise held by construction instead of by remembering. +// +// The prose stays hand-written, deliberately. Grouping ⌘1/⌘2/⌘3 into one row, or saying +// "Back / forward (⌘← / ⌘→ too)" rather than listing four chords, is editorial judgement +// a generator would flatten — and the sheet's job is to be *read*. +// +// Literal rows (`keys:` instead of `ids:`) are the things that aren't B2 chords at all: +// the platform's own behavior (Tab, ⏎ on a focused button, first-letter typeahead), and +// the arrow families whose key → move mapping belongs to a pure module of its own — +// treenav.ts, sidenav.ts, settingstabs.ts. Copying those keys into the registry would +// give them two owners, which is the drift the registry exists to end; bindings.ts says +// the same thing from the other side. // // B2 ships on macOS only (crates/b2-desktop), so modifiers are the platform's glyphs — // ⌘ command, ⇧ shift, ⌫ delete, ⏎ return — while keys macOS itself spells out in menus // stay spelled out (Esc, Tab, Space, Home/End). That's the split the app's existing -// tooltips already use ("Close (Esc)"), and the one the sheet must not drift from: -// ⎋ and ⇥ exist, but nobody reads them. +// tooltips already use ("Close (Esc)"); `displayChord` in bindings.ts now applies it. +import { type BindingId, displayKeys } from "./bindings.ts"; -/** One chord and what it does. `keys` is display text — the wiring lives in main.ts. */ +/** One chord and what it does. `keys` is display text, projected from the registry. */ export interface Shortcut { keys: string; action: string; @@ -26,43 +42,62 @@ export interface ShortcutGroup { items: Shortcut[]; } -export const SHORTCUTS: ShortcutGroup[] = [ +/** A row of the sheet: either the commands it documents, or — for the platform's own + * keys and the arrow families — the literal text to print. */ +export type SheetRow = + | { readonly ids: readonly BindingId[]; readonly action: string } + | { readonly keys: string; readonly action: string }; + +export interface SheetGroup { + readonly title: string; + readonly rows: readonly SheetRow[]; +} + +export const SHEET: readonly SheetGroup[] = [ { title: "Getting around", - items: [ - { keys: "⌘1 / ⌘2 / ⌘3", action: "Focus the files, the note, or discovery" }, - { keys: "⇧⌘F", action: "Search the vault" }, - { keys: "⌘F", action: "Find in this note" }, - { keys: "⌘G / ⇧⌘G", action: "Next / previous match" }, - { keys: "⌘[ / ⌘]", action: "Back / forward (⌘← / ⌘→ too)" }, + rows: [ + { + ids: ["pane.tree", "pane.note", "pane.discovery"], + action: "Focus the files, the note, or discovery", + }, + { ids: ["search.focus"], action: "Search the vault" }, + { ids: ["find.open"], action: "Find in this note" }, + { ids: ["find.next", "find.prev"], action: "Next / previous match" }, + { ids: ["nav.back", "nav.forward"], action: "Back / forward (⌘← / ⌘→ too)" }, + // The platform's own activation of a focused button or link — not a B2 binding. + // The graph's ⏎ *is* one (SVG has no native activation); it has its own row below. { keys: "⏎", action: "Follow the focused link, card, or graph node" }, ], }, { title: "The file tree", - items: [ + rows: [ { keys: "↑ / ↓", action: "Move between rows" }, { keys: "→", action: "Expand a folder, or step into it" }, { keys: "←", action: "Collapse a folder, or step out to its parent" }, { keys: "Home / End", action: "First / last row" }, { keys: "A–Z", action: "Jump to the next row starting with that letter" }, { keys: "⏎ / Space", action: "Open the note or file; fold the folder" }, - { keys: "⌘N / ⇧⌘N", action: "New note / new folder in the selected folder" }, - { keys: "F2", action: "Rename the focused row" }, - { keys: "⌘⌫", action: "Delete the focused row (a folder confirms first)" }, - { keys: "⇧F10", action: "Open the row's menu — Rename, Move…, Delete" }, + { + ids: ["tree.new-note", "tree.new-folder"], + action: "New note / new folder in the selected folder", + }, + { ids: ["tree.rename"], action: "Rename the focused row" }, + { ids: ["delete.focused"], action: "Delete the focused row (a folder confirms first)" }, + { ids: ["menu.open"], action: "Open the row's menu — Rename, Move…, Delete" }, ], }, { title: "Reading and editing", - items: [ - { keys: "⌘E", action: "Enter or leave edit mode" }, - { keys: "⌘S", action: "Save now (editing autosaves anyway)" }, - { keys: "⌘B / ⌘I", action: "Bold / italic" }, - { keys: "⌘T", action: "Insert a table" }, - { keys: "⇧⌘V", action: "Paste as plain text" }, + rows: [ + { ids: ["edit.toggle"], action: "Enter or leave edit mode" }, + { ids: ["editor.save"], action: "Save now (editing autosaves anyway)" }, + { ids: ["format.bold", "format.italic"], action: "Bold / italic" }, + { ids: ["editor.table"], action: "Insert a table" }, + { ids: ["editor.paste-plain"], action: "Paste as plain text" }, { keys: "[[", action: "Wikilink completion — ↑↓ then ⏎" }, - { keys: "⌘⏎", action: "Save the frontmatter drawer (Esc discards)" }, + { ids: ["fm.save"], action: "Save the frontmatter drawer (Esc discards)" }, ], }, // Discovery gets its own group now that the right column navigates like the tree @@ -70,29 +105,39 @@ export const SHORTCUTS: ShortcutGroup[] = [ // chord with two meanings in a single group is a group the reader can't trust. { title: "Discovery (the right column)", - items: [ + rows: [ { keys: "↑ / ↓", action: "Move between section heads and cards" }, { keys: "→ / ←", action: "Unfold / fold a section or a card's details" }, { keys: "Home / End", action: "First / last row" }, { keys: "⏎ / Space", action: "Open the card's note; fold a section head" }, - { keys: "⇧F10", action: "Open a card's menu — Open note, Link…" }, + { ids: ["menu.open"], action: "Open a card's menu — Open note, Link…" }, ], }, { title: "The graph and menus", - items: [ - { keys: "⏎ / Space", action: "Open a graph node; a ghost opens the link palette" }, - { keys: "↑ / ↓", action: "Move through an open menu" }, - { keys: "Esc", action: "Close a menu, a modal, the find bar, or the graph" }, + rows: [ + { + ids: ["graph.activate"], + action: "Open a graph node; a ghost opens the link palette", + }, + { ids: ["menu.item.prev", "menu.item.next"], action: "Move through an open menu" }, + { ids: ["dismiss"], action: "Close a menu, a modal, the find bar, or the graph" }, ], }, { title: "The app", - items: [ - { keys: "⌘,", action: "Settings" }, - { keys: "⇧⌘A", action: "Review the last index pass's anomalies" }, - { keys: "?", action: "This table (Settings → Keyboard)" }, - { keys: "Tab", action: "Step through the controls on screen" }, + rows: [ + { ids: ["settings.toggle"], action: "Settings" }, + { ids: ["anomalies.toggle"], action: "Review the last index pass's anomalies" }, + { ids: ["help.keyboard"], action: "This table (Settings → Keyboard)" }, + { ids: ["overlay.focus.step"], action: "Step through the controls on screen" }, + // The dialogs' commit chord lives here rather than beside the graph's ⏎: two ⏎ rows + // in one group is the ambiguity the per-group duplicate check exists to catch, and + // this one is a sibling of Tab above it — both are how a dialog is driven. + { + ids: ["link.commit", "delete.confirm"], + action: "Commit the open dialog — Link…, or a delete confirm", + }, ], }, // The settings dialog is a tabbed surface (settingstabs.ts), and a tab rail is exactly @@ -100,11 +145,23 @@ export const SHORTCUTS: ShortcutGroup[] = [ // sections are visibly *there*, so nobody thinks to look for a chord. { title: "Settings (⌘,)", - items: [ + rows: [ { keys: "↑ / ↓", action: "Move between the sections, with the rail focused" }, { keys: "Home / End", action: "First / last section" }, - { keys: "⌃Tab / ⇧⌃Tab", action: "Next / previous section, from anywhere in the dialog" }, - { keys: "Esc", action: "Close Settings" }, + { + ids: ["settings.section.next", "settings.section.prev"], + action: "Next / previous section, from anywhere in the dialog", + }, + { ids: ["dismiss"], action: "Close Settings" }, ], }, ]; + +/** The sheet as render.ts paints it — every row's chords resolved to display text. */ +export const SHORTCUTS: ShortcutGroup[] = SHEET.map((group) => ({ + title: group.title, + items: group.rows.map((row) => ({ + keys: "ids" in row ? displayKeys(row.ids) : row.keys, + action: row.action, + })), +}));