import AppKit
import SwiftUI

/// The lane strip — the board itself (03-board-ui.md § Layout — full visibility).
///
/// **Every lane is always on screen.** There is no horizontal scroll and no minimum lane width: the
/// window's width divides across the lanes' width units, a lane of n units taking n whole units of
/// that division, and resizing the window is the width control. A board with more units than the
/// window comfortably fits compresses every lane; that degenerate case is accepted, not floored
/// (the remedy is the user's — fewer units or a bigger window).
///
/// Two mechanisms change a lane's width and they are deliberately opposites (03-board-ui.md § Lane):
/// the **right-edge drag** grows or shrinks the *window* one standard width per snap so the other
/// lanes keep their exact pixels (`LaneResizeSession`), while the **stepper** — and its ⌥⌘→/⌥⌘←
/// keyboard face — re-divides the existing window width across the new unit total, compressing the
/// siblings and never touching the window (`BoardStore.setLaneWidth`).
///
/// ### The three interactions it hosts
///
/// - **Lane resize** — the right-edge grab strip (above). Deliberately *not* a drag session
///   (DRAG-REORDER.md § Adjacent interaction).
/// - **Drag & drop** — cards (in either container) and lanes travel as **system drag sessions**, which is what
///   crosses window boundaries, draws the copy badge and gives the full-size replica
///   (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
///   registry and the strip-level drop target; the lanes own theirs.
/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`,
///   `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
/// - **The board's fixed grammar keys** (11-command-nexus.md ▸ Fixed grammar keys) — the four
///   arrows and their ⇧/⌥ modes, Return's create/rename dispatch, ⌫'s staged delete, Escape's step
///   outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
///   here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md ▸
///   Configurable bindings draws between what remaps and what does not.
/// - **The standard Edit items the board answers as a responder** — Select All, and Cut/Copy/Paste
///   beside it (`ClipboardCommands.swift`). They are not menu items of ours: the Edit menu already
///   carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
///   is ruled out (04-interactions.md ▸ Configurable bindings).
///
/// - **The trash column** — trailing, one fixed unit, joining and leaving the width division as
///   View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### The live search filter
///
/// The filter itself is a pure predicate (`SearchFilter`) and the field is the toolbar's
/// (`BoardSearchField`, installed by `BoardWindowHost`); what belongs to this file is the two places
/// the board *reads* it — the arrow grammar's order lists and jump containers, so navigation walks
/// the filtered board, and Escape's middle step. Everything else follows from `LaneView`'s and
/// `TrashLaneView`'s own narrowing, because the drop zones, the marquee and the file-drop targets
/// all read what those two rendered.
///
/// ### What is deliberately not here yet
///
/// External Finder file drops join the very drop delegates this file already attaches (see
/// `BoardDrops.swift`).
struct BoardView: View {

    let store: BoardStore

    /// How the resize session reaches the host window it grows and shrinks. Injected by
    /// `BoardWindowHost`, which owns the window controller; a closure because the window attaches
    /// after the first body evaluation.
    let window: @MainActor () -> NSWindow?

    /// The window's purge-alert host — see `TrashConfirmations` for why a menu item's confirmation
    /// has to be presented from here.
    let confirmations: TrashConfirmations

    /// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
    /// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
    /// needs the board's own window ref, which is the host's identity and not the board's.
    let openCard: @MainActor (ItemID) -> Void

    /// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
    /// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the
    /// strip's own `@FocusState`, which nothing outside this view can reach.
    let search: BoardSearchPresentation

    /// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
    /// Styling ▸ Controls).
    @Environment(AppModel.self) private var appModel

    /// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
    /// view does, which is the interaction's whole lifetime.
    @State private var resize = LaneResizeSession()

    /// Where this window's lanes draw their card grids and how tall their cards are — the measured
    /// half of a card drop's geometry, plus the strip's own frame (`LaneDropRegistry`). `@State` for
    /// the resize session's reason: one per window, living exactly as long as the window.
    @State private var laneDrops = LaneDropRegistry()

    /// One rubber band at a time, per window (`MarqueeSession`).
    @State private var marquee = MarqueeSession()

    /// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is —
    /// the card faces on either side only *register* into it (`MarqueeTargetRegistry`).
    @State private var marqueeTargets = MarqueeTargetRegistry()

    /// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
    /// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
    /// included, and no global or lane-local space is that.
    static let stripSpace = "board-strip"

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

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

    /// Reduce Transparency, for the backdrop's title-bar frost — the one glass underlay this view
    /// draws (10-accessibility.md: "glass underlays go solid, wherever they appear";
    /// `Accommodations.frost`).
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency

    /// **The board's ruler** (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). Injected on this view by
    /// `BoardWindowHost` and read all the way down the strip; every `BoardMetrics` figure below takes
    /// `zoom.bodyPointSize` rather than the system's, which is the whole of what View ▸ Zoom In does.
    @Environment(\.boardZoom) private var zoom

    /// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
    /// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
    /// must go back to meaning create/rename rather than nothing at all.
    @FocusState private var isBoardFocused: Bool

