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 collapse chevron. **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 collapse chevron,
/// which sits in an overlay outside it. **Card creation has no header control**: the "+" that once
/// lived beside the chevron is gone (m-collapse's own follow-up), and every creation path — ⌘N,
/// Return on a selected lane, double-click on lane empty space — is untouched by its absence.
///
/// ### 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`. **Redesigned 2026-08-09**
/// (Pipeline card 22f660b0) to the owner's five groups — Rename/Properties…/Style, Copy/Cut/Paste,
/// Width, Collapse/Expand, Send to Trash — `laneMenu`'s own doc comment carries the full shape and
/// every journaled deviation.
///
/// ### Collapsed: the slim strip
///
/// A lane whose `collapsed` reading is `true` (`LaneLayoutMath.isCollapsed`) draws **none** of the
/// above: no header, no masonry, no resize handle — a fixed-width vertical strip carrying the lane's
/// glyph, the same count badge, and its title rotated to read **bottom-up** and justified to the top of
/// the room below them (03-board-ui.md § Lane ▸ Collapsed lanes; the orientation is an owner ruling of
/// 2026-08-08/09 — see `collapsedTitle`). The accent band, the plate, the selection treatment and the
/// cut dim are unchanged, because a folded lane is still that lane.
///
/// What the fold takes away it takes away **by not drawing it**, which is how the hidden trash's
/// invisibility works and why this needs so little code elsewhere: no card face means no marquee
/// registration and no navigation frame, no grid registration means the card zones have nothing to
/// resolve against (`BoardDropContext.retargetCards` answers end-of-lane instead), and no header means
/// no rename target and no collapse chevron — the strip's own plain click is its reversal instead. The
/// strip keeps exactly three gestures: a plain click expands, a modified click selects the lane, and a
/// drag reorders it.
///
/// ### The card face
///
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and selection changes
/// only the face's styling — never its geometry. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
/// a click, and viewing an attachment's media is the card window's job, not the face's
/// (03-board-ui.md § Card face).
///
/// ### Equality gate
///
/// The lane is `Equatable` and instantiated through `.equatable()` (`BoardView.laneSlot`), because
/// the strip re-runs for reasons that have nothing to do with any one lane: `BoardView`'s body reads
/// the drag session, so **every drop-proposal change re-evaluates it**, and without a gate that
/// rebuilds every lane on the board — and, through them, every card face — on every cursor move
/// during a drag. See the `==` below for what the gate covers.
struct LaneView: View, Equatable {

    let store: BoardStore
    let lane: Lane

    /// The app-wide quick-style recents (03-board-ui.md § Styling ▸ Controls — "never board data"),
    /// read from the environment rather than threaded down the strip: the list belongs to the app,
    /// not to this board, and every context menu in the window wants it.
    @Environment(AppModel.self) private var appModel

    /// Interior masonry columns — the lane's width units, or the resize session's snapped count
    /// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
    /// override it (see `BoardView.laneSlot`).
    let columns: Int

    /// This lane's resting slot width — the replica's width, so the image under the cursor is the
    /// lane at its real on-screen size (03-board-ui.md § Motion: "a faithful, full-size replica").
    let slotWidth: CGFloat

    /// The board window's drop machinery: the app-wide session, the geometry registry this lane
    /// registers its card grid into, and the shared retarget every hover and every autoscroll step
    /// goes through (`BoardDropContext`).
    let drops: BoardDropContext

    /// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
    /// face registers its frame into the same registry (`MarqueeControl`).
    let marquee: MarqueeControl

    /// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
    /// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
    /// business knowing about `WindowGroup` keys.
    let openCard: @MainActor (ItemID) -> Void

    /// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
    /// and handed to `Motion`, which owns what "reduced" means.
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// Increase Contrast, for the selection stroke and the plate's resting edge below
    /// (10-accessibility.md). Read from the environment and handed to `Accommodations`, which owns
    /// what "increased" does to a stroke.
    @Environment(\.colorSchemeContrast) private var contrast

    /// Reduce Transparency, for the lane plate's wash (`Accommodations.lanePlateWash` — translucent
    /// normally, the standard secondary background under the setting; the trash plate's rule).
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency

    /// The window's appearance — the input to 10-accessibility.md's runtime-contrast rule, and the
    /// answer for every board whose background is not a hand-written hex (`headerInk`).
    ///
    /// Read in `body` rather than resolved once, which is what makes "recomputed on appearance
    /// change" free: a light/dark flip re-evaluates this view, and the decision below is taken again
    /// against the colours of the appearance the window is now in.
    @Environment(\.colorScheme) private var colorScheme

    /// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`), injected on the strip by
    /// `BoardWindowHost`. An `@Environment` read rather than a value passed down deliberately: this
    /// view is `.equatable()`, and environment values are the one input the gate cannot suppress.
    @Environment(\.boardZoom) private var zoom

