A card's context menu finds its four groups — style, clipboard, navigation, and the trash

CardFaceView.boardMenu/boardActions restructured to the owner's shape (card
fe66c461): Open/Copy Link/Rename/Style▸(Symbol,Color), a divider, then
Copy/Cut/Paste/Paste Special▸(Paste Image into Card), a divider, then
Navigation▸(Move Left,Move Right), a divider, then Send to Trash. Every row
routes through existing machinery — no new commands.

ClipboardStore gains copy(from:targeting:)/cut(from:targeting:) and their
canCopy/canCut twins, so Copy and Cut can widen to the clicked card exactly
as Delete and Style already do ("right-clicking something outside the
selection acts on what was clicked"), without disturbing the Edit-menu path.

LaneMoveTarget.destination is extracted out of MoveLaneCommands so the
card menu's Navigation rows validate against the identical sole-live-lane
predicate as Board ▸ Move Left/Right. Since a card id can never itself
satisfy that predicate, the two rows are wired to the real store call but
unconditionally disabled — reading the live selection per card face would
reproduce the O(board) render regression isSelected/selectedCount exist to
prevent (contextMenu's builder is not lazy).

Style ▸ Symbol and ▸ Color both open the one existing style popover — no
per-section pre-focus (StyleEditorSession has no such concept, and
StyleEditorView internals are out of scope while another pass redesigns
the pickers). Paste and Paste Image into Card reduce their .disabled
checks to selection/snapshot-free forms, proven safe by construction (a
rendered card face already guarantees a live lane / a live board card).

Journaled on the card: Copy Link kept (shipped same day, not in the
owner's list), "Delete" relabeled "Send to Trash" (board-side move, not
the permanent trash delete), quick-style recents row dropped from this
menu, Navigation's always-disabled rows, and Paste not retargeting to the
clicked card — all flagged needs owner review. DESIGN/11-command-nexus.md's
Card row is owed a rewrite, left for the main session.

Tests: LaneMoveTarget.destination (new), targeted copy/cut (new), plus
existing ClipboardStore/PasteTarget/MoveLane/CopyLink/Trash-menu/PasteImage/
PasteFile/Style/CaretChord/render-performance/equatable-gate suites —
156 tests, all passing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent a429a7ec4d
commit 72ca22251b
5 changed files with 478 additions and 68 deletions
+51 -25
View File
@@ -270,17 +270,57 @@ struct MoveCardCommands: View {
// MARK: - Lane moves
/// **The pure predicate behind Board Move Left / Move Right** extracted so the menu-bar row
/// (`MoveLaneCommands`) and the card context menu's Navigation submenu (`CardFaceView`, 2026-08-09
/// "redesign context menu for cards") validate off one definition rather than two that could drift.
///
/// "Lane selection only (one slot; never into the trash)" (11-command-nexus.md), closing
/// 10-accessibility.md's lane-move defect (04 Accessibility).
///
/// **Sole lane, deliberately.** The width pair batches over a multi-lane selection; this rule's
/// inventory line says "Lane selection only" with no batching clause, and a multi-lane move has no
/// single unambiguous meaning ("one slot" for a discontiguous pair is not one answer). So this
/// validates on exactly one selected live lane.
///
/// **A card id answers `nil` here, on purpose and unconditionally** "a card id is in no lane
/// order, so this is also the 'not a lane' test". That is the whole of why the card context menu's
/// own Navigation rows are a structural mismatch rather than a card-scoped move: this predicate reads
/// the *board's live selection*, never the clicked card, so a card menu's Move Left/Right can only
/// ever be live when the selection elsewhere on the board happens to be a sole lane see
/// `CardFaceView`'s own note on why it does not even attempt to read this per card.
///
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step past the
/// last real lane is simply off the end which is also the disable rule at the walls, following the
/// width stepper's floor style rather than letting the store no-op silently.
enum LaneMoveTarget {
/// The sole selected live lane and the display slot one step would put it in `nil` when there
/// is no such lane or it is already at that wall.
///
/// `from + delta` **is** the index `moveLane` wants: that method counts display positions among
/// the live lanes *with the moved lane already removed*, so inserting at `from - 1` puts the lane
/// before its old predecessor and at `from + 1` after its old successor one slot each way. The
/// convention is easy to get backwards, which is why it is pinned by a test.
static func destination(
selection: ItemReferenceSet,
snapshot: BoardModel,
delta: Int
) -> (lane: ItemID, index: Int)? {
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first
else { return nil }
let lanes = SelectionGrammar.lanes(in: snapshot)
// A card id is in no lane order, so this is also the "not a lane" test.
guard let from = lanes.firstIndex(of: id) else { return nil }
let to = from + delta
guard lanes.indices.contains(to) else { return nil }
return (id, to)
}
}
/// Board Move Left / Move Right (/) "Lane selection only (one slot; never into the trash)"
/// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 Accessibility).
///
/// **Sole lane, deliberately.** The width pair one row below explicitly batches over a multi-lane
/// selection; this row's inventory line says "Lane selection only" with no batching clause, and a
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
/// answer). So the items validate on exactly one selected live lane.
///
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step
/// past the last real lane is simply off the end which is also the disable rule at the walls,
/// following the width stepper's floor style rather than letting the store no-op silently.
/// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 Accessibility). The
/// validating predicate is `LaneMoveTarget.destination(selection:snapshot:delta:)`; this struct is the
/// menu-bar row around it.
///
/// **Caret chords yield to any focused text control** (04-interactions.md Grammar, settled):
/// / are the standard line-start/end chords, and an enabled key equivalent fires before a
@@ -311,23 +351,9 @@ struct MoveLaneCommands: View {
caretChordsYield(boardInfo: boardInfo, search: search)
}
/// The sole selected live lane and the display slot one step would put it in `nil` when there
/// is no such lane or it is already at that wall.
///
/// `from + delta` **is** the index `moveLane` wants: that method counts display positions among
/// the live lanes *with the moved lane already removed*, so inserting at `from - 1` puts the lane
/// before its old predecessor and at `from + 1` after its old successor one slot each way. The
/// convention is easy to get backwards, which is why it is pinned by a test.
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil }
let lanes = SelectionGrammar.lanes(in: store.snapshot)
// A card id is in no lane order, so this is also the "not a lane" test.
guard let from = lanes.firstIndex(of: id) else { return nil }
let to = from + delta
guard lanes.indices.contains(to) else { return nil }
return (id, to)
return LaneMoveTarget.destination(selection: store.selection, snapshot: store.snapshot, delta: delta)
}
private func move(by delta: Int) {