    /// The inter-lane gap, and the strip's outer margin — one number, because the standard-width
    /// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
    ///
    /// **Font-derived** (`BoardMetrics.stripGap`), which is what keeps the no-horizontal-scroll
    /// invariant honest under 10-accessibility.md's full-relative-scaling rule: a larger system text
    /// size widens the gap and therefore *narrows* every lane, since the window's width still divides
    /// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
    /// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
    private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) }

    /// A collapsed lane's fixed strip width (03-board-ui.md § Lane ▸ Collapsed lanes) — **the one lane
    /// width on the strip that is not the window's division**. It is taken off the top and the
    /// remainder is what the expanded lanes divide (`LaneLayoutMath.standardWidth`), so folding a lane
    /// is a re-divide trigger of the Show/Hide Trash family: the window never moves and the siblings
    /// grow into what the lane gave up.
    private var collapsedWidth: CGFloat {
        BoardMetrics.collapsedLaneWidth(bodyPointSize: zoom.bodyPointSize)
    }

    var body: some View {
        // The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
        // between "the gate did not suppress" and "Observation invalidated the lane directly".
        #if DEBUG
        let _ = BoardRenderMetrics.countStripBody()
        #endif
        GeometryReader { proxy in
            // The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
            // shadows opened at the proposal. Recomputed on every render, so a foreign reload
            // mid-drag simply moves the zones (rule 1 of 04-interactions.md ▸ Drag and drop's
            // re-grounding trio) — nothing about a drag is cached across a snapshot.
            let slots = stripSlots
            ZStack(alignment: .topLeading) {
                backdrop
                laneStrip(slots, standard: standardWidth(stripWidth: proxy.size.width))
            }
            .padding(spacing)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            // The band, drawn **outside the padding** so its offset is a strip coordinate directly —
            // and outside every animated modifier above, because 03-board-ui.md § Motion puts the
            // marquee in the animation-free-by-construction list ("1:1 cursor following — animating
            // input echo would be lag").
            .overlay(alignment: .topLeading) { marqueeBand }
            // The space the marquee is resolved in — see `BoardView.stripSpace`. It goes on the
            // padded container so x = 0 is the strip's leading edge with the outer margin included,
            // which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee coordinate —
            // the band's own drag samples and each registered item frame — is measured here too, so
            // nothing ever converts between spaces.
            .coordinateSpace(.named(Self.stripSpace))
            // The same rectangle in the window's *global* space, which is where the physical cursor
            // lands once converted (`BoardDropContext.globalCursor`). Written into the registry
            // rather than into `@State` so the drop delegates read it live at event time rather than
            // as of the last body evaluation.
            .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
            // The board's ruler, into the registry beside the strip's frame and for its reason: the
            // drop delegates run at event time, outside any body, and `nominalCardHeight` — the
            // stand-in they tile un-measured rows with — has to be computed on the zoom the board is
            // actually drawing at (03-board-ui.md ▸ Layout — zoom). `initial: true` because the first
            // render is already a level, not a change.
            .onChange(of: zoom.bodyPointSize, initial: true) {
                laneDrops.bodyPointSize = zoom.bodyPointSize
                // And the resting layouts built on the old ruler go with it. `RestingLayoutCache`'s
                // entry key is snapshot generation, heights generation, board root and hidden set —
                // deliberately not the point size, because until zoom existed the point size could
                // not move. This is what keeps that key honest rather than adding a fifth term to it:
                // a level change is rare, a cache miss costs one rebuild, and `ZoomCommands` already
                // holds the rows shut while a drag is in flight, so in practice there is nothing
                // standing here to clear.
                dropContext.session.restingLayouts.clear()
            }
            // **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
            // column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
            // trash) and so simply falls through to here. It accepts *every* session type — ours and
            // external Finder file drags alike — and routes internally, because single-target
            // dispatch has no fall-through (DRAG-REORDER.md).
            .onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
        }
        .background(boardBackground)
        // The window-level fallback, *behind* the specific targets: a release over any in-window
        // region they don't cover commits the current proposal instead of leaking the session into a
        // cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
        .background {
            Color.clear
                .onDrop(of: boardDropTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
        }
        // **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
        // the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
        // discards itself the moment a snapshot lands — because holding a moment longer would draw
        // the arrangement twice.
        .onChange(of: store.snapshotGeneration) { _, generation in
            appModel.dragSession.handOff(root: store.rootKey, generation: generation)
        }
        // **The lane-resize release hold's hand-off** (`LaneWidthHold`), the same shape one rung
        // narrower: that overlay stands in for an *arrangement* and any landing retires it, this one
        // for a *width* and only a landing that carries it will do — an unrelated reload arriving
        // first must not hand the strip back to a snapshot that still says the old unit count.
        //
        // **`landedReloads`, not `snapshotGeneration`**: the question is "is the width current",
        // asked on every walk that landed, so a reload whose tree came back value-equal — which
        // skips the assignment and moves no generation — still gets to answer it.
        .onChange(of: store.landedReloads) { _, _ in
            resize.handOff(against: store.snapshot.lanes)
        }
        // **The stationary modifier flip's re-proposal** (`BoardDropContext.retargetAfterModifierFlip`).
        // Drop callbacks arrive only while the mouse moves, so a ⌥ pressed against a still pointer
        // reaches the drag through the session's `.flagsChanged` watch instead — and lands back on
        // the one shared retarget here, in the board window whose surface resolved the proposal.
        //
        // Every open board window carries this handler and exactly one of them acts: the flip
        // replays against a `LaneDropRegistry`, which is this window's alone.
        //
        // **No transaction of its own**, deliberately: a flip must produce exactly what the next
        // mouse sample would have produced, and `dropUpdated` wraps nothing either. What moves is
        // keyed declaratively where it is drawn — the strip on its proposal, each lane on its shadow
        // run (03-board-ui.md § Motion, transactions keyed narrowly).
        .onChange(of: appModel.dragSession.modifierGeneration) { _, _ in
            dropContext.retargetAfterModifierFlip()
        }
        .trashPurgeAlert(store: store, confirmations: confirmations)
        // The board's own anchor for the Style… popover — the surface a board-targeted session hangs
        // off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
        .popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
            StyleEditorPopover(store: store, recents: appModel.styleRecents)
        }
        // **The board is one tab stop** (10-accessibility.md ▸ Full Keyboard Access: "the board is
        // one tab stop with arrow-key navigation within"), and this is it: one focusable strip,
        // whose interior movement is the arrow grammar below rather than a focus stop per card.
        // The controls *around* the cards — each lane's collapse chevron, the popovers, the toolbar —
        // are ordinary `Button`s and stay Tab-reachable in their own right, which is the other half
        // of the same sentence ("every control … is Tab-reachable").
        //
        // The focus *ring* is normally off: the strip is the window's content, not a control, and a
        // rectangle around the whole board would read as an error state. **Under Full Keyboard
        // Access it comes back**, because that reasoning inverts completely there — FKA's premise is
        // that the user can see where Tab landed, and an invisible tab stop is not one
        // (`Accommodations.isFullKeyboardAccessEnabled`).
        .focusable()
        .focusEffectDisabled(!Accommodations.isFullKeyboardAccessEnabled)
        .focused($isBoardFocused)
        .onAppear {
            isBoardFocused = true
            // **Escape's second step, wired from the side that can perform it** (04 § Search): the
            // field can resign first responder on its own, but only the strip can *take* the
            // keyboard, and a window with a resigned field and an unfocused board would swallow
            // every grammar key. `@FocusState`'s setter is nonmutating, so the closure writes the
            // same storage this view reads.
            search.focusBoard = { isBoardFocused = true }
        }
        .onChange(of: store.isEditingInline) { _, editing in
            // An editor took focus and has now given it back. Without this the strip stays unfocused
            // after every rename and Return silently stops working.
            if !editing { isBoardFocused = true }
        }
        .onKeyPress(keys: [.return], phases: .down) { handleReturn($0) }
        .onKeyPress(.escape) { handleEscape() }
        .onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
        // **The arrows** (04-interactions.md ▸ Grammar), on `.down` *and* `.repeat`: holding an
        // arrow must walk the board, and a handler registered for `.down` alone sees the first
        // press only.
        .onKeyPress(
            keys: [.upArrow, .downArrow, .leftArrow, .rightArrow],
            phases: [.down, .repeat]
        ) { handleArrow($0) }
        // **Select All** (04-interactions.md ▸ The map). Edit ▸ Select All is the standard menu
        // item and it dispatches `selectAll:` down the responder chain, so the board answers it as a
        // responder rather than growing a second menu item with the same title — which titles-are-API
        // forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so
        // ⌘A inside an inline editor stays text selection with no guard needed here.
        .onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
        // **Cut / Copy / Paste** (04-interactions.md ▸ Clipboard), through the same responder door
        // Select All above uses and for the same titles-are-API reason. Each handler is attached only
        // while its command applies, which is what makes AppKit's automatic enablement mirror the
        // validation exactly — see `boardClipboardCommands`.
        .boardClipboardCommands(store: store, clipboard: appModel.clipboard)
        // **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical
        // mouse button being up. A finished session's phase events can arrive *after* the user has
        // started the next drag, and an ungated handler would wipe the new session's state — no
        // shadow, drop dead. `DragSession`'s watchdog is the braces (see `armWatchdog`), and it is
        // what makes refusing here free.
        .onDragSessionUpdated { session in
            switch session.phase {
            case .ended, .dataTransferCompleted:
                MainActor.assumeIsolated { appModel.dragSession.endIfButtonReleased() }
            default:
                break
            }
        }
    }

    // MARK: - The strip's layers

    /// The empty surface behind the lanes: a plain click clears the selection, a drag rubber-bands.
    ///
    /// `Color.clear` with a `contentShape` rather than a real fill — the board's *painted*
    /// background is `boardBackground`, outside the geometry reader, and this layer exists only to
    /// be hit. Modified clicks are deliberately no-ops: ⌘ and ⇧ on the backdrop name no target, and
    /// Finder's own desktop behaves the same way.
    private var backdrop: some View {
        Color.clear
            .contentShape(Rectangle())
            .onTapGesture {
                guard ClickModifier.current == .plain else { return }
                store.clearSelection()
            }
            .simultaneousGesture(marqueeControl.gesture(in: .board))
    }

    /// The lanes and the drag's shadows, plus the trash column when it is shown.
    @ViewBuilder
    private func laneStrip(_ slots: [StripSlot], standard: CGFloat) -> some View {
        HStack(alignment: .top, spacing: spacing) {
            ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
                switch slot {
                case let .lane(lane):
                    laneSlot(lane, standard: standard)
                        // "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
                        // A create, a delete and an undo all reach the strip as a lane arriving in
                        // or leaving this `ForEach`; whether that *performs* is decided upstream, at
                        // the reload that carried it (`Motion.reloadAnimates`) — a transition with no
                        // animated transaction around it is simply an appearance.
                        .transition(Motion.laneTransition(reduced: reduceMotion))
                        // **Lanes are read in lane `order`** (10-accessibility.md ▸ The board
                        // through VoiceOver). Geometry already agrees — an `HStack` lays the slots
                        // out left to right in this very sequence — so unlike the masonry's
                        // column-major divergence (`LaneView`) this is a statement rather than a
                        // correction. It is written anyway for what it buys below: the priorities
                        // stay above the trash's, which is the only way "the trash is the LAST
                        // container" survives a right-to-left layout direction or a lane slot the
                        // resize session lifts to `zIndex(1)`.
                        .accessibilitySortPriority(Double(slots.count - index))
                case let .shadow(_, units, collapsed):
                    // One of the drag's N contiguous shadows, at the exact width the arriving lane
                    // will occupy — its units measured against *this* strip's standard, which is
                    // what makes the drop land precisely where the shadow shows. **A folded lane's
                    // shadow is the slim strip**: the drop leaves it folded, so its `width` key is not
                    // what the strip is about to draw (03-board-ui.md § Lane ▸ Collapsed lanes).
                    DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize))
                        .frame(width: LaneLayoutMath.drawnWidth(
                            units: units, isCollapsed: collapsed,
                            standard: standard, gap: spacing, collapsedWidth: collapsedWidth))
                        .frame(maxHeight: .infinity)
                }
            }
            if isTrashVisible {
                // Trailing, always — the column has no position of its own to lose, which is
                // also why it never appears in the drop proposal's inputs (those are built from
                // `boardLanes`) and why the terminal slot clamps in front of it.
                TrashLaneView(
                    store: store,
                    confirmations: confirmations,
                    drops: dropContext,
                    marquee: marqueeControl
                )
                .frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
                .frame(maxHeight: .infinity, alignment: .top)
                // It arrives and leaves like a lane, because that is what it looks like — the
                // column scales and fades while every real lane compresses to make room for its
                // unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the
                // menu toggle's (`ShowTrashCommand`).
                .transition(Motion.laneTransition(reduced: reduceMotion))
                // "When shown, it is the **last** container" (10-accessibility.md ▸ Trash lane) —
                // below every lane's priority, whatever the lane count, because zero is the floor
                // the expression above never reaches.
                .accessibilitySortPriority(0)
            }
        }
        // The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
        // (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
        // proposal … never on broad state"). Narrowed further to the *strip's* proposal: a card
        // session moving its shadow inside a lane must not re-time the whole strip. The replica's
        // own tracking is the system drag image's and touches nothing here, which is the same
        // bullet's other half — animating input echo would be lag.
        //
        // It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
        // drawn beside it is never inside an animated transaction (03 § Motion again).
        .animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal)
        // **The search filter's reflow**, keyed on **the query** and nothing else — 03-board-ui.md
        // § Motion names it in the narrow-keys list ("on the search query (filter reflow)") — and in
        // the *content* voice rather than the structural one: "search filtering and undo/redo
        // restore, deliberately paired so a restore reads like the search filter — leavers and
        // arrivers run their transition, survivors reflow under one gentle spring". The leavers and
        // arrivers are the card and row transitions already attached inside the lanes and the trash
        // column; this is the survivors' spring around them.
        //
        // **Every way the filter changes rides it**, which is the reason the key is the filter rather
        // than the transaction being wrapped at each mutation: typing, the field's Escape, the
        // board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
        // here without any of them knowing about motion.
        //
        // The *filter*, not the query, since comments joined the search's scope (04 ▸ Search, re-ruled
        // 2026-07-29): a landed comment-index sweep widens what matches without the query changing at
        // all, and keying on the query alone would make those cards pop in while every other arrival
        // eased. `SearchFilter` is `Equatable` and carries the index' answer, so one key covers both.
        .animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchFilter)
    }

    /// The rubber band itself: a translucent accent fill with a hairline border, in strip
    /// coordinates and **never animated** (03-board-ui.md § Motion — the marquee "tracks the cursor
    /// 1:1", and an eased band visibly lags the mouse).
    ///
    /// Hit-testing off, because the band is feedback: the drag that draws it is already recognised,
    /// and a rectangle that swallowed clicks would eat the release.
    @ViewBuilder
    private var marqueeBand: some View {
        if let rect = marquee.rect {
            Rectangle()
                .fill(Color.accentColor.opacity(0.12))
                .frame(width: rect.width, height: rect.height)
                // Increase Contrast takes the band's edge to full strength and a point heavier: the
                // fill is a 12%-alpha wash by design, so the border is the only thing that says where
                // the sweep actually reaches (`Accommodations`).
                .overlay(
                    Rectangle().strokeBorder(
                        Color.accentColor.opacity(Accommodations.accentOpacity(0.5, contrast: contrast)),
                        lineWidth: Accommodations.borderWidth(1, contrast: contrast)
                    )
                )
                .offset(x: rect.minX, y: rect.minY)
                .allowsHitTesting(false)
        }
    }

    /// What the strip lends its empty surfaces and its sweepable items — the band's session, the
    /// registry, and the store it selects into (`MarqueeControl`).
    private var marqueeControl: MarqueeControl {
        MarqueeControl(session: marquee, registry: marqueeTargets, store: store)
    }

    // MARK: - Styling

    /// The board's `background`, painting "the board window's content background (the surface behind
    /// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities).
    ///
    /// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
    /// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
    /// its ≥ 4.5:1 rule to: text does sit on it.
    ///
    /// ### Three layers, and the window is the frame
    ///
    /// The colour is the underlay, the image draws over it, and a frosted strip sits on top of both
    /// under the title bar. All of it runs the **full height of the window** — `ignoresSafeArea`
    /// here is what the window's own `fullSizeContentView` flip is for (`HostedWindowController
    /// .setExtendsContentUnderTitlebar`, driven from `BoardWindowHost`), and the two only ever move
    /// together: a board with no background of its own draws none of this and keeps the standard
    /// chrome exactly as it has always looked.
    ///
    /// The **colour is painted even while an image is loading**, and stays painted underneath it: a
    /// decode is asynchronous (`BoardBackdropImage`) and a window that flashed the system background
    /// on open would be the hitch that work exists to avoid. It is also what a failed or missing
    /// image degrades to, with nothing said about it.
    ///
    /// The **frost** is the price of the extended chrome: the title bar's own material is gone, so
    /// the traffic lights, the board-name widget and the toolbar would otherwise sit directly on a
    /// saturated colour or a photograph. It is a glass underlay (`Accommodations.frost` — the
    /// ladder's thin weight, both ends tried and retired: `.bar` reads as barely-there over a
    /// busy image, `.regular` and up as a veil the backdrop shouldn't pay for) and takes the
    /// standard accommodation: solid
    /// under Reduce Transparency, "wherever they appear". Its geometry is `frostStrip`'s — full
    /// strength through the top safe-area inset, dissolving over a short tail below it — with the
    /// inset read from a `GeometryReader` that is itself inside the `ignoresSafeArea`: the proxy
    /// still reports the inset it was told to ignore, which is exactly the title-bar-plus-toolbar
    /// band and moves on its own when the toolbar's size class changes. Nothing here hit-tests, so
    /// the widget and the toolbar above it are untouched.
    ///
    /// ### The contrast rule is the colour's, and the image is outside it
    ///
    /// The rule is enforced from the *text* side rather than here, because this view paints the
    /// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name, a
    /// hand-written hex, or the `color` subkey of the mapping form, they reach the same place — has
    /// its text colour computed against the threshold by `BoardTextInk`, composited over the window
    /// background in the active appearance and recomputed on an appearance flip; the two subtrees
    /// that sit on this fill (`LaneView.header` and `TrashLaneView.header` — their plates are
    /// translucent washes the colour shows through, where every card carries its own opaque plate,
    /// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
    ///
    /// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
    /// statically, by a test over the ink this seam chooses for each of them (03-board-ui.md §
    /// Styling ▸ Controls' "AA-verified at design time", which is a claim about the *pair* and so
    /// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
    /// renders, because its value arrives from a file.
    ///
    /// **An image makes no AA claim at all**, and the ink does not try to derive one from it. Ink
    /// still follows the `color` reading — the colour the author chose to sit under the picture, or
    /// the default when they chose none — which is the same bytes-from-a-file posture an arbitrary
    /// hex already has, one step further out: a photograph has no single luminance to threshold
    /// against, the field has no in-app control that could warn about one, and a per-pixel answer
    /// would change as the window resized. An author who lays text over a busy picture is doing what
    /// the raw file exists to let them do.
    ///
    /// A value that resolves to nothing paints nothing, so the window keeps the standard background:
    /// the same lenient degrade as the other two levels, and the bytes stay as written.
    @ViewBuilder
    private var boardBackground: some View {
        let color = Palette.color(for: store.snapshot.background)
        let image = BoardBackdrop.imageURL(for: store.snapshot, root: store.rootURL)
        if color != nil || image != nil {
            GeometryReader { proxy in
                ZStack(alignment: .top) {
                    color
                    if let image {
                        BoardBackdropImage(url: image, reloads: store.landedReloads)
                    }
                    frostStrip(inset: proxy.safeAreaInsets.top)
                }
            }
            .ignoresSafeArea()
            // A background is scenery: the strip's own empty-surface gestures — the click that
            // clears the selection, the rubber band — live in `backdrop`, one layer in, and would be
            // swallowed by anything here that answered a hit test.
            .allowsHitTesting(false)
        }
    }

    /// The frost, full-strength through the title-bar band and dissolving over a short tail below
    /// it — a scroll-edge dissolve rather than a shelf. The chrome sits on an even material the
    /// whole way down, and the strip's bottom edge is nowhere in particular, so the backdrop reads
    /// as one surface the chrome floats over rather than a bar laid across a picture. The tail is a
    /// fraction of the band, so it scales with the toolbar's own height and only ever reaches into
    /// the strip's outer padding, not the lanes.
    ///
    /// Under Reduce Transparency the fade goes with the glass: "solid" means an honest opaque bar
    /// with the standard chrome's own hard edge (`Accommodations.frost`), not a solid that thins
    /// out — a partially transparent solid would be the setting's own defeat.
    @ViewBuilder
    private func frostStrip(inset: CGFloat) -> some View {
        let underlay = Accommodations.frost(reduceTransparency: reduceTransparency)
        if underlay == .solid {
            Rectangle().fill(underlay.style).frame(height: inset)
        } else if inset > 0 {
            let tail = inset * 0.35
            Rectangle()
                .fill(underlay.style)
                .frame(height: inset + tail)
                .mask {
                    LinearGradient(
                        stops: [
                            .init(color: .black, location: 0),
                            .init(color: .black, location: inset / (inset + tail)),
                            .init(color: .clear, location: 1),
                        ],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                }
        }
    }

    // MARK: - Lanes

    /// One lane's strip slot, plus its trailing grab strip.
    ///
    /// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the
    /// interior gaps it spans). While THIS lane is being resized the slot holds two layers, kept
    /// structurally stable so the `LaneView` never loses identity — its scroll position, its
    /// masonry cache — across the drag:
    ///
    ///   • a shadow at the SNAPPED slot width, full strip height, behind the lane — the resting
    ///     footprint the siblings and the window are already aligned to;
    ///   • the live `LaneView` in front at `liveWidth`, which tracks the cursor and so overflows
    ///     (drawing over the right neighbour, hence the slot's `zIndex(1)`) or underfills the shadow
    ///     between ticks.
    ///
    /// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
    /// off the tidy snapped layout regardless of the live overflow.
    ///
    /// A lane being **dragged as a move** is simply absent from the strip: it is lifted out of the
    /// resting layout at pickup and stays out until release, while the system drag session carries
    /// its replica. A lane being dragged as a **copy** stays in the strip instead, because the copy
    /// leaves it there (DRAG-REORDER.md § Resting-layout zones) — which on this board means the
    /// cross-board case alone, ⌥ being ignored by a within-board lane drag.
    @ViewBuilder
    private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
        // **The session governs through the release**, not just the drag: after the release its
        // standard and the unit count it *wrote* keep answering here until the snapshot
        // carries that width back (`LaneWidthHold`). Reading `lane.width` in that window would draw
        // the pre-drag layout for a round trip.
        let resizing = resize.governs(lane.id)
        let units = resize.displayUnits(of: lane)
        // **A folded lane takes the slim strip and nothing else** (03-board-ui.md § Lane ▸ Collapsed
        // lanes). Its `width` still reads — the key is preserved untouched while collapsed, which is how
        // expanding restores the lane the user had — but the strip is what it draws, and the division
        // above has already taken this width off the top rather than dividing it.
        let collapsed = LaneLayoutMath.isCollapsed(lane)
        let slotWidth = LaneLayoutMath.drawnWidth(
            units: units, isCollapsed: collapsed,
            standard: standard, gap: spacing, collapsedWidth: collapsedWidth)
        ZStack(alignment: .topLeading) {
            if resizing {
                DragShadow(
                    cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize),
                    dashed: false
                )
                .frame(width: slotWidth)
                .frame(maxHeight: .infinity)
            }
            // Interior columns follow the SNAPPED unit count while this lane is being resized — a
            // column count is integral, so it tracks k (which ticks and animates), not the live
            // continuous width and not the not-yet-committed `lane.width`. The live width still
            // narrows and widens the columns continuously, so the cards reflow under the cursor
            // between ticks (free via `MasonryLayout`).
            LaneView(
                store: store,
                lane: lane,
                columns: units,
                slotWidth: slotWidth,
                drops: dropContext,
                marquee: marqueeControl,
                openCard: openCard
            )
            // **The value gate** (`LaneView.==`): this body re-runs on every drop-proposal change,
            // and without this every lane on the board — and every card face in it — rebuilds on
            // every cursor move of every drag.
            .equatable()
            .frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
        }
        .frame(width: slotWidth, alignment: .topLeading)
        .zIndex(resizing ? 1 : 0)
        // **No grab strip on a folded lane** (03-board-ui.md § Lane ▸ Collapsed lanes: "lane resize
        // handles don't appear on collapsed lanes"). The strip's width is a constant and its `width` key
        // is outside the division while it is folded, so a drag there would be a gesture with nothing to
        // move — and it would grow the *window* per snap for a lane that is not drawing units at all.
        // The width stepper and ⌥⌘→/⌥⌘← stay available and still edit the preserved key, which is what
        // the lane will be when it unfolds.
        .overlay(alignment: .trailing) {
            if !collapsed {
                LaneResizeHandle(
                    store: store,
                    session: resize,
                    laneID: lane.id,
                    committedUnits: units,
                    standard: standard,
                    gap: spacing,
                    totalUnits: stripTotalUnits,
                    window: window
                )
                // The read-only lock disables every mutating gesture, not just the menu items
                // (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
                // resizes the *window* on the way, so a refused commit would leave the window grown
                // around a lane that snapped back — and the lock's row is already saying why nothing
                // can be written. The focused-editor rule closes it too, like every board command, and
                // so does a drag session in flight — the edge drag "refuses to start while a card/lane
                // session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
                // one strip layout is not a state this view has a meaning for.
                .disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
            }
        }
    }

    /// The lanes the strip lays out, in snapshot order — every lane the board has. Deleting a lane
    /// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
    /// the snapshot is a lane on the board, with no hidden state to filter for.
    private var boardLanes: [Lane] {
        store.snapshot.lanes
    }

    // MARK: - Trash

    /// Whether the trash column is on screen — transient, board-scoped, hidden on every open
    /// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it
    /// gets a name rather than being spelled twice.
    private var isTrashVisible: Bool {
        store.transient.isTrashVisible
    }

    /// The trash's **rows** as the column is showing them, both kinds in rank order — the shown
    /// trash's contents "participate in the filter exactly like any other card" (03-board-ui.md §
    /// Trash), and the arrows walk what is on screen (`TrashLaneView` applies the identical predicate
    /// to the identical rows).
    ///
    /// **Rows and not cards, because this is navigation** (04-interactions.md ▸ The trash: "inside,
    /// plain arrows walk every row, card and lane row alike (navigation crosses kinds)", and ▸
    /// Grammar gives ⌥→ "the shown non-empty trash — its first *entry*"). The kind-scoped lists are
    /// the *ranging* grammar's (`SelectionGrammar.order`), which is what stops a ⇧-arrow at the kind
    /// boundary while a plain one crosses it.
    ///
    /// Read by the three keyboard destinations that reach into the column — the arrow origin's
    /// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
    /// filter took away.
    private var trashRows: [ItemID] {
        SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
    }

    // MARK: - The drag

    /// What each of this window's drop targets — and its lanes' autoscroll drivers — is handed.
    ///
    /// Every geometric input is a **closure**, read at event time (`BoardDropContext`): a captured
    /// snapshot of the strip's frame or its standard width goes stale the moment the layout animates,
    /// and two delegates holding different snapshots would flap the proposal between them.
    private var dropContext: BoardDropContext {
        BoardDropContext(
            store: store,
            session: appModel.dragSession,
            registry: laneDrops,
            gap: spacing,
            window: window,
            stripFrame: { laneDrops.stripFrame },
            standard: { standardWidth(stripWidth: laneDrops.stripFrame.width) }
        )
    }

    /// The strip's 1× lane width, for a strip `stripWidth` points wide.
    ///
    /// **The width is a parameter, and which width the caller passes matters.** Body evaluation
    /// passes the `GeometryReader`'s live proposal, because that is the one source that invalidates
    /// the view when it changes — the registry's `stripFrame` is deliberately unobserved (it exists
    /// for event-time reads), so a body computed from it renders the *previous* layout's width: on
    /// first open that is `.zero`, every lane collapses to `standardWidth`'s 1pt floor, and the
    /// board stays that way until an unrelated invalidation happens by. The drop delegates pass
    /// `laneDrops.stripFrame.width` instead, which is the same number read at event time — exactly
    /// the registry's purpose. The two agree because the padded container fills the reader.
    ///
    /// During a resize session the standard comes from the session and not from the viewport: the
    /// window is animating mid-resize, so deriving it from the live width would feed that animation
    /// back into every lane and pulse the whole strip. Within the screen's fit the session's answer is
    /// its drag-start value, **frozen** — the window is sized on each tick so it equals what the
    /// formula yields once the session ends, and the siblings never move. Past the fit the window is
    /// pinned and the session re-divides instead (settled 2026-08-08, 03-board-ui.md § Lane), so the
    /// number returned here shrinks with each tick and every lane compresses — which is exactly how
    /// the drag borrows the stepper's mechanism. Either way the handoff at the end is seamless (see
    /// `LaneResizeSession`).
    ///
    /// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that
    /// makes the handoff seamless is between the frozen standard and the formula run over the *new*
    /// unit total, and the write takes a watcher round trip to put that total in the snapshot;
    /// dividing the already-grown window by the stale total in between is exactly the two-step the
    /// hold exists to remove.
    ///
    /// Otherwise it is the ordinary division, over three contributions:
    ///
    /// - the **live lanes**' units. A lane in flight is still a lane on the board, so a within-board
    ///   drag does *not* re-divide the strip: "the shadow occupies its units" (DRAG-REORDER.md § The
    ///   lane strip's resting layout is arithmetic). Recomputing at pickup and again at release is
    ///   exactly the motion-feeds-back-into-logic failure the model exists to avoid.
    /// - the **trash's** one fixed unit, *only while shown* — the whole of "Show/Hide Trash is a
    ///   re-divide trigger" (03-board-ui.md § Trash): the window is never touched, the existing width
    ///   simply divides across one more unit and every lane compresses.
    /// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
    ///   from the destination's side: the shadow occupies its units, and the strip has to make room
    ///   for them or the shadow would be drawn at a width the lane will not have
    ///   (`arrivingLaneUnits`).
    /// - a **collapsed** lane's fixed strip, which is the one contribution that is *subtracted* rather
    ///   than divided (03-board-ui.md § Lane ▸ Collapsed lanes: "the strip is not part of the width
    ///   re-division; collapsed strips consume their fixed width"). Folding a lane therefore re-divides
    ///   exactly as the trash's unit joining and leaving does — the window is untouched and the expanded
    ///   lanes grow into the space the lane gave up.
    private func standardWidth(stripWidth: CGFloat) -> CGFloat {
        if resize.isActive { return resize.standard }
        return LaneLayoutMath.standardWidth(
            stripWidth: stripWidth,
            totalUnits: stripTotalUnits + arrivingLaneUnits,
            gap: spacing,
            collapsedCount: stripCollapsedCount + arrivingCollapsedLanes,
            collapsedWidth: collapsedWidth
        )
    }

    /// The strip's resting divide: every lane's units plus the trash's fixed one while it is shown.
    ///
    /// Named because it is asked twice for two purposes. The resting layout divides the viewport by
    /// it (plus a cross-board arrival's units, which are a hover-only addition and no part of the
    /// strip itself), and each lane's grab strip hands it to `LaneResizeSession.begin` as the total
    /// the drag's re-divide works from once the window has spent the screen (03-board-ui.md § Lane).
    /// The arrival is deliberately absent from the second: a resize refuses to start while a drag
    /// session is in flight, so there is never an arriving lane at a drag's begin.
    private var stripTotalUnits: Int {
        LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
    }

    /// How many of this strip's lanes are drawn as slim strips — `standardWidth`'s subtraction, and the
    /// other half of the divide beside `stripTotalUnits` (03-board-ui.md § Lane ▸ Collapsed lanes).
    ///
    /// Read off the **snapshot** rather than off what the strip is showing, exactly as the unit total is:
    /// a lane in flight is still a lane on the board, so a within-board drag never re-divides the strip
    /// (DRAG-REORDER.md § The lane strip's resting layout is arithmetic).
    private var stripCollapsedCount: Int {
        LaneLayoutMath.collapsedCount(of: boardLanes)
    }

    /// The units a cross-board lane run would add to this strip while its shadow is proposed here;
    /// zero for a within-board drag, whose lanes are already counted.
    ///
    /// **A cross-board lane arrival pre-divides the destination strip during hover**
    /// (04-interactions.md ▸ Drag and drop, settled): "while a foreign lane drag proposes into a
    /// board, the destination's standard width is computed with the arriving run's units included, so
    /// the shadow draws at the width the lane will actually take — without this it overflows the
    /// strip (the pathfinder's `stripWidthUnits`)". The units are the run's own, frozen at pickup by
    /// the *source* board (`DragSession.laneUnits`) — both boards are open in this app, so nothing
    /// has to cross the pasteboard for the destination to know how wide its visitor is.
    ///
    /// Gated on a live proposal *on this board* rather than on hover alone, which is the same bullet's
    /// accepted residue: "the first entry samples the un-widened standard for one frame before
    /// hysteresis settles — accepted, imperceptible".
    ///
    /// One number, two readers: body evaluation draws the strip and the shadow with it
    /// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`,
    /// so the zones can never disagree with what is on screen.
    private var arrivingLaneUnits: Int {
        guard isArrivingFromAnotherBoard else { return 0 }
        let session = appModel.dragSession
        let folded = session.laneCollapsed
        // **A folded visitor brings no units, only its strip** — the resting rule read from the
        // destination's side: what the shadow has to make room for is the width the lane will *draw*,
        // and a collapsed lane draws the fixed strip `arrivingCollapsedLanes` accounts for.
        return session.laneUnits.enumerated()
            .filter { !($0.offset < folded.count && folded[$0.offset]) }
            .reduce(0) { $0 + $1.element }
    }

    /// How many **folded** lanes a cross-board run would add to this strip while its shadow is proposed
    /// here — `arrivingLaneUnits`' other half, and the subtraction side of the same pre-divide.
    private var arrivingCollapsedLanes: Int {
        guard isArrivingFromAnotherBoard else { return 0 }
        return appModel.dragSession.laneCollapsed.prefix(appModel.dragSession.laneUnits.count).count { $0 }
    }

    /// Whether a lane run from **another** board is proposing into this strip right now — the gate both
    /// pre-divide contributions share, so they can never disagree about whether there is a visitor.
    private var isArrivingFromAnotherBoard: Bool {
        let session = appModel.dragSession
        guard session.isDraggingLanes,
              session.stripProposal(onBoardRooted: store.rootKey) != nil,
              let source = session.sourceRoot,
              source != store.rootKey
        else { return false }
        return true
    }

    /// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
    /// shadow run opens.
    private var stripProposal: Int? {
        appModel.dragSession.stripProposal(onBoardRooted: store.rootKey)
    }

    /// One position in the strip: a lane, or one of the drag's N contiguous shadows.
    private enum StripSlot: Identifiable {
        case lane(Lane)
        /// A shadow at the arriving lane's **drawn** footprint — its frozen unit count, plus whether it
        /// arrives folded, which decides between the two (03-board-ui.md § Lane ▸ Collapsed lanes).
        case shadow(index: Int, units: Int, collapsed: Bool)

        var id: String {
            switch self {
            case let .lane(lane): "lane:\(lane.id.rawValue)"
            // Constant per position, so a shadow run keeps its identity as the proposal slides and
            // the run animates as a move rather than blinking out and back in.
            case let .shadow(index, _, _): "shadow:\(index)"
            }
        }
    }

    /// What the strip lays out: the resting lanes — the dragged run lifted out when the release
    /// would be a move, left standing when it would be a copy (`DragSession.hiddenMembers`) — with
    /// the shadows opened at the proposal.
    private var stripSlots: [StripSlot] {
        let session = appModel.dragSession
        let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
        var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
        guard let index = stripProposal else { return slots }
        let folded = session.laneCollapsed
        let shadows = session.laneUnits.enumerated().map {
            StripSlot.shadow(
                index: $0.offset,
                units: $0.element,
                collapsed: $0.offset < folded.count && folded[$0.offset])
        }
        slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
        return slots
    }

    // MARK: - Grammar keys

    /// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an
    /// inline rename, a sole selected live lane begins a new-card placeholder at its bottom, and
    /// everything else is ignored — a multi-card selection is explicitly inert, and a lane's rename
    /// path is Board ▸ Rename precisely because Return on a lane creates.
    ///
    /// Inert while an inline editor is open: "all grammar keys inert while a title editor is
    /// focused". The field consumes Return itself, so this guard is belt over braces — but the belt
    /// matters, because a stray Return reaching here mid-edit would open a *second* editor.
    private func handleReturn(_ press: KeyPress) -> KeyPress.Result {
        // **Plain Return only**, the delete handler's rule for its reason. ⌘↩ belongs to Board ▸
        // Open Card and AppKit routes it to the menu first — but only while that item is *enabled*,
        // and a disabled one lets the chord fall through to here. ⌥↩ and ⇧↩ are nobody's key
        // equivalent at all. Neither may open a rename or a placeholder.
        guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
            return .ignored
        }
        guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
        let selection = store.selection
        guard selection.container == .board,
              selection.ids.count == 1,
              let id = selection.ids.first,
              let target = BoardStore.boardItem(id, in: store.snapshot)
        else { return .ignored }

        if target.cardID == nil {
            store.transient.beginPlaceholder(inLane: target.laneID)
        } else {
            store.transient.beginRename(of: id, currentTitle: target.title)
        }
        return .handled
    }

    /// **Plain ⌫ deletes the selection** — "a plain-key synonym of File ▸ Delete, kept grammar so no
    /// second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The map).
    ///
    /// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same
    /// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28) — a board
    /// selection moves into `.trash/`, a trash selection deletes permanently.
    ///
    /// **Which means the same confirmation, too.** It goes through `TrashConfirmations.requestDelete`
    /// rather than straight to `BoardStore.deleteSelection`, because "the same chord deletes
    /// permanently … confirmation per 03's recoverability rule" and a bare key that skipped the alert
    /// the menu item raises would be the one path in the app where one keystroke destroys a card
    /// silently. The staging itself is still the store's — the alert is the only thing this adds.
    ///
    /// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
    /// and a stray one reaching the board mid-edit would delete the item being renamed.
    private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
        // **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete), ⇧⌘⌫
        // (Empty Trash…) — and AppKit routes a key equivalent to the menu before the view sees it.
        // But ⌥⌫ and ⌃⌫ are nobody's key equivalent, and a fall-through that deleted the selection on
        // a mistyped text-editing chord would be exactly the kind of accident 04-interactions.md's
        // fixed grammar is careful to avoid.
        guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
            return .ignored
        }
        guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
        let selection = store.selection
        guard !selection.isEmpty else { return .ignored }
        confirmations.requestDelete(in: store)
        return .handled
    }

    /// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
    /// clear the search, else clear the selection.
    ///
    /// **The search takes Escape before its clear-selection meaning** (04 § Search, settled): "with
    /// *board* focus and an active search, one press clears the search and the full board returns —
    /// search takes Escape before its clear-selection meaning, which applies only when no search is
    /// active." So a board-focused Escape under a query returns the board and *keeps* the selection;
    /// a second press then deselects. One press, one layer, all the way out.
    ///
    /// This is the third step of a staircase whose first two are the field's own — a non-empty field
    /// clears its query and keeps the keyboard, an empty one hands the keyboard back here — and the
    /// two halves never both fire, because exactly one of the field and the strip holds focus (see
    /// `BoardSearchField`).
    ///
    /// The editors handle Escape themselves while they hold focus; that branch is the outer net for
    /// the case where focus has drifted off the field with an editor still open, and it abandons
    /// both kinds because at most one can be open at a time.
    private func handleEscape() -> KeyPress.Result {
        if store.isEditingInline {
            store.transient.discardPlaceholder()
            store.transient.discardRename()
            return .handled
        }
        if !store.searchQuery.isEmpty {
            store.clearSearch()
            return .handled
        }
        guard !store.selection.isEmpty else { return .ignored }
        store.clearSelection()
        return .handled
    }

    // MARK: - The arrows

    /// What modifier an arrow carried, reduced to the three meanings the grammar gives it — the
    /// keyboard's `ClickModifier`.
    private enum ArrowMode {
        /// Plain: spatial navigation, replacing the selection.
        case step
        /// ⇧: extend the range from the anchor.
        case extend
        /// ⌥: jump to an end (04-interactions.md ▸ Grammar's "⌥-arrows jump").
        case jump
    }

    /// **The arrow grammar's one door** (04-interactions.md ▸ Grammar; 11-command-nexus.md ▸ Fixed
    /// grammar keys).
    ///
    /// The handlers below are deliberately thin over pure functions — `NavigationMath` for the
    /// geometry, `SelectionGrammar` for the order lists and the ranges — so what is written here is
    /// dispatch and nothing else.
    ///
    /// **⌘- and ⌥⌘-arrows never mean anything here.** They are menu key equivalents (Move Left/Right,
    /// Move Up/Down, the lane width pair) and AppKit routes them to the menu before any view sees
    /// them — but only while the item is *enabled*, so a disabled Move Right does deliver ⌘→ here.
    /// Rejecting every combination but plain, ⇧ and ⌥ is what keeps a disabled command from silently
    /// becoming a navigation gesture, and a mistyped text chord from moving the selection.
    private func handleArrow(_ press: KeyPress) -> KeyPress.Result {
        // "All grammar keys inert while a title editor is focused" — and the field owns the arrows
        // as caret movement, so this guard is load-bearing rather than belt over braces.
        guard !store.isEditingInline else { return .ignored }
        guard let direction = Self.direction(of: press.key) else { return .ignored }

        // Only the four meaningful flags are read: an arrow event also carries `.function` and
        // `.numericPad` on macOS, and testing the whole set for emptiness would reject every press.
        let modifiers = press.modifiers.intersection([.command, .control, .option, .shift])
        let mode: ArrowMode
        if modifiers.isEmpty {
            mode = .step
        } else if modifiers == .shift {
            mode = .extend
        } else if modifiers == .option {
            mode = .jump
        } else {
            return .ignored
        }

        guard let origin = arrowOrigin() else { return seed(direction, mode) }
        return origin.isLaneDomain
            ? laneArrow(direction, mode, from: origin.head)
            : cardArrow(direction, mode, from: origin.head, in: origin.container)
    }

    private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
        switch key.character {
        case KeyEquivalent.upArrow.character: .up
        case KeyEquivalent.downArrow.character: .down
        case KeyEquivalent.leftArrow.character: .left
        case KeyEquivalent.rightArrow.character: .right
        default: nil
        }
    }

    /// Where the next arrow steps from, and on which of the board's two levels — `nil` when the
    /// selection names nothing to step from, which is the seed rule's cue.
    ///
    /// The head is `TransientBoardState.selectionHead` when it is still in the order list, and
    /// otherwise the selection's **last member in that list** — the same "last in flatten order"
    /// anchor the ⌘N target rule and paste already share. That fallback is what makes a marquee, a
    /// Select All and a foreign reload leave the arrows somewhere sensible without any of them
    /// having to name a cursor.
    ///
    /// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
    /// rejoined 2026-07-29) — and the trash side is **never the lane domain**, whatever the
    /// selection's kind: the lane domain is the strip's grammar (←/→ walk lanes, ↓ descends into
    /// cards, ⌘←/⌘→ move one), and a trashed lane row is a row in a column, not a lane on the board
    /// ("The trash lane itself is never selectable *as a lane*" — 04 ▸ The trash). Its arrows are the
    /// spatial ones every row gets.
    ///
    /// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
    /// fallback lands on the last *visible* member rather than on a row the query hid.
    private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
        let selection = store.selection
        guard !selection.isEmpty else { return nil }

        let isLaneDomain: Bool
        let list: [ItemID]
        switch selection.container {
        case .board:
            guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
            isLaneDomain = kind == .lane
            list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
        case .trash:
            isLaneDomain = false
            list = trashRows
        }

        if let head = store.transient.selectionHead, list.contains(head) {
            return (head, selection.container, isLaneDomain)
        }
        guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
        return (last, selection.container, isLaneDomain)
    }

    /// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
    /// a deterministic origin, so an arrow from nothing always means the same thing.
    ///
    /// ⌥←/⌥→ are the exception, and the design states it: "the ⌥-jumps behave as specified
    /// regardless". Those two name an *absolute* destination and need no origin, so they run
    /// unchanged. ⌥↑/⌥↓ are relative to "the current lane", which an empty selection has none of, so
    /// they seed like a plain arrow — which is exactly what makes "two ⌥↑ presses from nothing reach
    /// the lane domain" true: the first seeds, the second escalates.
    private func seed(_ direction: NavigationMath.Direction, _ mode: ArrowMode) -> KeyPress.Result {
        if mode == .jump, direction == .left || direction == .right {
            return jumpToEndLane(direction)
        }
        guard let first = NavigationMath.firstCard(scanning: boardLanes, filter: store.searchFilter)
        else { return .handled }
        replaceSelection(with: first, in: .board)
        return .handled
    }

    // MARK: Card domain

    private func cardArrow(
        _ direction: NavigationMath.Direction,
        _ mode: ArrowMode,
        from head: ItemID,
        in container: ItemContainer
    ) -> KeyPress.Result {
        switch mode {
        case .step: step(direction, from: head)
        case .extend: extend(direction, from: head)
        case .jump:
            switch direction {
            case .left, .right: jumpToEndLane(direction)
            case .up, .down: jumpWithinContainer(direction, from: head, in: container)
            }
        }
    }

    /// **Nearest card in the direction, across interior grid columns and lanes** — and across the
    /// live/trash boundary too, since "plain arrows still walk across" (04 ▸ The trash).
    ///
    /// Every registered target is a candidate, which is also how the hidden trash stays invisible:
    /// a column that is not drawn registers nothing.
    ///
    /// **←/→ across lanes are the one exception, and they answer from the sticky ordinal instead**
    /// (ruled 2026-08-09; `NavigationMath.lateralHop`): a lane hop lands on the position the run is
    /// holding, clamped to the target lane, so 10-3-10 walks out and back to the same card. The three
    /// branches below are ordered by how specific they are —
    ///
    /// 1. **A same-lane neighbour wins first**, which is a wide lane's interior masonry column. That
    ///    is a column hop, not a lane hop, so it keeps its geometry *and* leaves the run's ordinal
    ///    exactly as it found it — "interior masonry position does not participate".
    /// 2. **Otherwise the lanes decide**, and the ordinal picks the card in the lane they name. The
    ///    collapsed and query-emptied lanes are skipped inside `lateralHop`, on `firstCard`'s rule
    ///    rather than by the accident of registering no frames.
    /// 3. **With no lane that way, geometry has the last word** — which is how `→` off the last lane
    ///    still reaches the shown trash, and how the trash's own ←/→ stay spatial (an origin over
    ///    there is not in `.board` at all, so branch 2 never sees it).
    private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
        guard let origin = marqueeTargets.targets[head] else { return .handled }
        let geometric = NavigationMath.nearest(
            from: origin.frame,
            direction: direction,
            among: marqueeTargets.all
        )

        if direction == .left || direction == .right, origin.container == .board {
            let sticky = store.transient.lateralOrdinal
            if let geometric, let home = lane(holding: head), lane(holding: geometric) == home {
                store.select(
                    [geometric], in: .board, anchor: geometric, head: geometric, lateralOrdinal: sticky
                )
                return .handled
            }
            if let hop = NavigationMath.lateralHop(
                from: head,
                direction,
                scanning: boardLanes,
                filter: store.searchFilter,
                sticky: sticky
            ) {
                store.select(
                    [hop.target],
                    in: .board,
                    anchor: hop.target,
                    head: hop.target,
                    lateralOrdinal: hop.ordinal
                )
                return .handled
            }
        }

        guard let nextID = geometric, let next = marqueeTargets.targets[nextID] else { return .handled }
        replaceSelection(with: next.id, in: next.container)
        return .handled
    }

    /// The live lane `card` sits in, or `nil` for anything the board is not showing as a card — a
    /// trash row included, which is what makes the same-lane test above reject a crossing candidate
    /// without a second container check.
    private func lane(holding card: ItemID) -> ItemID? {
        store.snapshot.lanes.first { $0.cards.contains { $0.id == card } }?.id
    }

    /// **⇧-arrow extends, and stops at the container boundary** (04 ▸ The trash: "⇧-arrow extension
    /// still stops at the container boundary") — a ⇧-arrow whose next step would cross from live
    /// cards into the trash, or back, is simply inert.
    ///
    /// **The kind boundary is no longer one of the stops inside the trash** (re-ruled 2026-07-31):
    /// "⇧-click ranges, ⇧-arrow extension … all sweep every row" there, so a ⇧-arrow from a trash
    /// card onto a lane row extends across it. On the board the kind test stands — the live grammar
    /// is still cards XOR lanes.
    ///
    /// The *step* that would cross is what goes inert — the crossing item is never stepped over in
    /// search of a legal one, because that would silently drop the held range for a longer reach
    /// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
    /// tested: a different side (or, on the board, a different kind) means this press does nothing
    /// at all.
    private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
        guard let origin = marqueeTargets.targets[head],
              let nextID = NavigationMath.nearest(
                  from: origin.frame,
                  direction: direction,
                  among: marqueeTargets.all
              ),
              let next = marqueeTargets.targets[nextID],
              next.container == origin.container,
              next.container == .trash || next.kind == origin.kind
        else { return .handled }

        // An extension with no anchor makes one of where it started — the keyboard's equivalent of
        // a ⇧-click after a marquee, which the grammar degrades to a plain click for the same
        // reason: a range needs an origin, and the only honest one is the cursor's own position.
        let anchor = store.transient.selectionAnchor ?? head
        guard let ids = SelectionGrammar.range(
            from: anchor,
            to: next.id,
            kind: next.kind,
            in: next.container,
            snapshot: store.snapshot,
            // The span is the *filtered* board's, so a range under a search collects exactly the
            // cards between the two endpoints that are on screen (04 § Search: "ranges … read it").
            filter: store.searchFilter
        ) else { return .handled }
        store.select(ids, in: next.container, anchor: anchor, head: next.id)
        return .handled
    }

    /// **⌥↑/⌥↓ jump to the current container's first/last row** — the lane's cards, or the trash
    /// column's rows when that is where the cursor is.
    ///
    /// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
    /// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects
    /// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
    /// so a second ⌥↑ there is simply inert.
    ///
    /// **In the trash the jump crosses kinds**, like every other navigation there: the ends it names
    /// are the column's, so ⌥↑ from a card can land on a lane row sitting above it.
    private func jumpWithinContainer(
        _ direction: NavigationMath.Direction,
        from head: ItemID,
        in container: ItemContainer
    ) -> KeyPress.Result {
        let siblings: [ItemID]
        var lane: ItemID?
        switch container {
        case .trash:
            siblings = trashRows
        case .board:
            guard let home = store.snapshot.lanes.first(where: { lane in
                lane.cards.contains { $0.id == head }
            }) else { return .handled }
            lane = home.id
            // The container is what the lane is *showing*: a jump to "the lane's first card" under
            // a search means its first surviving card, not one the filter animated out.
            let filter = store.searchFilter
            siblings = home.cards.filter { filter.matches($0) }.map(\.id)
        }

        guard let target = direction == .up ? siblings.first : siblings.last else { return .handled }
        if direction == .up, target == head, let lane, store.selection.ids == [head] {
            replaceSelection(with: lane, in: .board)
            return .handled
        }
        replaceSelection(with: target, in: container)
        return .handled
    }

    /// **⌥←/⌥→ to the first/last lane** (04 ▸ Grammar) — landing, in the card domain, on that lane's
    /// first card, since ⌥↑ is the one keyboard entry to lane selection.
    ///
    /// **⌥→ reaches the shown trash** first (04 ▸ The trash: "the shown trash is the last container
    /// for card navigation, and ⌥→ jumps to it" — ▸ Grammar names the landing as "its first
    /// **entry**", which is a row of either kind); an empty or hidden column is not a destination, so
    /// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
    /// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
    private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
        if direction == .right, isTrashVisible, let first = trashRows.first {
            replaceSelection(with: first, in: .trash)
            return .handled
        }
        let lanes = boardLanes
        let filter = store.searchFilter
        // A lane the search emptied is scanned past exactly as an empty one is — and so is a collapsed
        // one (03-board-ui.md § Lane ▸ Collapsed lanes). The jump lands on the first lane that is
        // *showing* a card, which is what the user can see (`NavigationMath.firstCard`).
        let target = direction == .right
            ? NavigationMath.firstCard(scanning: lanes.reversed(), filter: filter)
            : NavigationMath.firstCard(scanning: lanes, filter: filter)
        guard let target else { return .handled }
        replaceSelection(with: target, in: .board)
        return .handled
    }

    // MARK: Lane domain

    /// The arrows with a **lane** selected (04-interactions.md ▸ Grammar, ▸ The map).
    ///
    /// - ←/→ move the lane selection one lane, inert at the ends — **and the trash is never reached**
    ///   ("with a lane selected, ←/→ and ⌥→ stop at the last real lane"), which falls out for free
    ///   from walking the live lane order and nothing else.
    /// - ⇧←/⇧→ extend that selection from the anchor, the same range a ⇧-click would give.
    /// - ↓ descends back into the lane's cards at the first card, ⌥↓ at the last; an empty lane has
    ///   nothing to descend into.
    /// - ↑ and ⌥↑ are inert: the lane domain is the top of the hierarchy.
    /// - ⌥←/⌥→ jump to the first/last lane, staying in the lane domain.
    private func laneArrow(
        _ direction: NavigationMath.Direction,
        _ mode: ArrowMode,
        from head: ItemID
    ) -> KeyPress.Result {
        let lanes = SelectionGrammar.lanes(in: store.snapshot)
        guard let index = lanes.firstIndex(of: head) else { return .handled }

        switch (direction, mode) {
        case (.left, .step), (.right, .step), (.left, .extend), (.right, .extend):
            let next = index + (direction == .left ? -1 : 1)
            guard lanes.indices.contains(next) else { return .handled }
            if mode == .step {
                replaceSelection(with: lanes[next], in: .board)
            } else {
                let anchor = store.transient.selectionAnchor ?? head
                guard let ids = SelectionGrammar.range(
                    from: anchor,
                    to: lanes[next],
                    kind: .lane,
                    in: .board,
                    snapshot: store.snapshot
                ) else { return .handled }
                store.select(ids, in: .board, anchor: anchor, head: lanes[next])
            }

        case (.left, .jump), (.right, .jump):
            guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
            replaceSelection(with: target, in: .board)

        case (.down, .step), (.down, .jump):
            guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
                return .handled
            }
            // **A folded lane has nothing to descend into** (03-board-ui.md § Lane ▸ Collapsed lanes):
            // its cards are not rendered, so ↓ is inert exactly as it is on an empty lane — landing on
            // a card the strip draws nowhere would leave the arrows with no frame to step from next.
            // The way in is to expand the lane, which is what the strip's own click does.
            guard !LaneLayoutMath.isCollapsed(lane) else { return .handled }
            let cards = lane.cards
            guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
            replaceSelection(with: target.id, in: .board)

        case (.up, _), (.down, .extend):
            // Nothing above the lane domain, and no vertical range within it.
            break
        }
        return .handled
    }

    // MARK: Shared

    /// A jump's and a plain step's shared landing: one item, both cursors on it.
    private func replaceSelection(with id: ItemID, in container: ItemContainer) {
        store.select([id], in: container, anchor: id, head: id)
    }
}