    /// The live body metric — every figure this lane lays out on is a multiple of it
    /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule), at the board's zoom.
    private var pointSize: CGFloat { zoom.bodyPointSize }

    /// Spacing between cards, and between the interior columns.
    private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }

    /// The lane plate's corner radius — shared by the selection treatment and the accent band, whose
    /// top corners round to exactly this so the band reads as the lane's own edge.
    private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }

    /// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
    private var bandHeight: CGFloat { BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize) }

    /// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
    /// tall as the strip gives it.
    @State private var measuredHeight: CGFloat = 0

    /// The title bar's drawn height — **the replica's anchor** (`dragReplica`), measured for the same
    /// reason the lane's height is: the bar is as tall as the text in it, at whatever size the system
    /// is set to.
    @State private var measuredHeaderHeight: CGFloat = 0

    /// The vertical room the collapsed strip's rotated title has to run in — measured, because it is
    /// whatever the strip's height leaves after the glyph and the badge, and the title's **truncation
    /// is stated against it** (`collapsedTitle`). Zero until the strip lays out, which draws no title
    /// for one frame rather than a badly-placed one.
    @State private var collapsedTitleTravel: CGFloat = 0

    /// The card stack's viewport height — the masonry's height *floor* (`scrollableCards`).
    /// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum
    /// can stretch the content to fill it; only an explicit minimum can.
    @State private var scrollViewportHeight: CGFloat = 0

    /// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
    /// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
    @State private var autoScroller = DragAutoScroller()

    /// The whole of what this lane is a function of **as far as the strip is concerned**: the lane
    /// value (`Lane` is `Equatable`, its cards included, so an edit anywhere under this lane makes it
    /// a different value), the two layout figures the strip resolves rather than the lane
    /// (`columns` follows the live resize session, `slotWidth` the strip's standard width), and the
    /// window-lived collaborators — the store by identity, the band and the drop machinery by their
    /// own equivalence tests, which exist because `BoardView` rebuilds both structs, closures and
    /// all, on every body pass.
    ///
    /// **What the gate does not suppress is the point.** Everything this body reads through
    /// Observation — `drops.session`'s proposal, members and file target, `store.selection`,
    /// `store.searchFilter`, `store.transient`, `appModel.styleRecents` — invalidates this view
    /// directly, and `.equatable()` has no say in that. So the lane a drag is actually over still
    /// re-runs on every proposal change, and its `shadowRun` animation key still moves with it; what
    /// stops is the *other* lanes re-running because the strip did.
    ///
    /// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
    /// `@Environment` values (SwiftUI invalidates on those itself), and `openCard` — a closure the
    /// host rebuilds every pass, which is a pure hand-off to a `WindowGroup` key and identical in
    /// behaviour whatever closure object carries it.
    nonisolated static func == (lhs: LaneView, rhs: LaneView) -> Bool {
        lhs.lane == rhs.lane
            && lhs.columns == rhs.columns
            && lhs.slotWidth == rhs.slotWidth
            && lhs.store === rhs.store
            && lhs.marquee.isEquivalent(to: rhs.marquee)
            && lhs.drops.isEquivalent(to: rhs.drops)
    }

    var body: some View {
        // The strip's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because
        // `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
        #if DEBUG
        let _ = BoardRenderMetrics.countLaneBody()
        #endif
        // `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
        // lane's top edge, so it must sit outside the content inset rather than in it.
        VStack(alignment: .leading, spacing: 0) {
            accentBand
            // **The fold is a different body, not a hidden one** (03-board-ui.md § Lane ▸ Collapsed
            // lanes): the header and the masonry are not built at all, which is what makes every
            // "collapsed lanes are excluded from …" rule below true by construction rather than by a
            // guard per gesture — nothing registers a frame, a grid, a header rect or a marquee target
            // because nothing exists to register one.
            if isCollapsed {
                collapsedStrip
                    .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
            } else {
                VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
                    header
                    cardStack
                }
                .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
            }
        }
        .background(lanePlate)
        .overlay(selectionStroke)
        // The fold's own drop feedback — see `collapsedDropStroke`. Outside the `if` so it is one
        // overlay whatever the lane is doing, and inert (clear) whenever there is nothing to say.
        .overlay(collapsedDropStroke)
        // The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
        // lane is cut cards and all.
        .cutTreatment(of: lane.id, in: store)
        .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
        // **This lane's drop target**, on the whole body. It accepts *every* session type and routes
        // internally — card sessions against this lane's masonry zones, lane sessions forwarded to
        // the strip's logic, external Finder file sessions against those same zones — because
        // single-target dispatch has no fall-through (DRAG-REORDER.md).
        .onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
        // **The lane is an accessibility container** — "window → lanes (accessibility containers, in
        // lane `order`) → cards (leaf elements, in card `order`)" (10-accessibility.md ▸ The board
        // through VoiceOver). `.contain` rather than `.combine`: the header, the collapse chevron and
        // every card must stay individually reachable, which is the whole point of a container the
        // VoiceOver cursor enters (`TrashLaneView` states the same rule from the trash's side).
        .accessibilityElement(children: .contain)
        // "⟨title⟩, lane, N cards", where **N is the rendered count and therefore the filter's** —
        // the very collection the visible badge counts, so the spoken count and the drawn one are
        // one number ("the count reads the search filter like the visible badge"). A card the query
        // hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
        // which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
        // by construction rather than by a second rule.
        .accessibilityLabel(AccessibilityPhrases.laneLabel(
            title: lane.title.value,
            // **A collapsed lane speaks its held cards, not its rendered ones** — it renders none, and
            // "0 cards" would say the lane was empty when it is merely shut. The badge on the strip
            // shows the same number for the same reason (`collapsedStrip`).
            cards: isCollapsed ? lane.cards.count : renderedCards.count,
            collapsed: isCollapsed))
    }

    /// Whether this lane is folded to its slim strip — `LaneLayoutMath.isCollapsed`'s reading, named
    /// here because this body asks it a dozen times (03-board-ui.md § Lane ▸ Collapsed lanes).
    private var isCollapsed: Bool {
        LaneLayoutMath.isCollapsed(lane)
    }

    // MARK: - The collapsed strip

    /// The fold's whole face: the lane's glyph, its title rotated to read top-to-bottom, and the count
    /// badge, stacked in a column the width of `BoardMetrics.collapsedLaneWidth` (03-board-ui.md § Lane
    /// ▸ Collapsed lanes).
    ///
    /// **The whole strip is one surface with three gestures**, deliberately fewer than the expanded
    /// lane's two-surface arrangement (header + empty space) because there is nothing to tell apart: a
    /// plain click **expands** (the fold's own reversal, and the discoverable one — there is no header
    /// to hang a chevron on), a ⌘/⇧ click enters the ordinary selection grammar so a folded lane can
    /// still be selected and ranged over by pointer, and a drag reorders the lane exactly as its header
    /// would (`startLaneDrag`). A double click is a plain click twice: the first expands, and the second
    /// lands on the lane that is now open — no create, because the strip has no empty space to
    /// double-click *into*.
    private var collapsedStrip: some View {
        VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
            // **The glyph and the badge are the strip's chrome, and they sit at its top** (owner ruling
            // 2026-08-08/09): the title is top-justified below them, so the two fixed pieces stay
            // exactly where they are however long the title runs — a badge underneath a
            // variable-length run would drift with the words above it.
            Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
                .foregroundStyle(.secondary)
                .imageScale(.medium)
            // **The lane's held count, not its rendered one**: a folded lane renders no card, so the
            // filter has nothing to narrow here and `0` would be a lie about the lane's contents. The
            // search's own answer is the *expanded* badge's rule and stays there (`countBadge`).
            countBadgeText("\(lane.cards.count)")
            collapsedTitle
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        // The strip is chrome all the way down, so it has to be hit-testable across its whole height —
        // including the empty stretch under the badge, which is most of it on a tall board.
        .contentShape(Rectangle())
        .onTapGesture {
            let modifier = ClickModifier.current
            guard modifier == .plain else {
                // ⌘/⇧ keep their grammar: the strip is the lane's one pointer surface while folded, so
                // taking the modifiers away would make a collapsed lane unselectable by mouse.
                store.click(
                    SelectionTarget(id: lane.id, kind: .lane, container: .board),
                    modifier: modifier
                )
                return
            }
            expand()
        }
        .onDrag(startLaneDrag, preview: { dragReplica })
        // **The strip is the lane's heading while folded** — the header's role, since there is no
        // header (10-accessibility.md ▸ Rotor: "lane titles are headings, so the headings rotor jumps
        // lane-to-lane"). Flattened for the same reason the header is: the glyph and the count are the
        // container's information, already spoken by its label.
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
        .accessibilityAddTraits(headerTraits)
        // The keyboard's way out of the fold, and the pointer's, on one method: VO-Space would
        // otherwise be the one path that could not undo what it can see.
        .accessibilityAction(named: "Expand Lane") { expand() }
        .accessibilityAction { toggleLaneSelection() }
        .boardTextInk(headerInk)
        .contextMenu { laneMenu }
        .accessibilityActions { laneActions }
        // The Style… popover's anchor while folded — the strip stands in for the header, which is
        // where an expanded lane's popover hangs (`styleEditorPresentation` decides whether this lane
        // is the session's anchor at all). Without it a restyle invoked from the strip's own menu would
        // have nowhere to present from.
        .popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .trailing) {
            StyleEditorPopover(store: store, recents: appModel.styleRecents)
        }
    }

    /// The title, turned a quarter turn **counterclockwise** so it reads **bottom-up** — the European
    /// book-spine orientation — and **justified to the top** of the room below the strip's chrome
    /// (owner ruling 2026-08-08/09, superseding the top-down reading this shipped with for an hour).
    ///
    /// Both halves are one alignment plus one sign, and both are worth spelling out because the
    /// rotation is a *visual* transform over an unrotated layout:
    ///
    /// - **`-90°`** maps the unrotated `+x` direction (the reading direction) onto `-y` (up), so the
    ///   string's first character lands at the run's bottom and the last at its top: the eye travels
    ///   upward, and the tail ellipsis — plain `.truncationMode(.tail)`, untouched — lands at the top
    ///   end, which is where the string's tail lands by construction.
    /// - **`alignment: .trailing`** puts a short string against the unrotated box's right edge, and
    ///   right maps to up under the same rotation: the glyphs occupy the *topmost* stretch of the run
    ///   and the slack falls away beneath them. Anchoring the box instead of the glyphs would leave a
    ///   short title floating in the middle of a full-height frame.
    ///
    /// **It truncates against the strip's height**, which is the whole reason the travel is measured
    /// rather than derived: a rotated `Text` given no width lays out at its full ideal length and simply
    /// overhangs the strip's ends. Laying it out at `collapsedTitleTravel` first — the leftover height,
    /// measured by the clear box it is drawn over — is 03's graceful-truncation rule holding in the one
    /// place on the board where the axis is vertical.
    ///
    /// The clear box takes whatever the chrome above it left (`maxHeight: .infinity`), so the leftover
    /// arithmetic is the layout's rather than this file's.
    private var collapsedTitle: some View {
        Color.clear
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { collapsedTitleTravel = $0 }
            .overlay { rotatedTitle(travel: collapsedTitleTravel) }
    }

    /// The title turned on its side, laid out against `travel` points of vertical room — the strip's and
    /// its replica's one construction, so the drag image reads and truncates exactly as the lane does.
    private func rotatedTitle(travel: CGFloat) -> some View {
        Text(lane.title.value ?? "Untitled")
            .boardFont(.headline)
            .foregroundStyle(lane.title.value == nil ? .secondary : .primary)
            .lineLimit(1)
            .truncationMode(.tail)
            .frame(width: travel, alignment: .trailing)
            .rotationEffect(.degrees(-90))
    }

    /// **The drop feedback a folded lane can give** (03-board-ui.md § Lane ▸ Collapsed lanes: a drop on
    /// the strip appends at the lane's end, "with the normal drop feedback").
    ///
    /// The normal feedback is a shadow opening in the masonry at the landing slot, and a strip has no
    /// masonry to open one in — so the strip says the same thing the way the card-attach highlight does
    /// (04-interactions.md ▸ Drag and drop: "each target gets one clear signal, and the card-attach
    /// highlight exists precisely because that target has no shadow"): an accent edge around the lane
    /// while the proposal names it. Both session kinds reach it, ours and Finder's, because both land
    /// at the same place.
    ///
    /// Clear rather than absent when there is nothing to say, so the overlay never changes identity.
    @ViewBuilder
    private var collapsedDropStroke: some View {
        let proposing = isCollapsed
            && (cardProposal != nil
                || drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) != nil)
        RoundedRectangle(cornerRadius: cornerRadius)
            .strokeBorder(
                proposing
                    ? Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast))
                    : .clear,
                lineWidth: Accommodations.borderWidth(2, contrast: contrast)
            )
            .allowsHitTesting(false)
    }

    /// Unfolds the lane — the strip's click, its VoiceOver action, and the menu row's expand half, on
    /// one method so the three cannot mean different things.
    private func expand() {
        guard store.acceptsBoardMutations else { return }
        store.setLaneCollapsed(lane.id, collapsed: false)
    }

    /// Folds it — the header chevron's and the menu row's collapse half.
    private func collapse() {
        guard store.acceptsBoardMutations else { return }
        store.setLaneCollapsed(lane.id, collapsed: true)
    }

    // MARK: - Header

    private var header: some View {
        headerContent
            // **The lane title is a heading** — "lane titles are headings, so the headings rotor
            // jumps lane-to-lane; on a one-dimensional board that *is* structural navigation"
            // (10-accessibility.md ▸ Rotor). One flattened element rather than icon + text + badge:
            // the glyph and the count are the container's information, already spoken by its label,
            // and three stops where the design asks for a heading would make the rotor useless.
            //
            // `.contain` while a rename is open, because the flattening would otherwise swallow the
            // text field the user is typing into — the one moment this subtree holds a control
            // rather than chrome.
            .accessibilityElement(children: isRenaming ? .contain : .ignore)
            .accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
            .accessibilityAddTraits(headerTraits)
            // **VO-Space toggles the lane's selection** — the ⌘-click analogue 10-accessibility.md
            // gives a card, applied to the other selectable thing on the board, and routed through
            // the same `BoardStore.click` funnel the pointer uses so the homogeneity and
            // container rules are `SelectionGrammar`'s single answer rather than a second one.
            // Deliberately **not** the header's own plain-click semantics: "moving the VO cursor
            // never mutates selection … VO-Space on a card toggles its selection (the ⌘-click
            // analogue — a toggle, never plain click's replace)", and a VO-Space that replaced would
            // silently wipe a multi-lane selection the user had just built.
            .accessibilityAction { toggleLaneSelection() }
            // The bar is the drag surface, so it must be hit-testable across its whole width —
            // including the empty stretch between the badge and the button.
            .contentShape(Rectangle())
            // **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
            // click on the already-selected lane's header unselects, one lane-click behavior
            // everywhere, so a full lane keeps a pointer path out of selection". Hence the same
            // `togglesOnRepeat` the empty space passes — the two surfaces differ only in where they
            // are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
            // the drag off until the pointer actually moves, so a click is never a drag.
            .onTapGesture {
                store.click(
                    SelectionTarget(id: lane.id, kind: .lane, container: .board),
                    modifier: .current,
                    togglesOnRepeat: true
                )
            }
            .onDrag(startLaneDrag, preview: { dragReplica })
            // **The header is the file drop's topmost position** (04-interactions.md ▸ Drag and drop,
            // settled 2026-07-28: "a release on the lane header resolves to the topmost position —
            // forgiving beats a dead stripe"). Its frame is registered rather than derived from the
            // grid's because the grid is scroll-view *content*: scrolled down, its top edge climbs
            // past the header and would stop being a boundary at all. The bar itself never moves.
            .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
                drops.registry.update(header: frame, for: lane.id)
                // The same measurement answers a second question, so it is read once: the bar is
                // what a lane drag is grabbed by, and its height is where the replica has to hang
                // from the pointer (`replicaHeaderCenterY`).
                measuredHeaderHeight = frame.height
            }
            .onDisappear { drops.registry.removeHeader(lane.id) }
            // **The header's trailing chrome, outside the drag region** — the overlay is attached
            // after `.onDrag` deliberately, which is what makes the chevron clickable rather than
            // grabbable (the new-card button's old arrangement, now the chevron's alone — the button
            // itself is gone).
            .overlay(alignment: .trailing) {
                collapseButton
            }
            // **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on
            // a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike,
            // since the ink they need is the same question and only the *verification* differs
            // (`ContrastMath`).
            //
            // A lane's plate is a *translucent wash* (`lanePlate`), so the title, the icon, the
            // count badge and the rename field still land, effectively, on the board's `background`
            // — the surface the design binds the threshold to ("text does sit on it",
            // `BoardView.boardBackground`); the wash shifts it too little to change the answer. The
            // card faces below are a different matter and deliberately untouched: they carry their
            // own opaque plate (`BoardSurface.cardPlate`), so their titles never see the board
            // colour.
            //
            // **Placed here, not at the end of the chain**, which is the modifier order doing real
            // work: everything above — including the collapse chevron in the overlay — is text on the
            // board background and takes the computed ink, while the context menu and the Style…
            // popover attached below stay in the window's own appearance, because a menu is system
            // chrome drawn on its own surface and not on this board's colour.
            .boardTextInk(headerInk)
            .contextMenu { laneMenu }
            // **The context menu's plain rows, additionally as custom actions** — "where SwiftUI
            // additionally surfaces menu items as custom accessibility actions, that's free
            // improvement, not a separate design surface" (10-accessibility.md). The menu itself
            // stays the inventory and is reachable the standard way (VO-⇧-M); this is the same
            // commands one rotor turn closer — see `laneActions`' own doc comment for what is left out
            // and why (Style…, Properties…).
            .accessibilityActions { laneActions }
            // The lane's half of the Style… popover. Anchored on the header because that is the
            // lane's own furniture — `styleEditorPresentation` decides whether this lane is the
            // session's presenting anchor at all.
            .popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .bottom) {
                StyleEditorPopover(store: store, recents: appModel.styleRecents)
            }
    }

    /// Which appearance's label vocabulary the header's text is drawn in — the window's own, unless
    /// the **board** paints a background whose composited surface fails ≥ 4.5:1 against it
    /// (10-accessibility.md ▸ Text scaling & visual accommodations; `BoardTextInk`).
    ///
    /// It is not only the exotic case: ten of the twelve palette wells are dark colours, and every
    /// one of them needs the dark appearance's label in a *light* window. A board styled entirely
    /// from the in-app grid reaches this line as often as a hand-written one does.
    ///
    /// It reads the *board*'s background and never this lane's, which is 03-board-ui.md ▸ Styling's
    /// C7 ruling showing up as arithmetic: a lane's colour is an edge band, not a fill, so it is
    /// never behind this text and carries no contrast obligation (`accentBand`).
    private var headerInk: ColorScheme {
        BoardTextInk.scheme(forBoardBackground: store.snapshot.background, appearance: colorScheme)
    }

    /// The lane's colour as C7 — "a lane's color paints a full-width band along its top edge; the
    /// surfaces themselves keep the standard chrome, so colored title text never sits on a colored
    /// fill" (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's treatment
    /// shootout).
    ///
    /// A value that resolves to nothing paints **no band**, and the bytes stay on disk exactly as
    /// written — the card stripe's rule, for its reason: there is no sensible default colour for
    /// "the author meant something we can't read", and a wrong colour is worse than none.
    @ViewBuilder
    private var accentBand: some View {
        if let color = Palette.color(for: lane.background) {
            UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
                .fill(color)
                .frame(height: bandHeight)
                .frame(maxWidth: .infinity)
                // Decoration only: the header below it owns the lane's click and drag.
                .allowsHitTesting(false)
        }
    }

    // MARK: - The lane's one context menu

    /// **The lane context menu, redesigned** (2026-08-09 ▸ "redesign context menu for lanes", Pipeline
    /// card 22f660b0, twinning the card menu's own reshape the same day — card fe66c461): five groups,
    /// a divider between each, the owner's shape verbatim —
    ///
    /// 1. Rename, Properties…, Style ▸ (Symbol, Color)
    /// 2. Copy, Cut, Paste
    /// 3. Width — Increase, Decrease, Reset
    /// 4. Collapse Lane / Expand Lane
    /// 5. Send to Trash
    ///
    /// Every row routes through the *existing* command it twins — nothing here is new capability,
    /// only a new arrangement of ones the app already has (`BoardRenameCommand`, `StyleEditorSession`,
    /// `ClipboardStore`, `store.setLaneWidth`, `store.setLaneCollapsed`, `store.delete`).
    ///
    /// ### Properties… — a placeholder, deliberately inert
    ///
    /// The owner's own word for it. There is no properties pane yet, so the row opens nothing and is
    /// unconditionally `.disabled(true)` — reserved furniture for a future card, not a stub wired to
    /// dead code. It is left out of `laneActions` below for the same reason: a VoiceOver custom action
    /// that always does nothing has nothing to announce a reason for, where a sighted row can at least
    /// be *seen* greyed out beside its live neighbors.
    ///
    /// ### Style ▸ Symbol / Color — one popover, not two (v1, flagged for owner review)
    ///
    /// The card menu's own posture, unchanged here: `StyleEditorSession` carries only a `StyleTarget`,
    /// no notion of "arrived here for the symbol section" versus "the color section", so both rows
    /// open the identical existing popover rather than two that do not exist. Flagged for owner
    /// review exactly as the card menu's is: a future pass that teaches the editor to pre-focus a
    /// section should aim these two rows at it.
    ///
    /// **The quick-style recents row is gone from this menu too**, for symmetry with the card menu's
    /// own drop (`CardFaceView.boardMenu`'s doc comment) — the owner's list names only Symbol and Color
    /// under Style, neither the card's list nor this one mentions the recents row, and leaving it on
    /// the lane alone while the card dropped it would be an asymmetry nothing asked for. `StyleMenuItems`
    /// (the row's old bundle of "Style…" plus `QuickStyleRow`) is no longer called from here; this menu
    /// builds the submenu directly, `Menu("Style") { Button("Symbol"); Button("Color") }`, the card
    /// menu's own shape. **Flagged for owner review** — easy to bring back as a third row under Style
    /// if the drop was not intended.
    ///
    /// ### Copy / Cut / Paste — targeting
    ///
    /// **Copy and Cut widen exactly as Delete and Style already do** — `clipboardTarget`, `targetIDs`'s
    /// `ItemReferenceSet` wrapper, the card menu's own extension of "right-clicking something outside
    /// the selection acts on what was clicked" to the clipboard (`ClipboardStore.copy(from:targeting:)`/
    /// `cut(from:targeting:)`, added for the card menu, reused verbatim here). Multi-selection semantics
    /// are `ClipboardStore`'s own: a multi-lane selection copies/cuts every member, in flatten order.
    ///
    /// **Paste does not retarget to the clicked lane** — the card menu's own posture: Paste is a
    /// *destination* operation (`PasteTarget`), not an item operation, with no per-item widening
    /// precedent anywhere in this codebase. This row calls the one existing rule
    /// (`ClipboardStore.paste(into:)`) rather than inventing a click-anchored variant, exactly as the
    /// card menu's Paste row does.
    ///
    /// ### Width — Increase / Decrease / Reset
    ///
    /// The existing labeled `Section` (`widthControl`), gaining a third row: **Reset Width** calls
    /// `store.setLaneWidth(lane.id, units: 1)` — the *exact* call Decrease already makes at the floor,
    /// no new store method. `setLaneWidth`'s own remove-at-default rule already turns a landing on one
    /// unit into an absent `width` key (`LaneWidthWriteTests.landingOnOneRemovesTheKey`), so "reset"
    /// falls out of machinery that already exists rather than adding a second write path that has to
    /// agree with the first. Disabled at the identical floor Decrease already respects: a lane
    /// already displaying one unit has nothing to reset.
    ///
    /// ### Collapse / Expand — one row, title flips
    ///
    /// `collapseControl`, unchanged: "a menu row that toggles a *geometry* reads better as the verb it
    /// performs than as a state it reports" (its own doc comment) — this app's one context-menu toggle
    /// precedent, distinct from the menu-bar's stable-titled checkmark `Toggle` (`ShowTrashCommand`),
    /// which lives in `Commands` rather than a `.contextMenu` and needs a stable title as a defaults key.
    ///
    /// ### Send to Trash
    ///
    /// Relabeled from "Delete" — the owner's own word for the group, and the more accurate one for
    /// what this row does: the staged move into `<root>/.trash/` (`store.delete`, unchanged), not a
    /// permanent delete (that word stays the trash lane's own menu, out of this card's scope). Lanes
    /// rejoined the trash 2026-07-29/30 (`f7c8088`, `8014bde`); this row has called `store.delete`
    /// through that whole history and still does.
    ///
    /// ### Render-safety: why Copy/Cut/Paste read the real predicates here, unlike the card menu
    ///
    /// The card menu reduces its own Copy/Cut/Paste `.disabled` checks to selection/snapshot-free forms
    /// because a card face is otherwise never subscribed to `store.selection`/`store.snapshot` at all —
    /// `.contextMenu`'s builder is not lazy, so reading either there would be the very O(board)
    /// regression `isSelected` exists to prevent (`CardFaceView.boardMenu`'s own doc comment).
    ///
    /// **That premise does not hold for a lane.** This struct's own "Equality gate" section states it
    /// plainly: `store.selection` is read unconditionally, every body pass, for `isSelected`
    /// (`.overlay(selectionStroke)`, outside the collapsed/expanded split), and `store.snapshot` is
    /// read unconditionally for `headerInk` (both branches) — "`.equatable()` has no say" over either.
    /// So calling `ClipboardStore`'s real `canCopy(from:targeting:)`/`canCut(from:targeting:)`/
    /// `canPaste(into:)` here (`copyEnabled`/`cutEnabled`/`pasteEnabled` below) adds no *new*
    /// subscription — only the predicate's own cheap work, paid once per **lane** (a handful on any
    /// board) rather than once per **card** (hundreds), which is the axis the card menu's reduction
    /// was actually protecting. No O(board) traversal is added either way: every predicate here reads
    /// `clipboardTarget` (at most the live selection) against the already-resident `store.snapshot`.
    @ViewBuilder
    private var laneMenu: some View {
        // Group 1 — Rename, Properties…, Style ▸ (Symbol, Color).
        //
        // Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
        // currentTitle:)`, seeded with the lane's live title. The menu-bar item additionally requires
        // this lane to be the *sole* selection; a context menu already names its target by where it
        // was invoked, so — standard macOS practice — it acts on the clicked lane outright.
        Button("Rename") { beginRename() }
            .disabled(!store.acceptsBoardMutations)
        // Properties…: deliberately inert — see the type comment's own section.
        Button("Properties…") {}
            .disabled(true)
        Menu("Style") {
            // Both rows open the identical popover — see the type comment's "one popover, not two".
            Button("Symbol") { store.transient.beginStyleEditor(for: styleTarget) }
                .disabled(!store.acceptsBoardMutations)
            Button("Color") { store.transient.beginStyleEditor(for: styleTarget) }
                .disabled(!store.acceptsBoardMutations)
        }
        // Both rows share this exact condition, so the submenu itself greys out with them rather than
        // opening to reveal two disabled rows.
        .disabled(!store.acceptsBoardMutations)

        Divider()

        // Group 2 — Copy, Cut, Paste.
        Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
            .disabled(!copyEnabled)
        Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
            .disabled(!cutEnabled)
        Button(pasteTitle) { appModel.clipboard.paste(into: store) }
            .disabled(!pasteEnabled)

        Divider()

        // Group 3 — Width: Increase, Decrease, Reset.
        widthControl

        Divider()

        // Group 4 — the fold's own row, one row whose title says which direction it goes — see the
        // type comment's own section.
        collapseControl
            .disabled(!store.acceptsBoardMutations)

        Divider()

        // Group 5 — Send to Trash: File ▸ Delete's exact store path (`store.delete`), on the same
        // widened target set Style… above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling
        // below) — the successor-selection rule is `delete(_:)`'s own, so this row gets it for free.
        // Relabeled from "Delete" — see the type comment.
        Button("Send to Trash") { deleteTargets() }
            .disabled(!store.acceptsBoardMutations)
    }

    /// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions`
    /// call site). Every one of them calls the *same* private method its menu row does, so the two
    /// surfaces cannot drift into meaning different things — which is the only way "not a separate
    /// design surface" is checkable rather than merely intended.
    ///
    /// **Style and Properties… are absent** — Style for `CardFaceView.boardActions`' own reason (it
    /// opens a popover, its own accessible surface); Properties… for the type comment's own reason (an
    /// always-disabled row with nothing behind it has nothing to announce a custom action for).
    @ViewBuilder
    private var laneActions: some View {
        let units = LaneLayoutMath.displayUnits(of: lane)
        Button("Rename") { beginRename() }
            .disabled(!store.acceptsBoardMutations)
        Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
            .disabled(!copyEnabled)
        Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
            .disabled(!cutEnabled)
        Button(pasteTitle) { appModel.clipboard.paste(into: store) }
            .disabled(!pasteEnabled)
        Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) }
            .disabled(!store.acceptsBoardMutations)
        Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
            .disabled(!store.acceptsBoardMutations || units <= 1)
        Button("Reset Width") { store.setLaneWidth(lane.id, units: 1) }
            .disabled(!store.acceptsBoardMutations || units <= 1)
        collapseControl
            .disabled(!store.acceptsBoardMutations)
        Button("Send to Trash") { deleteTargets() }
            .disabled(!store.acceptsBoardMutations)
    }

    /// The fold's one row, in the direction this lane can actually go — the context menu's and its
    /// VoiceOver twin's, spelled once so the two surfaces cannot drift into meaning different things
    /// (the `laneActions` rule).
    private var collapseControl: some View {
        isCollapsed
            ? Button("Expand Lane") { expand() }
            : Button("Collapse Lane") { collapse() }
    }

    /// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers
    /// (the context menu row and its accessibility twin).
    private func beginRename() {
        store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
    }

    /// File ▸ Delete's store path over the context-menu target set — the menu row's body and its
    /// accessibility twin's alike.
    private func deleteTargets() {
        store.delete(targetIDs)
    }

    /// VO-Space's landing: the ⌘-click funnel, on this lane. `togglesOnRepeat` stays false because
    /// only the *plain* branch reads it — the ⌘ branch is already a toggle, which is the point.
    private func toggleLaneSelection() {
        store.click(
            SelectionTarget(id: lane.id, kind: .lane, container: .board),
            modifier: .command
        )
    }

    /// The header element's traits: a heading always, and **selected when the lane is** — "state is
    /// never colour-alone: selection is a ring plus trait" (10-accessibility.md).
    private var headerTraits: AccessibilityTraits {
        isSelected ? [.isHeader, .isSelected] : [.isHeader]
    }

    /// The width stepper — "the header context menu's Width control (stepper, uncapped) is the
    /// precise control … it never touches the window, it **re-divides** the existing width across the
    /// new unit total" (03-board-ui.md § Lane). A +/− pair rather than a slider or a fixed 1×/2×/3×
    /// list, because the control is uncapped in one direction and floored at one unit in the other.
    /// **Reset Width** (2026-08-09 ▸ "redesign context menu for lanes") joins them at the same floor —
    /// see `laneMenu`'s own "Width" section for why it is exactly `setLaneWidth(lane.id, units: 1)`
    /// and no new store method.
    ///
    /// **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)
            Button("Reset Width") {
                store.setLaneWidth(lane.id, units: 1)
            }
            // Nothing to reset at the floor already — the same no-op-reads-disabled rule as Decrease.
            .disabled(units <= 1)
        }
        .disabled(!store.acceptsBoardMutations)
    }

    /// What this lane's menu styles: the whole selection when this lane is part of it, else this lane
    /// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
    /// Right-clicking something outside the selection acts on what was clicked.
    private var styleTarget: StyleTarget {
        guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
            return .items([lane.id])
        }
        return .items(store.selection.ids)
    }

    /// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
    /// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
    /// on the live side).
    private var targetIDs: Set<ItemID> {
        guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
            return [lane.id]
        }
        return store.selection.ids
    }

    // MARK: - Copy / Cut / Paste

    /// Copy's and Cut's own widened target — `styleTarget`/`targetIDs`' exact pairing, one type over
    /// (`CardFaceView.clipboardTarget`'s twin): the clicked lane alone, or the live selection when the
    /// clicked lane is a member of it. `.board`, always — a lane's context menu never opens from the
    /// trash (`TrashLaneView` carries its own menu, out of this card's scope).
    private var clipboardTarget: ItemReferenceSet {
        ItemReferenceSet(ids: targetIDs, container: .board)
    }

    /// Copy's context-menu enablement — the real predicate, not a render-safe reduction of it. See
    /// `laneMenu`'s own "Render-safety" section for why a lane face can afford to call
    /// `ClipboardStore.canCopy(from:targeting:)` directly where `CardFaceView` cannot: this body is
    /// already unconditionally subscribed to `store.selection`/`store.snapshot` (`isSelected`,
    /// `headerInk`), so nothing new is subscribed by reading them again here.
    private var copyEnabled: Bool {
        appModel.clipboard.canCopy(from: store, targeting: clipboardTarget)
    }

    /// `copyEnabled`'s cut twin — `canCut(from:targeting:)`, `copyEnabled`'s own reasoning.
    private var cutEnabled: Bool {
        appModel.clipboard.canCut(from: store, targeting: clipboardTarget)
    }

    /// Paste's context-menu enablement. **Does not retarget to the clicked lane** — `canPaste(into:)`,
    /// the same generic call ⌘V and Edit ▸ Paste use, the card menu's own Paste row's reasoning: Paste
    /// is a destination operation with no per-item widening precedent anywhere in this codebase.
    private var pasteEnabled: Bool {
        appModel.clipboard.canPaste(into: store)
    }

    /// **The Paste row's title** — `ClipboardManifest.pasteMenuTitle(for:)`'s own doc comment carries
    /// the full rule ("Paste Card"/"Paste N Cards" and their lane twins, plain "Paste" otherwise),
    /// shared verbatim with `CardFaceView.pasteTitle`. Reads `appModel.clipboard.payload` directly
    /// rather than routing through `canPaste(into:)` as `pasteEnabled` does above: this body is already
    /// unconditionally subscribed to `store.selection`/`store.snapshot` (`laneMenu`'s own
    /// "Render-safety" section), so there is no render-safety reduction to preserve here and no reason
    /// to read the payload a second time through a predicate that also checks board mutation and a
    /// target — this only ever needs the payload itself.
    private var pasteTitle: String {
        ClipboardManifest.pasteMenuTitle(for: appModel.clipboard.payload)
    }

    private var headerContent: some View {
        HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
            Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
                .foregroundStyle(.secondary)
                .imageScale(.medium)
            headerTitle
            countBadge
            Spacer(minLength: 0)
        }
        // Reserves the trailing chrome's width so a long title truncates before it collides, and keeps
        // the chevron out of the gestured region. **Font-derived** rather than a fixed figure: it is an
        // `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph itself at
        // a large system text size and 03-board-ui.md's graceful-truncation rule would quietly stop
        // holding (`BoardMetrics.laneHeaderTrailingReserve`, the collapse chevron's own room now that
        // the new-card button that once shared it is gone).
        .padding(.trailing, BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: pointSize))
        .padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
    }

    /// The title, or the rename editor when this lane is the one being renamed.
    ///
    /// A lane's **only** rename path is Board ▸ Rename (04-interactions.md ▸ Selection: "the menu
    /// item is a lane's only rename path, since Return on a lane creates a card"), so nothing in
    /// this view opens the editor — it only renders one that is already open.
    @ViewBuilder
    private var headerTitle: some View {
        if isRenaming {
            InlineTitleField(
                text: renameDraft,
                prompt: "Lane name",
                onCommit: { store.commitRename() },
                onAbandon: { store.transient.discardRename() },
                onFocusLoss: { store.commitRename() },
                // A lane has no card window; ⌘↩ still commits, which is the half of the rule that
                // applies (04 ▸ Grammar's carve-out is "commits the edit — placeholder or rename —
                // and open[s] the card window", and only a card has one to open).
                onCommitAndOpen: { store.commitRename() }
            )
            .boardFont(.headline)
        } else {
            Text(lane.title.value ?? "Untitled")
                .boardFont(.headline)
                .foregroundStyle(lane.title.value == nil ? .secondary : .primary)
                .lineLimit(1)
                .truncationMode(.tail)
        }
    }

    /// The card-count badge — quiet, secondary (03-board-ui.md § Lane).
    ///
    /// **It counts exactly what the body renders**, because it reads the same `renderedCards` the
    /// masonry iterates. That is deliberate rather than incidental: "The count reads the search
    /// filter like every other surface — during a search it shows the visible count, not the
    /// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
    /// follows by construction, with no second rule to keep in step. **A lane the query empties
    /// shows `0` and keeps its slot** (04-interactions.md § Search, settled: "lanes are never
    /// filtered out … the search filters cards, and the board's structure is not a search result") —
    /// which is `BoardView.liveLanes` never consulting the filter at all, made visible here.
    ///
    /// The rename exemption rides along for the same reason every other rule does: the badge counts
    /// what the body renders, and while an inline rename is open its card is one of the things the
    /// body renders (see `renderedCards`).
    private var countBadge: some View {
        countBadgeText("\(renderedCards.count)")
    }

    /// The badge's chrome with the number as a parameter — the header's and the collapsed strip's
    /// badge are the *same* badge, and the two differ only in which count they are of (see
    /// `collapsedStrip`).
    private func countBadgeText(_ text: String) -> some View {
        Text(text)
            .boardFont(.caption)
            .monospacedDigit()
            .foregroundStyle(.secondary)
            .padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
            .padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
            .background(Capsule().fill(.quaternary))
    }

    /// **The collapse chevron** (03-board-ui.md § Lane ▸ Collapsed lanes) — the pointer's way into the
    /// fold, at the header's trailing edge, and its **only** trailing control since the new-card button
    /// that once sat beside it was removed (this milestone).
    ///
    /// **Always visible, not hover-revealed**, because the header has no hover chrome at all: an
    /// always-on glyph that only sometimes appeared would read as a glitch rather than as a convention.
    /// The cost is paid in the header's trailing reserve, which is the figure that keeps the title
    /// truncating before it (`headerContent`).
    ///
    /// `chevron.left` because the fold travels that way — the lane's content collapses toward its
    /// leading edge and leaves a spine — and the strip's reversal is a click on the strip itself, which
    /// is why there is no `chevron.right` twin drawn over there: the whole strip is the button.
    ///
    /// Mutating, so it disables exactly where every other write path does — the read-only lock and the
    /// focused-editor rule (`store.acceptsBoardMutations`, the pointer twin of a disabled menu item).
    private var collapseButton: some View {
        Button {
            collapse()
        } label: {
            Image(systemName: "chevron.left")
                .imageScale(.small)
                .foregroundStyle(.secondary)
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .accessibilityLabel(AccessibilityPhrases.collapseLaneLabel(lane: lane.title.value))
        .disabled(!store.acceptsBoardMutations)
        .onHover { inside in
            if inside { NSCursor.arrow.push() } else { NSCursor.pop() }
        }
    }

    // MARK: - The lane drag

    /// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
    ///
    /// **Dragging any member of a multi-selection drags the whole selection**, in board order —
    /// which is the lane level's flatten order. A lane outside the selection drags alone, standard
    /// macOS targeting.
    ///
    /// Refused under the read-only lock and while an inline editor is focused, like every other
    /// mutating gesture (02-architecture.md § The lock's scope; 04 ▸ Grammar's focused-editor rule).
    /// A refusal is an item provider carrying nothing: no session begins, every drop target declines,
    /// and the image snaps back.
    private func startLaneDrag() -> NSItemProvider {
        guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
        let selection = store.selection
        let ids: Set<ItemID> = selection.container == .board
            && selection.ids.contains(lane.id)
            && selection.ids.count > 1
            ? selection.ids
            : [lane.id]

        let members = store.snapshot.lanes.filter { ids.contains($0.id) }
        guard !members.isEmpty else { return NSItemProvider() }

        let root = store.rootURL
        let payload = DragPayload(
            boardRoot: root,
            kind: .lanes,
            container: .board,
            items: members.map {
                DragPayload.Item(
                    id: $0.id.rawValue,
                    folder: root.appendingPathComponent($0.id.rawValue, isDirectory: true).path,
                    title: $0.title.value
                )
            }
        )
        drops.session.beginLanes(
            members.map(\.id),
            folders: payload.folders,
            // The dragged items' own sizes, frozen at drag start — the one thing that is
            // (03-board-ui.md § Motion).
            units: members.map { LaneLayoutMath.displayUnits(of: $0) },
            // And which of them are folded, frozen with them: a collapsed lane's shadow is its slim
            // strip rather than the slot its preserved `width` would buy (`DragSession.laneCollapsed`).
            collapsed: members.map { LaneLayoutMath.isCollapsed($0) },
            source: store,
            // The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among
            // the lanes with the dragged run removed — the strip proposal's own index space. A
            // within-board lane drag never resolves to `.copy` (⌥ is ignored there), so unlike the
            // card seed this one carries no modifier clause.
            seed: DropTarget(
                boardRoot: store.rootKey,
                container: .strip,
                index: store.snapshot.lanes.prefix { $0.id != lane.id }
                    .count { !ids.contains($0.id) }
            )
        )
        return payload.itemProvider()
    }

    /// The image travelling under the cursor: **a faithful, full-size replica of the whole lane**,
    /// not the strip of title bar that was grabbed (03-board-ui.md § Motion), fanned with ghosts and
    /// a count badge for a multi-drag.
    ///
    /// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
    /// scrolling, no gestures and no geometry observers, and the card list is capped because anything
    /// past the lane's height is clipped anyway.
    ///
    /// **The pointer keeps the point it grabbed** — the Finder-icon promise, and the whole of what
    /// the anchoring padding below buys. SwiftUI centres a preview on the view the drag started
    /// from, which here is the *title bar*: uncompensated, a full-height replica centred on a bar a
    /// line and a half tall hangs half a lane above the cursor, and the cursor lands in the middle of
    /// the image rather than on the bar it grabbed. Padding the replica so its own title bar is the
    /// image's centre undoes exactly that, and lands every other pixel of the replica over the lane
    /// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
    private var dragReplica: some View {
        let count = max(1, draggedLaneCount)
        // **A folded lane needs no anchoring, and must not have any.** The padding exists because the
        // grab surface — the title bar — is a sliver at the top of a full-height replica, so the image
        // has to be shifted for the cursor to keep the point it grabbed. The strip *is* the whole lane:
        // SwiftUI centres the preview on the view the drag started from, which here is already the
        // thing being carried, so a shift would be the very displacement the anchor exists to undo.
        let anchor = isCollapsed
            ? DragPreviewAnchor.Padding.none
            : DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
        return ZStack {
            if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
            if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
            replicaFace
        }
        .overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
        .padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
        // Transparent, and only ever on one side — see `DragPreviewAnchor`, whose figure is
        // deliberately invariant under the symmetric margin above, so the two paddings compose in
        // any order.
        .padding(.top, anchor.before)
        .padding(.bottom, anchor.after)
        // **Back to the window's own appearance**, undoing `header`'s runtime-contrast override for
        // this one subtree (`boardTextInk`). The preview is attached inside that modifier and would
        // otherwise inherit it — but the replica is not text on the board background: it draws its
        // own opaque plate (`replicaFace`) and floats over whatever the cursor is above, which during
        // a cross-window drag is another board entirely. The rule's premise is a user-chosen surface
        // behind the glyphs, and here there is none.
        .boardTextInk(colorScheme)
    }

    private var draggedLaneCount: Int {
        let selection = store.selection
        guard selection.container == .board, selection.ids.contains(lane.id) else { return 1 }
        return selection.ids.count
    }

    private var replicaFace: some View {
        VStack(alignment: .leading, spacing: 0) {
            accentBand
            // **A folded lane's replica is its strip** (03-board-ui.md § Motion's faithful-replica
            // rule, read literally): the drop leaves the lane folded, so carrying a squeezed header and
            // a stack of card rows would show the user a lane the board is not about to draw.
            if isCollapsed {
                collapsedReplicaStrip
            } else {
                VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
                    headerContent
                    VStack(alignment: .leading, spacing: cardSpacing) {
                        ForEach(renderedCards.prefix(12)) { card in
                            HStack(alignment: .firstTextBaseline,
                                   spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
                                Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
                                    .foregroundStyle(.secondary)
                                    .imageScale(.medium)
                                Text(card.title.value ?? "Untitled")
                                    .boardFont(.body)
                                    .lineLimit(2)
                                Spacer(minLength: 0)
                            }
                            .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .background(
                                RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
                                    .fill(BoardSurface.cardPlate)
                            )
                        }
                        Spacer(minLength: 0)
                    }
                }
                .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
            }
        }
        .frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
        // The lane's own wash over an opaque base — the replica floats over whatever the cursor is
        // above, so unlike the resting lane it cannot let the wash composite against the board; the
        // opaque `.background` behind it stands in for the window's neutral surface.
        .background(lanePlate)
        .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
        .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
        .dragReplicaShadow(zoom: zoom)
    }

    /// The collapsed strip as the replica draws it: the same three pieces in the same column, with the
    /// travel a *fixed* figure rather than a measured one.
    ///
    /// A drag image is a snapshot — no geometry observers — so the live strip's measured travel is not
    /// available here, and reusing `collapsedTitleTravel` would let the replica write into the state the
    /// resting lane truncates by. The replica's own height less its furniture is the honest figure, and
    /// it is the same arithmetic the live strip's layout performs.
    private var collapsedReplicaStrip: some View {
        VStack(spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
            Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
                .foregroundStyle(.secondary)
                .imageScale(.medium)
            countBadgeText("\(lane.cards.count)")
            // The live strip's own travel, **fixed rather than flexible**: a drag image is a snapshot
            // and must not observe its own geometry (it would write into the state the resting lane
            // truncates by, from inside a preview). The figure is honest because the replica is drawn at
            // the lane's own height, so the leftover room is the same leftover room.
            Color.clear
                .frame(maxWidth: .infinity)
                .frame(height: collapsedTitleTravel)
                .overlay { rotatedTitle(travel: collapsedTitleTravel) }
            Spacer(minLength: 0)
        }
        .frame(maxWidth: .infinity, alignment: .top)
        .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
    }

    /// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
    /// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
    /// ask the same question the drawing does, and two derivations of one figure would be two
    /// answers.
    ///
    /// **A folded lane takes the strip's exact width, floor and all**: the floor exists for a lane that
    /// has not measured itself and so has no width to draw at, and the strip's width is a constant
    /// nobody has to measure (`BoardMetrics.collapsedLaneWidth`) — applying a 6-em floor to a 3.4-em
    /// strip would draw a replica almost twice as wide as the thing it is a replica of.
    private var replicaWidth: CGFloat {
        guard !isCollapsed else { return BoardMetrics.collapsedLaneWidth(bodyPointSize: pointSize) }
        return max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
    }

    private var replicaHeight: CGFloat {
        max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize))
    }

    /// Where the title bar sits inside the replica — its **centre**, measured down from the replica's
    /// top edge, which is the point the pointer must hold (`dragReplica`).
    ///
    /// The replica stacks exactly what the lane stacks, in the same order and off the same figures,
    /// so this is the lane's own layout arithmetic rather than a second description of it: the accent
    /// band when the lane's colour resolves to one (`accentBand` draws nothing when it does not, and
    /// contributes no height either), the plate's inset, and half the measured bar.
    private var replicaHeaderCenterY: CGFloat {
        let band = Palette.color(for: lane.background) == nil ? 0 : bandHeight
        return band + BoardMetrics.lanePlatePadding(bodyPointSize: pointSize) + measuredHeaderHeight / 2
    }

    // MARK: - Body

    /// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
    /// click selects the lane or, when it is already the selection, clears it; a double click
    /// creates a card at the bottom with its title editor focused.
    ///
    /// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar): the reader watches the
    /// navigation head — the cursor the arrows move, not the whole selection — and scrolls only when
    /// the head names a card *this* lane renders, so exactly one lane responds to any one press.
    /// Deliberately unwrapped by `withAnimation`: 03-board-ui.md § Motion has selection follow
    /// "whatever transaction is active rather than easing on its own".
    private var cardStack: some View {
        ScrollViewReader { proxy in
            scrollableCards
                .onChange(of: store.transient.selectionHead) { _, head in
                    guard let head, let card = renderedCards.first(where: { $0.id == head }) else { return }
                    proxy.scrollTo(LaneSlot.identity(of: card.id))
                }
        }
    }

    private var scrollableCards: some View {
        // Evaluated ONCE per body, deliberately: the per-element `.accessibilitySortPriority`
        // below reads `slots.count`, and reading the computed property from inside the `ForEach`
        // closure re-runs the whole chain (`slots` → `renderedCards` → the O(n) card filter and
        // the session's hidden-member resolution) once per element — O(n²) per lane body, which
        // a drag pickup's synchronous whole-board layout multiplied into a visible stall.
        let slots = self.slots
        // **The board-side selection, read once for the whole lane.** This body is already subscribed
        // to it — the header's own `isSelected` reads it — so hoisting the set here costs nothing new
        // and lets every face take its selected-ness as a *compared parameter* instead of reading the
        // store itself. That is the difference between a selection change re-running one body per
        // lane and re-running one per card on the board (`CardFaceView.isSelected`,
        // RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies).
        //
        // Hoisted for `slots`' reason too: reading it inside the `ForEach` closure would re-derive
        // the container branch and re-count the set once per element.
        let selection = store.selection
        let selectedIDs = selection.container == .board ? selection.ids : []
        let selectedCount = max(1, selectedIDs.count)
        // **This lane's folder — the container every one of its cards resolves its hero against**
        // (`CardHero.imageURL(for:inContainer:)`), hoisted for `slots`' reason: it is one value for
        // the whole lane, and building it inside the `ForEach` closure would mint the same URL once
        // per card. A face cannot resolve its own path — it knows its card and its container, not
        // where the card sits — and finding it from the snapshot would be a board walk per face.
        let cardsFolder = store.rootURL.appendingPathComponent(lane.id.rawValue, isDirectory: true)
        // **The card menu's Navigation inputs, hoisted the same way** (2026-08-09, card 06322636):
        // one `store.snapshot` read for the lane's own neighbours (`CardMoveTarget.hasNeighbor`) and
        // one `Set` comparison over the selection already read above for the multi-lane-spread
        // refusal (`CardMoveTarget.selectionSpansOtherLanes`) — never a per-face read, `selectedIDs`'
        // own reason. `store.snapshot` costs this body nothing new: `headerInk` already subscribes
        // the whole lane to it.
        let hasLeftNeighbor = CardMoveTarget.hasNeighbor(of: lane.id, delta: -1, in: store.snapshot)
        let hasRightNeighbor = CardMoveTarget.hasNeighbor(of: lane.id, delta: 1, in: store.snapshot)
        let selectionSpansLanes = CardMoveTarget.selectionSpansOtherLanes(
            selectedIDs, laneCardIDs: Set(lane.cards.map(\.id)))
        return ScrollView(.vertical) {
            // Cards stay standard width whatever the lane spans: at a slot width of
            // `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
            // `units` columns of `standard` (03-board-ui.md § Layout — full visibility).
            MasonryLayout(columns: columns, spacing: cardSpacing) {
                ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
                    Group {
                        switch slot {
                        case let .card(card):
                            CardFaceView(
                                store: store,
                                card: card,
                                role: .board(openCard: openCard),
                                marquee: marquee,
                                drops: drops,
                                hero: CardHero.imageURL(for: card, inContainer: cardsFolder),
                                isSelected: selectedIDs.contains(card.id),
                                // 1 for an unselected face: the replica's fan and count badge want
                                // "how many ride along", and a card outside the selection drags
                                // alone (`CardFaceView.draggedIDs`).
                                selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1,
                                hasLeftNeighbor: hasLeftNeighbor,
                                hasRightNeighbor: hasRightNeighbor,
                                selectionSpansLanes: selectionSpansLanes
                            )
                            // **The value gate** (`CardFaceView.==`) — the lane's own, one level
                            // down: this body re-runs on every proposal change while a drag is over
                            // *this* lane, and the faces it draws are almost never what changed.
                            .equatable()
                        case let .placeholder(phase):
                            NewCardStubView(store: store, phase: phase, openCard: openCard)
                        case let .shadow(_, height):
                            // One of the drag's N contiguous shadows, at the dragged card's frozen
                            // height — the run's real footprint, so the drop lands exactly here.
                            DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
                                .frame(height: height)
                        }
                    }
                    // "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
                    // (03-board-ui.md § Motion), which is how a create, a delete, a restore and
                    // (m5) a search filter's leavers all reach the masonry. The placeholder wears it
                    // too: it is the card, one round trip early. Whether any of it *performs* is
                    // decided upstream — at the reload for the real cards (`Motion.reloadAnimates`),
                    // at the gesture for the placeholder, which touches no disk.
                    //
                    // **The create handoff deliberately never reaches it.** A committed placeholder
                    // is already keyed by the arriving card's identity (`LaneSlot`), so when the echo
                    // reload swaps the pseudo-card for the real one the `ForEach` element is neither
                    // inserted nor removed — only its content changes — and an appear/disappear
                    // transition has nothing to run on. That is the whole of "the handoff must read
                    // as one arrival" (02-architecture.md ▸ TransientBoardState ▸ overlays), and it
                    // holds identically under Reduce Motion: a transition that does not fire has no
                    // variant to choose between.
                    .transition(Motion.cardTransition(reduced: reduceMotion))
                    // **VoiceOver reads the masonry by `order`, not by drawn position** —
                    // 10-accessibility.md ▸ Logical order, not masonry position (decided).
                    //
                    // The divergence narrowed when the masonry went column-major — walking down
                    // one column now *is* consecutive `order` — but it is still real, and it is
                    // why an explicit priority is needed at all: a geometry-sorted accessibility
                    // tree (which is what a container does without this) sweeps in reading order,
                    // left-to-right then down, which over a column-major grid interleaves the
                    // columns: 1, 4, 7, 2, 5, 8 …, an order that exists nowhere in the model, on
                    // disk, or in the keyboard grammar. Priority descends with the slot index, so
                    // the highest reads first and the list is exactly `slots` — the same sequence
                    // the masonry is handed and the same one `SelectionGrammar` flattens.
                    //
                    // The drag shadows are inert here: `DragShadow` hides itself from the tree, and
                    // a slot that is not an element consumes no priority.
                    .accessibilitySortPriority(Double(slots.count - index))
                    // The scroll target. `ForEach` already carries this identity, but `scrollTo`
                    // resolves against an explicit `.id`, and it goes outermost so the transition
                    // above stays inside the identified view rather than around it.
                    .id(slot.id)
                }
            }
            // **The measured viewport is the masonry's height floor.** A `ScrollView` proposes
            // nothing along its scroll axis, so `maxHeight: .infinity` cannot stretch this view —
            // sized to fit its cards, it left the blank space beneath them (the whole body, in an
            // empty lane) outside the content shape below, and every surface attached to it dead
            // there: the lane-select tap, the double-click create, the context menu, and the band.
            // The explicit minimum is what makes "click-drag rubber-bands across lanes" arm from a
            // lane's own empty space (04-interactions.md § Selection; `TrashLaneView` is the twin).
            .frame(maxWidth: .infinity, minHeight: scrollViewportHeight, 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 empty-space click surfaces live on a background layer, not on the container**
            // (the 2026-08-06 click-latency fix). The pathfinder shape — `.onTapGesture(count: 2)`
            // stacked over `.onTapGesture` on the container that wraps the masonry — made SwiftUI
            // hold *every* single click in the lane, its own empty space and every card face
            // alike, hostage to the system double-click interval while the sequential two-tap
            // recogniser disambiguated (measured ~475 ms click-to-selection on a hosted board;
            // `PointerLatencyTests` pins the recovery).
            //
            // The background layer is the structural answer to both halves of that defect. A card
            // click hit-tests to the card, and the card is **not** a descendant of this layer, so
            // no recogniser here can ever hold a card's click — where a container gesture always
            // shares the card's gesture path, an ancestor's simultaneity notwithstanding. And a
            // click that does land here *is* empty space by construction — the double-click create
            // needs no geometric guard against the cards, the way the rubber band's begin does
            // (`MarqueeControl`), because the masonry above already consumed everything that was
            // not empty.
            .background {
                Rectangle()
                    .fill(.clear)
                    .contentShape(Rectangle())
                    // **No drag source on this layer, deliberately** (measured on real events,
                    // 2026-08-07): an `.onDrag` here — even one whose provider is empty — claims
                    // the mouse-drag the moment the cursor crosses the drag threshold, and the
                    // rubber band's simultaneous `DragGesture` on the container gets one sample
                    // and then silence: the band begins and freezes, and even the mouseUp never
                    // reaches the app. Dragless, the same sweep tracks every sample. And the drag
                    // source buys nothing in return: a bare count-1 tap on this layer fires in
                    // ~1–3 ms in a real event stream, identical to the with-`.onDrag` shape — the
                    // hold that made an empty provider look load-bearing (2026-08-06, "a lone
                    // click never fired") was the hosted harness's sterile `NSApp.postEvent`
                    // stream, which over-holds; real streams have nothing to disambiguate here.
                    // So: drags from empty space belong wholly to the band (`MarqueeControl`),
                    // whose begin guard keeps card-face drags out by geometry.
                    // **One recogniser, both meanings** — a single `.onTapGesture` that branches
                    // on `PointerClick.count`, AppKit's own `mouseDown` idiom. Not a second
                    // two-tap recogniser in *either* form: sequential stacking is the bug this
                    // fix removes, and a *multi-click* recogniser — sequential or simultaneous —
                    // is what makes macOS hold a dragless subtree's primary clicks for the whole
                    // double-click interval (see `PointerClick`; the count-1 tap alone triggers
                    // no such hold).
                    // A lone tap fires once; a double fires it once per click, so the branch is
                    // Finder's cadence exactly: the first click selects, the second creates.
                    //
                    // "Single click selects the lane (click again to unselect)" — the toggle the
                    // header shares (04-interactions.md § Selection), with the modifier grammar
                    // on top. "Double click creates a card at the bottom" — **plain only**: ⌘ and
                    // ⇧ double-clicks are selection gestures that happened twice, the card face's
                    // settled reading, so their second click re-enters the grammar instead of
                    // creating.
                    .onTapGesture {
                        let modifier = ClickModifier.current
                        if modifier == .plain, PointerClick.count > 1 {
                            // The second click of a plain double: the create. Never also the
                            // toggle — it would unselect the lane the first click just selected,
                            // under the placeholder this opens. A third click of a triple lands
                            // here too and no-ops on `isEditingInline`: the placeholder is open.
                            guard !store.isReadOnly, !store.isEditingInline else { return }
                            store.transient.beginPlaceholder(inLane: lane.id)
                            return
                        }
                        store.click(
                            SelectionTarget(id: lane.id, kind: .lane, container: .board),
                            modifier: modifier,
                            togglesOnRepeat: true
                        )
                    }
            }
            // 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.
            //
            // **Behind the click layer above** (a later `.background` stacks further back): the
            // anchor is a plain `NSView`, hit-testable by default, and in front of the click layer
            // it would swallow every empty-space click before the layer's recognisers saw one. It
            // needs no hits itself — it exists to sit in the hierarchy and resolve its enclosing
            // scroll view.
            .background {
                DragAutoScrollAnchor(scroller: autoScroller) {
                    drops.retargetCards(inLane: lane.id)
                }
            }
            // The rubber band's first surface — "click-drag rubber-bands across lanes". On the
            // container, not the layer above: ancestry means a drag over cards and empty space
            // alike reaches it, and the band's own begin guard is what keeps a drag that started
            // on a card face out of it (`MarqueeControl`). Simultaneous, so the taps stay instant.
            .simultaneousGesture(marquee.gesture(in: .board))
            // The same menu the header carries — "one menu, invoked on the header or lane empty
            // space alike" (03-board-ui.md § Lane, settled).
            .contextMenu { laneMenu }
        }
        // The autoscroll driver, **structurally terminated**: a `.task(id:)` keyed on whether a card
        // session is in flight at all, so it is cancelled the moment the session ends — and
        // `DragSession`'s watchdog guarantees that flag clears however the drag finished
        // (DRAG-REORDER.md § Edge autoscroll). Within a session, a pointer outside this lane's
        // engagement rect simply scrolls nothing.
        .task(id: drops.session.isDraggingCards) {
            guard drops.session.isDraggingCards else { return }
            // The engagement rect's reaches are distances to this lane's own furniture, so they are
            // measured on the board's ruler (03-board-ui.md ▸ Layout — zoom). Read once at the head
            // of the drag, which is once per level: zoom is inert while a session is in flight.
            autoScroller.bodyPointSize = pointSize
            await autoScroller.run()
        }
        // The floor's measurement — the scroll view's own height, which is exactly the space the
        // masonry must cover for the empty-surface gestures above. No feedback loop: the lane's
        // height is the strip's to give, so the content growing to the floor never moves the floor.
        .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
    }

    /// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
    private var cardProposal: Int? {
        drops.session.laneProposal(onBoardRooted: store.rootKey, laneID: lane.id)
    }

    /// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one
    /// make-room mechanism, and the reflow's narrow animation key.
    ///
    /// Two sessions feed it and they are mutually exclusive by construction (a file session never
    /// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight):
    ///
    /// - **a card drag**, at the dragged cards' frozen heights — the run's real footprint, so the
    ///   drop lands exactly where the shadows are;
    /// - **a Finder file drag**, at the nominal height, one shadow per file — the cards being
    ///   proposed do not exist yet, so there is no measured height to be faithful to.
    private var shadowRun: ShadowRun? {
        if let position = cardProposal {
            return ShadowRun(position: position, heights: drops.session.cardHeights)
        }
        if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) {
            return ShadowRun(
                position: proposal.index,
                heights: Array(repeating: drops.registry.nominalCardHeight, count: proposal.count)
            )
        }
        return nil
    }

    /// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
    /// proposal, and the new-card placeholder when this lane is the one being created into.
    ///
    /// The placeholder is inserted **at the position the card will actually take** — after its anchor
    /// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the
    /// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
    /// cannot appear anywhere but where the real card lands. Both insertions are computed against
    /// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
    /// it, so neither displaces the other.
    ///
    /// **The phase rides along**, because it is what keys the slot: a committed placeholder wears the
    /// arriving card's identity so the handoff is one arrival rather than two (`LaneSlot`). The
    /// position math above is untouched by that — the key changes, the index does not — so the
    /// masonry cannot flinch at the moment of commit.
    private var slots: [LaneSlot] {
        var result = renderedCards.map(LaneSlot.card)

        let run = shadowRun
        let shadowPosition = run.map { min(max(0, $0.position), result.count) }
        var placeholder: (position: Int, phase: NewCardPlaceholder.Phase)?
        if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id {
            let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards)
                ?? result.count
            placeholder = (position, pending.phase)
        }

        let heights = run?.heights ?? []
        if let shadowPosition {
            let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
            result.insert(contentsOf: shadows, at: shadowPosition)
        }
        if var placeholder {
            if let shadowPosition, placeholder.position >= shadowPosition {
                placeholder.position += heights.count
            }
            result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
        }
        return result
    }

    /// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
    /// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
    /// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
    /// effective-liveness predicate are retired with the flag they read.
    ///
    /// **A card being dragged *as a move* renders nowhere, for as long as the session lasts.** It is
    /// lifted out of the resting layout at pickup and stays out until release. A card being dragged
    /// as a **copy** stays exactly where it is and dims (`CardFaceView`), because that is what the
    /// copy will leave behind — so flipping ⌥ or ⌘ mid-drag reflows this lane once, which is the
    /// feedback the modifier is asking for (DRAG-REORDER.md § Resting-layout zones, ruled
    /// 2026-08-01). Either way the drop's index is counted in the layout on screen, which is why
    /// `copyCards` and `moveCards` take their index in different spaces.
    ///
    /// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
    /// "cards whose title *and* body both miss the query animate out". This is the one collection
    /// that narrowing, which is what makes the filter "the single source of truth for what's on the
    /// board" true of this lane's every surface at once — the masonry, the count badge (see
    /// `countBadge`), the drop zones' resting layout, the marquee registration and the Finder
    /// file-drop targets all read this list or the registry it populates, so none of them needs a
    /// rule of its own.
    ///
    /// **With one exception, and it is the open inline rename** (04-interactions.md § Search,
    /// settled): "an open inline rename survives the filter hiding its card — the editor is a surface
    /// the filter doesn't reach; it stays open and focused, commits by UUID wherever the card lives,
    /// Escape abandons". The editor is drawn *inside* its card's slot (`CardFaceView.isRenaming`), so
    /// on this board a surface the filter doesn't reach means precisely a slot the filter doesn't
    /// take away: filtering the card out would unmount the field mid-keystroke and silently drop what
    /// the user had typed, which is the dirty-buffer courtesy read backwards. The exemption lasts
    /// exactly as long as the editor — commit or Escape retires it, the predicate applies again in
    /// the same pass, and a card that no longer matches animates out then (which is also the whole of
    /// "rename deliberately gets no carve-out": the *query* still stands throughout).
    ///
    /// It cannot arrive by typing, because focusing the search field is focus loss and commits the
    /// rename first; the case it serves is a foreign edit that stops the card matching while the user
    /// is renaming it.
    private var renderedCards: [Card] {
        Self.rendered(
            lane.cards,
            hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootKey),
            filter: store.searchFilter,
            renaming: store.transient.renameEditor?.targetID
        )
    }

    /// `renderedCards` as a pure function of its four inputs — see there for every rule it applies.
    /// Split out only so the rules can be pinned without a view (`SearchFilterTests`); the lane's
    /// masonry, its count badge and its drop zones all read the property, which reads this.
    nonisolated static func rendered(
        _ cards: [Card],
        hiddenByDrag hidden: Set<ItemID>,
        filter: SearchFilter,
        renaming: ItemID?
    ) -> [Card] {
        cards.filter { card in
            guard !hidden.contains(card.id) else { return false }
            return filter.matches(card) || card.id == renaming
        }
    }

    // MARK: - Selection

    private var isSelected: Bool {
        store.selection.container == .board && store.selection.ids.contains(lane.id)
    }

    /// The lane's resting plate — the standard chrome's middle step (03-board-ui.md § Styling ▸
    /// Capabilities: window, washed lane, opaque card), the same quaternary wash the trash column
    /// wears (`Accommodations.lanePlateWash`). Translucent deliberately: a board-chosen colour
    /// shows through, so the header ink computed against the board's background (`headerInk`)
    /// keeps its premise on a painted board.
    private var lanePlate: some View {
        RoundedRectangle(cornerRadius: cornerRadius)
            .fill(Accommodations.lanePlateWash(reduceTransparency: reduceTransparency).style)
    }

    /// The selection treatment: a hairline accent edge with a soft accent glow bleeding outward —
    /// the system focus ring's vocabulary at selection strength (resettled 2026-08-06, replacing
    /// the whole-lane wash-and-solid-ring, whose emphasis scaled with the lane's area: what read as
    /// a ring on a card read as a slab on a column). No wash at all now — nothing tints the cards'
    /// backdrop, and lane *colour* stays the accent band's alone (03-board-ui.md § Styling).
    ///
    /// The glow rides the stroke shape, never the plate: the plate is translucent
    /// (`Accommodations.lanePlateWash`), and a shadow drawn behind it would bleed through as murk.
    /// Under Increase Contrast the halo yields to the solid full-alpha ring — the two width bases
    /// below — so the setting's selection indicator is exactly what it was before the resettlement
    /// (`Accommodations.drawsSelectionHalo`; the trash's lane rows and `CardFaceView` are
    /// card-scale and keep the solid ring at every contrast: one selection vocabulary, intensity
    /// inverse to area).
    private var selectionStroke: some View {
        RoundedRectangle(cornerRadius: cornerRadius)
            .strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
            .shadow(color: haloColor, radius: 5)
    }

    /// The halo's glow — clear whenever the halo doesn't draw, because a conditional modifier
    /// would change the overlay's identity where a clear shadow just paints nothing.
    private var haloColor: Color {
        guard isSelected, Accommodations.drawsSelectionHalo(contrast: contrast) else { return .clear }
        return Color.accentColor.opacity(0.3)
    }

    private var plateStroke: AnyShapeStyle {
        if isSelected {
            AnyShapeStyle(Color.accentColor.opacity(Accommodations.accentOpacity(0.55, contrast: contrast)))
        } else if Accommodations.drawsRestingBorder(contrast: contrast) {
            AnyShapeStyle(.separator)
        } else {
            AnyShapeStyle(.clear)
        }
    }

    private var plateStrokeWidth: CGFloat {
        // The halo's edge is a hairline; with the halo off (Increase Contrast) the selected edge
        // returns to the solid ring's 1.5 base, which `borderWidth` then strengthens as ever.
        let base: CGFloat = isSelected && !Accommodations.drawsSelectionHalo(contrast: contrast) ? 1.5 : 1
        return Accommodations.borderWidth(base, contrast: contrast)
    }

    // MARK: - Rename plumbing

    private var isRenaming: Bool {
        store.transient.renameEditor?.targetID == lane.id
    }

    /// The draft, as a binding onto transient state rather than as `@State`: the editor's text lives
    /// in `TransientBoardState` because a reload has rules about it (the vanish discard), and a
    /// second copy in the view would be the one the commit did not read.
    private var renameDraft: Binding<String> {
        Binding(
            get: { store.transient.renameEditor?.draftTitle ?? "" },
            set: { store.transient.updateRenameDraft($0) }
        )
    }
}

