Implement the keyboard grammar and full command map

The board's fixed grammar keys and the menu-backed chords of
04-interactions.md § Keyboard, per the Command Nexus inventory:

- Spatial arrow navigation (NavigationMath.nearest over the marquee
  registry's frames — one geometry source), walking across interior
  masonry columns, lanes, and into the shown trash; ⇧-arrows extend via
  the same range function as ⇧-click and go inert at the liveness and
  kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓
  descent; the empty selection seeds at the first lane's first card;
  selection scrolls into view.
- selectionHead — the navigation cursor beside the anchor, set by every
  click, moved by every arrow, dropped by the reload vanish rule.
- Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the
  placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓
  (within-lane sort, gather-then-step, rank-permuting writes in one
  bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the
  trash) — all validating and acting off one shared answer.
- Delete now selects the Finder-style successor sibling from the
  pre-write snapshot, so repeated ⌫ walks down a lane; external
  vanishing still only shrinks the selection.
- handleReturn rejects modified Returns; the trash column renders
  eagerly so every row stays registered for navigation and the marquee.

686 unit tests (27 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 19:32:30 -04:00
parent 2e229735b1
commit 4035ba7986
13 changed files with 1582 additions and 75 deletions
+25 -1
View File
@@ -313,7 +313,23 @@ struct LaneView: View {
/// The card stack. Its empty space is a click target in its own right (04 Selection): one
/// click selects the lane or, when it is already the selection, clears it; a double click
/// creates a card at the bottom with its title editor focused.
///
/// **"Selection scrolls into view"** (04-interactions.md Grammar): the reader watches the
/// navigation head the cursor the arrows move, not the whole selection and scrolls only when
/// the head names a card *this* lane renders, so exactly one lane responds to any one press.
/// Deliberately unwrapped by `withAnimation`: 03-board-ui.md § Motion has selection follow
/// "whatever transaction is active rather than easing on its own".
private var cardStack: some View {
ScrollViewReader { proxy in
scrollableCards
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, let card = renderedCards.first(where: { $0.id == head }) else { return }
proxy.scrollTo(LaneSlot.identity(of: card.id))
}
}
}
private var scrollableCards: some View {
ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
@@ -340,6 +356,10 @@ struct LaneView: View {
// decided upstream at the reload for the real cards (`Motion.reloadAnimates`),
// at the gesture for the placeholder, which touches no disk.
.transition(Motion.cardTransition(reduced: reduceMotion))
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
// resolves against an explicit `.id`, and it goes outermost so the transition
// above stays inside the identified view rather than around it.
.id(slot.id)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -447,12 +467,16 @@ private enum LaneSlot: Identifiable {
var id: String {
switch self {
case let .card(card): "card:\(card.id.rawValue)"
case let .card(card): Self.identity(of: card.id)
// Constant, because there is only ever one placeholder in one lane at a time and it must
// keep its identity and therefore its keyboard focus while the user types.
case .placeholder: "placeholder"
}
}
/// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot
/// disagree about what `scrollTo` is looking for.
static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }
}
// MARK: - Card face