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). 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 /// 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 /// 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 /// 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 /// 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() 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) // 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)) } // 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()) // **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) } .onDisappear { drops.registry.removeHeader(lane.id) } .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' 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") { store.transient.beginRename(of: lane.id, currentTitle: lane.title.value) } .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` sibling below) — the // successor-selection rule is `delete(_:)`'s own, so this row gets it for free. Button("Delete") { store.delete(targetIDs) } .disabled(!store.acceptsBoardMutations) } /// 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` /// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here /// on the live side). private var targetIDs: Set { 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: 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. **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)") .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) } // 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 = 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. private var dragReplica: some View { let count = max(1, draggedLaneCount) 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(12) } 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: 8) { headerContent VStack(alignment: .leading, spacing: cardSpacing) { ForEach(renderedCards.prefix(12)) { card in HStack(alignment: .firstTextBaseline, spacing: 6) { Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card)) .foregroundStyle(.secondary) .imageScale(.medium) Text(card.title.value ?? "Untitled") .font(.body) .lineLimit(2) Spacer(minLength: 0) } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary)) } Spacer(minLength: 0) } } .padding(6) } .frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background)) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) } // 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 { 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, marquee: marquee, drops: drops, openCard: openCard ) 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: 8) .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 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. // // **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)) // 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 } 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.rootURL, 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.rootURL, laneID: lane.id) { return ShadowRun( position: proposal.index, heights: Array(repeating: LaneDropRegistry.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 } /// **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. /// /// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out /// of the resting layout at pickup and stays out until release *whatever the effective operation /// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a /// layout that re-admitted them on every flip would flap the board under the cursor /// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands. /// /// **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.rootURL), 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, 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 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 { 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 card plate's metrics /// The card plate's geometry, spelled once because **two views draw it**: the real face /// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s /// `.awaitingArrival` face). 02-architecture.md ▸ TransientBoardState ▸ overlays makes the handoff /// "read as one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and /// exact is only checkable if there is one set of numbers rather than two that happen to agree. private enum CardFaceMetrics { /// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part /// of the card's edge rather than a bar laid over it. static let cornerRadius: CGFloat = 8 /// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether /// or not a stripe paints, so colouring a card never shifts its title. static let stripeWidth: CGFloat = 4 /// The plate's inset around its content. static let contentPadding: CGFloat = 10 /// Between the icon, the title and the attachments chip. static let rowSpacing: CGFloat = 6 } // MARK: - 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). /// /// ### One presentation, selection styling only /// /// The face is a top-aligned title row and its two decorations — the accent stripe and the /// selection stroke — are shapes in overlays. **A card has one presentation** (resettled /// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this /// face's styling — the selection stroke below — and never its geometry, so the masonry never /// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid, /// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an /// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the /// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card /// face). private struct CardFaceView: View { let store: BoardStore let card: Card /// The strip's rubber band — the registry this face registers its drawn frame into. let marquee: MarqueeControl /// The board window's drop machinery: this face registers its measured height into the geometry /// registry (the resting grid's input) and starts the card drag session from `.onDrag`. let drops: BoardDropContext 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. /// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to /// draw this same plate for the create handoff to read as one arrival. private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius } /// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's /// treatment shootout). private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth } var body: some View { titleRow .frame(maxWidth: .infinity, alignment: .leading) .padding(CardFaceMetrics.contentPadding) // Constant, whether or not a stripe paints: every card's text sits on the same grid, so // colouring a card never shifts its title relative to its uncoloured neighbours. .padding(.leading, stripeWidth) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary)) .overlay(alignment: .leading) { accentStripe } // The selection treatment, which a hovering Finder file drag borrows outright: "the card // highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is // already this face's vocabulary for "this one". The hover draws it a touch heavier so a // hovered card that is *also* selected still reads as the target. .overlay( RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder( isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: isFileHovered ? 2.5 : 1.5 ) ) // The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste // moves them"). Above `contentShape` so the face stays fully clickable while it waits. .cutTreatment(of: card.id, in: store) .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. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is // `SelectionGrammar`'s, reached through the store's one funnel. .onTapGesture { store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current) } // "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection). // // `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a // second tap recogniser on the same view makes the single click *wait* to see whether a // second one arrives, and selection must stay instant. Simultaneous means the first click // of the pair selects and the second opens — Finder's own behaviour. // // **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening // a window out from under a range the user is still building would be a surprise. .simultaneousGesture(TapGesture(count: 2).onEnded { guard ClickModifier.current == .plain else { return } openCard(card.id) }) // **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag` // beside the tap recognisers above is the click-versus-drag split, the system's own: it holds // the session off until the pointer really moves, so selecting and opening stay instant. .onDrag(startCardDrag, preview: { dragReplica }) // The card's height, for the drop model's analytic resting grid. A height is content-driven // and does not animate under the reflow — only positions do, and those are never measured // (`LaneDropRegistry`). .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in drops.registry.update(height: height, for: card.id) } .onDisappear { drops.registry.removeHeight(card.id) } .marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry) .contextMenu { cardMenu } .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) { StyleEditorPopover(store: store, recents: appModel.styleRecents) } } // MARK: - The card drag /// Begins the card'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 **flatten order** — /// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of /// it, which is also the order the drop inserts in. A card outside the selection drags alone. /// /// Refused under the read-only lock and while an inline editor is focused, like every other /// mutating gesture; a refusal is an item provider carrying nothing, so no session begins. private func startCardDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let snapshot = store.snapshot let selection = store.selection let ids: Set = selection.container == .board && selection.ids.contains(card.id) && selection.ids.count > 1 ? selection.ids : [card.id] // Flatten order, and the lane each member currently lives in — the folder path's middle // component. var lanesByCard: [ItemID: ItemID] = [:] var titles: [ItemID: String] = [:] for lane in snapshot.lanes { for member in lane.cards where ids.contains(member.id) { lanesByCard[member.id] = lane.id titles[member.id] = member.title.value } } let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) } guard !ordered.isEmpty else { return NSItemProvider() } let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .cards, container: .board, items: ordered.compactMap { id in guard let laneID = lanesByCard[id] else { return nil } return DragPayload.Item( id: id.rawValue, folder: root .appendingPathComponent(laneID.rawValue, isDirectory: true) .appendingPathComponent(id.rawValue, isDirectory: true) .path, title: titles[id] ) } ) drops.session.beginCards( ordered, folders: payload.folders, // The dragged items' sizes, frozen at drag start — the pickup transition scales the // replica, and its lingering "last measured frame" would mis-size the shadow and the // span-cap (03-board-ui.md § Motion). heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight }, container: .board, source: store ) return payload.itemProvider() } /// The image travelling under the cursor: this card's face at its real size, fanned with ghosts /// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion). private var dragReplica: some View { let count = store.selection.container == .board && store.selection.ids.contains(card.id) ? max(1, store.selection.ids.count) : 1 return ZStack { if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) } if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) } replicaFace } .overlay(alignment: .topTrailing) { DragCountBadge(count: count) } .padding(12) } /// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no /// editor and no geometry observers. private var replicaFace: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card)) .foregroundStyle(iconTint) .imageScale(.medium) Text(card.title.value ?? "Untitled") .font(.body) .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator } .padding(10) .padding(.leading, stripeWidth) .frame(width: 220, alignment: .leading) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary)) .overlay(alignment: .leading) { accentStripe } } // MARK: - Context menu /// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context /// menus' Card row, in its order, complete as of m5. @ViewBuilder private var cardMenu: some View { // Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card // alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and // Delete below it, this row never widens to the selection; Open never opens multiple, even // when the clicked card is part of one. It calls the very `openCard` closure the double-click // gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from // a focused inline editor to *this* card's context menu, so there is nothing here to commit // first — only the plain open. Button("Open") { openCard(card.id) } Divider() // Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of: // currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires // this card to be the *sole* selection; a context menu already names its target by where it // was invoked, so — standard macOS practice — it acts on the clicked card outright. Button("Rename") { store.transient.beginRename(of: card.id, currentTitle: card.title.value) } .disabled(!store.acceptsBoardMutations) StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget) Divider() // Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the // widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own, // so this row gets it for free. Button("Delete") { store.delete(targetIDs) } .disabled(!store.acceptsBoardMutations) } /// What this card's menu acts on: the whole selection when this card is part of it, else this card /// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete /// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked. private var styleTarget: StyleTarget { guard store.selection.container == .board, store.selection.ids.contains(card.id) else { return .items([card.id]) } return .items(store.selection.ids) } /// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set` /// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here /// on the live side). private var targetIDs: Set { guard store.selection.container == .board, store.selection.ids.contains(card.id) else { return [card.id] } return store.selection.ids } // MARK: - Title row private var titleRow: some View { HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) { Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card)) .foregroundStyle(iconTint) .imageScale(.medium) titleOrEditor // The title takes the row's width so the indicator sits hard against the trailing // edge — and so the rename field fills the same span the title occupied. .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator } } /// The title, or the rename editor when this card is the rename target. 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 — a title wrapping across its full four lines included. @ViewBuilder private var accentStripe: some View { if let color = Palette.color(for: card.background) { UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius) .fill(color) .frame(width: stripeWidth) // Decoration only: the whole plate is one click target for selection. .allowsHitTesting(false) } } // MARK: - Selection and rename plumbing private var isSelected: Bool { store.selection.container == .board && store.selection.ids.contains(card.id) } /// Whether an external Finder file drag is hovering **this** card — the attach highlight /// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is /// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch /// to one implementation (`BoardDrops`). private var isFileHovered: Bool { drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id } private var isRenaming: Bool { store.transient.renameEditor?.targetID == card.id } private var draft: Binding { 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. /// /// ### 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 `CardFaceMetrics` 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 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) } } ) .font(.body) .frame(maxWidth: .infinity, alignment: .leading) .padding(CardFaceMetrics.contentPadding) .background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary)) .overlay( RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius) .strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5) ) } /// **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 /// (`CardFaceMetrics`) — 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: CardFaceMetrics.rowSpacing) { Image(systemName: ItemSymbol.card) .foregroundStyle(.secondary) .imageScale(.medium) Text(store.transient.newCardPlaceholder?.draftTitle ?? "") .font(.body) .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) .padding(CardFaceMetrics.contentPadding) .padding(.leading, CardFaceMetrics.stripeWidth) .background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary)) } /// 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 { 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() } } }