// MARK: - Lane slots

/// The run of shadows a lane opens for whichever session is proposing into it — where it starts and
/// what each shadow is worth in height.
///
/// `Equatable` because it is the reflow's animation key: within a session the heights never change,
/// so the value moves exactly when the proposal does.
private struct ShadowRun: Equatable {
    var position: Int
    var heights: [CGFloat]
}

/// What a lane's masonry lays out — its cards, plus at most one pseudo-card.
///
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
/// title commits (02-architecture.md § Layering, the one named exception to the one-way flow).
/// Modelling it as a sibling case rather than as a fake `Card` is what keeps that true — nothing can
/// accidentally hand it to code expecting an item that exists.
///
/// ### The create handoff, which is entirely a question of `id`
///
/// 02-architecture.md ▸ TransientBoardState ▸ overlays (settled, 2026-07-28) requires the handoff to
/// **read as one arrival**: "the placeholder renders at the arriving card's exact geometry/chrome".
/// The mechanism is this enum's identity function and nothing else — no `matchedGeometryEffect`, no
/// second transaction, no suppression flag.
///
/// A placeholder carries its phase, and the phase decides its key:
///
/// - **`.editing`** keys to the constant `"placeholder"`. There is only ever one placeholder in one
///   lane at a time, and it must hold its identity — and therefore its keyboard focus — for as long
///   as the user types.
/// - **`.awaitingArrival(id)`** keys to `identity(of: id)`: **the very key the arriving card will
///   use**. The Writer's create has run, so the real card's UUID exists a full round trip before its
///   `Card` does, and adopting it early is what makes the handoff a *content swap inside one
///   `ForEach` element* rather than a removal and an insertion at coincident slots. The element
///   persists across the echo reload, so `Motion.cardTransition` — attached per slot in the masonry
///   — never fires on it: one arrival, one geometry, no double scale-and-fade.
///
/// The key therefore changes exactly once, at commit, and that change is deliberately outside every
/// animated transaction the board runs (`BoardStore.commitPlaceholder` is a plain synchronous call
/// from the editor's Return; `land`'s `withAnimation` comes a round trip later), so it costs no
/// motion either.
///
/// Two slots can never collide on the arriving key: `BoardStore.land` assigns the snapshot and
/// re-grounds the transient state — the discard-on-arrival among it — inside one transaction, so no
/// render pass ever sees both the real card and the placeholder standing in for it.
enum LaneSlot: Identifiable {
    case card(Card)
    /// The new-card placeholder, carrying the phase that decides both what it draws and what it is
    /// keyed by. Passed down rather than re-read in the stub so the key and the face cannot disagree
    /// about which half of the handoff this is.
    case placeholder(NewCardPlaceholder.Phase)
    /// One of a drag's N contiguous shadows, at the dragged card's frozen height.
    case shadow(index: Int, height: CGFloat)

