Files
lanework/Kanban/UI/Board/LaneView.swift
T
rzen cfee4a4b41 The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board
View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight
rungs, app-wide and persisted (the Show Comments precedent) — a viewing
comfort, not a property of any one board. The level travels as
BoardZoomContext in the environment, injected on BoardView alone so the
banner strip, search bar, sheets and popovers stay at the system size; the
environment is also what carries it through CardFaceView's equality gate,
which compares nothing that moves with the level. Every BoardMetrics figure
follows zoom.bodyPointSize — card and lane chrome, drag replicas and the
count badge, the resize handle, the trash column — and the drop registry
carries the ruler for event-time reads, with the autoscroller's three
reaches turning font-derived (reachSide named as the stripGap it always
equalled). Lanes still divide the window; zoom never moves the window or
its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the
menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung
announces itself to VoiceOver, and the render suite pins both invariants:
a rung repaints every face, a no-op Actual Size repaints nothing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 11:22:02 -04:00

1341 lines
75 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: - LaneView
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
///
/// ### The title bar (this milestone's subject)
///
/// Leading SF Symbol from `icon` — lenient, an unknown name renders the `square.stack` default
/// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
/// a click selects the lane — toggling off on a repeat, exactly as empty space does
/// (04-interactions.md § Selection, settled) — and movement begins a **system drag session**
/// carrying the lane (`DragSession`, DRAG-REORDER.md). The click-versus-drag split is the system's
/// own now: `.onTapGesture` and `.onDrag` coexist, so a hesitant click can never start a drag and a
/// drag can never also select. The one thing carved out of the drag region is the new-card button,
/// which sits in an overlay outside it.
///
/// ### The lane's one context menu
///
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Rename, Style…,
/// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus'
/// Lane row, in its order, complete as of m5.
///
/// ### The card face
///
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and selection changes
/// only the face's styling — never its geometry. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
/// a click, and viewing an attachment's media is the card window's job, not the face's
/// (03-board-ui.md § Card face).
///
/// ### Equality gate
///
/// The lane is `Equatable` and instantiated through `.equatable()` (`BoardView.laneSlot`), because
/// the strip re-runs for reasons that have nothing to do with any one lane: `BoardView`'s body reads
/// the drag session, so **every drop-proposal change re-evaluates it**, and without a gate that
/// rebuilds every lane on the board — and, through them, every card face — on every cursor move
/// during a drag. See the `==` below for what the gate covers.
struct LaneView: View, Equatable {
let store: BoardStore
let lane: Lane
/// The app-wide quick-style recents (03-board-ui.md § Styling ▸ Controls — "never board data"),
/// read from the environment rather than threaded down the strip: the list belongs to the app,
/// not to this board, and every context menu in the window wants it.
@Environment(AppModel.self) private var appModel
/// Interior masonry columns — the lane's width units, or the resize session's snapped count
/// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
/// override it (see `BoardView.laneSlot`).
let columns: Int
/// This lane's resting slot width — the replica's width, so the image under the cursor is the
/// lane at its real on-screen size (03-board-ui.md § Motion: "a faithful, full-size replica").
let slotWidth: CGFloat
/// The board window's drop machinery: the app-wide session, the geometry registry this lane
/// registers its card grid into, and the shared retarget every hover and every autoscroll step
/// goes through (`BoardDropContext`).
let drops: BoardDropContext
/// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
/// face registers its frame into the same registry (`MarqueeControl`).
let marquee: MarqueeControl
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
/// business knowing about `WindowGroup` keys.
let openCard: (ItemID) -> Void
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
/// and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Increase Contrast, for the selection stroke and the plate's resting edge below
/// (10-accessibility.md). Read from the environment and handed to `Accommodations`, which owns
/// what "increased" does to a stroke.
@Environment(\.colorSchemeContrast) private var contrast
/// Reduce Transparency, for the lane plate's wash (`Accommodations.lanePlateWash` — translucent
/// normally, the standard secondary background under the setting; the trash plate's rule).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// The window's appearance — the input to 10-accessibility.md's runtime-contrast rule, and the
/// answer for every board whose background is not a hand-written hex (`headerInk`).
///
/// Read in `body` rather than resolved once, which is what makes "recomputed on appearance
/// change" free: a light/dark flip re-evaluates this view, and the decision below is taken again
/// against the colours of the appearance the window is now in.
@Environment(\.colorScheme) private var colorScheme
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`), injected on the strip by
/// `BoardWindowHost`. An `@Environment` read rather than a value passed down deliberately: this
/// view is `.equatable()`, and environment values are the one input the gate cannot suppress.
@Environment(\.boardZoom) private var zoom
/// The live body metric — every figure this lane lays out on is a multiple of it
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule), at the board's zoom.
private var pointSize: CGFloat { zoom.bodyPointSize }
/// Spacing between cards, and between the interior columns.
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
/// The lane plate's corner radius — shared by the selection treatment and the accent band, whose
/// top corners round to exactly this so the band reads as the lane's own edge.
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
private var bandHeight: CGFloat { BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize) }
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
/// tall as the strip gives it.
@State private var measuredHeight: CGFloat = 0
/// The title bar's drawn height — **the replica's anchor** (`dragReplica`), measured for the same
/// reason the lane's height is: the bar is as tall as the text in it, at whatever size the system
/// is set to.
@State private var measuredHeaderHeight: CGFloat = 0
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
@State private var autoScroller = DragAutoScroller()
/// The whole of what this lane is a function of **as far as the strip is concerned**: the lane
/// value (`Lane` is `Equatable`, its cards included, so an edit anywhere under this lane makes it
/// a different value), the two layout figures the strip resolves rather than the lane
/// (`columns` follows the live resize session, `slotWidth` the strip's standard width), and the
/// window-lived collaborators — the store by identity, the band and the drop machinery by their
/// own equivalence tests, which exist because `BoardView` 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 — `drops.session`'s proposal, members and file target, `store.selection`,
/// `store.searchFilter`, `store.transient`, `appModel.styleRecents` — invalidates this view
/// directly, and `.equatable()` has no say in that. So the lane a drag is actually over still
/// re-runs on every proposal change, and its `shadowRun` animation key still moves with it; what
/// stops is the *other* lanes re-running because the strip did.
///
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
/// `@Environment` values (SwiftUI invalidates on those itself), and `openCard` — a closure the
/// host rebuilds every pass, which is a pure hand-off to a `WindowGroup` key and identical in
/// behaviour whatever closure object carries it.
nonisolated static func == (lhs: LaneView, rhs: LaneView) -> Bool {
lhs.lane == rhs.lane
&& lhs.columns == rhs.columns
&& lhs.slotWidth == rhs.slotWidth
&& lhs.store === rhs.store
&& lhs.marquee.isEquivalent(to: rhs.marquee)
&& lhs.drops.isEquivalent(to: rhs.drops)
}
var body: some View {
// The strip's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because
// `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
#if DEBUG
let _ = BoardRenderMetrics.countLaneBody()
#endif
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
// lane's top edge, so it must sit outside the content inset rather than in it.
VStack(alignment: .leading, spacing: 0) {
accentBand
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
header
cardStack
}
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
}
.background(selectionBackground)
// Behind the selection wash, not composed into it: the plate is the lane's resting
// surface, the wash above it is the selection's own layer, and stacked `.background`s
// put the later one further back.
.background(lanePlate)
.overlay(selectionStroke)
// The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
// lane is cut cards and all.
.cutTreatment(of: lane.id, in: store)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
// the strip's logic, external Finder file sessions against those same zones — because
// single-target dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
// **The lane is an accessibility container** — "window → lanes (accessibility containers, in
// lane `order`) → cards (leaf elements, in card `order`)" (10-accessibility.md ▸ The board
// through VoiceOver). `.contain` rather than `.combine`: the header, the new-card button and
// every card must stay individually reachable, which is the whole point of a container the
// VoiceOver cursor enters (`TrashLaneView` states the same rule from the trash's side).
.accessibilityElement(children: .contain)
// "⟨title⟩, lane, N cards", where **N is the rendered count and therefore the filter's** —
// the very collection the visible badge counts, so the spoken count and the drawn one are
// one number ("the count reads the search filter like the visible badge"). A card the query
// hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
// which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
// by construction rather than by a second rule.
.accessibilityLabel(AccessibilityPhrases.laneLabel(title: lane.title.value, cards: renderedCards.count))
}
// MARK: - Header
private var header: some View {
headerContent
// **The lane title is a heading** — "lane titles are headings, so the headings rotor
// jumps lane-to-lane; on a one-dimensional board that *is* structural navigation"
// (10-accessibility.md ▸ Rotor). One flattened element rather than icon + text + badge:
// the glyph and the count are the container's information, already spoken by its label,
// and three stops where the design asks for a heading would make the rotor useless.
//
// `.contain` while a rename is open, because the flattening would otherwise swallow the
// text field the user is typing into — the one moment this subtree holds a control
// rather than chrome.
.accessibilityElement(children: isRenaming ? .contain : .ignore)
.accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
.accessibilityAddTraits(headerTraits)
// **VO-Space toggles the lane's selection** — the ⌘-click analogue 10-accessibility.md
// gives a card, applied to the other selectable thing on the board, and routed through
// the same `BoardStore.click` funnel the pointer uses so the homogeneity and
// container rules are `SelectionGrammar`'s single answer rather than a second one.
// Deliberately **not** the header's own plain-click semantics: "moving the VO cursor
// never mutates selection … VO-Space on a card toggles its selection (the ⌘-click
// analogue — a toggle, never plain click's replace)", and a VO-Space that replaced would
// silently wipe a multi-lane selection the user had just built.
.accessibilityAction { toggleLaneSelection() }
// The bar is the drag surface, so it must be hit-testable across its whole width —
// including the empty stretch between the badge and the button.
.contentShape(Rectangle())
// **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
// click on the already-selected lane's header unselects, one lane-click behavior
// everywhere, so a full lane keeps a pointer path out of selection". Hence the same
// `togglesOnRepeat` the empty space passes — the two surfaces differ only in where they
// are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
// the drag off until the pointer actually moves, so a click is never a drag.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current,
togglesOnRepeat: true
)
}
.onDrag(startLaneDrag, preview: { dragReplica })
// **The header is the file drop's topmost position** (04-interactions.md ▸ Drag and drop,
// settled 2026-07-28: "a release on the lane header resolves to the topmost position —
// forgiving beats a dead stripe"). Its frame is registered rather than derived from the
// grid's because the grid is scroll-view *content*: scrolled down, its top edge climbs
// past the header and would stop being a boundary at all. The bar itself never moves.
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
drops.registry.update(header: frame, for: lane.id)
// The same measurement answers a second question, so it is read once: the bar is
// what a lane drag is grabbed by, and its height is where the replica has to hang
// from the pointer (`replicaHeaderCenterY`).
measuredHeaderHeight = frame.height
}
.onDisappear { drops.registry.removeHeader(lane.id) }
.overlay(alignment: .trailing) { newCardButton }
// **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on
// a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike,
// since the ink they need is the same question and only the *verification* differs
// (`ContrastMath`).
//
// A lane's plate is a *translucent wash* (`lanePlate`), so the title, the icon, the
// count badge and the rename field still land, effectively, on the board's `background`
// — the surface the design binds the threshold to ("text does sit on it",
// `BoardView.boardBackground`); the wash shifts it too little to change the answer. The
// card faces below are a different matter and deliberately untouched: they carry their
// own opaque plate (`BoardSurface.cardPlate`), so their titles never see the board
// colour.
//
// **Placed here, not at the end of the chain**, which is the modifier order doing real
// work: everything above — including the new-card button in the overlay — is text on the
// board background and takes the computed ink, while the context menu and the Style…
// popover attached below stay in the window's own appearance, because a menu is system
// chrome drawn on its own surface and not on this board's colour.
.boardTextInk(headerInk)
.contextMenu { laneMenu }
// **The context menu's plain 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). The menu itself
// stays the inventory and is reachable the standard way (VO-⇧-M); this is the same four
// commands one rotor turn closer. Style… is deliberately absent: it opens a popover —
// its own accessible surface — and the quick-style swatch `Picker` beside it is not an
// action at all.
.accessibilityActions { laneActions }
// The lane's half of the Style… popover. Anchored on the header because that is the
// lane's own furniture — `styleEditorPresentation` decides whether this lane is the
// session's presenting anchor at all.
.popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
}
/// Which appearance's label vocabulary the header's text is drawn in — the window's own, unless
/// the **board** paints a background whose composited surface fails ≥ 4.5:1 against it
/// (10-accessibility.md ▸ Text scaling & visual accommodations; `BoardTextInk`).
///
/// It is not only the exotic case: ten of the twelve palette wells are dark colours, and every
/// one of them needs the dark appearance's label in a *light* window. A board styled entirely
/// from the in-app grid reaches this line as often as a hand-written one does.
///
/// It reads the *board*'s background and never this lane's, which is 03-board-ui.md ▸ Styling's
/// C7 ruling showing up as arithmetic: a lane's colour is an edge band, not a fill, so it is
/// never behind this text and carries no contrast obligation (`accentBand`).
private var headerInk: ColorScheme {
BoardTextInk.scheme(forBoardBackground: store.snapshot.background, appearance: colorScheme)
}
/// The lane's colour as C7 — "a lane's color paints a full-width band along its top edge; the
/// surfaces themselves keep the standard chrome, so colored title text never sits on a colored
/// fill" (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's treatment
/// shootout).
///
/// A value that resolves to nothing paints **no band**, and the bytes stay on disk exactly as
/// written — the card stripe's rule, for its reason: there is no sensible default colour for
/// "the author meant something we can't read", and a wrong colour is worse than none.
@ViewBuilder
private var accentBand: some View {
if let color = Palette.color(for: lane.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
.fill(color)
.frame(height: bandHeight)
.frame(maxWidth: .infinity)
// Decoration only: the header below it owns the lane's click and drag.
.allowsHitTesting(false)
}
}
// MARK: - The lane's one context menu
/// Rename, Style…, the quick-style recents row, the Width control, Delete — 11-command-nexus.md ▸
/// Context menus' Lane row, in its order, complete as of m5.
@ViewBuilder
private var laneMenu: some View {
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
// currentTitle:)`, seeded with the lane's live title. The menu-bar item additionally requires
// this lane 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 lane outright.
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Divider()
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
widthControl
Divider()
// Delete: File ▸ Delete's exact store path (`store.delete`), on the same widened target set
// Style… above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling below) — the
// successor-selection rule is `delete(_:)`'s own, so this row gets it for free.
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
}
/// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions`
/// call site). Every one of them calls the *same* private method its menu row does, so the two
/// surfaces cannot drift into meaning different things — which is the only way "not a separate
/// design surface" is checkable rather than merely intended.
@ViewBuilder
private var laneActions: some View {
let units = LaneLayoutMath.displayUnits(of: lane)
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) }
.disabled(!store.acceptsBoardMutations)
Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
.disabled(!store.acceptsBoardMutations || units <= 1)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
}
/// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers
/// (the context menu row and its accessibility twin).
private func beginRename() {
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
}
/// File ▸ Delete's store path over the context-menu target set — the menu row's body and its
/// accessibility twin's alike.
private func deleteTargets() {
store.delete(targetIDs)
}
/// VO-Space's landing: the ⌘-click funnel, on this lane. `togglesOnRepeat` stays false because
/// only the *plain* branch reads it — the ⌘ branch is already a toggle, which is the point.
private func toggleLaneSelection() {
store.click(
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .command
)
}
/// The header element's traits: a heading always, and **selected when the lane is** — "state is
/// never colour-alone: selection is a ring plus trait" (10-accessibility.md).
private var headerTraits: AccessibilityTraits {
isSelected ? [.isHeader, .isSelected] : [.isHeader]
}
/// The width stepper — "the header context menu's Width control (stepper, uncapped) is the
/// precise control … it never touches the window, it **re-divides** the existing width across the
/// new unit total" (03-board-ui.md § Lane). A +/ pair rather than a slider or a fixed 1×/2×/3×
/// list, because the control is uncapped in one direction and floored at one unit in the other.
///
/// **Single-lane by nature**, unlike the style entries above it: the design gives the batch to
/// the ⌥⌘→/⌥⌘← menu items and keeps the stepper on the lane whose menu is open.
private var widthControl: some View {
let units = LaneLayoutMath.displayUnits(of: lane)
return Section("Width — \(units)×") {
Button("Increase Width") {
store.setLaneWidth(lane.id, units: units + 1)
}
Button("Decrease Width") {
store.setLaneWidth(lane.id, units: units - 1)
}
// A one-unit lane cannot shrink (`width` is ≥ 1), and an item whose only outcome is a
// no-op reads better disabled than dead — `LaneWidthCommands`' rule, same floor.
.disabled(units <= 1)
}
.disabled(!store.acceptsBoardMutations)
}
/// What this lane's menu styles: the whole selection when this lane is part of it, else this lane
/// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
/// Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
return .items([lane.id])
}
return .items(store.selection.ids)
}
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
/// on the live side).
private var targetIDs: Set<ItemID> {
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
return [lane.id]
}
return store.selection.ids
}
private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
.foregroundStyle(.secondary)
.imageScale(.medium)
headerTitle
countBadge
Spacer(minLength: 0)
}
// Reserves the button's width so a long title truncates before it collides, and keeps the
// button out of the gestured region. **Font-derived** rather than a fixed 22pt: the button
// is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph
// itself at a large system text size and 03-board-ui.md's graceful-truncation rule would
// quietly stop holding (`BoardMetrics.newCardButtonReserve`).
.padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize))
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
}
/// The title, or the rename editor when this lane is the one being renamed.
///
/// A lane's **only** rename path is Board ▸ Rename (04-interactions.md ▸ Selection: "the menu
/// item is a lane's only rename path, since Return on a lane creates a card"), so nothing in
/// this view opens the editor — it only renders one that is already open.
@ViewBuilder
private var headerTitle: some View {
if isRenaming {
InlineTitleField(
text: renameDraft,
prompt: "Lane name",
onCommit: { store.commitRename() },
onAbandon: { store.transient.discardRename() },
onFocusLoss: { store.commitRename() },
// A lane has no card window; ⌘↩ still commits, which is the half of the rule that
// applies (04 ▸ Grammar's carve-out is "commits the edit — placeholder or rename —
// and open[s] the card window", and only a card has one to open).
onCommitAndOpen: { store.commitRename() }
)
.boardFont(.headline)
} else {
Text(lane.title.value ?? "Untitled")
.boardFont(.headline)
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.tail)
}
}
/// The card-count badge — quiet, secondary (03-board-ui.md § Lane).
///
/// **It counts exactly what the body renders**, because it reads the same `renderedCards` the
/// masonry iterates. That is deliberate rather than incidental: "The count reads the search
/// filter like every other surface — during a search it shows the visible count, not the
/// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
/// follows by construction, with no second rule to keep in step. **A lane the query empties
/// shows `0` and keeps its slot** (04-interactions.md § Search, settled: "lanes are never
/// filtered out … the search filters cards, and the board's structure is not a search result") —
/// which is `BoardView.liveLanes` never consulting the filter at all, made visible here.
///
/// The rename exemption rides along for the same reason every other rule does: the badge counts
/// what the body renders, and while an inline rename is open its card is one of the things the
/// body renders (see `renderedCards`).
private var countBadge: some View {
Text("\(renderedCards.count)")
.boardFont(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
.background(Capsule().fill(.quaternary))
}
/// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*:
/// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its
/// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so
/// it passes this lane and no anchor rather than consulting `NewCardTarget`.
private var newCardButton: some View {
Button {
store.transient.beginPlaceholder(inLane: lane.id)
} label: {
Image(systemName: "plus")
.imageScale(.small)
.foregroundStyle(.secondary)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// "The lane header's new-card button is a labeled child ('New card in ⟨lane⟩')"
// (10-accessibility.md ▸ The board through VoiceOver) — the header's one child element, which
// is why it lives in an overlay outside the flattened bar rather than inside it.
.accessibilityLabel(AccessibilityPhrases.newCardLabel(lane: lane.title.value))
// Mutating, so the read-only lock disables it like every other write path
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
// inline editor is open (04 ▸ Grammar) — the pointer twin of a disabled menu item.
.disabled(store.isReadOnly || store.isEditingInline)
}
// MARK: - The lane drag
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in board order —
/// which is the lane level's flatten order. A lane outside the selection drags alone, standard
/// macOS targeting.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture (02-architecture.md § The lock's scope; 04 ▸ Grammar's focused-editor rule).
/// A refusal is an item provider carrying nothing: no session begins, every drop target declines,
/// and the image snaps back.
private func startLaneDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection
let ids: Set<ItemID> = selection.container == .board
&& selection.ids.contains(lane.id)
&& selection.ids.count > 1
? selection.ids
: [lane.id]
let members = store.snapshot.lanes.filter { ids.contains($0.id) }
guard !members.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .lanes,
container: .board,
items: members.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: root.appendingPathComponent($0.id.rawValue, isDirectory: true).path,
title: $0.title.value
)
}
)
drops.session.beginLanes(
members.map(\.id),
folders: payload.folders,
// The dragged items' own sizes, frozen at drag start — the one thing that is
// (03-board-ui.md § Motion).
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
source: store
)
return payload.itemProvider()
}
/// The image travelling under the cursor: **a faithful, full-size replica of the whole lane**,
/// not the strip of title bar that was grabbed (03-board-ui.md § Motion), fanned with ghosts and
/// a count badge for a multi-drag.
///
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
/// past the lane's height is clipped anyway.
///
/// **The pointer keeps the point it grabbed** — the Finder-icon promise, and the whole of what
/// the anchoring padding below buys. SwiftUI centres a preview on the view the drag started
/// from, which here is the *title bar*: uncompensated, a full-height replica centred on a bar a
/// line and a half tall hangs half a lane above the cursor, and the cursor lands in the middle of
/// the image rather than on the bar it grabbed. Padding the replica so its own title bar is the
/// image's centre undoes exactly that, and lands every other pixel of the replica over the lane
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
private var dragReplica: some View {
let count = max(1, draggedLaneCount)
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
return ZStack {
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
// Transparent, and only ever on one side — see `DragPreviewAnchor`, whose figure is
// deliberately invariant under the symmetric margin above, so the two paddings compose in
// any order.
.padding(.top, anchor.before)
.padding(.bottom, anchor.after)
// **Back to the window's own appearance**, undoing `header`'s runtime-contrast override for
// this one subtree (`boardTextInk`). The preview is attached inside that modifier and would
// otherwise inherit it — but the replica is not text on the board background: it draws its
// own opaque plate (`replicaFace`) and floats over whatever the cursor is above, which during
// a cross-window drag is another board entirely. The rule's premise is a user-chosen surface
// behind the glyphs, and here there is none.
.boardTextInk(colorScheme)
}
private var draggedLaneCount: Int {
let selection = store.selection
guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 }
return selection.ids.count
}
private var replicaFace: some View {
VStack(alignment: .leading, spacing: 0) {
accentBand
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
headerContent
VStack(alignment: .leading, spacing: cardSpacing) {
ForEach(renderedCards.prefix(12)) { card in
HStack(alignment: .firstTextBaseline,
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(.secondary)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.boardFont(.body)
.lineLimit(2)
Spacer(minLength: 0)
}
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.fill(BoardSurface.cardPlate)
)
}
Spacer(minLength: 0)
}
}
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
}
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
// The lane's own wash over an opaque base — the replica floats over whatever the cursor is
// above, so unlike the resting lane it cannot let the wash composite against the board; the
// opaque `.background` behind it stands in for the window's neutral surface.
.background(lanePlate)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.dragReplicaShadow(zoom: zoom)
}
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
/// ask the same question the drawing does, and two derivations of one figure would be two
/// answers.
private var replicaWidth: CGFloat {
max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
}
private var replicaHeight: CGFloat {
max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize))
}
/// Where the title bar sits inside the replica — its **centre**, measured down from the replica's
/// top edge, which is the point the pointer must hold (`dragReplica`).
///
/// The replica stacks exactly what the lane stacks, in the same order and off the same figures,
/// so this is the lane's own layout arithmetic rather than a second description of it: the accent
/// band when the lane's colour resolves to one (`accentBand` draws nothing when it does not, and
/// contributes no height either), the plate's inset, and half the measured bar.
private var replicaHeaderCenterY: CGFloat {
let band = Palette.color(for: lane.background) == nil ? 0 : bandHeight
return band + BoardMetrics.lanePlatePadding(bodyPointSize: pointSize) + measuredHeaderHeight / 2
}
// MARK: - Body
/// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
/// click selects the lane or, when it is already the selection, clears it; a double click
/// creates a card at the bottom with its title editor focused.
///
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar): the reader watches the
/// navigation head — the cursor the arrows move, not the whole selection — and scrolls only when
/// the head names a card *this* lane renders, so exactly one lane responds to any one press.
/// Deliberately unwrapped by `withAnimation`: 03-board-ui.md § Motion has selection follow
/// "whatever transaction is active rather than easing on its own".
private var cardStack: some View {
ScrollViewReader { proxy in
scrollableCards
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, let card = renderedCards.first(where: { $0.id == head }) else { return }
proxy.scrollTo(LaneSlot.identity(of: card.id))
}
}
}
private var scrollableCards: some View {
// Evaluated ONCE per body, deliberately: the per-element `.accessibilitySortPriority`
// below reads `slots.count`, and reading the computed property from inside the `ForEach`
// closure re-runs the whole chain (`slots` → `renderedCards` → the O(n) card filter and
// the session's hidden-member resolution) once per element — O(n²) per lane body, which
// a drag pickup's synchronous whole-board layout multiplied into a visible stall.
let slots = self.slots
return ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
// `units` columns of `standard` (03-board-ui.md § Layout — full visibility).
MasonryLayout(columns: columns, spacing: cardSpacing) {
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
Group {
switch slot {
case let .card(card):
CardFaceView(
store: store,
card: card,
role: .board(openCard: openCard),
marquee: marquee,
drops: drops
)
// **The value gate** (`CardFaceView.==`) — the lane's own, one level
// down: this body re-runs on every proposal change while a drag is over
// *this* lane, and the faces it draws are almost never what changed.
.equatable()
case let .placeholder(phase):
NewCardStubView(store: store, phase: phase, openCard: openCard)
case let .shadow(_, height):
// One of the drag's N contiguous shadows, at the dragged card's frozen
// height — the run's real footprint, so the drop lands exactly here.
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.frame(height: height)
}
}
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
// (03-board-ui.md § Motion), which is how a create, a delete, a restore and
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
// too: it is the card, one round trip early. Whether any of it *performs* is
// decided upstream — at the reload for the real cards (`Motion.reloadAnimates`),
// at the gesture for the placeholder, which touches no disk.
//
// **The create handoff deliberately never reaches it.** A committed placeholder
// is already keyed by the arriving card's identity (`LaneSlot`), so when the echo
// reload swaps the pseudo-card for the real one the `ForEach` element is neither
// inserted nor removed — only its content changes — and an appear/disappear
// transition has nothing to run on. That is the whole of "the handoff must read
// as one arrival" (02-architecture.md ▸ TransientBoardState ▸ overlays), and it
// holds identically under Reduce Motion: a transition that does not fire has no
// variant to choose between.
.transition(Motion.cardTransition(reduced: reduceMotion))
// **VoiceOver reads the masonry by `order`, not by drawn position** —
// 10-accessibility.md ▸ Logical order, not masonry position (decided).
//
// The divergence narrowed when the masonry went column-major — walking down
// one column now *is* consecutive `order` — but it is still real, and it is
// why an explicit priority is needed at all: a geometry-sorted accessibility
// tree (which is what a container does without this) sweeps in reading order,
// left-to-right then down, which over a column-major grid interleaves the
// columns: 1, 4, 7, 2, 5, 8 …, an order that exists nowhere in the model, on
// disk, or in the keyboard grammar. Priority descends with the slot index, so
// the highest reads first and the list is exactly `slots` — the same sequence
// the masonry is handed and the same one `SelectionGrammar` flattens.
//
// The drag shadows are inert here: `DragShadow` hides itself from the tree, and
// a slot that is not an element consumes no priority.
.accessibilitySortPriority(Double(slots.count - index))
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
// resolves against an explicit `.id`, and it goes outermost so the transition
// above stays inside the identified view rather than around it.
.id(slot.id)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
.animation(Motion.dragReflow(reduced: reduceMotion), value: shadowRun)
// Where this lane's card grid is drawn, in the window's global space — the analytic
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
// re-derived, so the zones and the drawn grid cannot disagree.
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
drops.registry.update(
LaneDropRegistry.Grid(frame: frame, columns: max(1, columns), spacing: cardSpacing),
for: lane.id
)
}
.onDisappear { drops.registry.removeGrid(lane.id) }
// The edge-autoscroll anchor, **inside** the scroll view's content so
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
// the proposal through the same shared retarget the drop delegate uses, because the
// cursor is stationary while the content moves under it.
.background {
DragAutoScrollAnchor(scroller: autoScroller) {
drops.retargetCards(inLane: lane.id)
}
}
.contentShape(Rectangle())
// Order matters: the two-tap recogniser must be attached first so a double click is not
// consumed as two singles.
.onTapGesture(count: 2) {
guard !store.isReadOnly, !store.isEditingInline else { return }
store.transient.beginPlaceholder(inLane: lane.id)
}
// "Single click selects the lane (click again to unselect)" — the toggle the header
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current,
togglesOnRepeat: true
)
}
// The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
// started on a card face out of it (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(in: .board))
// The same menu the header carries — "one menu, invoked on the header or lane empty
// space alike" (03-board-ui.md § Lane, settled).
.contextMenu { laneMenu }
}
// The autoscroll driver, **structurally terminated**: a `.task(id:)` keyed on whether a card
// session is in flight at all, so it is cancelled the moment the session ends — and
// `DragSession`'s watchdog guarantees that flag clears however the drag finished
// (DRAG-REORDER.md § Edge autoscroll). Within a session, a pointer outside this lane's
// engagement rect simply scrolls nothing.
.task(id: drops.session.isDraggingCards) {
guard drops.session.isDraggingCards else { return }
// The engagement rect's reaches are distances to this lane's own furniture, so they are
// measured on the board's ruler (03-board-ui.md ▸ Layout — zoom). Read once at the head
// of the drag, which is once per level: zoom is inert while a session is in flight.
autoScroller.bodyPointSize = pointSize
await autoScroller.run()
}
}
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
private var cardProposal: Int? {
drops.session.laneProposal(onBoardRooted: store.rootKey, laneID: lane.id)
}
/// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one
/// make-room mechanism, and the reflow's narrow animation key.
///
/// Two sessions feed it and they are mutually exclusive by construction (a file session never
/// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight):
///
/// - **a card drag**, at the dragged cards' frozen heights — the run's real footprint, so the
/// drop lands exactly where the shadows are;
/// - **a Finder file drag**, at the nominal height, one shadow per file — the cards being
/// proposed do not exist yet, so there is no measured height to be faithful to.
private var shadowRun: ShadowRun? {
if let position = cardProposal {
return ShadowRun(position: position, heights: drops.session.cardHeights)
}
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) {
return ShadowRun(
position: proposal.index,
heights: Array(repeating: drops.registry.nominalCardHeight, count: proposal.count)
)
}
return nil
}
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
/// proposal, and the new-card placeholder when this lane is the one being created into.
///
/// The placeholder is inserted **at the position the card will actually take** — after its anchor
/// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
/// cannot appear anywhere but where the real card lands. Both insertions are computed against
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
/// it, so neither displaces the other.
///
/// **The phase rides along**, because it is what keys the slot: a committed placeholder wears the
/// arriving card's identity so the handoff is one arrival rather than two (`LaneSlot`). The
/// position math above is untouched by that — the key changes, the index does not — so the
/// masonry cannot flinch at the moment of commit.
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
let run = shadowRun
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
var placeholder: (position: Int, phase: NewCardPlaceholder.Phase)?
if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id {
let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards)
?? result.count
placeholder = (position, pending.phase)
}
let heights = run?.heights ?? []
if let shadowPosition {
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
result.insert(contentsOf: shadows, at: shadowPosition)
}
if var placeholder {
if let shadowPosition, placeholder.position >= shadowPosition {
placeholder.position += heights.count
}
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
}
return result
}
/// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
/// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
/// effective-liveness predicate are retired with the flag they read.
///
/// **A card being dragged *as a move* renders nowhere, for as long as the session lasts.** It is
/// lifted out of the resting layout at pickup and stays out until release. A card being dragged
/// as a **copy** stays exactly where it is and dims (`CardFaceView`), because that is what the
/// copy will leave behind — so flipping ⌥ or ⌘ mid-drag reflows this lane once, which is the
/// feedback the modifier is asking for (DRAG-REORDER.md § Resting-layout zones, ruled
/// 2026-08-01). Either way the drop's index is counted in the layout on screen, which is why
/// `copyCards` and `moveCards` take their index in different spaces.
///
/// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
/// "cards whose title *and* body both miss the query animate out". This is the one collection
/// that narrowing, which is what makes the filter "the single source of truth for what's on the
/// board" true of this lane's every surface at once — the masonry, the count badge (see
/// `countBadge`), the drop zones' resting layout, the marquee registration and the Finder
/// file-drop targets all read this list or the registry it populates, so none of them needs a
/// rule of its own.
///
/// **With one exception, and it is the open inline rename** (04-interactions.md § Search,
/// settled): "an open inline rename survives the filter hiding its card — the editor is a surface
/// the filter doesn't reach; it stays open and focused, commits by UUID wherever the card lives,
/// Escape abandons". The editor is drawn *inside* its card's slot (`CardFaceView.isRenaming`), so
/// on this board a surface the filter doesn't reach means precisely a slot the filter doesn't
/// take away: filtering the card out would unmount the field mid-keystroke and silently drop what
/// the user had typed, which is the dirty-buffer courtesy read backwards. The exemption lasts
/// exactly as long as the editor — commit or Escape retires it, the predicate applies again in
/// the same pass, and a card that no longer matches animates out then (which is also the whole of
/// "rename deliberately gets no carve-out": the *query* still stands throughout).
///
/// It cannot arrive by typing, because focusing the search field is focus loss and commits the
/// rename first; the case it serves is a foreign edit that stops the card matching while the user
/// is renaming it.
private var renderedCards: [Card] {
Self.rendered(
lane.cards,
hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootKey),
filter: store.searchFilter,
renaming: store.transient.renameEditor?.targetID
)
}
/// `renderedCards` as a pure function of its four inputs — see there for every rule it applies.
/// Split out only so the rules can be pinned without a view (`SearchFilterTests`); the lane's
/// masonry, its count badge and its drop zones all read the property, which reads this.
nonisolated static func rendered(
_ cards: [Card],
hiddenByDrag hidden: Set<ItemID>,
filter: SearchFilter,
renaming: ItemID?
) -> [Card] {
cards.filter { card in
guard !hidden.contains(card.id) else { return false }
return filter.matches(card) || card.id == renaming
}
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.container == .board && store.selection.ids.contains(lane.id)
}
/// The lane's resting plate — the standard chrome's middle step (03-board-ui.md § Styling ▸
/// Capabilities: window, washed lane, opaque card), the same quaternary wash the trash column
/// wears (`Accommodations.lanePlateWash`). Translucent deliberately: a board-chosen colour
/// shows through, so the header ink computed against the board's background (`headerInk`)
/// keeps its premise on a painted board.
private var lanePlate: some View {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Accommodations.lanePlateWash(reduceTransparency: reduceTransparency).style)
}
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
/// a fill that would compete with it once that lands.
private var selectionBackground: some View {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
}
/// The selection ring — and, under Increase Contrast, the plate's resting edge as well
/// (10-accessibility.md: "Increase Contrast strengthens borders and the selection indicator";
/// `Accommodations`, and `CardFaceView.plateStroke` for the same three-way branch on a card).
///
/// A lane is otherwise bounded by its quiet wash and the gap between it and its neighbour,
/// which is the distinction the setting most needs to restore here.
private var selectionStroke: some View {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
}
private var plateStroke: AnyShapeStyle {
if isSelected {
AnyShapeStyle(Color.accentColor)
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
AnyShapeStyle(.separator)
} else {
AnyShapeStyle(.clear)
}
}
private var plateStrokeWidth: CGFloat {
Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast)
}
// MARK: - Rename plumbing
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == lane.id
}
/// The draft, as a binding onto transient state rather than as `@State`: the editor's text lives
/// in `TransientBoardState` because a reload has rules about it (the vanish discard), and a
/// second copy in the view would be the one the commit did not read.
private var renameDraft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// MARK: - Lane slots
/// The run of shadows a lane opens for whichever session is proposing into it — where it starts and
/// what each shadow is worth in height.
///
/// `Equatable` because it is the reflow's animation key: within a session the heights never change,
/// so the value moves exactly when the proposal does.
private struct ShadowRun: Equatable {
var position: Int
var heights: [CGFloat]
}
/// What a lane's masonry lays out — its cards, plus at most one pseudo-card.
///
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
/// title commits (02-architecture.md § Layering, the one named exception to the one-way flow).
/// Modelling it as a sibling case rather than as a fake `Card` is what keeps that true — nothing can
/// accidentally hand it to code expecting an item that exists.
///
/// ### The create handoff, which is entirely a question of `id`
///
/// 02-architecture.md ▸ TransientBoardState ▸ overlays (settled, 2026-07-28) requires the handoff to
/// **read as one arrival**: "the placeholder renders at the arriving card's exact geometry/chrome".
/// The mechanism is this enum's identity function and nothing else — no `matchedGeometryEffect`, no
/// second transaction, no suppression flag.
///
/// A placeholder carries its phase, and the phase decides its key:
///
/// - **`.editing`** keys to the constant `"placeholder"`. There is only ever one placeholder in one
/// lane at a time, and it must hold its identity — and therefore its keyboard focus — for as long
/// as the user types.
/// - **`.awaitingArrival(id)`** keys to `identity(of: id)`: **the very key the arriving card will
/// use**. The Writer's create has run, so the real card's UUID exists a full round trip before its
/// `Card` does, and adopting it early is what makes the handoff a *content swap inside one
/// `ForEach` element* rather than a removal and an insertion at coincident slots. The element
/// persists across the echo reload, so `Motion.cardTransition` — attached per slot in the masonry
/// — never fires on it: one arrival, one geometry, no double scale-and-fade.
///
/// The key therefore changes exactly once, at commit, and that change is deliberately outside every
/// animated transaction the board runs (`BoardStore.commitPlaceholder` is a plain synchronous call
/// from the editor's Return; `land`'s `withAnimation` comes a round trip later), so it costs no
/// motion either.
///
/// Two slots can never collide on the arriving key: `BoardStore.land` assigns the snapshot and
/// re-grounds the transient state — the discard-on-arrival among it — inside one transaction, so no
/// render pass ever sees both the real card and the placeholder standing in for it.
enum LaneSlot: Identifiable {
case card(Card)
/// The new-card placeholder, carrying the phase that decides both what it draws and what it is
/// keyed by. Passed down rather than re-read in the stub so the key and the face cannot disagree
/// about which half of the handoff this is.
case placeholder(NewCardPlaceholder.Phase)
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
case shadow(index: Int, height: CGFloat)
var id: String {
switch self {
case let .card(card): Self.identity(of: card.id)
case let .placeholder(phase): Self.identity(ofPlaceholderIn: phase)
// Constant per position in the run, so the shadows animate as slides when the proposal moves
// rather than blinking out and back in.
case let .shadow(index, _): "shadow:\(index)"
}
}
/// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot
/// disagree about what `scrollTo` is looking for.
static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }
/// The key an open editor holds while it has no identity of its own to hold.
static let editingPlaceholderIdentity = "placeholder"
/// **The handoff, as one pure function.** See the type's doc comment: a committed placeholder
/// answers with the arriving card's key, which is what makes the swap continuous.
static func identity(ofPlaceholderIn phase: NewCardPlaceholder.Phase) -> String {
switch phase {
case .editing: editingPlaceholderIdentity
case let .awaitingArrival(id): identity(of: id)
}
}
}
// MARK: - The new-card placeholder
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width —
/// 02-architecture.md § Layering's one named exception to the one-way flow, finally rendered.
///
/// Two faces, one per phase:
///
/// - **`.editing`** — a focused text field. Return commits, Escape abandons, and **click-away
/// discards**: the placeholder's rule, "the deliberate exception because nothing exists on disk
/// yet" (04-interactions.md ▸ Grammar).
/// - **`.awaitingArrival`** — the committed title as plain text, deliberately *not* an editor. The
/// Writer's create has run and the overlay is only covering the gap until the watcher round-trips
/// the real card; leaving a live field there would invite edits that have nowhere to go, and its
/// focus loss would fire the discard rule against a card that is already on its way.
///
/// ### The two faces are two *different* faces, on purpose
///
/// The editing face is an editor — the accent-stroked well the user is typing into. The awaiting
/// face is **a card**: the same plate, inset, stripe gutter, icon, font and title position a default
/// new card gets (`CardFaceView`, via the `BoardMetrics` both read). That is the second half of
/// 02-architecture.md ▸ TransientBoardState ▸ overlays' one-arrival rule — the first half is
/// `LaneSlot` keying a committed placeholder by the arriving card's identity, which makes the echo
/// reload a content swap inside one persistent element; drawing that content identically is what
/// makes the swap invisible rather than merely un-animated.
///
/// A newly created card is always default-styled — `BoardWriter.createCard` writes `schema`, `title`
/// and `order` and nothing else — so "the arriving card's chrome" is exactly: the level-default
/// symbol, the standard secondary tint, no accent stripe, and no selection stroke (the commit
/// re-selects the *lane*).
private struct NewCardStubView: View {
let store: BoardStore
/// How far along the birth is — handed down from the slot rather than re-read from the store, so
/// the face this view draws and the identity the slot is keyed by are the same answer.
let phase: NewCardPlaceholder.Phase
let openCard: (ItemID) -> Void
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
/// The board's ruler (`BoardZoom`) — the same environment the real face reads, so the placeholder
/// and the card that replaces it are drawn at one zoom.
@Environment(\.boardZoom) private var zoom
/// The live body metric — the same one the real face reads, which is what makes "the numbers are
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
private var pointSize: CGFloat { zoom.bodyPointSize }
var body: some View {
switch phase {
case .editing: editor
case .awaitingArrival: arrivingFace
}
}
/// The editor well — an accent-stroked plate around the field, which is what a thing being typed
/// into should look like and deliberately not what a card looks like.
private var editor: some View {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { commit() },
onAbandon: { store.transient.discardPlaceholder() },
onFocusLoss: { store.transient.discardPlaceholder() },
onCommitAndOpen: {
// The one board command that stays enabled mid-edit: commit, then open
// (04 ▸ Grammar's carve-out). A commit that discarded — empty title, a
// vanished lane, a failed create — hands back no id and opens nothing.
if let id = commit() { openCard(id) }
}
)
.boardFont(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.background(
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.fill(BoardSurface.cardPlate)
)
.overlay(
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
// Increase Contrast takes the well to full strength and a point heavier — a 60%-alpha
// outline is exactly the kind of border the setting exists to rescue
// (`Accommodations`).
.strokeBorder(
Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast)),
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast)
)
)
}
/// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
/// `CardFaceView.body`/`titleRow`, and the numbers are the same numbers rather than equal ones
/// (`BoardMetrics`) — when the echo reload swaps this view for the real face inside the one
/// slot they share, nothing about the plate, the icon, the font or the title's position changes.
///
/// No stripe overlay and no selection stroke: both would be `.clear` for a default, unselected
/// new card, and a shape that paints nothing is better left unwritten than written and disabled.
private var arrivingFace: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.card)
.foregroundStyle(.secondary)
.imageScale(.medium)
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
.boardFont(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
.padding(.leading, BoardMetrics.cardStripeWidth(bodyPointSize: pointSize))
.background(
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.fill(BoardSurface.cardPlate)
)
}
/// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return
/// = next card)" (04-interactions.md ▸ Grammar). The lane rather than the new card is what makes
/// a run of Return-type-Return file a stack of cards without the user's hands leaving the
/// keyboard.
///
/// The lane is read before the commit, because every discard path clears the overlay that holds
/// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
/// something that renders nowhere would break the one-container invariant until the
/// next reload swept it away.
@discardableResult
private func commit() -> ItemID? {
let lane = store.transient.newCardPlaceholder?.laneID
let id = store.commitPlaceholder()
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
store.select([lane], in: .board)
}
return id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.newCardPlaceholder?.draftTitle ?? "" },
set: { store.transient.updateDraft($0) }
)
}
}
// MARK: - The inline title field
/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and
/// a lane rename — so the grammar around it is written once (04-interactions.md ▸ Grammar).
///
/// The four exits, and who differs on them:
///
/// | Exit | Placeholder | Rename |
/// |---|---|---|
/// | Return | commits | commits |
/// | Escape | discards | abandons |
/// | Click-away | **discards** | **commits** |
/// | ⌘↩ | commits + opens | commits + opens |
///
/// Only the click-away row differs, which is why it is a caller-supplied closure rather than a
/// branch in here: this view knows *that* focus left, never what that should mean.
///
/// **Every handler must be idempotent**, because the exits overlap by construction: Return commits
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard…` all no-op against
/// an editor that is already closed, so the overlap costs nothing.
struct InlineTitleField: View {
@Binding var text: String
let prompt: String
let onCommit: () -> Void
let onAbandon: () -> Void
let onFocusLoss: () -> Void
let onCommitAndOpen: () -> Void
@FocusState private var isFocused: Bool
var body: some View {
TextField(prompt, text: $text)
.textFieldStyle(.plain)
.lineLimit(1)
.focused($isFocused)
// The editor is born focused: every entry point to it is a deliberate "edit this now"
// (Return, ⌘N, the header button, a double click, Board ▸ Rename), and one that landed
// unfocused would need a second click to do anything.
.onAppear { isFocused = true }
.onSubmit(onCommit)
// ⌘↩ before the field sees the Return: the one board command enabled mid-edit
// (04 ▸ Grammar). Anything without the modifier is passed straight through, so plain
// Return still reaches `onSubmit`.
.onKeyPress(keys: [.return], phases: .down) { press in
guard press.modifiers.contains(.command) else { return .ignored }
onCommitAndOpen()
return .handled
}
// Escape reaches a focused text field as AppKit's cancel operation on some paths and as
// a plain key press on others; both are wired to the same idempotent abandon rather than
// guessing which one this control will get.
.onKeyPress(.escape) {
onAbandon()
return .handled
}
.onExitCommand(perform: onAbandon)
.onChange(of: isFocused) { _, focused in
guard !focused else { return }
onFocusLoss()
}
}
}