Files
lanework/Kanban/UI/Board/LaneView.swift
T
rzen 5ebf9fb90c Implement the motion language
One named home for every curve and duration — the two-voice split 03
fixes (snappy structural: drag reflow 0.18, delete 0.25, lane resize
0.2; smooth content-reflow 0.28), the scale-and-fade appear/disappear
transitions (cards 0.8, lanes 0.9), and a Reduce Motion variant on
every accessor (animations go instant, transitions go crossfade). A
post-migration grep holds the invariant: zero motion literals outside
Motion.swift. The animate-vs-snap split lands where Lanework's
one-way flow puts it: the store's reload seam. An app-mediated echo
applies its snapshot inside the structural transaction; foreign and
reconciling reloads — and every bracket-ending wholesale reload —
assign bare, because live-reload is the board becoming what's on
disk, not an event to perform. A window that merged foreign events
into an app-mediated span animates, deliberately: the ratified merge
rule makes it indistinguishable from a pure echo, and a test pins
that reading so a future mixed case fails loudly. The lane-reorder
reflow keys on the drop proposal alone (pointer tracking stays 1:1),
the resize session freezes its Reduce Motion answer at drag start so
the unit tick and the window resize can never disagree, and the m5
drag / m5 search / m7 undo voices are named seams waiting for their
call sites. 13 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 15:59:59 -04:00

