Both context menus (`CardFaceView`'s pointer menu and VoiceOver twin, `LaneView`'s own pair) twin a plain "Paste" row over `ClipboardStore.paste(into:)`. It now reads the clipboard's own manifest and titles itself "Paste Card" / "Paste Lane" for a single copied item, "Paste N Cards" / "Paste N Lanes" for several, and stays plain "Paste" for no app payload — a Finder file copy, a screenshot, anything else the paste command still accepts but never decodes to a `ClipboardManifest`. `ClipboardManifest.pasteMenuTitle(for:)` is the one pure function both menus call — `kind` and `entries.count` are the whole of it, since `SelectionKind` is singular by construction (`SelectionGrammar.mixesKinds` refuses a mixed copy), so there is no mixed shape to compose a plural for. `CardFaceView.pasteTitle` rides the exact Observable read `pasteEnabled` already makes (`appModel.clipboard.payload`) — no new subscription on a builder that is not lazy. `LaneView.pasteTitle` reads the same field directly rather than through `canPaste(into:)`, since this view is already unconditionally subscribed to selection/snapshot and there is no reduction to preserve. Edit ▸ Paste on the menu bar stays plain — it is a responder attached to the platform's own Edit menu row (`ClipboardCommands.boardClipboardCommands`), not a `Button` this app titles, and the card's scope is context menus only. Flagged for a follow-up: DESIGN/11-command-nexus.md's Card and Lane rows (lines 110-111) describe Paste generically and could note the dynamic title. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1484 lines
89 KiB
Swift
1484 lines
89 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
// MARK: - Which side of the board a face is on
|
||
|
||
/// **The one axis a card face has** — which container it is drawn in, and the collaborator that
|
||
/// container's grammar needs.
|
||
///
|
||
/// 03-board-ui.md § Trash, resettled 2026-07-28: "A trashed card is an ordinary card in a special
|
||
/// place — search, selection, rendering, styling, and clipboard all treat it exactly like any other
|
||
/// card". So there is **one** card face in this app, and this is the whole of what differs between
|
||
/// its two homes. Everything the two sides share — the plate, the stripe, the icon tint, the
|
||
/// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag — is
|
||
/// shared by construction rather than by two views agreeing.
|
||
///
|
||
/// The three differences are all *absences on the trash side*, and each is 04-interactions.md ▸ The
|
||
/// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is
|
||
/// simply not taken:
|
||
///
|
||
/// - **no Open** — no double-click gesture at all ("trash cards don't open — double-click stops at
|
||
/// selection"), which is also why `openCard` is the board case's payload rather than the view's;
|
||
/// - **no Rename** — the inline editor is board-only (`isRenaming`), so a rename that somehow
|
||
/// targeted a trashed card would render nothing rather than open a field over it;
|
||
/// - **no Style…** — no popover anchor, and no Style rows in the context menu.
|
||
///
|
||
/// Plus the two that are not about editing: Finder file drops are inert over the trash (▸ The trash),
|
||
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
|
||
/// it needs the window's confirmation host (11-command-nexus.md ▸ Context menus' Trash cards row).
|
||
///
|
||
/// **A fourth board-only row joined 2026-08-09**: Copy Link (design ruling, card 737a949f) — not one
|
||
/// of the three edit-shaped absences above (it changes nothing on disk), but "sole selected **live**
|
||
/// card" is its own words for the same board-only scope, so it lives in `boardMenu` only and has no
|
||
/// trash-side counterpart, absence or otherwise.
|
||
///
|
||
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
|
||
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
|
||
/// card opener.
|
||
/// `Sendable` because `CardFaceView.==` is nonisolated and a nonisolated context may only read a
|
||
/// main-actor `let` of Sendable type — which is why `openCard` is typed `@MainActor` (an isolated
|
||
/// function type is Sendable; a bare one is not, and would sink the whole enum).
|
||
enum CardFaceRole: Sendable {
|
||
|
||
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
||
/// (04-interactions.md ▸ Selection).
|
||
case board(openCard: @MainActor (ItemID) -> Void)
|
||
|
||
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
||
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
||
case trash(confirmations: TrashConfirmations)
|
||
|
||
/// Which container a click on this face selects in, which container its drag begins in, and which
|
||
/// container the rubber band sweeps it as — one answer, so the three can never disagree
|
||
/// (`SelectionGrammar`: "the container is the surface's, not the item's").
|
||
var container: ItemContainer {
|
||
switch self {
|
||
case .board: .board
|
||
case .trash: .trash
|
||
}
|
||
}
|
||
|
||
/// Whether two roles put the face in the same home with the same collaborator — the comparison
|
||
/// `CardFaceView.==` makes, and the reason this enum is not simply `Equatable`.
|
||
///
|
||
/// **The board case's `openCard` is deliberately not compared.** It is a closure the strip
|
||
/// rebuilds on every body pass, so comparing it is impossible and ignoring it is correct: it is a
|
||
/// pure hand-off to the host's `WindowGroup` key, identical in behaviour whatever closure object
|
||
/// carries it, and a face that changed *which window it opens into* would be a face in a
|
||
/// different window and therefore a different view identity entirely.
|
||
///
|
||
/// The trash case's `confirmations` **is** compared, by identity: it is window-lived state
|
||
/// (`@State` in `BoardWindowHost`), so identity is both cheap and meaningful, and it is the one
|
||
/// collaborator a role carries that the face actually reads state off.
|
||
nonisolated func isEquivalent(to other: CardFaceRole) -> Bool {
|
||
switch (self, other) {
|
||
case (.board, .board): true
|
||
case let (.trash(lhs), .trash(rhs)): lhs === rhs
|
||
default: false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Card face
|
||
|
||
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
|
||
/// placeholder), two quiet trailing chips — attachments and comments, each present-only — and a
|
||
/// left-edge colour accent stripe (03-board-ui.md § Card face, § Styling ▸ Capabilities). The
|
||
/// comments chip joined 2026-08-09 (design ruling, card e729e30a).
|
||
///
|
||
/// ### One face, two containers
|
||
///
|
||
/// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized
|
||
/// trash): "a trashed card is an ordinary card in a special place … rendering … treat it exactly like
|
||
/// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew;
|
||
/// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it
|
||
/// did in its lane, because it is the same card and the same view. What the trash takes away is
|
||
/// listed on `CardFaceRole` and nowhere else.
|
||
///
|
||
/// ### Title-only, deliberately
|
||
///
|
||
/// **No body excerpt** — settled, and still true after 2026-08-09's growth (the hero banner, the
|
||
/// comments chip): the face never draws a preview of the card's own prose, and never will — what
|
||
/// grew is exposure of facts the card already carries structurally (an attachment named as hero, a
|
||
/// folder count), not content. Two face chips are in scope: **attachments**, "a quiet indicator when
|
||
/// the card has files — the title dominates", and **comments**, its same-vocabulary twin — a
|
||
/// secondary-tinted glyph (plus a count, for comments — see `commentsIndicator`) rather than a count
|
||
/// pill, so the eye still lands on the title first.
|
||
///
|
||
/// ### Two lenient fields, two different fallbacks
|
||
///
|
||
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
|
||
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
|
||
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
|
||
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
|
||
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
|
||
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
|
||
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
|
||
///
|
||
/// ### One presentation, selection styling only
|
||
///
|
||
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
|
||
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
|
||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
|
||
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
|
||
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
|
||
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
|
||
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
|
||
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
|
||
/// face).
|
||
///
|
||
/// ### Equality gate
|
||
///
|
||
/// The face is `Equatable` and instantiated through `.equatable()` (`LaneView.scrollableCards`,
|
||
/// `TrashLaneView.scrollableCards`), because its parent re-runs for reasons that have nothing to do
|
||
/// with any one card: a lane's body re-evaluates on **every drop-proposal change** while a drag is
|
||
/// in flight, and without a gate that rebuilds every face in every lane on every cursor move. See
|
||
/// the `==` below for what the gate covers and what it deliberately does not.
|
||
struct CardFaceView: View, Equatable {
|
||
|
||
let store: BoardStore
|
||
let card: Card
|
||
|
||
/// Which side of the board this face is drawn on, and what that side's grammar needs — the view's
|
||
/// one axis (`CardFaceRole`).
|
||
let role: CardFaceRole
|
||
|
||
/// The strip's rubber band — the registry this face registers its drawn frame into.
|
||
let marquee: MarqueeControl
|
||
|
||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||
let drops: BoardDropContext
|
||
|
||
/// **This card's hero image, resolved** — the file its `hero` key names inside its own
|
||
/// `attachments/`, or `nil` for the overwhelmingly common card that names none
|
||
/// (03-board-ui.md § Card face ▸ Hero image; `CardHero.imageURL(for:inContainer:)`).
|
||
///
|
||
/// A parameter rather than a resolution done here, and for `isSelected`'s kind of reason one axis
|
||
/// over: resolving it needs the card's *folder*, which this view does not know — it knows its card
|
||
/// and its container, and finding the path from the snapshot would be a board walk per face. The
|
||
/// two parents each know their own container folder and compute it once for the whole strip.
|
||
///
|
||
/// Whether the file exists, decodes, or is an image at all is deliberately not asked here: this is
|
||
/// a URL, and a name that leads nowhere draws no band (`CardHeroImage`), which is the ruling's
|
||
/// "renders exactly as with no key" kept structurally.
|
||
let hero: URL?
|
||
|
||
/// Whether this card is in the selection **of its own container** — a parameter rather than a
|
||
/// read off the store, and that is the whole of the fix RENDER-INSTRUMENTATION.md ▸ "Selection is
|
||
/// O(board) in card bodies" asked for.
|
||
///
|
||
/// Observation tracks whole properties, so a body that reads `store.selection` is subscribed to
|
||
/// *every* selection change on the board — 180 faces re-running per marquee sample on the 6×30
|
||
/// fixture, 515 on a real board, and `.equatable()` powerless over any of it because a direct
|
||
/// Observation invalidation never consults the gate. Taking selected-ness as a compared input
|
||
/// instead moves the subscription up one level, to the parent that already holds it: the lane's
|
||
/// body reads the selection once for its own header, hands each face the answer, and the gate
|
||
/// below then re-runs exactly the faces whose flag actually flipped.
|
||
let isSelected: Bool
|
||
|
||
/// The size of the selection this face belongs to — **1 when unselected**, which the parent
|
||
/// computes and normalizes so the drag replica's fan and count badge need no store read of their
|
||
/// own (`dragReplica`). Deliberately not `Int?`: "how many ride along" has an answer for every
|
||
/// face, and one is it.
|
||
let selectedCount: Int
|
||
|
||
/// Whether this face's own lane has a live neighbour one step to the **left** — Navigation ▸ Move
|
||
/// Left's render-safe enablement input (2026-08-09 ▸ "Give the card context menu's Navigation rows
|
||
/// real card-move behavior", card 06322636), `isSelected`'s exact pattern one row over: the parent
|
||
/// (`LaneView`) reads `store.snapshot` **once per lane** (`CardMoveTarget.hasNeighbor`) and hands
|
||
/// every face in it the answer, so a click never costs a per-face board walk. Defaults `false` —
|
||
/// the trash side never constructs Navigation rows at all, so its faces never need a real answer
|
||
/// here (`CardFaceRole`).
|
||
let hasLeftNeighbor: Bool
|
||
|
||
/// `hasLeftNeighbor`'s mirror, for Move Right.
|
||
let hasRightNeighbor: Bool
|
||
|
||
/// Whether the **live board selection** reaches outside this face's own lane — the "no coherent
|
||
/// left for a spread" refusal (owner ruling, card 06322636): a selection spanning more than one
|
||
/// lane disables Navigation for every one of its own selected members, because the widened target
|
||
/// (`targetIDs`) would name cards `CardMoveTarget.destination` can never place in one direction.
|
||
/// Only read together with `isSelected` — an unselected face's target is always itself alone,
|
||
/// trivially confined to its own lane regardless of what this says (`moveLeftEnabled`).
|
||
///
|
||
/// Lane-hoisted like the two neighbour flags above (`CardMoveTarget.selectionSpansOtherLanes`):
|
||
/// the parent already reads the selection once for `isSelected`/`selectedCount`, so this costs one
|
||
/// more `Set` comparison over cards it already has in hand, never a second `store.selection` read.
|
||
let selectionSpansLanes: Bool
|
||
|
||
/// Explicit only for `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` — the three
|
||
/// Navigation inputs, all defaulted to `false` (every row reads disabled) so the trash side's one
|
||
/// call site (`TrashLaneView.cardRow`, whose faces never draw Navigation at all) and the pre-move
|
||
/// equatable-gate tests need no opinion about a feature their construction never exercises. Every
|
||
/// other parameter stays required — the memberwise init's own shape — because the board side
|
||
/// (`LaneView.scrollableCards`) always has a real answer for all eight and a silently-defaulted
|
||
/// `card` or `isSelected` would be the wrong kind of convenience.
|
||
init(
|
||
store: BoardStore,
|
||
card: Card,
|
||
role: CardFaceRole,
|
||
marquee: MarqueeControl,
|
||
drops: BoardDropContext,
|
||
hero: URL?,
|
||
isSelected: Bool,
|
||
selectedCount: Int,
|
||
hasLeftNeighbor: Bool = false,
|
||
hasRightNeighbor: Bool = false,
|
||
selectionSpansLanes: Bool = false
|
||
) {
|
||
self.store = store
|
||
self.card = card
|
||
self.role = role
|
||
self.marquee = marquee
|
||
self.drops = drops
|
||
self.hero = hero
|
||
self.isSelected = isSelected
|
||
self.selectedCount = selectedCount
|
||
self.hasLeftNeighbor = hasLeftNeighbor
|
||
self.hasRightNeighbor = hasRightNeighbor
|
||
self.selectionSpansLanes = selectionSpansLanes
|
||
}
|
||
|
||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||
@Environment(AppModel.self) private var appModel
|
||
|
||
/// Increase Contrast, for the plate's borders below (10-accessibility.md: "Increase Contrast
|
||
/// strengthens borders and the selection indicator"). Read from the environment and handed to
|
||
/// `Accommodations`, which owns what "increased" does to a stroke.
|
||
@Environment(\.colorSchemeContrast) private var contrast
|
||
|
||
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). **The environment is what
|
||
/// makes zoom reach a card face at all**: this view is `.equatable()`, and the gate above compares
|
||
/// nothing that moves with the level — but it does not compare environment values either, because
|
||
/// SwiftUI invalidates on those itself. A level threaded any other way would be swallowed here.
|
||
@Environment(\.boardZoom) private var zoom
|
||
|
||
/// The live body metric — every figure this face lays out on is a multiple of it
|
||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
|
||
/// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
|
||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||
|
||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||
/// Read from `BoardMetrics` rather than spelled here, because the new-card placeholder has to
|
||
/// draw this same plate for the create handoff to read as one arrival.
|
||
private var cornerRadius: CGFloat { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) }
|
||
|
||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||
/// treatment shootout).
|
||
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
|
||
|
||
/// This face's drawn width — **the replica's** (`replicaFace`). Measured rather than derived,
|
||
/// because a face is as wide as the interior masonry column its lane gives it, and that is a
|
||
/// function of the lane's slot width and its column count rather than of the font
|
||
/// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's
|
||
/// fallback is for.
|
||
@State private var measuredWidth: CGFloat = 0
|
||
|
||
/// The whole of what this face is a function of **as far as its parent is concerned**: the card
|
||
/// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home
|
||
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the hero file the parent resolved for it,
|
||
/// the two selection figures the parent resolves for it, and the three window-lived collaborators
|
||
/// — the store by identity, the band and the drop machinery by their own equivalence tests, which
|
||
/// exist because the strip rebuilds both structs, closures and all, on every body pass.
|
||
///
|
||
/// **The hero is compared as a URL, not as a picture.** It moves only when the card's key or its
|
||
/// container does, both of which are already `card`-and-role facts; comparing it costs a path
|
||
/// comparison on a value that is `nil` for almost every card, and the picture behind it is the
|
||
/// banner view's own state (`CardHeroImage`), which no gate here could see anyway.
|
||
///
|
||
/// **Selection is a compared input now, and that is what makes the gate reach it.** It used to be
|
||
/// an Observation read — `isSelected` off `store.selection` — which meant a click anywhere on the
|
||
/// board invalidated every face on it *directly*, past the gate entirely, and the measured cost
|
||
/// of a marquee sample was the whole board's worth of bodies. Selected-ness now rides down as
|
||
/// `isSelected`/`selectedCount` on the parent's own subscription (`LaneView` already reads the
|
||
/// selection for its header; `TrashLaneView` gained one hoisted read to match), so a selection
|
||
/// change re-runs the lane bodies that were subscribed anyway plus exactly the faces whose flag
|
||
/// flipped. See RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies.
|
||
///
|
||
/// **What the gate still does not suppress is the point.** What remains of this body's own
|
||
/// Observation reads — `store.transient.pendingCut` and the rename editor,
|
||
/// `drops.session.isDragging`, `appModel.styleRecents` — invalidates this view directly, and
|
||
/// `.equatable()` has no say in that. The gate only stops the *parent* handing a face a
|
||
/// new-but-identical set of inputs and re-running it for nothing, which during a card drag is
|
||
/// what every proposal change does to every face in the lane.
|
||
///
|
||
/// `store.searchFilter` is deliberately absent from that list: its only use here is `ownSlotSeed`,
|
||
/// reached from `startBoardCardDrag`, which runs when a drag begins rather than when the body
|
||
/// does — an event-time read subscribes nothing. `draggedIDs`, the context menus' `targetIDs` and
|
||
/// the deferred `styleTarget` read the selection the same way, inside actions, which is why they
|
||
/// stayed as they were.
|
||
///
|
||
/// **The three Navigation inputs joined 2026-08-09** (card 06322636), `isSelected`'s exact
|
||
/// reason one more time: `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` are what let
|
||
/// `moveLeftEnabled`/`moveRightEnabled` answer without a `store.snapshot`/`store.selection` read
|
||
/// inside this face's own `.disabled` — so a gate that swallowed them would leave a face's
|
||
/// Navigation rows wearing a stale enabled state after a neighbouring lane folded, a wall lane's
|
||
/// board-edge shifted, or the live selection grew across lanes, none of which touch `card`, `hero`
|
||
/// or the other selection figures.
|
||
///
|
||
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
|
||
/// `@Environment` values (SwiftUI invalidates on those itself), and the `.board` role's
|
||
/// `openCard` closure (see `CardFaceRole.isEquivalent(to:)`).
|
||
nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool {
|
||
lhs.card == rhs.card
|
||
&& lhs.role.isEquivalent(to: rhs.role)
|
||
&& lhs.hero == rhs.hero
|
||
&& lhs.isSelected == rhs.isSelected
|
||
&& lhs.selectedCount == rhs.selectedCount
|
||
&& lhs.hasLeftNeighbor == rhs.hasLeftNeighbor
|
||
&& lhs.hasRightNeighbor == rhs.hasRightNeighbor
|
||
&& lhs.selectionSpansLanes == rhs.selectionSpansLanes
|
||
&& lhs.store === rhs.store
|
||
&& lhs.marquee.isEquivalent(to: rhs.marquee)
|
||
&& lhs.drops.isEquivalent(to: rhs.drops)
|
||
}
|
||
|
||
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
|
||
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
|
||
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
|
||
/// gestures that fire and refuse (`CardFaceRole`).
|
||
@ViewBuilder
|
||
var body: some View {
|
||
// The lane's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because a
|
||
// `@ViewBuilder` body takes statements as views and a bare call would be one.
|
||
#if DEBUG
|
||
let _ = BoardRenderMetrics.countCardBody()
|
||
#endif
|
||
switch role {
|
||
case let .board(openCard):
|
||
face
|
||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||
//
|
||
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
|
||
// second tap recogniser on the same view makes the single click *wait* to see whether a
|
||
// second one arrives, and selection must stay instant. Simultaneous means the first click
|
||
// of the pair selects and the second opens — Finder's own behaviour.
|
||
//
|
||
// **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening
|
||
// a window out from under a range the user is still building would be a surprise.
|
||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||
guard ClickModifier.current == .plain else { return }
|
||
openCard(card.id)
|
||
})
|
||
.contextMenu { boardMenu(openCard: openCard) }
|
||
// **The menu's rows, additionally as custom actions** — "where SwiftUI additionally
|
||
// surfaces menu items as custom accessibility actions, that's free improvement, not
|
||
// a separate design surface" (10-accessibility.md ▸ Actions come from the context
|
||
// menu). The menu stays the inventory and stays reachable the standard way (VO-⇧-M).
|
||
// Style… is absent for `LaneView`'s reason: it opens a popover, and the quick-style
|
||
// swatch `Picker` beside it is not an action.
|
||
.accessibilityActions { boardActions(openCard: openCard) }
|
||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||
}
|
||
// Labels ▸ More… — the second session-backed popover this face can host, mounted
|
||
// exactly like the first (2026-08-09, card 28c79ffe). The two can never be open at
|
||
// once: each is opened by a row of the same menu, and opening either replaces nothing
|
||
// of the other's — but a user cannot press two menu rows in one gesture, and each
|
||
// session's own `begin` replaces only its own kind.
|
||
.popover(isPresented: labelEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||
LabelPickerPopover(store: store, recents: appModel.labelRecents)
|
||
}
|
||
case let .trash(confirmations):
|
||
face
|
||
.contextMenu { trashMenu(confirmations: confirmations) }
|
||
// The trash's two rows and **no third** — "there is no Open"
|
||
// (10-accessibility.md ▸ Trash lane; 03-board-ui.md's no-editing-in-the-trash). The
|
||
// absence is structural on this side too: `openCard` is the board case's payload, so
|
||
// there is nothing here an Open action could even call.
|
||
.accessibilityActions { trashActions(confirmations: confirmations) }
|
||
}
|
||
}
|
||
|
||
/// Everything the two containers share — which, after the pivot, is the face itself.
|
||
private var face: some View {
|
||
plateContent
|
||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||
.overlay(alignment: .leading) { accentStripe }
|
||
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
||
// highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
|
||
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
|
||
// hovered card that is *also* selected still reads as the target.
|
||
//
|
||
// **Increase Contrast strengthens both the ring and the plate's edge** (10-accessibility.md):
|
||
// the stroke goes a point heavier, and an *unselected* card — which normally floats on its
|
||
// fill alone — gains a separator hairline, because "this is one card and that is another" is
|
||
// exactly the distinction the setting exists to rescue (`Accommodations`).
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
||
)
|
||
// The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste
|
||
// moves them"). Above `contentShape` so the face stays fully clickable while it waits. It
|
||
// genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
|
||
// the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
|
||
.cutTreatment(of: card.id, in: store)
|
||
// The face being dragged out dims the same way while the session is in flight, wherever the
|
||
// source stays visible — which is now two places rather than one. On the **trash** side a
|
||
// restore is not a removal until the write lands, so the row never leaves. On the **board**
|
||
// side a drag whose effective operation is `.copy` leaves its originals in the resting
|
||
// layout, because the copy leaves them there (`DragSession.hiddenMembers`), and this is what
|
||
// marks them as the source of the drag rather than as ordinary neighbours. A `.move`'s
|
||
// originals are lifted out entirely and this has nothing to act on
|
||
// (`LaneView.renderedCards`).
|
||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||
.contentShape(Rectangle())
|
||
// **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
|
||
// VoiceOver): "label = title (or the untitled placeholder), value carries the attachment
|
||
// count when present, selected state via trait. Face icon and chips are decorative — folded
|
||
// into the element, never separately focusable". So the icon, the accent stripe and the
|
||
// paperclip contribute nothing of their own — the count they stood for rides the value below.
|
||
//
|
||
// `.contain` while a rename is open, `LaneView`'s header rule for its reason: flattening
|
||
// would swallow the text field the user is typing into. Board-only by construction, since
|
||
// `isRenaming` is (`CardFaceRole`).
|
||
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
||
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
|
||
// The attachment count, the comment count, the deferred cut's "cut, pending paste", or any
|
||
// mix of the three — and the empty string when none apply, which speaks as nothing (see
|
||
// `AccessibilityPhrases.cardValue` for why it is not a conditional modifier).
|
||
.accessibilityValue(AccessibilityPhrases.cardValue(
|
||
attachments: card.attachments.count,
|
||
comments: card.commentCount,
|
||
isCutPending: store.transient.pendingCut.ids.contains(card.id)
|
||
))
|
||
// "Selection state is always readable from the element (trait)" — the other half of "state
|
||
// is never colour-alone", whose visible half is the accent stroke above.
|
||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||
// **VO-Space toggles this card's selection** — "moving the VoiceOver cursor never mutates
|
||
// selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle,
|
||
// never plain click's replace)". Routed through the same `BoardStore.click` funnel the
|
||
// pointer uses, with the ⌘ modifier, so the homogeneity rule and the container boundary are
|
||
// `SelectionGrammar`'s single answer rather than a second one written here.
|
||
.accessibilityAction { toggleSelection() }
|
||
// **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
|
||
// two-stage Finder rename): one click selects and that is all it does — no timer, no
|
||
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
|
||
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||
//
|
||
// **The container travels with the click**, and that is what keeps the one remaining
|
||
// homogeneity boundary true: a ⌘-click across it replaces rather than mixing
|
||
// (04-interactions.md ▸ The trash). A double click in the trash is two of these and nothing
|
||
// more — no editor, no card window, no timer.
|
||
.onTapGesture {
|
||
store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current)
|
||
}
|
||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||
.onDrag(startDrag, preview: { dragReplica })
|
||
// The card's drawn size. Its **height** goes to the drop model's analytic resting grid — a
|
||
// height is content-driven and does not animate under the reflow, only positions do, and
|
||
// those are never measured (`LaneDropRegistry`). The trash side registers too: a trash card
|
||
// dragged out is an ordinary card session, and the shadow it opens in the destination lane
|
||
// should be its real footprint rather than the nominal guess.
|
||
//
|
||
// Its **width** stays here, for the drag replica: a face is as wide as its lane's interior
|
||
// column, which no metric can derive and only the laid-out face can report
|
||
// (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`).
|
||
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
|
||
drops.registry.update(height: size.height, for: card.id)
|
||
measuredWidth = size.width
|
||
}
|
||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
|
||
}
|
||
|
||
/// What sits on the plate: the hero band, then the padded title row (03-board-ui.md § Card face).
|
||
///
|
||
/// **The band is full-bleed and the title row is not**, which is the whole layout call. A hero is
|
||
/// a picture of what the card is about, so it takes the plate's own width and rounds its top
|
||
/// corners to the plate's radius (`CardHeroImage`); the row below keeps every inset it has always
|
||
/// had, including the stripe's reserved leading padding, so a card's title sits on exactly the
|
||
/// grid it sat on before whether or not the card has a hero.
|
||
///
|
||
/// **Zero spacing, and a band with no height when there is no picture** — so a card with no
|
||
/// `hero`, or one whose hero names a file that is missing or unreadable, lays out identically to
|
||
/// the face as it was: the stack's first element contributes nothing at all.
|
||
///
|
||
/// Everything else the face draws is attached *outside* this stack and is therefore untouched by
|
||
/// the band: the accent stripe still runs the plate's full leading edge (over the band's leading
|
||
/// corner — the stripe is the card's edge, and a picture does not interrupt it), the selection and
|
||
/// file-hover strokes still ring the whole plate, the cut and drag dims still cover it, and the
|
||
/// geometry the drop model registers is still the plate's — a hero card is simply a taller card,
|
||
/// which the masonry already understands.
|
||
private var plateContent: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
heroBanner
|
||
titleRow
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||
// Constant, whether or not a stripe paints: every card's text sits on the same grid,
|
||
// so colouring a card never shifts its title relative to its uncoloured neighbours.
|
||
.padding(.leading, stripeWidth)
|
||
}
|
||
}
|
||
|
||
/// The hero band, for a card that names a readable one — and nothing whatever for a card that
|
||
/// does not, which is the ruling's degrade stated as a branch that is simply not taken.
|
||
@ViewBuilder
|
||
private var heroBanner: some View {
|
||
if let hero {
|
||
CardHeroImage(
|
||
url: hero,
|
||
height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize),
|
||
cornerRadius: cornerRadius
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The card drag
|
||
|
||
/// Begins this card's system drag session — in **its own container**, which is the whole of what
|
||
/// makes a trash card's drag a restore (04-interactions.md ▸ The trash; `DragLocality.operation`).
|
||
private func startDrag() -> NSItemProvider {
|
||
switch role.container {
|
||
case .board: startBoardCardDrag()
|
||
case .trash: startTrashCardDrag()
|
||
}
|
||
}
|
||
|
||
/// A lane card's drag (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||
///
|
||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||
///
|
||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||
private func startBoardCardDrag() -> NSItemProvider {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||
let snapshot = store.snapshot
|
||
let ids = draggedIDs
|
||
|
||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||
// component.
|
||
var lanesByCard: [ItemID: ItemID] = [:]
|
||
var titles: [ItemID: String] = [:]
|
||
for lane in snapshot.lanes {
|
||
for member in lane.cards where ids.contains(member.id) {
|
||
lanesByCard[member.id] = lane.id
|
||
titles[member.id] = member.title.value
|
||
}
|
||
}
|
||
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .cards,
|
||
container: .board,
|
||
items: ordered.compactMap { id in
|
||
guard let laneID = lanesByCard[id] else { return nil }
|
||
return DragPayload.Item(
|
||
id: id.rawValue,
|
||
folder: root
|
||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||
.path,
|
||
title: titles[id]
|
||
)
|
||
}
|
||
)
|
||
drops.session.beginCards(
|
||
ordered,
|
||
folders: payload.folders,
|
||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||
// span-cap (03-board-ui.md § Motion).
|
||
heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
|
||
container: .board,
|
||
source: store,
|
||
seed: ownSlotSeed(dragging: ids, laneID: lanesByCard[card.id], in: snapshot)
|
||
)
|
||
return payload.itemProvider()
|
||
}
|
||
|
||
/// The pickup's own-slot proposal (`DragSession.begin`): the grabbed card's position among its
|
||
/// lane's rendered cards with the dragged run lifted out — the same resting-layout index space
|
||
/// the retargets and `moveCards` count in, filtered by the same `LaneView.rendered` rules, so
|
||
/// the seeded shadow run stands exactly where the lifted cards stood and pickup moves nothing.
|
||
///
|
||
/// **A ⌥-pickup seeds nothing.** A copy's resting layout keeps the originals in place, so there
|
||
/// is no vacated space for a shadow to hold; the first `dropUpdated` resolves the copy and opens
|
||
/// the run beside the originals, exactly as it does today (DRAG-REORDER.md § Resting-layout
|
||
/// zones — the modifier reflow is the feedback being asked for).
|
||
private func ownSlotSeed(
|
||
dragging ids: Set<ItemID>,
|
||
laneID: ItemID?,
|
||
in snapshot: BoardModel
|
||
) -> DropTarget? {
|
||
guard !NSEvent.modifierFlags.contains(.option),
|
||
let laneID,
|
||
let laneCards = snapshot.lanes.first(where: { $0.id == laneID })?.cards
|
||
else { return nil }
|
||
let index = LaneView.rendered(
|
||
Array(laneCards.prefix { $0.id != card.id }),
|
||
hiddenByDrag: ids,
|
||
filter: store.searchFilter,
|
||
renaming: store.transient.renameEditor?.targetID
|
||
).count
|
||
return DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: index)
|
||
}
|
||
|
||
/// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards`
|
||
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
|
||
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
|
||
/// out … there is no restore-specific machinery and no Put Back" (03 § Trash).
|
||
///
|
||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
||
/// every other card drop uses, committed by the same `moveCards`.
|
||
///
|
||
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||
/// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash).
|
||
///
|
||
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
|
||
/// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather
|
||
/// than let them fall silently out of the payload the flag travels and the *drop* refuses with
|
||
/// the notice (`DragSession.mixesKinds`). Pickup stays allowed — the selection is legal.
|
||
private func startTrashCardDrag() -> NSItemProvider {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||
let ids = draggedIDs
|
||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||
guard !rows.isEmpty else { return NSItemProvider() }
|
||
let mixesKinds = SelectionGrammar.mixesKinds(
|
||
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .cards,
|
||
container: .trash,
|
||
items: rows.map {
|
||
DragPayload.Item(
|
||
id: $0.id.rawValue,
|
||
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||
title: $0.title.value
|
||
)
|
||
}
|
||
)
|
||
drops.session.beginCards(
|
||
rows.map(\.id),
|
||
folders: payload.folders,
|
||
heights: rows.map { drops.registry.heights[$0.id] ?? drops.registry.nominalCardHeight },
|
||
container: .trash,
|
||
source: store,
|
||
mixesKinds: mixesKinds
|
||
)
|
||
return payload.itemProvider()
|
||
}
|
||
|
||
/// What travels: the whole selection when this card is in it, else this card alone — the drag's
|
||
/// half of the context-menu targeting rule, and container-scoped like everything else.
|
||
private var draggedIDs: Set<ItemID> {
|
||
let selection = store.selection
|
||
guard selection.container == role.container,
|
||
selection.ids.contains(card.id),
|
||
selection.ids.count > 1
|
||
else { return [card.id] }
|
||
return selection.ids
|
||
}
|
||
|
||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||
///
|
||
/// The count is `selectedCount`, not a fresh read of the selection: `.onDrag(_:preview:)`'s
|
||
/// preview builder is **non-escaping**, so it evaluates while the body does, and a `store.selection`
|
||
/// read here would have kept every face on the board subscribed no matter what the rest of this
|
||
/// view did. The parent already normalizes the figure to 1 for an unselected face, so the branch
|
||
/// that used to compute it is gone rather than moved (see `isSelected`'s note); the `max` is belt
|
||
/// over those braces, because a zero here would draw a badge reading nothing.
|
||
private var dragReplica: some View {
|
||
let count = max(1, selectedCount)
|
||
return ZStack {
|
||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||
replicaFace
|
||
}
|
||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||
}
|
||
|
||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||
/// editor and no geometry observers, and crucially no marquee registration (one built out of the
|
||
/// live face would re-register the card's frame from inside the preview's geometry and then
|
||
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
|
||
/// arrow keys).
|
||
private var replicaFace: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// **The band is drawn from the cache alone, synchronously.** A preview builder is
|
||
// non-escaping — it runs while the body does, and the system snapshots the result
|
||
// immediately — so there is no task to await a decode in. A hero the face is already
|
||
// showing is in the cache by definition, which is the only case that matters: you cannot
|
||
// drag a card whose banner has not drawn yet without having looked at it first. A miss
|
||
// draws no band, and the replica is then exactly the face a hero-less card lifts.
|
||
if let hero, let image = CardHeroCache.image(forFileAt: hero) {
|
||
Color.clear
|
||
.frame(height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize))
|
||
.overlay { Image(decorative: image, scale: 1).resizable().aspectRatio(contentMode: .fill) }
|
||
.clipShape(UnevenRoundedRectangle(
|
||
topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius))
|
||
}
|
||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(iconTint)
|
||
.imageScale(.medium)
|
||
Text(card.title.value ?? "Untitled")
|
||
.boardFont(.body)
|
||
.lineLimit(4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
attachmentsIndicator
|
||
commentsIndicator
|
||
}
|
||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||
.padding(.leading, stripeWidth)
|
||
}
|
||
// **The size of the face it was lifted from**, taken from that face's own measurement rather
|
||
// than from a representative figure: a card is as wide as its lane's interior column, so a
|
||
// replica drawn at a nominal width is visibly a different card from the one under the cursor,
|
||
// and — since the system centres a preview on the view the drag started from — leaves the
|
||
// pointer sitting beside the image instead of on it. The width is the only frame this needs:
|
||
// the replica lays the same band and the same row out with the same paddings and the same
|
||
// `lineLimit`, so at the face's width it comes out at the face's height (`BoardMetrics`).
|
||
.frame(
|
||
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
|
||
alignment: .leading
|
||
)
|
||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||
.overlay(alignment: .leading) { accentStripe }
|
||
.dragReplicaShadow(zoom: zoom)
|
||
}
|
||
|
||
// MARK: - Context menus
|
||
|
||
/// **The card context menu, redesigned** (2026-08-09 ▸ "redesign context menu for cards", card
|
||
/// fe66c461): four groups, a divider between each, the owner's shape verbatim —
|
||
///
|
||
/// 1. Open, Rename, Style ▸ (Symbol, Color), Labels ▸ (up to 12 ranked toggles, More…)
|
||
/// 2. Copy, Cut, Paste, Copy Special ▸ (Copy Link), Paste Special ▸ (Paste Image into Card, … more tbd)
|
||
/// 3. Navigation ▸ (Move Left, Move Right)
|
||
/// 4. Send to Trash
|
||
///
|
||
/// **Group 1 gained Labels and group 2 gained Copy Special on 2026-08-09** (card 28c79ffe) — see
|
||
/// `labelsMenu` for the submenu, and "Copy Link moves" below for the relocation.
|
||
///
|
||
/// Every row below routes through an *existing* write primitive it twins — nothing here invents a
|
||
/// new way to touch disk, only new arrangements and, for one group, a new targeting rule over ones
|
||
/// the app already has (`OpenCardCommand`, `BoardRenameCommand`, `StyleCommand`/`StyleEditorSession`,
|
||
/// `ClipboardStore`, `store.delete`/`TrashCommands`). **Navigation is the one exception to "new
|
||
/// arrangement, not new capability"**: this group shipped structurally in fe66c461 (wired but
|
||
/// unconditionally disabled — no per-card cross-lane move predicate existed yet) and gained the
|
||
/// real one in 06322636, `CardMoveTarget`, over the same `BoardStore.moveCards(_:toLane:at:)` a
|
||
/// released drag already calls — see that section below for the full story.
|
||
///
|
||
/// ### Two deviations from the card's literal list, both kept and both journaled
|
||
///
|
||
/// **Copy Link stays** — and, since 2026-08-09, has a home the owner named. It shipped the same
|
||
/// day the menu redesign was filed (design ruling, card 737a949f), so that card's list did not
|
||
/// mention it and it was parked in group 1 after Open as a deviation kept-and-journaled. The
|
||
/// owner's next layout resolves it: **Copy Link now sits in a new `Copy Special` submenu**, beside
|
||
/// the `Paste Special` it mirrors, and their own comment on card 28c79ffe says so in as many words
|
||
/// ("note 'copy link' has been moved to 'copy special'"). Nothing about the row itself changed —
|
||
/// same action, same `selectedCount == 1` enablement, same "a link is singular" rule.
|
||
///
|
||
/// **It stays in `boardActions` even so** (the VoiceOver custom-action list), which is a deliberate
|
||
/// exception to that list's own "submenus are absent" rule. The rule is about *containers* — Style,
|
||
/// Navigation and Paste Special are doors onto other surfaces, and a flat action list has nothing
|
||
/// to say about a door. Copy Link is an action that merely changed which door draws it, and
|
||
/// dropping it from the list would cost VoiceOver a real capability in exchange for a symmetry the
|
||
/// list does not owe. (Paste Image into Card is absent for the older reason: it never was in the
|
||
/// list.)
|
||
///
|
||
/// **The row reads "Send to Trash", not "Delete".** The owner's own word for group 4, and the more
|
||
/// accurate one for what this button does on the board side — it is the staged move into
|
||
/// `<root>/.trash/`, not a permanent delete (that word is reserved for the trash lane's own menu,
|
||
/// `trashMenu` below, which this rename does not touch). The action underneath is unchanged:
|
||
/// `store.delete(targetIDs)`, `TrashCommands`'s own twin.
|
||
///
|
||
/// ### Style ▸ Symbol / Color — one popover, not two (v1, flagged for owner review)
|
||
///
|
||
/// `StyleEditorSession` carries only a `StyleTarget`, no notion of "arrived here for the symbol
|
||
/// section" versus "the color section" (`StyleModel.swift`), and adding one would mean teaching
|
||
/// `StyleEditorView` to pre-focus a section — internals this card is explicitly told not to touch
|
||
/// (another agent is concurrently redesigning the pickers themselves). So both rows open the same
|
||
/// existing popover, showing both sections, exactly as the single "Style…" row always has. This is
|
||
/// the SCOPE's own named fallback ("if it doesn't [support pre-focus], opening the existing
|
||
/// popover for both rows is acceptable v1") — kept, and flagged: a future pass that adds
|
||
/// section-focus to the editor should aim these two rows at it.
|
||
///
|
||
/// **The quick-style recents row is gone from this menu.** `StyleMenuItems` used to bundle
|
||
/// "Style…" with a horizontal recents `Picker` beside it; the owner's list names only Symbol and
|
||
/// Color under Style, so the recents row — never mentioned — is dropped here to match the shape
|
||
/// exactly. It is untouched on the **lane** menu (`LaneView.laneMenu`, out of this card's scope,
|
||
/// which only asked for the *card* menu). Flagged for owner review: easy to bring back as a third
|
||
/// row under Style if the drop was not intended.
|
||
///
|
||
/// ### Navigation ▸ Move Left / Move Right — a real per-card cross-lane move (2026-08-09, card
|
||
/// 06322636)
|
||
///
|
||
/// **Not `LaneMoveTarget.destination`** — that predicate reads the **board's live selection** and
|
||
/// answers `nil` for any card id unconditionally ("a card id is in no lane order" — its own doc
|
||
/// comment), because it is Board ▸ Move Left/Right's *lane*-selection predicate, shared with the
|
||
/// menu-bar row. These two rows validate against `CardMoveTarget.destination` instead — the
|
||
/// cousin built for exactly this card, over this face's own `targetIDs` (Copy/Cut's widening:
|
||
/// the clicked card, or the live selection when the clicked card is a member of it) rather than
|
||
/// the live selection's lane membership.
|
||
///
|
||
/// **Destination is the adjacent live lane**, skipping nothing (owner ruling): the trash is never
|
||
/// a candidate (it is not a `Lane`) and a collapsed lane is a perfectly good landing — a fold
|
||
/// hides cards, it does not close the lane. **Position is index-preserving** — the clicked card's
|
||
/// own index in its lane's display order, carried over to the destination and clamped to its card
|
||
/// count, with the rest of a widened group riding along in their existing relative order
|
||
/// (`CardMoveTarget`'s own type comment has the full reasoning). The write is the ordinary
|
||
/// `BoardStore.moveCards(_:toLane:at:)` — the exact call a released drag makes — so rank-minting,
|
||
/// the undo step, the watcher echo and the banner all come free from that one path.
|
||
///
|
||
/// **Enablement stays render-safe the same way every other row here does.** `CardMoveTarget`
|
||
/// needs `store.snapshot` (for the neighbouring lane) and, when the clicked card is itself
|
||
/// selected, `store.selection` (for the multi-lane-spread refusal) — both of which are forbidden
|
||
/// inside this face's own `.disabled` (this struct's top-of-file note; `.contextMenu`'s builder is
|
||
/// not lazy). So neither is read here: `hasLeftNeighbor`/`hasRightNeighbor` and
|
||
/// `selectionSpansLanes` are lane-hoisted compared parameters, `isSelected`'s exact pattern —
|
||
/// `LaneView.scrollableCards` reads `store.snapshot`/`store.selection` **once per lane** and hands
|
||
/// every face in it the answer, so a click costs `O(lane count)`, never `O(board cards)`
|
||
/// (`moveLeftEnabled`/`moveRightEnabled`; `BoardRenderPerformanceTests` stays green throughout).
|
||
///
|
||
/// **A selection spanning more than one lane disables both rows** (owner ruling: "no coherent
|
||
/// left for a spread") — `selectionSpansLanes`, consulted only when `isSelected` is true, since an
|
||
/// unselected clicked card's own target is always itself alone.
|
||
///
|
||
/// ### Copy / Cut / Paste / Paste Special — targeting
|
||
///
|
||
/// **Copy and Cut widen exactly as Delete and Style do** — `clipboardTarget`, `targetIDs`'s own
|
||
/// `ItemReferenceSet` wrapper — "right-clicking something outside the selection acts on what was
|
||
/// clicked" (`targetIDs`'s doc comment), extended to the clipboard for the first time
|
||
/// (`ClipboardStore.copy(from:targeting:)`/`cut(from:targeting:)`). Multi-selection semantics are
|
||
/// `ClipboardStore`'s own, unchanged: a multi-card selection copies/cuts every member, in flatten
|
||
/// order, exactly as ⌘C/⌘X already do.
|
||
///
|
||
/// **Paste does not retarget to the clicked card.** Unlike Copy/Cut/Delete/Style, Paste has no
|
||
/// per-item widening precedent anywhere in this codebase — it is a *destination* operation
|
||
/// (`PasteTarget`), not an item operation, and its target has always been "wherever the live
|
||
/// selection anchors" (`PasteTarget.cards`), the same rule ⌘V and Edit ▸ Paste already use. This
|
||
/// row calls that exact rule (`ClipboardStore.paste(into:)`) rather than inventing a
|
||
/// click-anchored variant. Flagged for owner review if a click-anchored paste ("paste after the
|
||
/// card I right-clicked, regardless of the live selection") turns out to be what was wanted.
|
||
///
|
||
/// **Paste Image into Card is genuinely per-card** — "into card" is the row's own wording, and it
|
||
/// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own
|
||
/// attachments-header affordance calls for its picture (the ⌘V image branch it once rode retired
|
||
/// 2026-08-09 for that visible control). It always targets **this** card, never the widened
|
||
/// selection: like Open, Rename and Style's anchor, "single-card by nature" — the existing
|
||
/// precedent this SCOPE asks new rows to follow for such rows. "… more tbd" is left as the SCOPE
|
||
/// asks: one row today, the submenu built to grow.
|
||
///
|
||
/// ### Render-safety: what `.disabled` is allowed to read here
|
||
///
|
||
/// Every `.disabled` in this menu reads only plain, rarely-changing `BoardStore` flags
|
||
/// (`acceptsBoardMutations`, `isReadOnly`, `isEditingInline`) or the clipboard's own observable
|
||
/// state (`appModel.clipboard.payload`/`imagePayload`, which this file's own top note already
|
||
/// accepts reading directly, `appModel.styleRecents`' own precedent) — **never** `store.selection`
|
||
/// or `store.snapshot` directly, because `.contextMenu`'s builder is not lazy and either read would
|
||
/// re-subscribe every card face's body to board-wide state, the O(board) regression this file was
|
||
/// rebuilt to shed. Two rows lean on a proof rather than a literal call to make that hold:
|
||
///
|
||
/// - **Paste**: `PasteTarget.cards`'s only `nil` case is a board with no *renderable* lane
|
||
/// (`NewCardTarget.resolve`'s "a board whose every lane is folded has no target at all"). A card
|
||
/// face exists only because its own lane rendered it, so that case is already false by the time
|
||
/// this menu can even open — `canPaste(into:)` reduces to `acceptsBoardMutations && payload !=
|
||
/// nil` here, with no selection read needed (`pasteEnabled`).
|
||
/// - **Paste Image into Card**: `canPasteImage(intoCard:in:)`'s snapshot lookup
|
||
/// (`BoardStore.boardItem`) only ever answers `false` for a card that is not a live board item —
|
||
/// which, again, this very face rendering already rules out. Reduces to `!isReadOnly &&
|
||
/// imagePayload != nil` (`pasteImageEnabled`).
|
||
///
|
||
/// **The Paste row's dynamic title (`pasteTitle`, 2026-08-09 ▸ card 36cce96a) rides the same
|
||
/// channel `pasteEnabled` already opened** rather than adding one: it reads
|
||
/// `appModel.clipboard.payload` again, the identical Observable property, and hands it to
|
||
/// `ClipboardManifest.pasteMenuTitle(for:)` — a pure function of a value already in hand, not a
|
||
/// second store read. No new subscription, no selection or snapshot touched.
|
||
///
|
||
/// Copy and Cut lean on the same style of proof: `targetIDs` is always non-empty (at minimum this
|
||
/// card alone) and always homogeneous cards on the board side (`SelectionGrammar.mixesKinds`
|
||
/// answers `true` only for the trash — see its own doc comment), so `canCopy`/`canCut`'s
|
||
/// kind-checks are always satisfied and the rows reduce to `isEditingInline`/`acceptsBoardMutations`
|
||
/// (`copyEnabled`/`store.acceptsBoardMutations`) without reading the target at all for `.disabled`
|
||
/// — the target is still read, correctly, inside each action (`clipboardTarget`).
|
||
///
|
||
/// **Navigation ▸ Move Left / Move Right take the third route**: unlike Paste and Copy/Cut, there
|
||
/// is no algebraic reduction that makes `CardMoveTarget.destination` free of `store.snapshot`/
|
||
/// `store.selection` — a neighbouring lane and a cross-lane selection spread are both genuinely
|
||
/// board-wide facts. So the parent resolves them instead, **once per lane**, and hands this face
|
||
/// the answer as three more compared parameters (`hasLeftNeighbor`, `hasRightNeighbor`,
|
||
/// `selectionSpansLanes`) — `isSelected`/`selectedCount`'s own precedent, not a new one
|
||
/// (`moveLeftEnabled`/`moveRightEnabled`).
|
||
@ViewBuilder
|
||
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
|
||
// Group 1 — Open, Copy Link, Rename, Style ▸ (Symbol, Color). One run, no divider inside it:
|
||
// the owner's "open/rename+style" group is one group, not two (unlike the prior menu, which
|
||
// split Open/Copy Link from Rename/Style with a divider).
|
||
Button("Open") {
|
||
openCard(card.id)
|
||
}
|
||
Button("Rename") { beginRename() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Menu("Style") {
|
||
// Both rows open the identical popover — see the type comment's "one popover, not two".
|
||
Button("Symbol") { store.transient.beginStyleEditor(for: styleTarget) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button("Color") { store.transient.beginStyleEditor(for: styleTarget) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
// Both rows share this exact condition, so the submenu itself greys out with them rather than
|
||
// opening to reveal two disabled rows.
|
||
.disabled(!store.acceptsBoardMutations)
|
||
labelsMenu
|
||
|
||
Divider()
|
||
|
||
// Group 2 — Copy, Cut, Paste, Copy Special ▸ Copy Link, Paste Special ▸ Paste Image into Card.
|
||
Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
|
||
.disabled(!copyEnabled)
|
||
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button(pasteTitle) { appModel.clipboard.paste(into: store) }
|
||
.disabled(!pasteEnabled)
|
||
Menu("Copy Special") {
|
||
Button("Copy Link") { copyLink() }
|
||
.disabled(!copyLinkEnabled)
|
||
}
|
||
// Its one row's condition, so the submenu greys out with it rather than opening onto a
|
||
// disabled row — `Style`'s rule, one group down.
|
||
.disabled(!copyLinkEnabled)
|
||
Menu("Paste Special") {
|
||
Button("Paste Image into Card") {
|
||
appModel.clipboard.pasteImage(intoCard: card.id, in: store)
|
||
}
|
||
.disabled(!pasteImageEnabled)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// Group 3 — Navigation ▸ Move Left / Move Right, a real per-card cross-lane move — see the
|
||
// type comment's own section for the destination/position rules and the render-safety proof.
|
||
Menu("Navigation") {
|
||
Button("Move Left") { moveCardAcrossLane(by: -1) }
|
||
.disabled(!moveLeftEnabled)
|
||
Button("Move Right") { moveCardAcrossLane(by: 1) }
|
||
.disabled(!moveRightEnabled)
|
||
}
|
||
// The submenu greys out only when *both* rows would — a card in the leftmost lane can still
|
||
// move right, so the submenu itself has to stay open to it.
|
||
.disabled(!moveLeftEnabled && !moveRightEnabled)
|
||
|
||
Divider()
|
||
|
||
// Group 4 — Send to Trash: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s
|
||
// twin), on the widened target set (`targetIDs`) — the successor-selection rule is
|
||
// `delete(_:)`'s own, so this row gets it for free. Relabeled from "Delete" — see the type
|
||
// comment.
|
||
Button("Send to Trash") { deleteTargets() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// **Group 1's fourth row: `Labels`** (2026-08-09, Pipeline card 28c79ffe; `FrontmatterKeys.labels`,
|
||
/// whose reservation the owner retired the same day) — the owner's spec verbatim: "a list of up to
|
||
/// 12 most frequently and recently used labels", a separator, then "More… (show a dialog with a
|
||
/// list of all used labels + ability to create new)".
|
||
///
|
||
/// ### The twelve, and where they come from
|
||
///
|
||
/// `LabelRanking.ranked` — frequency across the board's live and trashed cards, tie-broken by an
|
||
/// app-side MRU, then alphabetically for a total order. That function's own doc comment states the
|
||
/// arithmetic and the one consequence worth flagging (a brand-new label does not jump a full menu).
|
||
///
|
||
/// **Toggles, not buttons**, so the row says what the card already is: a `Toggle` renders as a
|
||
/// checked row in an AppKit menu, which is exactly the "toggling membership for the clicked card"
|
||
/// the spec asks for made visible. The read is free — `card` is a compared parameter this view
|
||
/// already has, so a checkmark costs no lookup at all.
|
||
///
|
||
/// ### One card, and the checkmark is why
|
||
///
|
||
/// Every other item-shaped row in this menu widens to the selection when the clicked card is a
|
||
/// member of it (`targetIDs` — Copy, Cut, Style, Send to Trash). **These rows deliberately do
|
||
/// not**, for two reasons that point the same way:
|
||
///
|
||
/// - **A checkmark is a claim about one card.** A three-card selection where two carry `bug` has no
|
||
/// honest checked state, and an AppKit menu item has no mixed one to draw. The widening rows all
|
||
/// carry no state — they say what they will *do*, never what is already true — which is exactly
|
||
/// why widening costs them nothing and would cost this everything.
|
||
/// - **It keeps the menu render-safe.** `targetIDs` reads `store.selection`, and `.contextMenu`'s
|
||
/// builder is not lazy (this struct's top-of-file note; `copyLinkEnabled`'s doc comment): the
|
||
/// checkmark is computed *while the menu is built*, not inside an action, so a widened one would
|
||
/// subscribe every card face's body to board-wide selection state — the O(board) regression
|
||
/// `BoardRenderPerformanceTests.selectionStillRepaints` exists to catch.
|
||
///
|
||
/// Flagged for owner review: if tagging a multi-card selection at once is wanted, it is a
|
||
/// *different* control — a row that reads "Add ⟨label⟩ to 3 Cards", not a checkmark.
|
||
///
|
||
/// ### What this builder is allowed to read, and what it costs
|
||
///
|
||
/// Two Observation reads, both narrow and both rarely-changing — the class this menu's `.disabled`
|
||
/// modifiers already draw from:
|
||
///
|
||
/// - `store.labelIndex`, the board's used-labels universe. The O(board) walk behind it happens
|
||
/// **once per applied snapshot**, on the store, and the assignment is equality-gated so the
|
||
/// property changes only when the board's labels genuinely change (its own doc comment says why
|
||
/// that gate is load-bearing rather than an optimisation).
|
||
/// - `appModel.labelRecents.labels`, the MRU. `appModel.styleRecents` is read in this very body
|
||
/// already (the `==` gate's own note lists it), and this list moves on exactly the same cadence:
|
||
/// once per label the user applies, which is a deliberate gesture and not a marquee sample.
|
||
///
|
||
/// What is left in the builder is `LabelRanking.ranked`'s sort, whose size is the board's **label
|
||
/// vocabulary** — tens of entries — and not its card count. That is the whole of "no O(board) work
|
||
/// in the menu builder": the board-sized part is cached, and the part that runs here is bounded by
|
||
/// how many distinct labels exist.
|
||
///
|
||
/// ### More…
|
||
///
|
||
/// Opens `LabelPickerPopover` through a session in `TransientBoardState`, `Style…`'s exact
|
||
/// mechanism and for its reasons — see that view for why a popover rather than a sheet.
|
||
///
|
||
/// **The submenu never greys out whole.** Even a read-only board can open More… to *look* at the
|
||
/// board's vocabulary (Reveal in Finder's posture: inspection is not a mutation), so the lock
|
||
/// disables the twelve toggles and the dialog's own controls rather than the door to them.
|
||
@ViewBuilder
|
||
private var labelsMenu: some View {
|
||
Menu("Labels") {
|
||
ForEach(rankedLabels, id: \.self) { name in
|
||
Toggle(name, isOn: labelBinding(name))
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
if !rankedLabels.isEmpty {
|
||
Divider()
|
||
}
|
||
Button("More…") { store.transient.beginLabelEditor(forCard: card.id) }
|
||
}
|
||
}
|
||
|
||
/// The twelve, ranked — see `labelsMenu` for the cost argument behind these two reads.
|
||
private var rankedLabels: [String] {
|
||
LabelRanking.ranked(store.labelIndex, recents: appModel.labelRecents.labels)
|
||
}
|
||
|
||
/// One toggle row's state: whether **this** card carries the label, and the write that flips it.
|
||
///
|
||
/// The read is off `card.labels` — the value this face was handed — so it costs no store lookup and
|
||
/// no selection read. The write goes through the one funnel every label surface shares, so the
|
||
/// menu, the More… dialog and the card window's sidebar cannot come to mean three different things
|
||
/// (`LabelCommand`).
|
||
private func labelBinding(_ name: String) -> Binding<Bool> {
|
||
Binding(
|
||
get: { CardLabels.contains(name, in: card.labels.value ?? []) },
|
||
set: { _ in
|
||
LabelCommand.toggle(name, onCard: card.id, in: store, recents: appModel.labelRecents)
|
||
}
|
||
)
|
||
}
|
||
|
||
/// `boardMenu`'s plain (non-submenu) rows as VoiceOver custom actions — every one calling the
|
||
/// *same* private method its menu row does, so the two surfaces cannot come to mean different
|
||
/// things. Style, Paste Special and Navigation are absent for `LaneView`'s own reason (its Style…
|
||
/// note): each opens its own accessible surface — a popover, or the submenu itself, which the
|
||
/// context menu already exposes reachably (VO-⇧-M) — and is "not an action" in the flat sense this
|
||
/// list carries. **Copy Link is the one exception**, and it moved into a submenu on 2026-08-09
|
||
/// without leaving here — see `boardMenu`'s "Copy Link stays" note for why. Labels is absent like
|
||
/// its fellow submenus: it is a container, and its own rows are checkmarks rather than actions.
|
||
@ViewBuilder
|
||
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
|
||
Button("Open") { openCard(card.id) }
|
||
Button("Copy Link") { copyLink() }
|
||
.disabled(!copyLinkEnabled)
|
||
Button("Rename") { beginRename() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
|
||
.disabled(!copyEnabled)
|
||
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button(pasteTitle) { appModel.clipboard.paste(into: store) }
|
||
.disabled(!pasteEnabled)
|
||
Button("Send to Trash") { deleteTargets() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
||
///
|
||
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
||
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
||
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
|
||
/// exactly the errand it exists for.
|
||
///
|
||
/// The Delete row goes through the window's confirmation host rather than straight to the store,
|
||
/// because this delete is the **permanent** one and the alert is what stands between it and an
|
||
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
|
||
@ViewBuilder
|
||
private func trashMenu(confirmations: TrashConfirmations) -> some View {
|
||
Button("Delete") { requestPurge(confirmations) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
|
||
Divider()
|
||
|
||
Button("Reveal in Finder") { revealInFinder() }
|
||
}
|
||
|
||
/// `trashMenu`'s rows as VoiceOver custom actions — `boardActions`' twin, two rows and no Open.
|
||
@ViewBuilder
|
||
private func trashActions(confirmations: TrashConfirmations) -> some View {
|
||
Button("Delete") { requestPurge(confirmations) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button("Reveal in Finder") { revealInFinder() }
|
||
}
|
||
|
||
// MARK: - The rows' bodies
|
||
|
||
/// Board ▸ Rename's store path, seeded with the card's live title.
|
||
private func beginRename() {
|
||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||
}
|
||
|
||
/// File ▸ Delete's store path over the context-menu target set.
|
||
private func deleteTargets() {
|
||
store.delete(targetIDs)
|
||
}
|
||
|
||
/// The trash's **permanent** delete, through the window's confirmation host — never straight to
|
||
/// the store, because the alert is what stands between this row and an unrecoverable loss.
|
||
private func requestPurge(_ confirmations: TrashConfirmations) {
|
||
confirmations.requestTrashDelete(of: targetIDs, in: store)
|
||
}
|
||
|
||
private func revealInFinder() {
|
||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||
}
|
||
|
||
/// Copy Link's context-menu enablement — **`selectedCount`, never `targetIDs`**. `targetIDs`
|
||
/// reads `store.selection` directly, and `.contextMenu`'s content closure is not lazy: SwiftUI
|
||
/// evaluates `boardMenu` (and therefore any `.disabled(...)` inside it) on every ordinary body
|
||
/// pass, not only when the menu opens, exactly as building this row against `targetIDs` first
|
||
/// proved the hard way — every face's body re-ran on every selection change, the precise O(board)
|
||
/// regression `isSelected`/`selectedCount` exist to prevent (this struct's own top-of-file note;
|
||
/// `BoardRenderPerformanceTests.selectionStillRepaints`, which caught it).
|
||
///
|
||
/// `selectedCount` is the render-safe answer to the same question: the parent (`LaneView`) already
|
||
/// computes "the size of the selection this face belongs to, else 1" as a **plain, non-Observable
|
||
/// parameter** — `targetIDs.count`'s exact widening, paid for once per lane instead of once per
|
||
/// card-menu-construction. "A link is singular" (design ruling 2026-08-09, card 737a949f), so a
|
||
/// count above 1 disables rather than guessing which member was meant — the same reading Style…
|
||
/// and Delete's `targetIDs` give their own action, just checked here instead of only inside it,
|
||
/// because unlike them this row's *enabled state itself* has to say so.
|
||
private var copyLinkEnabled: Bool {
|
||
store.acceptsBoardMutations && selectedCount == 1
|
||
}
|
||
|
||
/// Copy Link's write — `copyLinkEnabled`'s one caller. Resolves **this card's own folder**
|
||
/// directly rather than through `targetIDs`: `copyLinkEnabled == true` already guarantees the
|
||
/// widened target is `card.id` alone (either this card sits outside the live selection, or it is
|
||
/// the selection's sole member), so there is no widened set left to read `store.selection` for.
|
||
/// `store.copyLinkTarget` is deliberately not reused either — that property reads the *live*
|
||
/// selection for the menu-bar row (`CopyLinkCommand`), which would answer wrongly for a card
|
||
/// clicked outside the current selection, exactly the case `TrashWriteTests`'
|
||
/// `contextMenuDeleteIgnoresTheSelection` pins for Delete.
|
||
private func copyLink() {
|
||
guard copyLinkEnabled,
|
||
let folder = ItemPath.resolve([card.id], in: role.container, snapshot: store.snapshot)
|
||
.first?.folder(under: store.rootURL)
|
||
else { return }
|
||
SystemFolderLinkPasteboard().write(link: folder)
|
||
}
|
||
|
||
/// VO-Space's landing: the ⌘-click funnel, on this card, **in this face's container** — so a
|
||
/// trash card's toggle can no more mix with a board selection than a ⌘-click could.
|
||
private func toggleSelection() {
|
||
store.click(
|
||
SelectionTarget(id: card.id, kind: .card, container: role.container),
|
||
modifier: .command
|
||
)
|
||
}
|
||
|
||
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
|
||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||
private var styleTarget: StyleTarget {
|
||
.items(targetIDs)
|
||
}
|
||
|
||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||
/// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never
|
||
/// widens to a board selection and vice versa.
|
||
private var targetIDs: Set<ItemID> {
|
||
guard store.selection.container == role.container, store.selection.ids.contains(card.id) else {
|
||
return [card.id]
|
||
}
|
||
return store.selection.ids
|
||
}
|
||
|
||
/// The folders Reveal in Finder points at — resolved in this face's container, so a trash row
|
||
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
|
||
private var targetFolders: [URL] {
|
||
ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot)
|
||
.map { $0.folder(under: store.rootURL) }
|
||
}
|
||
|
||
// MARK: - Copy / Cut / Paste
|
||
|
||
/// Copy's and Cut's own widened target, as the `ItemReferenceSet` `ClipboardStore`'s targeted
|
||
/// overloads take — `targetIDs` plus this face's container, `styleTarget`'s exact pairing one type
|
||
/// over. Read only inside an **action** closure (never inside a `.disabled`), `styleTarget`'s own
|
||
/// reason: it reads `store.selection`, and `.contextMenu`'s builder is not lazy.
|
||
private var clipboardTarget: ItemReferenceSet {
|
||
ItemReferenceSet(ids: targetIDs, container: role.container)
|
||
}
|
||
|
||
/// Copy's context-menu enablement — render-safe by construction, never by re-deriving
|
||
/// `targetIDs`/`store.selection` inline (`copyLinkEnabled`'s own reason).
|
||
///
|
||
/// `ClipboardStore.canCopy(from:targeting:)` is three clauses: not mid-edit, the target does not
|
||
/// mix kinds, and the target names a kind at all. On this face's own container (`.board`) the
|
||
/// latter two are always satisfied for `clipboardTarget`: it is never empty (at minimum this card
|
||
/// alone) and never mixes kinds (`SelectionGrammar.mixesKinds` answers `true` only on the trash —
|
||
/// its own doc comment: "only the trash can answer true"). So the whole predicate reduces to
|
||
/// `!store.isEditingInline`, which costs no selection read at all.
|
||
private var copyEnabled: Bool {
|
||
!store.isEditingInline
|
||
}
|
||
|
||
/// Paste's context-menu enablement, reduced the same render-safe way — see the type comment's
|
||
/// "Render-safety" section for the proof that `PasteTarget.cards` cannot answer `nil` from a card
|
||
/// face that exists at all, which is what lets this skip `store.selection` entirely.
|
||
private var pasteEnabled: Bool {
|
||
store.acceptsBoardMutations && appModel.clipboard.payload != nil
|
||
}
|
||
|
||
/// **The Paste row's title** — "Paste Card"/"Paste N Cards" and their lane twins, plain "Paste"
|
||
/// for anything else (`ClipboardManifest.pasteMenuTitle(for:)`'s own doc comment carries the full
|
||
/// rule). Reads exactly the same `appModel.clipboard.payload` `pasteEnabled` already reads above —
|
||
/// the render-safety proof this file's type comment states for `pasteEnabled` covers this property
|
||
/// too, since it is the identical Observable read, not a second one: no new subscription, no
|
||
/// `store.selection`/`store.snapshot` touched.
|
||
private var pasteTitle: String {
|
||
ClipboardManifest.pasteMenuTitle(for: appModel.clipboard.payload)
|
||
}
|
||
|
||
/// Paste Image into Card's enablement, `pasteEnabled`'s own reduction: `canPasteImage(intoCard:in:)`
|
||
/// only ever answers `false` on its snapshot lookup for a card that is not a live board item, which
|
||
/// this face rendering at all already rules out — see the type comment's "Render-safety" section.
|
||
private var pasteImageEnabled: Bool {
|
||
!store.isReadOnly && appModel.clipboard.imagePayload != nil
|
||
}
|
||
|
||
// MARK: - Navigation (Move Left / Move Right)
|
||
|
||
/// Navigation ▸ Move Left's render-safe enablement — `hasLeftNeighbor` (this face's own lane
|
||
/// against the live board order, lane-hoisted the way `isSelected` is), `acceptsBoardMutations`
|
||
/// (a narrow, rarely-changing `BoardStore` flag — every other row here reads it directly, the same
|
||
/// proof applies), and the multi-lane-spread refusal, consulted only when this card is itself
|
||
/// selected (an unselected clicked card's target is always itself alone, trivially confined to
|
||
/// this lane whatever `selectionSpansLanes` says — see the type comment's own section).
|
||
private var moveLeftEnabled: Bool {
|
||
store.acceptsBoardMutations && hasLeftNeighbor && !(isSelected && selectionSpansLanes)
|
||
}
|
||
|
||
/// `moveLeftEnabled`'s mirror, for Move Right.
|
||
private var moveRightEnabled: Bool {
|
||
store.acceptsBoardMutations && hasRightNeighbor && !(isSelected && selectionSpansLanes)
|
||
}
|
||
|
||
/// Navigation ▸ Move Left/Right's action — `CardMoveTarget.destination`, the per-card cousin of
|
||
/// `LaneMoveTarget.destination` (`MoveLaneCommands.move(by:)`'s own shape one type over), over
|
||
/// this face's own widened target (`targetIDs`, Copy/Cut's precedent). `destination` re-derives
|
||
/// exactly what `moveLeftEnabled`/`moveRightEnabled` already checked — a card menu names its
|
||
/// target by where it was invoked, never by a lingering `.disabled` read, `copyLink()`'s reason —
|
||
/// so a stale enablement can only ever make this a no-op, never a wrong move. The write is the
|
||
/// ordinary `moveCards`: rank-minting, the undo step, the watcher echo and the banner all come
|
||
/// free from that one call, exactly as a released drag's do.
|
||
private func moveCardAcrossLane(by delta: Int) {
|
||
guard store.acceptsBoardMutations,
|
||
let target = CardMoveTarget.destination(
|
||
clicked: card.id, targeting: targetIDs, snapshot: store.snapshot, delta: delta)
|
||
else { return }
|
||
store.moveCards(targetIDs, toLane: target.laneID, at: target.index)
|
||
}
|
||
|
||
// MARK: - Title row
|
||
|
||
private var titleRow: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(iconTint)
|
||
.imageScale(.medium)
|
||
titleOrEditor
|
||
// The title takes the row's width so the chips sit hard against the trailing
|
||
// edge — and so the rename field fills the same span the title occupied.
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
attachmentsIndicator
|
||
commentsIndicator
|
||
}
|
||
}
|
||
|
||
/// The title, or the rename editor when this card is the rename target. The four exits and their
|
||
/// store calls are 04-interactions.md ▸ Grammar's, stated once in `InlineTitleField`.
|
||
@ViewBuilder
|
||
private var titleOrEditor: some View {
|
||
if isRenaming {
|
||
InlineTitleField(
|
||
text: draft,
|
||
prompt: "Card title",
|
||
onCommit: { store.commitRename() },
|
||
onAbandon: { store.transient.discardRename() },
|
||
// **Click-away commits** — a rename's rule, and the deliberate opposite of the
|
||
// placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
|
||
// the card window's title field").
|
||
onFocusLoss: { store.commitRename() },
|
||
onCommitAndOpen: {
|
||
let id = card.id
|
||
store.commitRename()
|
||
if case let .board(openCard) = role { openCard(id) }
|
||
}
|
||
)
|
||
.boardFont(.body)
|
||
} else {
|
||
Text(card.title.value ?? "Untitled")
|
||
.boardFont(.body)
|
||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||
.lineLimit(4)
|
||
}
|
||
}
|
||
|
||
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
|
||
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
|
||
/// exists to make a *hand-coloured* icon stand out from its neighbours.
|
||
private var iconTint: AnyShapeStyle {
|
||
if let color = Palette.color(for: card.iconColor) {
|
||
AnyShapeStyle(color)
|
||
} else {
|
||
AnyShapeStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
/// One of the two face chips in scope — shown only when the card actually has files, and quiet
|
||
/// enough that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||
/// accessibility *value* rather than onto the face: it is useful to know, not to look at.
|
||
///
|
||
/// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are
|
||
/// decorative — folded into the element, never separately focusable … the flattened element
|
||
/// carries the attachment count in its value". The flattening above would drop a label here
|
||
/// anyway; saying it explicitly is what keeps the chip inert in the replica too, which is drawn
|
||
/// outside the flattened face.
|
||
@ViewBuilder
|
||
private var attachmentsIndicator: some View {
|
||
if !card.attachments.isEmpty {
|
||
Image(systemName: "paperclip")
|
||
.boardFont(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
/// The comments chip — attachments' twin, joined 2026-08-09 (design ruling, card e729e30a):
|
||
/// shown only when the card has one comment or more, same secondary-tinted, decorative,
|
||
/// present-only vocabulary as `attachmentsIndicator`. **Carries a visible count**, unlike the
|
||
/// paperclip, because the ruling asks for "a quiet indicator (bubble-style SF Symbol + count)" —
|
||
/// still quiet (caption size, secondary tint, no pill background), just not icon-only; the count
|
||
/// answers "how many" the way a lane's own card-count badge does one level up, without spending a
|
||
/// tap to find out.
|
||
///
|
||
/// **`card.commentCount` is the one and only source** — a snapshot field the loader fills with a
|
||
/// readdir (`BoardLoader.commentCount(in:)`, `Card.commentCount`'s own doc comment), never a
|
||
/// per-face parse — so drawing this chip costs nothing beyond reading a field already on the
|
||
/// compared `card` parameter (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies:
|
||
/// no new Observable read joins this body, and no new `CardFaceView` parameter was needed either,
|
||
/// since the count already rides inside `card`).
|
||
///
|
||
/// **Decorative and hidden outright**, `attachmentsIndicator`'s exact reasons: the flattened
|
||
/// element carries the comment count in its value (`AccessibilityPhrases.cardValue`), so a label
|
||
/// here would be redundant even before the flattening drops it.
|
||
@ViewBuilder
|
||
private var commentsIndicator: some View {
|
||
if card.commentCount > 0 {
|
||
HStack(spacing: BoardMetrics.chipGlyphSpacing(bodyPointSize: pointSize)) {
|
||
Image(systemName: "bubble")
|
||
Text("\(card.commentCount)")
|
||
}
|
||
.boardFont(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
/// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
|
||
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
|
||
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||
///
|
||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||
/// content does — a title wrapping across its full four lines included.
|
||
@ViewBuilder
|
||
private var accentStripe: some View {
|
||
if let color = Palette.color(for: card.background) {
|
||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
|
||
.fill(color)
|
||
.frame(width: stripeWidth)
|
||
// Decoration only: the whole plate is one click target for selection.
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
// MARK: - Selection and rename plumbing
|
||
|
||
/// What the plate's edge is painted with: the accent when this card is selected or a Finder file
|
||
/// drag is hovering it, a separator hairline under Increase Contrast, and nothing otherwise.
|
||
///
|
||
/// The three-way branch is the whole of "state is never colour-alone, and Increase Contrast
|
||
/// strengthens borders" meeting on one shape: the resting border is *chrome* (every card gets
|
||
/// one, so it says nothing), and the accent ring stays the only thing that means "this one".
|
||
private var plateStroke: AnyShapeStyle {
|
||
if isSelected || isFileHovered {
|
||
AnyShapeStyle(Color.accentColor)
|
||
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
||
AnyShapeStyle(.separator)
|
||
} else {
|
||
AnyShapeStyle(.clear)
|
||
}
|
||
}
|
||
|
||
private var plateStrokeWidth: CGFloat {
|
||
guard isSelected || isFileHovered else {
|
||
return Accommodations.borderWidth(1, contrast: contrast)
|
||
}
|
||
return Accommodations.borderWidth(isFileHovered ? 2.5 : 1.5, contrast: contrast)
|
||
}
|
||
|
||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
|
||
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
|
||
/// to one implementation (`BoardDrops`).
|
||
///
|
||
/// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md
|
||
/// ▸ The trash), and the trash column's own delegate clears the file highlight rather than
|
||
/// proposing one — so this is a second, structural statement of the same rule.
|
||
private var isFileHovered: Bool {
|
||
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootKey) == card.id
|
||
}
|
||
|
||
/// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename"
|
||
/// (04-interactions.md ▸ The trash). No path opens a rename on a trashed card, and this makes a
|
||
/// stray one render nothing rather than putting a live field over a card that cannot be edited.
|
||
private var isRenaming: Bool {
|
||
role.container == .board && store.transient.renameEditor?.targetID == card.id
|
||
}
|
||
|
||
private var draft: Binding<String> {
|
||
Binding(
|
||
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
||
set: { store.transient.updateRenameDraft($0) }
|
||
)
|
||
}
|
||
}
|