Skip to content

ui: make the desktop app keyboard-complete (invariant K1, GH #78) - #89

Merged
samkeen merged 2 commits into
mainfrom
claude/keyboard-nav-ui-yuwuhj
Jul 26, 2026
Merged

ui: make the desktop app keyboard-complete (invariant K1, GH #78)#89
samkeen merged 2 commits into
mainfrom
claude/keyboard-nav-ui-yuwuhj

Conversation

@samkeen

@samkeen samkeen commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01BF2Fx3uMZjJ18oHCp5mebY

Summary by CodeRabbit

  • New Features
    • Added comprehensive keyboard navigation across the file tree, overlays/modals, context menus, and graph nodes, including roving tab focus, typeahead, and predictable focus restoration after closing and deleting.
    • Added a keyboard shortcuts reference (via ? and a Settings button), plus keyboard chord hints in menus.
  • Bug Fixes
    • Improved keyboard/pointing synchronization and Escape/Enter behavior when overlays are open.
  • Style
    • Enhanced visible focus indicators and keyboard-focused styling for tree, graph, and menu items.
  • Documentation
    • Expanded accessibility guidance for keyboard operability and per-surface requirements.
  • Tests
    • Added automated coverage for shortcut data validation and tree navigation behavior.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF2Fx3uMZjJ18oHCp5mebY
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 89ccb37f-3bfe-4765-8e01-d77dc9bc3a31

📥 Commits

Reviewing files that changed from the base of the PR and between 1c5b4c2 and 8995dad.

📒 Files selected for processing (3)
  • crates/b2-desktop/CLAUDE.md
  • ui/src/main.ts
  • ui/src/render.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • ui/src/main.ts
  • ui/src/render.ts
  • crates/b2-desktop/CLAUDE.md

📝 Walkthrough

Walkthrough

The desktop UI gains keyboard-complete tree navigation, roving focus, overlay focus trapping, a shortcut reference modal, accessible graph controls, pane reset behavior, and related tests and documentation.

Changes

Keyboard-complete desktop UI

Layer / File(s) Summary
Tree navigation and roving focus
ui/src/treenav.ts, ui/src/render.ts, ui/src/main.ts, ui/src/state.ts, ui/src/treenav.test.ts
Shared tree ordering and navigation helpers drive ARIA rows, roving tab stops, arrow navigation, typeahead, and post-delete focus.
Focus routing and keyboard commands
ui/src/main.ts, ui/src/state.ts, crates/b2-desktop/CLAUDE.md, CLAUDE.md
Overlay focus trapping/restoration, keyboard guards, shortcut opening, pane focus, context-menu commands, and K1 guidance are added.
Shortcut sheet and accessible controls
ui/src/shortcuts.ts, ui/src/shortcuts.test.ts, ui/src/render.ts, ui/src/panes.ts, ui/style.css
Shortcut data and validation, shortcut and context-menu rendering, graph keyboard controls, pane reset behavior, modal hints, and focus styling are added.

Core link test maintenance

Layer / File(s) Summary
Link test type alias
crates/b2-core/src/link.rs
The link test uses a local alias for its expected tuple cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Keyboard
  participant main.ts
  participant treenav.ts
  participant render.ts
  Keyboard->>main.ts: Press tree navigation key
  main.ts->>treenav.ts: Resolve movement or typeahead target
  main.ts->>render.ts: Update focus and tree state
  render.ts->>Keyboard: Expose focused ARIA tree row
Loading

Possibly related issues

Possibly related PRs

Poem

A bunny taps arrows through folders of green,
With focus restored where the carrot had been.
“?” opens a shortcut sheet bright,
Escape tucks overlays out of sight.
Keyboard paws hop; mouse follows in flight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making the desktop app keyboard-complete under invariant K1.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/keyboard-nav-ui-yuwuhj

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
ui/src/render.ts (1)

1086-1093: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

chord is interpolated unescaped.

Every current caller passes a literal, so this is not exploitable today, but label/chord sit next to escapeHtml-guarded interpolations everywhere else in this file; escaping both keeps the helper safe if a path- or title-derived string is ever passed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/render.ts` around lines 1086 - 1093, Update contextItemHtml so both
label and chord are HTML-escaped before interpolation into the button markup,
while preserving the existing conditional chord hint and danger styling
behavior. Reuse the file’s existing escapeHtml helper.
ui/src/main.ts (1)

3061-3096: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

treeRows() rebuilds the whole tree on every keystroke.

buildTree + visibleRows run per arrow press and per typeahead character (twice on a typeahead miss path is avoided, but the build is still O(notes + resources) with a sort per folder). Fine at today's vault sizes; if the tree grows, memoize the row list per (notes, resources, dirs, expandedDirs) identity rather than recomputing inside the handler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/main.ts` around lines 3061 - 3096, Memoize the result of treeRows()
using the identities of notes, resources, dirs, and expandedDirs as the cache
key, so unchanged tree state reuses the existing row list. Update the tree
keydown handler to use that cached result for arrow navigation and typeahead
without rebuilding the tree on each keystroke. Invalidate or replace the cache
whenever any of those inputs changes.
ui/style.css (1)

2328-2332: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the ignored border-radius from the SVG shape focus ring.

.gshape is rendered as an SVG <circle>/<rect>, so this declaration cannot affect the shape curvature and only misleads future readers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/style.css` around lines 2328 - 2332, Remove the border-radius declaration
from the .gnode:focus-visible .gshape rule, leaving the outline and
outline-offset declarations unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ui/src/main.ts`:
- Around line 600-622: Update syncOverlayFocus so each overlay-kind transition
captures the current trigger element, rather than only capturing on the
null-to-overlay transition. When the overlay closes or returns to a previous
kind, restore the focus target associated with the immediately underlying
overlay while preserving the existing inline-input exception and link-modal
preferred focus behavior.
- Around line 3190-3218: Update the Tab-handling branch in the overlay keyboard
handler to call preventDefault whenever currentOverlay() is non-null, including
when overlayFocusables() returns no items. Preserve the existing focus-wrapping
behavior when focusable controls exist, while swallowing Tab for empty overlays
before returning.

In `@ui/src/render.ts`:
- Around line 159-169: Update treeRenameRowHtml and treeCreateRowHtml so their
inline editor wrapper uses role="none" while preserving the existing labelled
input and row behavior. Ensure the role="tree" container exposes only valid
tree-pattern children during inline create or rename operations.

---

Nitpick comments:
In `@ui/src/main.ts`:
- Around line 3061-3096: Memoize the result of treeRows() using the identities
of notes, resources, dirs, and expandedDirs as the cache key, so unchanged tree
state reuses the existing row list. Update the tree keydown handler to use that
cached result for arrow navigation and typeahead without rebuilding the tree on
each keystroke. Invalidate or replace the cache whenever any of those inputs
changes.

In `@ui/src/render.ts`:
- Around line 1086-1093: Update contextItemHtml so both label and chord are
HTML-escaped before interpolation into the button markup, while preserving the
existing conditional chord hint and danger styling behavior. Reuse the file’s
existing escapeHtml helper.

In `@ui/style.css`:
- Around line 2328-2332: Remove the border-radius declaration from the
.gnode:focus-visible .gshape rule, leaving the outline and outline-offset
declarations unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bcef0339-dfec-4e7c-b4d2-1e4473915b73

📥 Commits

Reviewing files that changed from the base of the PR and between 92a0fc2 and 1c5b4c2.

📒 Files selected for processing (12)
  • CLAUDE.md
  • crates/b2-core/src/link.rs
  • crates/b2-desktop/CLAUDE.md
  • ui/src/main.ts
  • ui/src/panes.ts
  • ui/src/render.ts
  • ui/src/shortcuts.test.ts
  • ui/src/shortcuts.ts
  • ui/src/state.ts
  • ui/src/treenav.test.ts
  • ui/src/treenav.ts
  • ui/style.css

Comment thread ui/src/main.ts Outdated
Comment thread ui/src/main.ts
Comment thread ui/src/render.ts
… review)

Three findings from review, all valid. The first turned out to be a
deeper bug than reported.

**Overlay focus return.** Closing the `?` sheet over Settings was
supposed to land on the "Keyboard shortcuts" button that opened it, and
didn't. The reported cause — `overlayReturn` captured only on the
null→overlay edge — was real but not the whole story: `captureReturnFocus`
read `document.activeElement` from inside `syncOverlayFocus`, which runs
at the *end* of `render()`, by which point `render()` has already swapped
`#modal-root`/`#menu-root` wholesale. The element that held focus is
destroyed and `activeElement` has fallen back to `<body>`, so the capture
returned null for *every* trigger living inside an overlay. Tree rows
looked fine only because `paintTree` self-heals by path independently.

So: track `lastFocused` continuously from a `focusin` listener (destroying
a focused node fires no `focusin`, so the last value is still the
trigger), and give the Settings button an id, since a thunk can only
re-find a control across the repaint by id. Focus bookkeeping also splits
into the two layers it always was — the sheet *stacks* over Settings while
everything else is mutually exclusive, so the sheet gets its own return
target while the base layer keeps capturing at the start of a chain (menu
→ Move… → close still lands on the tree row).

**Tab trap.** With no focusable controls the handler returned without
`preventDefault()`, letting Tab walk the page behind the backdrop —
precisely the failure the block exists to prevent. Swallowed
unconditionally now.

**Tree ARIA.** The inline create/rename rows are plain `<div>`s directly
inside the `role="tree"` container, so the tree owned a child of no known
role — while a screen-reader user is naming a note. `role="none"` on the
wrapper; the input keeps its own label.

Also guards `?` behind an open context menu, an edge the layering made
visible: a menu is transient and owns the keyboard like any other overlay.

Verified in Chromium: both sheet-from-Settings paths (mouse and keyboard)
now restore to the trigger, and the existing 32 keyboard paths still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF2Fx3uMZjJ18oHCp5mebY
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.

2 participants