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
+38 -15
View File
@@ -295,6 +295,19 @@ public final class TransientBoardState {
/// the same rule every other item reference gets.
public private(set) var selectionAnchor: ItemID?
/// **Where the next arrow steps from** the navigation cursor, AppKit's "lead": the item the
/// last click or arrow named (04-interactions.md Grammar's spatial navigation).
///
/// **Distinct from the anchor, and the difference is the whole reason both exist.** A -gesture
/// leaves the anchor exactly where it was that is what makes successive extensions sweep out
/// from one origin while the *head* walks to whatever was just reached, because the next
/// -arrow has to continue from there rather than from the origin. A plain click or arrow moves
/// both; a -click or -arrow moves only this.
///
/// A memory of a gesture like the anchor, on the selection's side by construction, and re-grounded
/// by `resolve(against:)` under the same universe rule.
public private(set) var selectionHead: ItemID?
/// The items a drag is carrying **empty when no drag is in flight**, which is what "no drag"
/// means here rather than a separate flag.
///
@@ -391,27 +404,34 @@ public final class TransientBoardState {
// MARK: - Selection
/// Replaces the selection, and sets the anchor a subsequent -click ranges from.
/// Replaces the selection, and sets the anchor a subsequent -click ranges from and the head a
/// subsequent arrow steps from.
///
/// The grammar itself is `SelectionGrammar`'s pure, testable, and the one funnel every click
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
/// own is the **anchor default**: `nil` with a sole member anchors on that member, `nil` with
/// any other count anchors on nothing. That makes the two callers that pass nothing behave
/// exactly as they should a one-item selection made by any route is a legitimate range origin,
/// while a marquee or a Select All names no click and so leaves a -click acting plain.
/// own is the **anchor default**, which the head shares: `nil` with a sole member takes that
/// member, `nil` with any other count takes nothing. That makes the two callers that pass
/// nothing behave exactly as they should a one-item selection made by any route is a
/// legitimate range origin *and* a legitimate place to arrow from, while a marquee or a Select
/// All names no gesture and so leaves a -click acting plain and the arrows re-deriving a
/// position from the set's last member.
///
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, liveness: liveness)
selectionAnchor = anchor ?? (ids.count == 1 ? ids.first : nil)
let sole = ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole
selectionHead = head ?? sole
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar). The anchor goes
/// with it: an empty selection has no origin to range from.
/// Selects nothing Escape's last step outward (04-interactions.md Grammar). The anchor and
/// the head go with it: an empty selection has no origin to range from and no cursor to step
/// from which is exactly the state the arrows' seed rule answers.
public func clearSelection() {
selection = .empty
selectionAnchor = nil
selectionHead = nil
}
/// Records that `laneID` is where the user is working a lane selected, or created into.
@@ -597,15 +617,18 @@ public final class TransientBoardState {
if let lane = lastActiveLaneID, !live.contains(lane) {
lastActiveLaneID = nil
}
if let anchor = selectionAnchor {
// The selection's side, because that is the side the anchor lives on by construction
// every route that sets it sets the selection to the same side in the same call. A
// vanished or liveness-flipped anchor is gone, which is the rule every item reference
// here gets: "a flip is a vanish from its side of the boundary".
if selectionAnchor != nil || selectionHead != nil {
// The selection's side, because that is the side both cursors live on by construction
// every route that sets either sets the selection to the same side in the same call. A
// vanished or liveness-flipped cursor is gone, which is the rule every item reference
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives
// from the selection's last member on the next arrow, which is the same fallback an
// anchorless -arrow already uses.
let universe = selection.liveness == .live
? live
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
if !universe.contains(anchor) { selectionAnchor = nil }
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
}