Files
lanework/Kanban/UI/Board/CardFaceView.swift
T
rzen 43e6ad229e The drag replicas cast the shadow the pickup lift already promised
DragReplicaStyle spells the pathfinder's constant ambient shadow once
(black 16%, radius 2.5, y 1.5) and all three replica faces — card,
lane, trashed lane — wear it via one modifier; the ghost fans compose
from the same faces so every copy inherits it, dimmed by its own
opacity. 03 § Motion's "pickup lift (scale + shadow)" supplies the
scale from the system session but no shadow on the preview image — the
replica is the only place it can come from. BoardMetrics.replicaPadding
was already written to keep exactly this shadow from clipping and was
simply unused until now.

Drag-perf card 24397310.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-03 13:33:52 -04:00

798 lines
44 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).
///
/// **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.
enum CardFaceRole {
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
/// (04-interactions.md ▸ Selection).
case board(openCard: (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), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
///
/// ### 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, "the face stays title-only … the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files — the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
///
/// ### 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
/// 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 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 { BoardMetrics.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:)`), 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.
///
/// **What the gate does not suppress is the point.** Everything this body reads through
/// Observation — `store.selection`, `store.searchFilter`, `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.
///
/// 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.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)
}
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 {
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)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.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 deferred cut's "cut, pending paste", or both — and the empty
// string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
// it is not a conditional modifier).
.accessibilityValue(AccessibilityPhrases.cardValue(
attachments: card.attachments.count,
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)
}
// 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] ?? LaneDropRegistry.nominalCardHeight },
container: .board,
source: store
)
return payload.itemProvider()
}
/// 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] ?? LaneDropRegistry.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).
private var dragReplica: some View {
let count = store.selection.container == role.container && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
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 {
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")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
.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 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(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
.dragReplicaShadow()
}
// MARK: - Context menus
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
/// menus' Card row, in its order.
@ViewBuilder
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
// Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
// alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and
// Delete below it, this row never widens to the selection; Open never opens multiple, even
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
// first — only the plain open.
Button("Open") {
openCard(card.id)
}
Divider()
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so — standard macOS practice — it acts on the clicked card outright.
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
// Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
}
/// `boardMenu`'s plain 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.
@ViewBuilder
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
Button("Open") { openCard(card.id) }
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Button("Delete") { 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)
}
/// 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: - 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 indicator sits hard against the trailing
// edge — and so the rename field fills the same span the title occupied.
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
}
/// 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) }
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.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)
}
}
/// The one face chip 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")
.font(.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
private var isSelected: Bool {
store.selection.container == role.container && store.selection.ids.contains(card.id)
}
/// 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) }
)
}
}