Files
lanework/Kanban/UI/Board/CardFaceView.swift
T
rzen 1817d6f0b9 The Paste row says what it is about to do — one card, one lane, or a count of either
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
2026-08-09 12:24:54 -04:00

1484 lines
89 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }
)
}
}