797 lines
38 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 SwiftUI
// MARK: - The strip's half of the header drag
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
/// Lane, "no separate grip").
///
/// The gesture lives on the header — that is where the design puts it — but the two things it needs
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
/// and what a release means (a proposal computed against the live lane order, then a write). Both
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
/// body-evaluation time.
@MainActor
struct LaneHeaderDrag {
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
/// for its duration (`LaneReorderSession.startCentre`).
let startCentre: () -> CGFloat
/// Commit the reorder at whatever the current proposal is, and end the session.
let commit: () -> Void
}
// 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 plain click selects the lane, movement past a small threshold begins a reorder
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
///
/// ### 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 Style…, the
/// quick-style recents row and the Width stepper today; Rename and Delete are m5's context-menus
/// card, and their rows go into that same builder rather than into a second menu.
///
/// ### What is still a later card's
///
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
/// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment
/// carousel.
struct LaneView: View {
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
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
let reorder: LaneReorderSession
let headerDrag: LaneHeaderDrag
/// 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
/// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8
/// 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 let cornerRadius: CGFloat = 10
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
private let bandHeight: CGFloat = 5
var body: some View {
// `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: 8) {
header
cardStack
}
.padding(6)
}
.background(selectionBackground)
.overlay(selectionStroke)
}
// MARK: - Header
private var header: some View {
headerContent
// 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())
.gesture(headerGesture)
.overlay(alignment: .trailing) { newCardButton }
.contextMenu { laneMenu }
// 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)
}
}
/// 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) — the style trio and the width stepper today.
@ViewBuilder
private var laneMenu: some View {
// m5-context-menus: Rename (a twin of Board ▸ Rename) and Delete (a twin of File ▸ Delete)
// belong to the card that brings the selection model and the delete command; both are rows
// of *this* menu when they land, not of a second one.
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
widthControl
}
/// 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.liveness == .live, store.selection.ids.contains(lane.id) else {
return .items([lane.id])
}
return .items(store.selection.ids)
}
private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
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.
.padding(.trailing, 22)
.padding(.horizontal, 4)
}
/// 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() }
)
.font(.headline)
} else {
Text(lane.title.value ?? "Untitled")
.font(.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.
private var countBadge: some View {
Text("\(renderedCards.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.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)
.accessibilityLabel("New card in \(lane.title.value ?? "Untitled")")
// 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)
}
/// One gesture recognising both halves of 04-interactions.md ▸ Selection's click-vs-drag split:
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
///
/// `minimumDistance: 0` so the release is seen even when nothing moved — that release *is* the
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
private var headerGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
// The focused-editor rule holds a drag off too: a reorder is a board command.
guard !store.isReadOnly, !store.isEditingInline else { return }
if !reorder.isDragging(lane.id) {
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
}
reorder.update(translation: value.translation.width)
}
.onEnded { _ in
if reorder.isDragging(lane.id) {
headerDrag.commit()
} else {
// A plain click on the header always selects — unlike lane empty space, it does
// not toggle off. 04 gives the click-again-to-unselect behaviour to empty space
// only, and a full lane has no empty space to reach for.
store.select([lane.id], liveness: .live)
}
}
}
// 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.
private var cardStack: some View {
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(slots) { slot in
Group {
switch slot {
case let .card(card):
CardFaceView(store: store, card: card, openCard: openCard)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
}
}
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back 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.
.transition(Motion.cardTransition(reduced: reduceMotion))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.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)
}
.onTapGesture { toggleLaneSelection() }
// 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 }
}
}
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
/// is the one being created into.
///
/// The overlay 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.
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
return result
}
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
result.insert(.placeholder, at: position ?? result.count)
return result
}
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane — the
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
/// tombstoned lane at all.
///
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
/// honest for free — see `countBadge`.
private var renderedCards: [Card] {
lane.cards.filter { !$0.isDeleted }
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
}
/// Click on empty space: select, or clear when this lane is already *the* selection.
///
/// "Single click selects the lane (click again to unselect)". The toggle-off tests for a
/// sole-membership selection rather than mere containment, so a future ⌘-click multi-selection
/// of lanes is narrowed by a click rather than wiped by it — the modifier grammar itself
/// (⌘-click toggles, ⇧-click range-extends, rubber band, homogeneity enforcement) is **m5's
/// selection-model card**, and nothing here should pre-empt it.
private func toggleLaneSelection() {
if store.selection.liveness == .live, store.selection.ids == [lane.id] {
store.clearSelection()
} else {
store.select([lane.id], liveness: .live)
}
}
/// 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: 10)
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
}
private var selectionStroke: some View {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
}
// 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
/// 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.
private enum LaneSlot: Identifiable {
case card(Card)
case placeholder
var id: String {
switch self {
case let .card(card): "card:\(card.id.rawValue)"
// Constant, because there is only ever one placeholder in one lane at a time and it must
// keep its identity — and therefore its keyboard focus — while the user types.
case .placeholder: "placeholder"
}
}
}
// 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).
///
/// ### 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).
///
/// ### Room for the carousel
///
/// The face is a top-aligned `VStack` and its two decorations — the accent stripe and the selection
/// stroke — are shapes in overlays, so both stretch to whatever height the content takes. That is
/// what lets m5's carousel expand *inside* this card without any of it being re-derived: the
/// masonry already isolates column heights, so a taller card pushes only the cards below it in its
/// own column.
private struct CardFaceView: View {
let store: BoardStore
let card: Card
let openCard: (ItemID) -> Void
/// The app-wide quick-style recents — see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
private let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
/// treatment shootout).
private let stripeWidth: CGFloat = 4
var body: some View {
VStack(alignment: .leading, spacing: 6) {
titleRow
// m5-carousel: the sole selected card's paged attachment carousel expands here — below
// the title, inside this same plate, keyed on the selection transaction
// (03-board-ui.md § Card face). It needs `card.attachments` (already loaded) and this
// view's `isSelected`; nothing above it changes.
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
// colouring a card never shifts its title relative to its uncoloured neighbours.
.padding(.leading, stripeWidth)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
.contentShape(Rectangle())
// **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.
.onTapGesture { store.select([card.id], liveness: .live) }
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
}
// MARK: - Context menu
/// Open, Rename, Style…, the quick-style recents row, Delete (11-command-nexus.md ▸ Context
/// menus) — the style pair today.
@ViewBuilder
private var cardMenu: some View {
// m5-context-menus: Open (a twin of Board ▸ Open Card, always the clicked card alone —
// a card window is tied to one card), Rename, and Delete land with the selection-model and
// delete cards, as rows of this same menu.
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
}
/// What this card's menu styles: the whole selection when this card is part of it, else this card
/// alone. Standard macOS — right-clicking outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
return .items([card.id])
}
return .items(store.selection.ids)
}
// MARK: - Title row
private var titleRow: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
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. Unchanged from the
/// stub this face replaces: 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()
openCard(id)
}
)
.font(.body)
} else {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
}
}
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
/// exists to make a *hand-coloured* icon stand out from its neighbours.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: card.iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility label rather than onto the face: it is useful to know, not to look at.
@ViewBuilder
private var attachmentsIndicator: some View {
if !card.attachments.isEmpty {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(card.attachments.count) attachments")
}
}
/// 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, m5's carousel expansion included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
.fill(color)
.frame(width: stripeWidth)
// Decoration only: the whole plate is one click target for selection.
.allowsHitTesting(false)
}
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(card.id)
}
private var isRenaming: Bool {
store.transient.renameEditor?.targetID == card.id
}
private var draft: Binding<String> {
Binding(
get: { store.transient.renameEditor?.draftTitle ?? "" },
set: { store.transient.updateRenameDraft($0) }
)
}
}
// 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.
private struct NewCardStubView: View {
let store: BoardStore
let openCard: (ItemID) -> Void
var body: some View {
Group {
if isEditing {
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) }
}
)
.font(.body)
} else {
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
)
}
private var isEditing: Bool {
store.transient.newCardPlaceholder?.phase == .editing
}
/// 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 homogeneous-by-liveness 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 && !$0.isDeleted }) {
store.select([lane], liveness: .live)
}
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.
private 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()
}
}
}