From 1c5b4c2a0f814d298f8c510802135dcf98e040d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 20:15:37 +0000 Subject: [PATCH 1/2] ui: make the desktop app keyboard-complete (invariant K1, GH #78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K1 promises B2 is fully operable from the keyboard, with the mouse an accelerator rather than a requirement. The GUI didn't keep it: the file tree had no arrow navigation (Tab-cycling 1500 rows was the only way in), graph nodes weren't focusable at all, Rename / Move… / Link… existed only behind a right-click, overlays neither took nor returned focus, and the chords that did exist lived only in button tooltips. The file tree now follows the ARIA `tree` pattern. Its shape *and its row order* move into `ui/src/treenav.ts`, pure and tested, because the paint and the arrow keys have to agree on that order down to the last tie-break — a tree you can arrow through in a different order than you can see is worse than no arrows at all. `render.ts` sorts through the same helpers. Rows get `role="treeitem"`, `aria-level`, and a roving `tabindex` (one Tab stop for the whole tree); ↑↓ walk visible rows, →← expand/enter and collapse/exit, Home/End jump, and a bare letter is first-letter typeahead. A delete hands focus to the neighbouring row rather than dropping it. Overlay focus is one hook — `syncOverlayFocus()`, called at the end of every `render()` and acting only on the open/close edge, so a toast timer's repaint can't steal focus mid-Tab. Opening takes focus, Tab is trapped while up, and closing restores it. Restoration is by identity that outlives an innerHTML swap (path → id → element), which is also how `paintTree` keeps a keyboard user in the tree across an unrelated repaint. New keyboard paths: ⇧F10 / Menu opens the context menu on the focused tree row or discovery card (the only route to Rename / Move… / Link…), F2 renames, ⌘⌫ now deletes the focused row (so folders get a chord, with the confirm), ⌘1/⌘2/⌘3 put the keyboard in a pane, ⏎/Space activate a graph node by dispatching the same click the mouse sends, ⏎ commits the link modal, and ⏎ resets a pane gutter (the double-click's missing sibling). Discoverability: `?` opens a shortcut sheet built from one table (`ui/src/shortcuts.ts`), also reachable from Settings; menu items carry their direct chord; the search box, gutters, graph toggle, and modals say what keys they answer to. Focus rings are a single `:focus-visible` rule. Verified in Chromium against the built bundle with a stubbed IPC: 32 keyboard paths, including focus restoration across repaints. Also fixes a pre-existing `clippy::type_complexity` failure in `link.rs`'s test table, tripped by Rust 1.96.1 and unrelated to this work — it was failing the gate on any branch. Refs: docs/design/invariants.md K1, crates/b2-desktop/CLAUDE.md Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BF2Fx3uMZjJ18oHCp5mebY --- CLAUDE.md | 6 +- crates/b2-core/src/link.rs | 5 +- crates/b2-desktop/CLAUDE.md | 57 +++++ ui/src/main.ts | 478 ++++++++++++++++++++++++++++++++++-- ui/src/panes.ts | 10 +- ui/src/render.ts | 261 ++++++++++++-------- ui/src/shortcuts.test.ts | 68 +++++ ui/src/shortcuts.ts | 85 +++++++ ui/src/state.ts | 17 ++ ui/src/treenav.test.ts | 231 +++++++++++++++++ ui/src/treenav.ts | 281 +++++++++++++++++++++ ui/style.css | 161 +++++++++++- 12 files changed, 1517 insertions(+), 143 deletions(-) create mode 100644 ui/src/shortcuts.test.ts create mode 100644 ui/src/shortcuts.ts create mode 100644 ui/src/treenav.test.ts create mode 100644 ui/src/treenav.ts diff --git a/CLAUDE.md b/CLAUDE.md index 09e2161..e4027e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,7 +245,11 @@ if/when one lands — `index-engine.md` §5.)* syntax-highlighted from CodeMirror's own grammar registry (`@codemirror/language-data`, lazily loaded one language per chunk) — `ui/src/highlight.ts` drives *every* surface (reading view, live preview, source mode) from one resolver and one theme-aware `tok-*` palette, so a fence looks the - same read or edited. + same read or edited. The GUI is **keyboard-complete** (invariant K1): the file tree follows the + ARIA `tree` pattern over `ui/src/treenav.ts`'s row order — the *same* order `render.ts` paints, so + the arrows and the eye can't disagree — every overlay traps and restores focus, `⇧F10` is the + keyboard's right-click, and `?` shows the whole chord table (`ui/src/shortcuts.ts`). 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-core/src/link.rs b/crates/b2-core/src/link.rs index 971180b..499c57b 100644 --- a/crates/b2-core/src/link.rs +++ b/crates/b2-core/src/link.rs @@ -236,6 +236,9 @@ fn extract_explanation(tail: &str) -> Option { mod tests { use super::*; + /// One expected link, as the case tables spell it: `(type, target, caption, embed, typed)`. + type Expected<'a> = (&'a str, &'a str, Option<&'a str>, bool, bool); + /// Shorthand: parse one line, return `(type, target, caption, embed, typed)`. fn parsed(line: &str) -> Vec<(String, String, Option, bool, bool)> { parse_links(line) @@ -246,7 +249,7 @@ mod tests { #[test] fn markdown_forms_yield_references_with_caption_and_embed() { - let cases: &[(&str, &[(&str, &str, Option<&str>, bool, bool)])] = &[ + let cases: &[(&str, &[Expected])] = &[ // ![alt](path) — embed with caption ( "See ![a sailboat](img/IMG_2041.jpg) here.", diff --git a/crates/b2-desktop/CLAUDE.md b/crates/b2-desktop/CLAUDE.md index 8ec8598..8cc51ef 100644 --- a/crates/b2-desktop/CLAUDE.md +++ b/crates/b2-desktop/CLAUDE.md @@ -89,6 +89,63 @@ add a UI concern to `b2-core`, that's the signal you're putting logic in the wro GUI/reindex threads), and the *implied* default scoped to `b2=debug` (not bare `debug`) so Tauri/wry/hyper tracing stays out of the file. `main` must hold the returned `WorkerGuard` for the whole run. +## The keyboard contract (invariant K1) + +[invariants.md](../../docs/design/invariants.md) **K1** — *B2 is fully operable from the keyboard; the +mouse is an accelerator, never a requirement* — names this file as its elaboration home. This is it. +K1 governs the **GUI**: the `b2` CLI satisfies it by nature, so everything below is about +`b2-desktop` + [`ui/`](../../ui) ([#78](https://github.com/AlteredCraft/B2/issues/78)). + +**The rule, in one line: no action reachable only by pointer.** If a gesture exists only as a click, +a right-click, or a drag, it is a bug — not a missing nicety. + +### The four obligations + +Every new surface owes all four. They are cheap while you're building it and expensive to retrofit. + +1. **Reachable.** A focusable control in a sensible tab order, or a documented chord. A `
` you + attach a click handler to is a mouse-only control; make it a `