    var id: String {
        switch self {
        case let .card(card): Self.identity(of: card.id)
        case let .placeholder(phase): Self.identity(ofPlaceholderIn: phase)
        // Constant per position in the run, so the shadows animate as slides when the proposal moves
        // rather than blinking out and back in.
        case let .shadow(index, _): "shadow:\(index)"
        }
    }

    /// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot
    /// disagree about what `scrollTo` is looking for.
    static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }

    /// The key an open editor holds while it has no identity of its own to hold.
    static let editingPlaceholderIdentity = "placeholder"

    /// **The handoff, as one pure function.** See the type's doc comment: a committed placeholder
    /// answers with the arriving card's key, which is what makes the swap continuous.
    static func identity(ofPlaceholderIn phase: NewCardPlaceholder.Phase) -> String {
        switch phase {
        case .editing: editingPlaceholderIdentity
        case let .awaitingArrival(id): identity(of: id)
        }
    }
}

// MARK: - The new-card placeholder

/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width —
/// 02-architecture.md § Layering's one named exception to the one-way flow, finally rendered.
///
/// Two faces, one per phase:
///
/// - **`.editing`** — a focused text field. Return commits, Escape abandons, and **click-away
///   discards**: the placeholder's rule, "the deliberate exception because nothing exists on disk
///   yet" (04-interactions.md ▸ Grammar).
/// - **`.awaitingArrival`** — the committed title as plain text, deliberately *not* an editor. The
///   Writer's create has run and the overlay is only covering the gap until the watcher round-trips
///   the real card; leaving a live field there would invite edits that have nowhere to go, and its
///   focus loss would fire the discard rule against a card that is already on its way.
///
/// ### The two faces are two *different* faces, on purpose
///
/// The editing face is an editor — the accent-stroked well the user is typing into. The awaiting
/// face is **a card**: the same plate, inset, stripe gutter, icon, font and title position a default
/// new card gets (`CardFaceView`, via the `BoardMetrics` both read). That is the second half of
/// 02-architecture.md ▸ TransientBoardState ▸ overlays' one-arrival rule — the first half is
/// `LaneSlot` keying a committed placeholder by the arriving card's identity, which makes the echo
/// reload a content swap inside one persistent element; drawing that content identically is what
/// makes the swap invisible rather than merely un-animated.
///
/// A newly created card is always default-styled — `BoardWriter.createCard` writes `schema`, `title`
/// and `order` and nothing else — so "the arriving card's chrome" is exactly: the level-default
/// symbol, the standard secondary tint, no accent stripe, and no selection stroke (the commit
/// re-selects the *lane*).
private struct NewCardStubView: View {

