Files
lanework/Kanban/UI/Board/CardFaceView.swift
T
rzen 273c182ef4 Build the VoiceOver tree and actions
The board window's accessibility tree per DESIGN/10: lanes are containers
labeled "<title>, lane, N cards" (filter-aware count = renderedCards, the
badge's own collection); cards are one flattened element each — label =
title or the untitled placeholder, value = attachment count + "cut,
pending paste", selection via trait; face icon, stripe, and paperclip are
decorative and hidden. Masonry never leaks into traversal: slots carry
order-keyed accessibilitySortPriority, so a wide lane reads by card order,
not column-major. Lane titles carry the heading trait for the rotor.

VO-Space is the ⌘-click analogue routed through the existing
BoardStore.click funnel (SelectionGrammar stays the single answer for
toggle and container-boundary rules) — cards and lane headers both.
Context-menu rows double as custom accessibility actions, each calling
the same private method as its menu row so the surfaces cannot drift;
trash cards expose Delete and Reveal in Finder and never Open. The trash
column is pinned last via sort priority 0, its label/value re-routed
through the new AccessibilityPhrases seam; toggling trash visibility
posts a one-line announcement from the store seam (both command faces).
The invisible lane-resize drag strip leaves the tree — the stepper and
menu items are the accessible width path.

AccessibilityPhrases is the pure vocabulary seam (labels, values, plural
folding shared with TrashModel.phrase), pinned by its own test suite.
Both schemes build; 1466 unit tests green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 07:38:38 -04:00

688 lines
37 KiB
Swift

import AppKit
import SwiftUI
// MARK: - The card plate's metrics
/// The card plate's geometry, spelled once because **three views draw it**: the real face
/// (`CardFaceView`), the placeholder standing in for a card on its way (`NewCardStubView`'s
/// `.awaitingArrival` face), and — since the face itself is what the trash column renders — nothing
/// else at all. 02-architecture.md ▸ TransientBoardState ▸ overlays makes the create handoff "read as
/// one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and exact is
/// only checkable if there is one set of numbers rather than two that happen to agree.
enum CardFaceMetrics {
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
static let stripeWidth: CGFloat = 4
/// The plate's inset around its content.
static let contentPadding: CGFloat = 10
/// Between the icon, the title and the attachments chip.
static let rowSpacing: CGFloat = 6
}
// 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).
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
}
}
}
// 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).
struct CardFaceView: View {
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
/// 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 `CardFaceMetrics` 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 { CardFaceMetrics.cornerRadius }
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
/// treatment shootout).
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
/// **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 {
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(CardFaceMetrics.contentPadding)
// 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.
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: isFileHovered ? 2.5 : 1.5
)
)
// 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 on the trash
// side, where the source stays visible: a restore is not a removal until the write lands.
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
// has anything to act on there — `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 height, for 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.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
}
.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 is `order` ascending like any lane's.
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 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
)
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(12)
}
/// 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: 6) {
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(10)
.padding(.leading, stripeWidth)
.frame(width: 220, alignment: .leading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
// 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: CardFaceMetrics.rowSpacing) {
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)
}
/// 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.rootURL) == 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) }
)
}
}