Files
lanework/Kanban/UI/Board/CardFaceView.swift
T
rzen 9c857ae0cc Selected-ness rides down as a compared parameter — a marquee crossing repaints its faces, not the board
A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on
the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body
read store.selection in three places — isSelected, the drag replica's count, and
the context menu's styleTarget — and Observation invalidates every reader of the
property, past the equatable gate entirely. The band overlay stayed cheap, which
is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind.

Now LaneView and TrashLaneView hoist one selection read per body and hand each
face isSelected/selectedCount as compared parameters; StyleMenuItems takes its
target as a deferred closure; TrashLaneRowView gains the same treatment plus the
Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A
growing band costs the selection's own running size; the real board's crossing
fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a
separate lane-level finding recorded in RENDER-INSTRUMENTATION.md.

Also: select() gains defaultsSoleMember — the marquee's explicit nils never
avoided the sole-member default, so a one-card band acquired a selectionHead and
could scroll the lane out from under its own drag.

MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a
growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
2026-08-07 15:10:35 -04:00

878 lines
50 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).
///
/// **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), 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
/// 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
/// 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 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.
///
/// **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.
///
/// 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.isSelected == rhs.isSelected
&& lhs.selectedCount == rhs.selectedCount
&& 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(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 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] ?? 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 {
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
}
.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(BoardSurface.cardPlate))
.overlay(alignment: .leading) { accentStripe }
.dragReplicaShadow(zoom: zoom)
}
// 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)
// The target is **deferred**, and that is not a style choice: `.contextMenu`'s builder is
// non-escaping, so it runs while this body does, and computing a `StyleTarget` eagerly meant
// reading `store.selection` at body time — the O(board) subscription this view was rebuilt to
// shed (`isSelected`). Passed as a closure, `styleTarget` evaluates when a row *acts*, which
// is where every other menu target here is already read from (`deleteTargets`).
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) }
}
)
.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)
}
}
/// 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")
.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) }
)
}
}