    let store: BoardStore

    /// How far along the birth is — handed down from the slot rather than re-read from the store, so
    /// the face this view draws and the identity the slot is keyed by are the same answer.
    let phase: NewCardPlaceholder.Phase

    let openCard: @MainActor (ItemID) -> Void

    /// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
    @Environment(\.colorSchemeContrast) private var contrast

    /// The board's ruler (`BoardZoom`) — the same environment the real face reads, so the placeholder
    /// and the card that replaces it are drawn at one zoom.
    @Environment(\.boardZoom) private var zoom

    /// The live body metric — the same one the real face reads, which is what makes "the numbers are
    /// the same numbers rather than equal ones" survive the move to font-derived metrics.
    private var pointSize: CGFloat { zoom.bodyPointSize }

    var body: some View {
        switch phase {
        case .editing: editor
        case .awaitingArrival: arrivingFace
        }
    }

    /// The editor well — an accent-stroked plate around the field, which is what a thing being typed
    /// into should look like and deliberately not what a card looks like.
    private var editor: some View {
        InlineTitleField(
            text: draft,
            prompt: "Card title",
            onCommit: { commit() },
            onAbandon: { store.transient.discardPlaceholder() },
            onFocusLoss: { store.transient.discardPlaceholder() },
            onCommitAndOpen: {
                // The one board command that stays enabled mid-edit: commit, then open
                // (04 ▸ Grammar's carve-out). A commit that discarded — empty title, a
                // vanished lane, a failed create — hands back no id and opens nothing.
                if let id = commit() { openCard(id) }
            }
        )
        .boardFont(.body)
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
        .background(
            RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
                .fill(BoardSurface.cardPlate)
        )
        .overlay(
            RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
                // Increase Contrast takes the well to full strength and a point heavier — a 60%-alpha
                // outline is exactly the kind of border the setting exists to rescue
                // (`Accommodations`).
                .strokeBorder(
                    Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast)),
                    lineWidth: Accommodations.borderWidth(1.5, contrast: contrast)
                )
        )
    }

    /// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
    /// `CardFaceView.body`/`titleRow`, and the numbers are the same numbers rather than equal ones
    /// (`BoardMetrics`) — when the echo reload swaps this view for the real face inside the one
    /// slot they share, nothing about the plate, the icon, the font or the title's position changes.
    ///
    /// No stripe overlay and no selection stroke: both would be `.clear` for a default, unselected
    /// new card, and a shape that paints nothing is better left unwritten than written and disabled.
    private var arrivingFace: some View {
        HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
            Image(systemName: ItemSymbol.card)
                .foregroundStyle(.secondary)
                .imageScale(.medium)
            Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
                .boardFont(.body)
                .lineLimit(4)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
        .padding(.leading, BoardMetrics.cardStripeWidth(bodyPointSize: pointSize))
        .background(
            RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
                .fill(BoardSurface.cardPlate)
        )
    }

    /// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return
    /// = next card)" (04-interactions.md ▸ Grammar). The lane rather than the new card is what makes
    /// a run of Return-type-Return file a stack of cards without the user's hands leaving the
    /// keyboard.
    ///
    /// The lane is read before the commit, because every discard path clears the overlay that holds
    /// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
    /// something that renders nowhere would break the one-container invariant until the
    /// next reload swept it away.
    @discardableResult
    private func commit() -> ItemID? {
        let lane = store.transient.newCardPlaceholder?.laneID
        let id = store.commitPlaceholder()
        if let lane, store.snapshot.lanes.contains(where: { $0.id == lane }) {
            store.select([lane], in: .board)
        }
        return id
    }

    private var draft: Binding<String> {
        Binding(
            get: { store.transient.newCardPlaceholder?.draftTitle ?? "" },
            set: { store.transient.updateDraft($0) }
        )
    }
}

// MARK: - The inline title field

/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and
/// a lane rename — so the grammar around it is written once (04-interactions.md ▸ Grammar).
///
/// The four exits, and who differs on them:
///
/// | Exit | Placeholder | Rename |
/// |---|---|---|
/// | Return | commits | commits |
/// | Escape | discards | abandons |
/// | Click-away | **discards** | **commits** |
/// | ⌘↩ | commits + opens | commits + opens |
///
/// Only the click-away row differs, which is why it is a caller-supplied closure rather than a
/// branch in here: this view knows *that* focus left, never what that should mean.
///
/// **Every handler must be idempotent**, because the exits overlap by construction: Return commits
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard…` all no-op against
/// an editor that is already closed, so the overlap costs nothing.
struct InlineTitleField: View {

    @Binding var text: String
    let prompt: String
    let onCommit: () -> Void
    let onAbandon: () -> Void
    let onFocusLoss: () -> Void
    let onCommitAndOpen: () -> Void

    @FocusState private var isFocused: Bool

    var body: some View {
        TextField(prompt, text: $text)
            .textFieldStyle(.plain)
            .lineLimit(1)
            .focused($isFocused)
            // The editor is born focused: every entry point to it is a deliberate "edit this now"
            // (Return, ⌘N, the header button, a double click, Board ▸ Rename), and one that landed
            // unfocused would need a second click to do anything.
            .onAppear { isFocused = true }
            .onSubmit(onCommit)
            // ⌘↩ before the field sees the Return: the one board command enabled mid-edit
            // (04 ▸ Grammar). Anything without the modifier is passed straight through, so plain
            // Return still reaches `onSubmit`.
            .onKeyPress(keys: [.return], phases: .down) { press in
                guard press.modifiers.contains(.command) else { return .ignored }
                onCommitAndOpen()
                return .handled
            }
            // Escape reaches a focused text field as AppKit's cancel operation on some paths and as
            // a plain key press on others; both are wired to the same idempotent abandon rather than
            // guessing which one this control will get.
            .onKeyPress(.escape) {
                onAbandon()
                return .handled
            }
            .onExitCommand(perform: onAbandon)
            .onChange(of: isFocused) { _, focused in
                guard !focused else { return }
                onFocusLoss()
            }
    }
}
