import AppKit
import SwiftUI

// MARK: - Which side of the board a face is on

/// **The one axis a card face has** — which container it is drawn in, and the collaborator that
/// container's grammar needs.
///
/// 03-board-ui.md § Trash, resettled 2026-07-28: "A trashed card is an ordinary card in a special
/// place — search, selection, rendering, styling, and clipboard all treat it exactly like any other
/// card". So there is **one** card face in this app, and this is the whole of what differs between
/// its two homes. Everything the two sides share — the plate, the stripe, the icon tint, the
/// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag — is
/// shared by construction rather than by two views agreeing.
///
/// The three differences are all *absences on the trash side*, and each is 04-interactions.md ▸ The
/// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is
/// simply not taken:
///
/// - **no Open** — no double-click gesture at all ("trash cards don't open — double-click stops at
///   selection"), which is also why `openCard` is the board case's payload rather than the view's;
/// - **no Rename** — the inline editor is board-only (`isRenaming`), so a rename that somehow
///   targeted a trashed card would render nothing rather than open a field over it;
/// - **no Style…** — no popover anchor, and no Style rows in the context menu.
///
/// Plus the two that are not about editing: Finder file drops are inert over the trash (▸ The trash),
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
/// it needs the window's confirmation host (11-command-nexus.md ▸ Context menus' Trash cards row).
///
/// **A fourth board-only row joined 2026-08-09**: Copy Link (design ruling, card 737a949f) — not one
/// of the three edit-shaped absences above (it changes nothing on disk), but "sole selected **live**
/// card" is its own words for the same board-only scope, so it lives in `boardMenu` only and has no
/// trash-side counterpart, absence or otherwise.
///
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
/// card opener.
/// `Sendable` because `CardFaceView.==` is nonisolated and a nonisolated context may only read a
/// main-actor `let` of Sendable type — which is why `openCard` is typed `@MainActor` (an isolated
/// function type is Sendable; a bare one is not, and would sink the whole enum).
enum CardFaceRole: Sendable {

    /// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
    /// (04-interactions.md ▸ Selection).
    case board(openCard: @MainActor (ItemID) -> Void)

    /// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
    /// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
    case trash(confirmations: TrashConfirmations)

    /// Which container a click on this face selects in, which container its drag begins in, and which
    /// container the rubber band sweeps it as — one answer, so the three can never disagree
    /// (`SelectionGrammar`: "the container is the surface's, not the item's").
    var container: ItemContainer {
        switch self {
        case .board: .board
        case .trash: .trash
        }
    }

    /// Whether two roles put the face in the same home with the same collaborator — the comparison
    /// `CardFaceView.==` makes, and the reason this enum is not simply `Equatable`.
    ///
    /// **The board case's `openCard` is deliberately not compared.** It is a closure the strip
    /// rebuilds on every body pass, so comparing it is impossible and ignoring it is correct: it is a
    /// pure hand-off to the host's `WindowGroup` key, identical in behaviour whatever closure object
    /// carries it, and a face that changed *which window it opens into* would be a face in a
    /// different window and therefore a different view identity entirely.
    ///
    /// The trash case's `confirmations` **is** compared, by identity: it is window-lived state
    /// (`@State` in `BoardWindowHost`), so identity is both cheap and meaningful, and it is the one
    /// collaborator a role carries that the face actually reads state off.
    nonisolated func isEquivalent(to other: CardFaceRole) -> Bool {
        switch (self, other) {
        case (.board, .board): true
        case let (.trash(lhs), .trash(rhs)): lhs === rhs
        default: false
        }
    }
}

// MARK: - Card face

/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
/// placeholder), two quiet trailing chips — attachments and comments, each present-only — and a
/// left-edge colour accent stripe (03-board-ui.md § Card face, § Styling ▸ Capabilities). The
/// comments chip joined 2026-08-09 (design ruling, card e729e30a).
///
/// ### One face, two containers
///
/// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized
/// trash): "a trashed card is an ordinary card in a special place … rendering … treat it exactly like
/// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew;
/// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it
/// did in its lane, because it is the same card and the same view. What the trash takes away is
/// listed on `CardFaceRole` and nowhere else.
///
/// ### Title-only, deliberately
///
/// **No body excerpt** — settled, and still true after 2026-08-09's growth (the hero banner, the
/// comments chip): the face never draws a preview of the card's own prose, and never will — what
/// grew is exposure of facts the card already carries structurally (an attachment named as hero, a
/// folder count), not content. Two face chips are in scope: **attachments**, "a quiet indicator when
/// the card has files — the title dominates", and **comments**, its same-vocabulary twin — a
/// secondary-tinted glyph (plus a count, for comments — see `commentsIndicator`) rather than a count
/// pill, so the eye still lands on the title first.
///
/// ### Two lenient fields, two different fallbacks
///
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### One presentation, selection styling only
///
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
///
/// ### Equality gate
///
/// The face is `Equatable` and instantiated through `.equatable()` (`LaneView.scrollableCards`,
/// `TrashLaneView.scrollableCards`), because its parent re-runs for reasons that have nothing to do
/// with any one card: a lane's body re-evaluates on **every drop-proposal change** while a drag is
/// in flight, and without a gate that rebuilds every face in every lane on every cursor move. See
/// the `==` below for what the gate covers and what it deliberately does not.
struct CardFaceView: View, Equatable {

    let store: BoardStore
    let card: Card

    /// Which side of the board this face is drawn on, and what that side's grammar needs — the view's
    /// one axis (`CardFaceRole`).
    let role: CardFaceRole

    /// The strip's rubber band — the registry this face registers its drawn frame into.
    let marquee: MarqueeControl

    /// The board window's drop machinery: this face registers its measured height into the geometry
    /// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
    let drops: BoardDropContext

    /// **This card's hero image, resolved** — the file its `hero` key names inside its own
    /// `attachments/`, or `nil` for the overwhelmingly common card that names none
    /// (03-board-ui.md § Card face ▸ Hero image; `CardHero.imageURL(for:inContainer:)`).
    ///
    /// A parameter rather than a resolution done here, and for `isSelected`'s kind of reason one axis
    /// over: resolving it needs the card's *folder*, which this view does not know — it knows its card
    /// and its container, and finding the path from the snapshot would be a board walk per face. The
    /// two parents each know their own container folder and compute it once for the whole strip.
    ///
    /// Whether the file exists, decodes, or is an image at all is deliberately not asked here: this is
    /// a URL, and a name that leads nowhere draws no band (`CardHeroImage`), which is the ruling's
    /// "renders exactly as with no key" kept structurally.
    let hero: URL?

    /// Whether this card is in the selection **of its own container** — a parameter rather than a
    /// read off the store, and that is the whole of the fix RENDER-INSTRUMENTATION.md ▸ "Selection is
    /// O(board) in card bodies" asked for.
    ///
    /// Observation tracks whole properties, so a body that reads `store.selection` is subscribed to
    /// *every* selection change on the board — 180 faces re-running per marquee sample on the 6×30
    /// fixture, 515 on a real board, and `.equatable()` powerless over any of it because a direct
    /// Observation invalidation never consults the gate. Taking selected-ness as a compared input
    /// instead moves the subscription up one level, to the parent that already holds it: the lane's
    /// body reads the selection once for its own header, hands each face the answer, and the gate
    /// below then re-runs exactly the faces whose flag actually flipped.
    let isSelected: Bool

    /// The size of the selection this face belongs to — **1 when unselected**, which the parent
    /// computes and normalizes so the drag replica's fan and count badge need no store read of their
    /// own (`dragReplica`). Deliberately not `Int?`: "how many ride along" has an answer for every
    /// face, and one is it.
    let selectedCount: Int

    /// Whether this face's own lane has a live neighbour one step to the **left** — Navigation ▸ Move
    /// Left's render-safe enablement input (2026-08-09 ▸ "Give the card context menu's Navigation rows
    /// real card-move behavior", card 06322636), `isSelected`'s exact pattern one row over: the parent
    /// (`LaneView`) reads `store.snapshot` **once per lane** (`CardMoveTarget.hasNeighbor`) and hands
    /// every face in it the answer, so a click never costs a per-face board walk. Defaults `false` —
    /// the trash side never constructs Navigation rows at all, so its faces never need a real answer
    /// here (`CardFaceRole`).
    let hasLeftNeighbor: Bool

    /// `hasLeftNeighbor`'s mirror, for Move Right.
    let hasRightNeighbor: Bool

    /// Whether the **live board selection** reaches outside this face's own lane — the "no coherent
    /// left for a spread" refusal (owner ruling, card 06322636): a selection spanning more than one
    /// lane disables Navigation for every one of its own selected members, because the widened target
    /// (`targetIDs`) would name cards `CardMoveTarget.destination` can never place in one direction.
    /// Only read together with `isSelected` — an unselected face's target is always itself alone,
    /// trivially confined to its own lane regardless of what this says (`moveLeftEnabled`).
    ///
    /// Lane-hoisted like the two neighbour flags above (`CardMoveTarget.selectionSpansOtherLanes`):
    /// the parent already reads the selection once for `isSelected`/`selectedCount`, so this costs one
    /// more `Set` comparison over cards it already has in hand, never a second `store.selection` read.
    let selectionSpansLanes: Bool

    /// Explicit only for `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` — the three
    /// Navigation inputs, all defaulted to `false` (every row reads disabled) so the trash side's one
    /// call site (`TrashLaneView.cardRow`, whose faces never draw Navigation at all) and the pre-move
    /// equatable-gate tests need no opinion about a feature their construction never exercises. Every
    /// other parameter stays required — the memberwise init's own shape — because the board side
    /// (`LaneView.scrollableCards`) always has a real answer for all eight and a silently-defaulted
    /// `card` or `isSelected` would be the wrong kind of convenience.
    init(
        store: BoardStore,
        card: Card,
        role: CardFaceRole,
        marquee: MarqueeControl,
        drops: BoardDropContext,
        hero: URL?,
        isSelected: Bool,
        selectedCount: Int,
        hasLeftNeighbor: Bool = false,
        hasRightNeighbor: Bool = false,
        selectionSpansLanes: Bool = false
    ) {
        self.store = store
        self.card = card
        self.role = role
        self.marquee = marquee
        self.drops = drops
        self.hero = hero
        self.isSelected = isSelected
        self.selectedCount = selectedCount
        self.hasLeftNeighbor = hasLeftNeighbor
        self.hasRightNeighbor = hasRightNeighbor
        self.selectionSpansLanes = selectionSpansLanes
    }

    /// The app-wide quick-style recents — see `LaneView`'s own note.
    @Environment(AppModel.self) private var appModel

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

    /// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). **The environment is what
    /// makes zoom reach a card face at all**: this view is `.equatable()`, and the gate above compares
    /// nothing that moves with the level — but it does not compare environment values either, because
    /// SwiftUI invalidates on those itself. A level threaded any other way would be swallowed here.
    @Environment(\.boardZoom) private var zoom

    /// The live body metric — every figure this face lays out on is a multiple of it
    /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
    /// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
    private var pointSize: CGFloat { zoom.bodyPointSize }

    /// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
    /// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
    /// Read from `BoardMetrics` rather than spelled here, because the new-card placeholder has to
    /// draw this same plate for the create handoff to read as one arrival.
    private var cornerRadius: CGFloat { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) }

    /// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
    /// treatment shootout).
    private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }

    /// This face's drawn width — **the replica's** (`replicaFace`). Measured rather than derived,
    /// because a face is as wide as the interior masonry column its lane gives it, and that is a
    /// function of the lane's slot width and its column count rather than of the font
    /// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's
    /// fallback is for.
    @State private var measuredWidth: CGFloat = 0

    /// The whole of what this face is a function of **as far as its parent is concerned**: the card
    /// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home
    /// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the hero file the parent resolved for it,
    /// the two selection figures the parent resolves for it, and the three window-lived collaborators
    /// — the store by identity, the band and the drop machinery by their own equivalence tests, which
    /// exist because the strip rebuilds both structs, closures and all, on every body pass.
    ///
    /// **The hero is compared as a URL, not as a picture.** It moves only when the card's key or its
    /// container does, both of which are already `card`-and-role facts; comparing it costs a path
    /// comparison on a value that is `nil` for almost every card, and the picture behind it is the
    /// banner view's own state (`CardHeroImage`), which no gate here could see anyway.
    ///
    /// **Selection is a compared input now, and that is what makes the gate reach it.** It used to be
    /// an Observation read — `isSelected` off `store.selection` — which meant a click anywhere on the
    /// board invalidated every face on it *directly*, past the gate entirely, and the measured cost
    /// of a marquee sample was the whole board's worth of bodies. Selected-ness now rides down as
    /// `isSelected`/`selectedCount` on the parent's own subscription (`LaneView` already reads the
    /// selection for its header; `TrashLaneView` gained one hoisted read to match), so a selection
    /// change re-runs the lane bodies that were subscribed anyway plus exactly the faces whose flag
    /// flipped. See RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies.
    ///
    /// **What the gate still does not suppress is the point.** What remains of this body's own
    /// Observation reads — `store.transient.pendingCut` and the rename editor,
    /// `drops.session.isDragging`, `appModel.styleRecents` — invalidates this view directly, and
    /// `.equatable()` has no say in that. The gate only stops the *parent* handing a face a
    /// new-but-identical set of inputs and re-running it for nothing, which during a card drag is
    /// what every proposal change does to every face in the lane.
    ///
    /// `store.searchFilter` is deliberately absent from that list: its only use here is `ownSlotSeed`,
    /// reached from `startBoardCardDrag`, which runs when a drag begins rather than when the body
    /// does — an event-time read subscribes nothing. `draggedIDs`, the context menus' `targetIDs` and
    /// the deferred `styleTarget` read the selection the same way, inside actions, which is why they
    /// stayed as they were.
    ///
    /// **The three Navigation inputs joined 2026-08-09** (card 06322636), `isSelected`'s exact
    /// reason one more time: `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes` are what let
    /// `moveLeftEnabled`/`moveRightEnabled` answer without a `store.snapshot`/`store.selection` read
    /// inside this face's own `.disabled` — so a gate that swallowed them would leave a face's
    /// Navigation rows wearing a stale enabled state after a neighbouring lane folded, a wall lane's
    /// board-edge shifted, or the live selection grew across lanes, none of which touch `card`, `hero`
    /// or the other selection figures.
    ///
    /// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
    /// `@Environment` values (SwiftUI invalidates on those itself), and the `.board` role's
    /// `openCard` closure (see `CardFaceRole.isEquivalent(to:)`).
    nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool {
        lhs.card == rhs.card
            && lhs.role.isEquivalent(to: rhs.role)
            && lhs.hero == rhs.hero
            && lhs.isSelected == rhs.isSelected
            && lhs.selectedCount == rhs.selectedCount
            && lhs.hasLeftNeighbor == rhs.hasLeftNeighbor
            && lhs.hasRightNeighbor == rhs.hasRightNeighbor
            && lhs.selectionSpansLanes == rhs.selectionSpansLanes
            && lhs.store === rhs.store
            && lhs.marquee.isEquivalent(to: rhs.marquee)
            && lhs.drops.isEquivalent(to: rhs.drops)
    }

    /// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
    /// sides share is in `face`; what the board has and the trash does not is attached here, so the
    /// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
    /// gestures that fire and refuse (`CardFaceRole`).
    @ViewBuilder
    var body: some View {
        // The lane's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because a
        // `@ViewBuilder` body takes statements as views and a bare call would be one.
        #if DEBUG
        let _ = BoardRenderMetrics.countCardBody()
        #endif
        switch role {
        case let .board(openCard):
            face
                // "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
                //
                // `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
                // second tap recogniser on the same view makes the single click *wait* to see whether a
                // second one arrives, and selection must stay instant. Simultaneous means the first click
                // of the pair selects and the second opens — Finder's own behaviour.
                //
                // **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening
                // a window out from under a range the user is still building would be a surprise.
                .simultaneousGesture(TapGesture(count: 2).onEnded {
                    guard ClickModifier.current == .plain else { return }
                    openCard(card.id)
                })
                .contextMenu { boardMenu(openCard: openCard) }
                // **The menu's 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 ▸ Actions come from the context
                // menu). The menu stays the inventory and stays reachable the standard way (VO-⇧-M).
                // Style… is absent for `LaneView`'s reason: it opens a popover, and the quick-style
                // swatch `Picker` beside it is not an action.
                .accessibilityActions { boardActions(openCard: openCard) }
                .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
                    StyleEditorPopover(store: store, recents: appModel.styleRecents)
                }
                // Labels ▸ More… — the second session-backed popover this face can host, mounted
                // exactly like the first (2026-08-09, card 28c79ffe). The two can never be open at
                // once: each is opened by a row of the same menu, and opening either replaces nothing
                // of the other's — but a user cannot press two menu rows in one gesture, and each
                // session's own `begin` replaces only its own kind.
                .popover(isPresented: labelEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
                    LabelPickerPopover(store: store, recents: appModel.labelRecents)
                }
        case let .trash(confirmations):
            face
                .contextMenu { trashMenu(confirmations: confirmations) }
                // The trash's two rows and **no third** — "there is no Open"
                // (10-accessibility.md ▸ Trash lane; 03-board-ui.md's no-editing-in-the-trash). The
                // absence is structural on this side too: `openCard` is the board case's payload, so
                // there is nothing here an Open action could even call.
                .accessibilityActions { trashActions(confirmations: confirmations) }
        }
    }

    /// Everything the two containers share — which, after the pivot, is the face itself.
    private var face: some View {
        plateContent
        .background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
        .overlay(alignment: .leading) { accentStripe }
        // The selection treatment, which a hovering Finder file drag borrows outright: "the card
        // highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
        // already this face's vocabulary for "this one". The hover draws it a touch heavier so a
        // hovered card that is *also* selected still reads as the target.
        //
        // **Increase Contrast strengthens both the ring and the plate's edge** (10-accessibility.md):
        // the stroke goes a point heavier, and an *unselected* card — which normally floats on its
        // fill alone — gains a separator hairline, because "this is one card and that is another" is
        // exactly the distinction the setting exists to rescue (`Accommodations`).
        .overlay(
            RoundedRectangle(cornerRadius: cornerRadius)
                .strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
        )
        // The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste
        // moves them"). Above `contentShape` so the face stays fully clickable while it waits. It
        // genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
        // the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
        .cutTreatment(of: card.id, in: store)
        // The face being dragged out dims the same way while the session is in flight, wherever the
        // source stays visible — which is now two places rather than one. On the **trash** side a
        // restore is not a removal until the write lands, so the row never leaves. On the **board**
        // side a drag whose effective operation is `.copy` leaves its originals in the resting
        // layout, because the copy leaves them there (`DragSession.hiddenMembers`), and this is what
        // marks them as the source of the drag rather than as ordinary neighbours. A `.move`'s
        // originals are lifted out entirely and this has nothing to act on
        // (`LaneView.renderedCards`).
        .opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
        .contentShape(Rectangle())
        // **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
        // VoiceOver): "label = title (or the untitled placeholder), value carries the attachment
        // count when present, selected state via trait. Face icon and chips are decorative — folded
        // into the element, never separately focusable". So the icon, the accent stripe and the
        // paperclip contribute nothing of their own — the count they stood for rides the value below.
        //
        // `.contain` while a rename is open, `LaneView`'s header rule for its reason: flattening
        // would swallow the text field the user is typing into. Board-only by construction, since
        // `isRenaming` is (`CardFaceRole`).
        .accessibilityElement(children: isRenaming ? .contain : .ignore)
        .accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
        // The attachment count, the comment count, the deferred cut's "cut, pending paste", or any
        // mix of the three — and the empty string when none apply, which speaks as nothing (see
        // `AccessibilityPhrases.cardValue` for why it is not a conditional modifier).
        .accessibilityValue(AccessibilityPhrases.cardValue(
            attachments: card.attachments.count,
            comments: card.commentCount,
            isCutPending: store.transient.pendingCut.ids.contains(card.id)
        ))
        // "Selection state is always readable from the element (trait)" — the other half of "state
        // is never colour-alone", whose visible half is the accent stroke above.
        .accessibilityAddTraits(isSelected ? [.isSelected] : [])
        // **VO-Space toggles this card's selection** — "moving the VoiceOver cursor never mutates
        // selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle,
        // never plain click's replace)". Routed through the same `BoardStore.click` funnel the
        // pointer uses, with the ⌘ modifier, so the homogeneity rule and the container boundary are
        // `SelectionGrammar`'s single answer rather than a second one written here.
        .accessibilityAction { toggleSelection() }
        // **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
        // two-stage Finder rename): one click selects and that is all it does — no timer, no
        // slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
        // Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
        // `SelectionGrammar`'s, reached through the store's one funnel.
        //
        // **The container travels with the click**, and that is what keeps the one remaining
        // homogeneity boundary true: a ⌘-click across it replaces rather than mixing
        // (04-interactions.md ▸ The trash). A double click in the trash is two of these and nothing
        // more — no editor, no card window, no timer.
        .onTapGesture {
            store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current)
        }
        // **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
        // beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
        // the session off until the pointer really moves, so selecting and opening stay instant.
        .onDrag(startDrag, preview: { dragReplica })
        // The card's drawn size. Its **height** goes to the drop model's analytic resting grid — a
        // height is content-driven and does not animate under the reflow, only positions do, and
        // those are never measured (`LaneDropRegistry`). The trash side registers too: a trash card
        // dragged out is an ordinary card session, and the shadow it opens in the destination lane
        // should be its real footprint rather than the nominal guess.
        //
        // Its **width** stays here, for the drag replica: a face is as wide as its lane's interior
        // column, which no metric can derive and only the laid-out face can report
        // (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`).
        .onGeometryChange(for: CGSize.self) { $0.size } action: { size in
            drops.registry.update(height: size.height, for: card.id)
            measuredWidth = size.width
        }
        .onDisappear { drops.registry.removeHeight(card.id) }
        .marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
    }

    /// What sits on the plate: the hero band, then the padded title row (03-board-ui.md § Card face).
    ///
    /// **The band is full-bleed and the title row is not**, which is the whole layout call. A hero is
    /// a picture of what the card is about, so it takes the plate's own width and rounds its top
    /// corners to the plate's radius (`CardHeroImage`); the row below keeps every inset it has always
    /// had, including the stripe's reserved leading padding, so a card's title sits on exactly the
    /// grid it sat on before whether or not the card has a hero.
    ///
    /// **Zero spacing, and a band with no height when there is no picture** — so a card with no
    /// `hero`, or one whose hero names a file that is missing or unreadable, lays out identically to
    /// the face as it was: the stack's first element contributes nothing at all.
    ///
    /// Everything else the face draws is attached *outside* this stack and is therefore untouched by
    /// the band: the accent stripe still runs the plate's full leading edge (over the band's leading
    /// corner — the stripe is the card's edge, and a picture does not interrupt it), the selection and
    /// file-hover strokes still ring the whole plate, the cut and drag dims still cover it, and the
    /// geometry the drop model registers is still the plate's — a hero card is simply a taller card,
    /// which the masonry already understands.
    private var plateContent: some View {
        VStack(alignment: .leading, spacing: 0) {
            heroBanner
            titleRow
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
                // Constant, whether or not a stripe paints: every card's text sits on the same grid,
                // so colouring a card never shifts its title relative to its uncoloured neighbours.
                .padding(.leading, stripeWidth)
        }
    }

    /// The hero band, for a card that names a readable one — and nothing whatever for a card that
    /// does not, which is the ruling's degrade stated as a branch that is simply not taken.
    @ViewBuilder
    private var heroBanner: some View {
        if let hero {
            CardHeroImage(
                url: hero,
                height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize),
                cornerRadius: cornerRadius
            )
        }
    }

    // MARK: - The card drag

    /// Begins this card's system drag session — in **its own container**, which is the whole of what
    /// makes a trash card's drag a restore (04-interactions.md ▸ The trash; `DragLocality.operation`).
    private func startDrag() -> NSItemProvider {
        switch role.container {
        case .board: startBoardCardDrag()
        case .trash: startTrashCardDrag()
        }
    }

    /// A lane card's drag (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
    ///
    /// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
    /// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
    /// it, which is also the order the drop inserts in. A card outside the selection drags alone.
    ///
    /// Refused under the read-only lock and while an inline editor is focused, like every other
    /// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
    private func startBoardCardDrag() -> NSItemProvider {
        guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
        let snapshot = store.snapshot
        let ids = draggedIDs

        // Flatten order, and the lane each member currently lives in — the folder path's middle
        // component.
        var lanesByCard: [ItemID: ItemID] = [:]
        var titles: [ItemID: String] = [:]
        for lane in snapshot.lanes {
            for member in lane.cards where ids.contains(member.id) {
                lanesByCard[member.id] = lane.id
                titles[member.id] = member.title.value
            }
        }
        let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
        guard !ordered.isEmpty else { return NSItemProvider() }

        let root = store.rootURL
        let payload = DragPayload(
            boardRoot: root,
            kind: .cards,
            container: .board,
            items: ordered.compactMap { id in
                guard let laneID = lanesByCard[id] else { return nil }
                return DragPayload.Item(
                    id: id.rawValue,
                    folder: root
                        .appendingPathComponent(laneID.rawValue, isDirectory: true)
                        .appendingPathComponent(id.rawValue, isDirectory: true)
                        .path,
                    title: titles[id]
                )
            }
        )
        drops.session.beginCards(
            ordered,
            folders: payload.folders,
            // The dragged items' sizes, frozen at drag start — the pickup transition scales the
            // replica, and its lingering "last measured frame" would mis-size the shadow and the
            // span-cap (03-board-ui.md § Motion).
            heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
            container: .board,
            source: store,
            seed: ownSlotSeed(dragging: ids, laneID: lanesByCard[card.id], in: snapshot)
        )
        return payload.itemProvider()
    }

    /// The pickup's own-slot proposal (`DragSession.begin`): the grabbed card's position among its
    /// lane's rendered cards with the dragged run lifted out — the same resting-layout index space
    /// the retargets and `moveCards` count in, filtered by the same `LaneView.rendered` rules, so
    /// the seeded shadow run stands exactly where the lifted cards stood and pickup moves nothing.
    ///
    /// **A ⌥-pickup seeds nothing.** A copy's resting layout keeps the originals in place, so there
    /// is no vacated space for a shadow to hold; the first `dropUpdated` resolves the copy and opens
    /// the run beside the originals, exactly as it does today (DRAG-REORDER.md § Resting-layout
    /// zones — the modifier reflow is the feedback being asked for).
    private func ownSlotSeed(
        dragging ids: Set<ItemID>,
        laneID: ItemID?,
        in snapshot: BoardModel
    ) -> DropTarget? {
        guard !NSEvent.modifierFlags.contains(.option),
              let laneID,
              let laneCards = snapshot.lanes.first(where: { $0.id == laneID })?.cards
        else { return nil }
        let index = LaneView.rendered(
            Array(laneCards.prefix { $0.id != card.id }),
            hiddenByDrag: ids,
            filter: store.searchFilter,
            renaming: store.transient.renameEditor?.targetID
        ).count
        return DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: index)
    }

    /// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards`
    /// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
    /// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
    /// out … there is no restore-specific machinery and no Put Back" (03 § Trash).
    ///
    /// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
    /// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
    /// every other card drop uses, committed by the same `moveCards`.
    ///
    /// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
    /// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash).
    ///
    /// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
    /// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather
    /// than let them fall silently out of the payload the flag travels and the *drop* refuses with
    /// the notice (`DragSession.mixesKinds`). Pickup stays allowed — the selection is legal.
    private func startTrashCardDrag() -> NSItemProvider {
        guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
        let ids = draggedIDs
        let rows = store.snapshot.trash.filter { ids.contains($0.id) }
        guard !rows.isEmpty else { return NSItemProvider() }
        let mixesKinds = SelectionGrammar.mixesKinds(
            ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)

        let root = store.rootURL
        let payload = DragPayload(
            boardRoot: root,
            kind: .cards,
            container: .trash,
            items: rows.map {
                DragPayload.Item(
                    id: $0.id.rawValue,
                    folder: ItemPath.trashCard($0.id).folder(under: root).path,
                    title: $0.title.value
                )
            }
        )
        drops.session.beginCards(
            rows.map(\.id),
            folders: payload.folders,
            heights: rows.map { drops.registry.heights[$0.id] ?? drops.registry.nominalCardHeight },
            container: .trash,
            source: store,
            mixesKinds: mixesKinds
        )
        return payload.itemProvider()
    }

    /// What travels: the whole selection when this card is in it, else this card alone — the drag's
    /// half of the context-menu targeting rule, and container-scoped like everything else.
    private var draggedIDs: Set<ItemID> {
        let selection = store.selection
        guard selection.container == role.container,
              selection.ids.contains(card.id),
              selection.ids.count > 1
        else { return [card.id] }
        return selection.ids
    }

    /// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
    /// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
    ///
    /// The count is `selectedCount`, not a fresh read of the selection: `.onDrag(_:preview:)`'s
    /// preview builder is **non-escaping**, so it evaluates while the body does, and a `store.selection`
    /// read here would have kept every face on the board subscribed no matter what the rest of this
    /// view did. The parent already normalizes the figure to 1 for an unselected face, so the branch
    /// that used to compute it is gone rather than moved (see `isSelected`'s note); the `max` is belt
    /// over those braces, because a zero here would draw a badge reading nothing.
    private var dragReplica: some View {
        let count = max(1, selectedCount)
        return ZStack {
            if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
            if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
            replicaFace
        }
        .overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
        .padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
    }

    /// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
    /// editor and no geometry observers, and crucially no marquee registration (one built out of the
    /// live face would re-register the card's frame from inside the preview's geometry and then
    /// deregister it when the image went away, quietly stealing the card from the rubber band and the
    /// arrow keys).
    private var replicaFace: some View {
        VStack(alignment: .leading, spacing: 0) {
            // **The band is drawn from the cache alone, synchronously.** A preview builder is
            // non-escaping — it runs while the body does, and the system snapshots the result
            // immediately — so there is no task to await a decode in. A hero the face is already
            // showing is in the cache by definition, which is the only case that matters: you cannot
            // drag a card whose banner has not drawn yet without having looked at it first. A miss
            // draws no band, and the replica is then exactly the face a hero-less card lifts.
            if let hero, let image = CardHeroCache.image(forFileAt: hero) {
                Color.clear
                    .frame(height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize))
                    .overlay { Image(decorative: image, scale: 1).resizable().aspectRatio(contentMode: .fill) }
                    .clipShape(UnevenRoundedRectangle(
                        topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius))
            }
            HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
                Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
                    .foregroundStyle(iconTint)
                    .imageScale(.medium)
                Text(card.title.value ?? "Untitled")
                    .boardFont(.body)
                    .lineLimit(4)
                    .frame(maxWidth: .infinity, alignment: .leading)
                attachmentsIndicator
                commentsIndicator
            }
            .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
            .padding(.leading, stripeWidth)
        }
        // **The size of the face it was lifted from**, taken from that face's own measurement rather
        // than from a representative figure: a card is as wide as its lane's interior column, so a
        // replica drawn at a nominal width is visibly a different card from the one under the cursor,
        // and — since the system centres a preview on the view the drag started from — leaves the
        // pointer sitting beside the image instead of on it. The width is the only frame this needs:
        // the replica lays the same band and the same row out with the same paddings and the same
        // `lineLimit`, so at the face's width it comes out at the face's height (`BoardMetrics`).
        .frame(
            width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
            alignment: .leading
        )
        .background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
        .overlay(alignment: .leading) { accentStripe }
        .dragReplicaShadow(zoom: zoom)
    }

    // MARK: - Context menus

    /// **The card context menu, redesigned** (2026-08-09 ▸ "redesign context menu for cards", card
    /// fe66c461): four groups, a divider between each, the owner's shape verbatim —
    ///
    /// 1. Open, Rename, Style ▸ (Symbol, Color), Labels ▸ (up to 12 ranked toggles, More…)
    /// 2. Copy, Cut, Paste, Copy Special ▸ (Copy Link), Paste Special ▸ (Paste Image into Card, … more tbd)
    /// 3. Navigation ▸ (Move Left, Move Right)
    /// 4. Send to Trash
    ///
    /// **Group 1 gained Labels and group 2 gained Copy Special on 2026-08-09** (card 28c79ffe) — see
    /// `labelsMenu` for the submenu, and "Copy Link moves" below for the relocation.
    ///
    /// Every row below routes through an *existing* write primitive it twins — nothing here invents a
    /// new way to touch disk, only new arrangements and, for one group, a new targeting rule over ones
    /// the app already has (`OpenCardCommand`, `BoardRenameCommand`, `StyleCommand`/`StyleEditorSession`,
    /// `ClipboardStore`, `store.delete`/`TrashCommands`). **Navigation is the one exception to "new
    /// arrangement, not new capability"**: this group shipped structurally in fe66c461 (wired but
    /// unconditionally disabled — no per-card cross-lane move predicate existed yet) and gained the
    /// real one in 06322636, `CardMoveTarget`, over the same `BoardStore.moveCards(_:toLane:at:)` a
    /// released drag already calls — see that section below for the full story.
    ///
    /// ### Two deviations from the card's literal list, both kept and both journaled
    ///
    /// **Copy Link stays** — and, since 2026-08-09, has a home the owner named. It shipped the same
    /// day the menu redesign was filed (design ruling, card 737a949f), so that card's list did not
    /// mention it and it was parked in group 1 after Open as a deviation kept-and-journaled. The
    /// owner's next layout resolves it: **Copy Link now sits in a new `Copy Special` submenu**, beside
    /// the `Paste Special` it mirrors, and their own comment on card 28c79ffe says so in as many words
    /// ("note 'copy link' has been moved to 'copy special'"). Nothing about the row itself changed —
    /// same action, same `selectedCount == 1` enablement, same "a link is singular" rule.
    ///
    /// **It stays in `boardActions` even so** (the VoiceOver custom-action list), which is a deliberate
    /// exception to that list's own "submenus are absent" rule. The rule is about *containers* — Style,
    /// Navigation and Paste Special are doors onto other surfaces, and a flat action list has nothing
    /// to say about a door. Copy Link is an action that merely changed which door draws it, and
    /// dropping it from the list would cost VoiceOver a real capability in exchange for a symmetry the
    /// list does not owe. (Paste Image into Card is absent for the older reason: it never was in the
    /// list.)
    ///
    /// **The row reads "Send to Trash", not "Delete".** The owner's own word for group 4, and the more
    /// accurate one for what this button does on the board side — it is the staged move into
    /// `<root>/.trash/`, not a permanent delete (that word is reserved for the trash lane's own menu,
    /// `trashMenu` below, which this rename does not touch). The action underneath is unchanged:
    /// `store.delete(targetIDs)`, `TrashCommands`'s own twin.
    ///
    /// ### Style ▸ Symbol / Color — one popover, not two (v1, flagged for owner review)
    ///
    /// `StyleEditorSession` carries only a `StyleTarget`, no notion of "arrived here for the symbol
    /// section" versus "the color section" (`StyleModel.swift`), and adding one would mean teaching
    /// `StyleEditorView` to pre-focus a section — internals this card is explicitly told not to touch
    /// (another agent is concurrently redesigning the pickers themselves). So both rows open the same
    /// existing popover, showing both sections, exactly as the single "Style…" row always has. This is
    /// the SCOPE's own named fallback ("if it doesn't [support pre-focus], opening the existing
    /// popover for both rows is acceptable v1") — kept, and flagged: a future pass that adds
    /// section-focus to the editor should aim these two rows at it.
    ///
    /// **The quick-style recents row is gone from this menu.** `StyleMenuItems` used to bundle
    /// "Style…" with a horizontal recents `Picker` beside it; the owner's list names only Symbol and
    /// Color under Style, so the recents row — never mentioned — is dropped here to match the shape
    /// exactly. It is untouched on the **lane** menu (`LaneView.laneMenu`, out of this card's scope,
    /// which only asked for the *card* menu). Flagged for owner review: easy to bring back as a third
    /// row under Style if the drop was not intended.
    ///
    /// ### Navigation ▸ Move Left / Move Right — a real per-card cross-lane move (2026-08-09, card
    /// 06322636)
    ///
    /// **Not `LaneMoveTarget.destination`** — that predicate reads the **board's live selection** and
    /// answers `nil` for any card id unconditionally ("a card id is in no lane order" — its own doc
    /// comment), because it is Board ▸ Move Left/Right's *lane*-selection predicate, shared with the
    /// menu-bar row. These two rows validate against `CardMoveTarget.destination` instead — the
    /// cousin built for exactly this card, over this face's own `targetIDs` (Copy/Cut's widening:
    /// the clicked card, or the live selection when the clicked card is a member of it) rather than
    /// the live selection's lane membership.
    ///
    /// **Destination is the adjacent live lane**, skipping nothing (owner ruling): the trash is never
    /// a candidate (it is not a `Lane`) and a collapsed lane is a perfectly good landing — a fold
    /// hides cards, it does not close the lane. **Position is index-preserving** — the clicked card's
    /// own index in its lane's display order, carried over to the destination and clamped to its card
    /// count, with the rest of a widened group riding along in their existing relative order
    /// (`CardMoveTarget`'s own type comment has the full reasoning). The write is the ordinary
    /// `BoardStore.moveCards(_:toLane:at:)` — the exact call a released drag makes — so rank-minting,
    /// the undo step, the watcher echo and the banner all come free from that one path.
    ///
    /// **Enablement stays render-safe the same way every other row here does.** `CardMoveTarget`
    /// needs `store.snapshot` (for the neighbouring lane) and, when the clicked card is itself
    /// selected, `store.selection` (for the multi-lane-spread refusal) — both of which are forbidden
    /// inside this face's own `.disabled` (this struct's top-of-file note; `.contextMenu`'s builder is
    /// not lazy). So neither is read here: `hasLeftNeighbor`/`hasRightNeighbor` and
    /// `selectionSpansLanes` are lane-hoisted compared parameters, `isSelected`'s exact pattern —
    /// `LaneView.scrollableCards` reads `store.snapshot`/`store.selection` **once per lane** and hands
    /// every face in it the answer, so a click costs `O(lane count)`, never `O(board cards)`
    /// (`moveLeftEnabled`/`moveRightEnabled`; `BoardRenderPerformanceTests` stays green throughout).
    ///
    /// **A selection spanning more than one lane disables both rows** (owner ruling: "no coherent
    /// left for a spread") — `selectionSpansLanes`, consulted only when `isSelected` is true, since an
    /// unselected clicked card's own target is always itself alone.
    ///
    /// ### Copy / Cut / Paste / Paste Special — targeting
    ///
    /// **Copy and Cut widen exactly as Delete and Style do** — `clipboardTarget`, `targetIDs`'s own
    /// `ItemReferenceSet` wrapper — "right-clicking something outside the selection acts on what was
    /// clicked" (`targetIDs`'s doc comment), extended to the clipboard for the first time
    /// (`ClipboardStore.copy(from:targeting:)`/`cut(from:targeting:)`). Multi-selection semantics are
    /// `ClipboardStore`'s own, unchanged: a multi-card selection copies/cuts every member, in flatten
    /// order, exactly as ⌘C/⌘X already do.
    ///
    /// **Paste does not retarget to the clicked card.** Unlike Copy/Cut/Delete/Style, Paste has no
    /// per-item widening precedent anywhere in this codebase — it is a *destination* operation
    /// (`PasteTarget`), not an item operation, and its target has always been "wherever the live
    /// selection anchors" (`PasteTarget.cards`), the same rule ⌘V and Edit ▸ Paste already use. This
    /// row calls that exact rule (`ClipboardStore.paste(into:)`) rather than inventing a
    /// click-anchored variant. Flagged for owner review if a click-anchored paste ("paste after the
    /// card I right-clicked, regardless of the live selection") turns out to be what was wanted.
    ///
    /// **Paste Image into Card is genuinely per-card** — "into card" is the row's own wording, and it
    /// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own
    /// attachments-header affordance calls for its picture (the ⌘V image branch it once rode retired
    /// 2026-08-09 for that visible control). It always targets **this** card, never the widened
    /// selection: like Open, Rename and Style's anchor, "single-card by nature" — the existing
    /// precedent this SCOPE asks new rows to follow for such rows. "… more tbd" is left as the SCOPE
    /// asks: one row today, the submenu built to grow.
    ///
    /// ### Render-safety: what `.disabled` is allowed to read here
    ///
    /// Every `.disabled` in this menu reads only plain, rarely-changing `BoardStore` flags
    /// (`acceptsBoardMutations`, `isReadOnly`, `isEditingInline`) or the clipboard's own observable
    /// state (`appModel.clipboard.payload`/`imagePayload`, which this file's own top note already
    /// accepts reading directly, `appModel.styleRecents`' own precedent) — **never** `store.selection`
    /// or `store.snapshot` directly, because `.contextMenu`'s builder is not lazy and either read would
    /// re-subscribe every card face's body to board-wide state, the O(board) regression this file was
    /// rebuilt to shed. Two rows lean on a proof rather than a literal call to make that hold:
    ///
    /// - **Paste**: `PasteTarget.cards`'s only `nil` case is a board with no *renderable* lane
    ///   (`NewCardTarget.resolve`'s "a board whose every lane is folded has no target at all"). A card
    ///   face exists only because its own lane rendered it, so that case is already false by the time
    ///   this menu can even open — `canPaste(into:)` reduces to `acceptsBoardMutations && payload !=
    ///   nil` here, with no selection read needed (`pasteEnabled`).
    /// - **Paste Image into Card**: `canPasteImage(intoCard:in:)`'s snapshot lookup
    ///   (`BoardStore.boardItem`) only ever answers `false` for a card that is not a live board item —
    ///   which, again, this very face rendering already rules out. Reduces to `!isReadOnly &&
    ///   imagePayload != nil` (`pasteImageEnabled`).
    ///
    /// **The Paste row's dynamic title (`pasteTitle`, 2026-08-09 ▸ card 36cce96a) rides the same
    /// channel `pasteEnabled` already opened** rather than adding one: it reads
    /// `appModel.clipboard.payload` again, the identical Observable property, and hands it to
    /// `ClipboardManifest.pasteMenuTitle(for:)` — a pure function of a value already in hand, not a
    /// second store read. No new subscription, no selection or snapshot touched.
    ///
    /// Copy and Cut lean on the same style of proof: `targetIDs` is always non-empty (at minimum this
    /// card alone) and always homogeneous cards on the board side (`SelectionGrammar.mixesKinds`
    /// answers `true` only for the trash — see its own doc comment), so `canCopy`/`canCut`'s
    /// kind-checks are always satisfied and the rows reduce to `isEditingInline`/`acceptsBoardMutations`
    /// (`copyEnabled`/`store.acceptsBoardMutations`) without reading the target at all for `.disabled`
    /// — the target is still read, correctly, inside each action (`clipboardTarget`).
    ///
    /// **Navigation ▸ Move Left / Move Right take the third route**: unlike Paste and Copy/Cut, there
    /// is no algebraic reduction that makes `CardMoveTarget.destination` free of `store.snapshot`/
    /// `store.selection` — a neighbouring lane and a cross-lane selection spread are both genuinely
    /// board-wide facts. So the parent resolves them instead, **once per lane**, and hands this face
    /// the answer as three more compared parameters (`hasLeftNeighbor`, `hasRightNeighbor`,
    /// `selectionSpansLanes`) — `isSelected`/`selectedCount`'s own precedent, not a new one
    /// (`moveLeftEnabled`/`moveRightEnabled`).
    @ViewBuilder
    private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
        // Group 1 — Open, Copy Link, Rename, Style ▸ (Symbol, Color). One run, no divider inside it:
        // the owner's "open/rename+style" group is one group, not two (unlike the prior menu, which
        // split Open/Copy Link from Rename/Style with a divider).
        Button("Open") {
            openCard(card.id)
        }
        Button("Rename") { beginRename() }
            .disabled(!store.acceptsBoardMutations)
        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)
        labelsMenu

        Divider()

        // Group 2 — Copy, Cut, Paste, Copy Special ▸ Copy Link, Paste Special ▸ Paste Image into Card.
        Button("Copy") { appModel.clipboard.copy(from: store, targeting: clipboardTarget) }
            .disabled(!copyEnabled)
        Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
            .disabled(!store.acceptsBoardMutations)
        Button(pasteTitle) { appModel.clipboard.paste(into: store) }
            .disabled(!pasteEnabled)
        Menu("Copy Special") {
            Button("Copy Link") { copyLink() }
                .disabled(!copyLinkEnabled)
        }
        // Its one row's condition, so the submenu greys out with it rather than opening onto a
        // disabled row — `Style`'s rule, one group down.
        .disabled(!copyLinkEnabled)
        Menu("Paste Special") {
            Button("Paste Image into Card") {
                appModel.clipboard.pasteImage(intoCard: card.id, in: store)
            }
            .disabled(!pasteImageEnabled)
        }

        Divider()

        // Group 3 — Navigation ▸ Move Left / Move Right, a real per-card cross-lane move — see the
        // type comment's own section for the destination/position rules and the render-safety proof.
        Menu("Navigation") {
            Button("Move Left") { moveCardAcrossLane(by: -1) }
                .disabled(!moveLeftEnabled)
            Button("Move Right") { moveCardAcrossLane(by: 1) }
                .disabled(!moveRightEnabled)
        }
        // The submenu greys out only when *both* rows would — a card in the leftmost lane can still
        // move right, so the submenu itself has to stay open to it.
        .disabled(!moveLeftEnabled && !moveRightEnabled)

        Divider()

        // Group 4 — Send to Trash: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s
        // twin), on the widened target set (`targetIDs`) — 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)
    }

    /// **Group 1's fourth row: `Labels`** (2026-08-09, Pipeline card 28c79ffe; `FrontmatterKeys.labels`,
    /// whose reservation the owner retired the same day) — the owner's spec verbatim: "a list of up to
    /// 12 most frequently and recently used labels", a separator, then "More… (show a dialog with a
    /// list of all used labels + ability to create new)".
    ///
    /// ### The twelve, and where they come from
    ///
    /// `LabelRanking.ranked` — frequency across the board's live and trashed cards, tie-broken by an
    /// app-side MRU, then alphabetically for a total order. That function's own doc comment states the
    /// arithmetic and the one consequence worth flagging (a brand-new label does not jump a full menu).
    ///
    /// **Toggles, not buttons**, so the row says what the card already is: a `Toggle` renders as a
    /// checked row in an AppKit menu, which is exactly the "toggling membership for the clicked card"
    /// the spec asks for made visible. The read is free — `card` is a compared parameter this view
    /// already has, so a checkmark costs no lookup at all.
    ///
    /// ### One card, and the checkmark is why
    ///
    /// Every other item-shaped row in this menu widens to the selection when the clicked card is a
    /// member of it (`targetIDs` — Copy, Cut, Style, Send to Trash). **These rows deliberately do
    /// not**, for two reasons that point the same way:
    ///
    /// - **A checkmark is a claim about one card.** A three-card selection where two carry `bug` has no
    ///   honest checked state, and an AppKit menu item has no mixed one to draw. The widening rows all
    ///   carry no state — they say what they will *do*, never what is already true — which is exactly
    ///   why widening costs them nothing and would cost this everything.
    /// - **It keeps the menu render-safe.** `targetIDs` reads `store.selection`, and `.contextMenu`'s
    ///   builder is not lazy (this struct's top-of-file note; `copyLinkEnabled`'s doc comment): the
    ///   checkmark is computed *while the menu is built*, not inside an action, so a widened one would
    ///   subscribe every card face's body to board-wide selection state — the O(board) regression
    ///   `BoardRenderPerformanceTests.selectionStillRepaints` exists to catch.
    ///
    /// Flagged for owner review: if tagging a multi-card selection at once is wanted, it is a
    /// *different* control — a row that reads "Add ⟨label⟩ to 3 Cards", not a checkmark.
    ///
    /// ### What this builder is allowed to read, and what it costs
    ///
    /// Two Observation reads, both narrow and both rarely-changing — the class this menu's `.disabled`
    /// modifiers already draw from:
    ///
    /// - `store.labelIndex`, the board's used-labels universe. The O(board) walk behind it happens
    ///   **once per applied snapshot**, on the store, and the assignment is equality-gated so the
    ///   property changes only when the board's labels genuinely change (its own doc comment says why
    ///   that gate is load-bearing rather than an optimisation).
    /// - `appModel.labelRecents.labels`, the MRU. `appModel.styleRecents` is read in this very body
    ///   already (the `==` gate's own note lists it), and this list moves on exactly the same cadence:
    ///   once per label the user applies, which is a deliberate gesture and not a marquee sample.
    ///
    /// What is left in the builder is `LabelRanking.ranked`'s sort, whose size is the board's **label
    /// vocabulary** — tens of entries — and not its card count. That is the whole of "no O(board) work
    /// in the menu builder": the board-sized part is cached, and the part that runs here is bounded by
    /// how many distinct labels exist.
    ///
    /// ### More…
    ///
    /// Opens `LabelPickerPopover` through a session in `TransientBoardState`, `Style…`'s exact
    /// mechanism and for its reasons — see that view for why a popover rather than a sheet.
    ///
    /// **The submenu never greys out whole.** Even a read-only board can open More… to *look* at the
    /// board's vocabulary (Reveal in Finder's posture: inspection is not a mutation), so the lock
    /// disables the twelve toggles and the dialog's own controls rather than the door to them.
    @ViewBuilder
    private var labelsMenu: some View {
        Menu("Labels") {
            ForEach(rankedLabels, id: \.self) { name in
                Toggle(name, isOn: labelBinding(name))
                    .disabled(!store.acceptsBoardMutations)
            }
            if !rankedLabels.isEmpty {
                Divider()
            }
            Button("More…") { store.transient.beginLabelEditor(forCard: card.id) }
        }
    }

    /// The twelve, ranked — see `labelsMenu` for the cost argument behind these two reads.
    private var rankedLabels: [String] {
        LabelRanking.ranked(store.labelIndex, recents: appModel.labelRecents.labels)
    }

    /// One toggle row's state: whether **this** card carries the label, and the write that flips it.
    ///
    /// The read is off `card.labels` — the value this face was handed — so it costs no store lookup and
    /// no selection read. The write goes through the one funnel every label surface shares, so the
    /// menu, the More… dialog and the card window's sidebar cannot come to mean three different things
    /// (`LabelCommand`).
    private func labelBinding(_ name: String) -> Binding<Bool> {
        Binding(
            get: { CardLabels.contains(name, in: card.labels.value ?? []) },
            set: { _ in
                LabelCommand.toggle(name, onCard: card.id, in: store, recents: appModel.labelRecents)
            }
        )
    }

    /// `boardMenu`'s plain (non-submenu) rows as VoiceOver custom actions — every one calling the
    /// *same* private method its menu row does, so the two surfaces cannot come to mean different
    /// things. Style, Paste Special and Navigation are absent for `LaneView`'s own reason (its Style…
    /// note): each opens its own accessible surface — a popover, or the submenu itself, which the
    /// context menu already exposes reachably (VO-⇧-M) — and is "not an action" in the flat sense this
    /// list carries. **Copy Link is the one exception**, and it moved into a submenu on 2026-08-09
    /// without leaving here — see `boardMenu`'s "Copy Link stays" note for why. Labels is absent like
    /// its fellow submenus: it is a container, and its own rows are checkmarks rather than actions.
    @ViewBuilder
    private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
        Button("Open") { openCard(card.id) }
        Button("Copy Link") { copyLink() }
            .disabled(!copyLinkEnabled)
        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(!store.acceptsBoardMutations)
        Button(pasteTitle) { appModel.clipboard.paste(into: store) }
            .disabled(!pasteEnabled)
        Button("Send to Trash") { deleteTargets() }
            .disabled(!store.acceptsBoardMutations)
    }

    /// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
    /// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
    ///
    /// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
    /// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
    /// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
    /// exactly the errand it exists for.
    ///
    /// The Delete row goes through the window's confirmation host rather than straight to the store,
    /// because this delete is the **permanent** one and the alert is what stands between it and an
    /// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
    @ViewBuilder
    private func trashMenu(confirmations: TrashConfirmations) -> some View {
        Button("Delete") { requestPurge(confirmations) }
            .disabled(!store.acceptsBoardMutations)

        Divider()

        Button("Reveal in Finder") { revealInFinder() }
    }

    /// `trashMenu`'s rows as VoiceOver custom actions — `boardActions`' twin, two rows and no Open.
    @ViewBuilder
    private func trashActions(confirmations: TrashConfirmations) -> some View {
        Button("Delete") { requestPurge(confirmations) }
            .disabled(!store.acceptsBoardMutations)
        Button("Reveal in Finder") { revealInFinder() }
    }

    // MARK: - The rows' bodies

    /// Board ▸ Rename's store path, seeded with the card's live title.
    private func beginRename() {
        store.transient.beginRename(of: card.id, currentTitle: card.title.value)
    }

    /// File ▸ Delete's store path over the context-menu target set.
    private func deleteTargets() {
        store.delete(targetIDs)
    }

    /// The trash's **permanent** delete, through the window's confirmation host — never straight to
    /// the store, because the alert is what stands between this row and an unrecoverable loss.
    private func requestPurge(_ confirmations: TrashConfirmations) {
        confirmations.requestTrashDelete(of: targetIDs, in: store)
    }

    private func revealInFinder() {
        NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
    }

    /// Copy Link's context-menu enablement — **`selectedCount`, never `targetIDs`**. `targetIDs`
    /// reads `store.selection` directly, and `.contextMenu`'s content closure is not lazy: SwiftUI
    /// evaluates `boardMenu` (and therefore any `.disabled(...)` inside it) on every ordinary body
    /// pass, not only when the menu opens, exactly as building this row against `targetIDs` first
    /// proved the hard way — every face's body re-ran on every selection change, the precise O(board)
    /// regression `isSelected`/`selectedCount` exist to prevent (this struct's own top-of-file note;
    /// `BoardRenderPerformanceTests.selectionStillRepaints`, which caught it).
    ///
    /// `selectedCount` is the render-safe answer to the same question: the parent (`LaneView`) already
    /// computes "the size of the selection this face belongs to, else 1" as a **plain, non-Observable
    /// parameter** — `targetIDs.count`'s exact widening, paid for once per lane instead of once per
    /// card-menu-construction. "A link is singular" (design ruling 2026-08-09, card 737a949f), so a
    /// count above 1 disables rather than guessing which member was meant — the same reading Style…
    /// and Delete's `targetIDs` give their own action, just checked here instead of only inside it,
    /// because unlike them this row's *enabled state itself* has to say so.
    private var copyLinkEnabled: Bool {
        store.acceptsBoardMutations && selectedCount == 1
    }

    /// Copy Link's write — `copyLinkEnabled`'s one caller. Resolves **this card's own folder**
    /// directly rather than through `targetIDs`: `copyLinkEnabled == true` already guarantees the
    /// widened target is `card.id` alone (either this card sits outside the live selection, or it is
    /// the selection's sole member), so there is no widened set left to read `store.selection` for.
    /// `store.copyLinkTarget` is deliberately not reused either — that property reads the *live*
    /// selection for the menu-bar row (`CopyLinkCommand`), which would answer wrongly for a card
    /// clicked outside the current selection, exactly the case `TrashWriteTests`'
    /// `contextMenuDeleteIgnoresTheSelection` pins for Delete.
    private func copyLink() {
        guard copyLinkEnabled,
              let folder = ItemPath.resolve([card.id], in: role.container, snapshot: store.snapshot)
                  .first?.folder(under: store.rootURL)
        else { return }
        SystemFolderLinkPasteboard().write(link: folder)
    }

    /// VO-Space's landing: the ⌘-click funnel, on this card, **in this face's container** — so a
    /// trash card's toggle can no more mix with a board selection than a ⌘-click could.
    private func toggleSelection() {
        store.click(
            SelectionTarget(id: card.id, kind: .card, container: role.container),
            modifier: .command
        )
    }

    /// What this card's menu acts on: the whole selection when this card is part of it, else this card
    /// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
    /// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
    private var styleTarget: StyleTarget {
        .items(targetIDs)
    }

    /// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
    /// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never
    /// widens to a board selection and vice versa.
    private var targetIDs: Set<ItemID> {
        guard store.selection.container == role.container, store.selection.ids.contains(card.id) else {
            return [card.id]
        }
        return store.selection.ids
    }

    /// The folders Reveal in Finder points at — resolved in this face's container, so a trash row
    /// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
    private var targetFolders: [URL] {
        ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot)
            .map { $0.folder(under: store.rootURL) }
    }

    // MARK: - Copy / Cut / Paste

    /// Copy's and Cut's own widened target, as the `ItemReferenceSet` `ClipboardStore`'s targeted
    /// overloads take — `targetIDs` plus this face's container, `styleTarget`'s exact pairing one type
    /// over. Read only inside an **action** closure (never inside a `.disabled`), `styleTarget`'s own
    /// reason: it reads `store.selection`, and `.contextMenu`'s builder is not lazy.
    private var clipboardTarget: ItemReferenceSet {
        ItemReferenceSet(ids: targetIDs, container: role.container)
    }

    /// Copy's context-menu enablement — render-safe by construction, never by re-deriving
    /// `targetIDs`/`store.selection` inline (`copyLinkEnabled`'s own reason).
    ///
    /// `ClipboardStore.canCopy(from:targeting:)` is three clauses: not mid-edit, the target does not
    /// mix kinds, and the target names a kind at all. On this face's own container (`.board`) the
    /// latter two are always satisfied for `clipboardTarget`: it is never empty (at minimum this card
    /// alone) and never mixes kinds (`SelectionGrammar.mixesKinds` answers `true` only on the trash —
    /// its own doc comment: "only the trash can answer true"). So the whole predicate reduces to
    /// `!store.isEditingInline`, which costs no selection read at all.
    private var copyEnabled: Bool {
        !store.isEditingInline
    }

    /// Paste's context-menu enablement, reduced the same render-safe way — see the type comment's
    /// "Render-safety" section for the proof that `PasteTarget.cards` cannot answer `nil` from a card
    /// face that exists at all, which is what lets this skip `store.selection` entirely.
    private var pasteEnabled: Bool {
        store.acceptsBoardMutations && appModel.clipboard.payload != nil
    }

    /// **The Paste row's title** — "Paste Card"/"Paste N Cards" and their lane twins, plain "Paste"
    /// for anything else (`ClipboardManifest.pasteMenuTitle(for:)`'s own doc comment carries the full
    /// rule). Reads exactly the same `appModel.clipboard.payload` `pasteEnabled` already reads above —
    /// the render-safety proof this file's type comment states for `pasteEnabled` covers this property
    /// too, since it is the identical Observable read, not a second one: no new subscription, no
    /// `store.selection`/`store.snapshot` touched.
    private var pasteTitle: String {
        ClipboardManifest.pasteMenuTitle(for: appModel.clipboard.payload)
    }

    /// Paste Image into Card's enablement, `pasteEnabled`'s own reduction: `canPasteImage(intoCard:in:)`
    /// only ever answers `false` on its snapshot lookup for a card that is not a live board item, which
    /// this face rendering at all already rules out — see the type comment's "Render-safety" section.
    private var pasteImageEnabled: Bool {
        !store.isReadOnly && appModel.clipboard.imagePayload != nil
    }

    // MARK: - Navigation (Move Left / Move Right)

    /// Navigation ▸ Move Left's render-safe enablement — `hasLeftNeighbor` (this face's own lane
    /// against the live board order, lane-hoisted the way `isSelected` is), `acceptsBoardMutations`
    /// (a narrow, rarely-changing `BoardStore` flag — every other row here reads it directly, the same
    /// proof applies), and the multi-lane-spread refusal, consulted only when this card is itself
    /// selected (an unselected clicked card's target is always itself alone, trivially confined to
    /// this lane whatever `selectionSpansLanes` says — see the type comment's own section).
    private var moveLeftEnabled: Bool {
        store.acceptsBoardMutations && hasLeftNeighbor && !(isSelected && selectionSpansLanes)
    }

    /// `moveLeftEnabled`'s mirror, for Move Right.
    private var moveRightEnabled: Bool {
        store.acceptsBoardMutations && hasRightNeighbor && !(isSelected && selectionSpansLanes)
    }

    /// Navigation ▸ Move Left/Right's action — `CardMoveTarget.destination`, the per-card cousin of
    /// `LaneMoveTarget.destination` (`MoveLaneCommands.move(by:)`'s own shape one type over), over
    /// this face's own widened target (`targetIDs`, Copy/Cut's precedent). `destination` re-derives
    /// exactly what `moveLeftEnabled`/`moveRightEnabled` already checked — a card menu names its
    /// target by where it was invoked, never by a lingering `.disabled` read, `copyLink()`'s reason —
    /// so a stale enablement can only ever make this a no-op, never a wrong move. The write is the
    /// ordinary `moveCards`: rank-minting, the undo step, the watcher echo and the banner all come
    /// free from that one call, exactly as a released drag's do.
    private func moveCardAcrossLane(by delta: Int) {
        guard store.acceptsBoardMutations,
              let target = CardMoveTarget.destination(
                  clicked: card.id, targeting: targetIDs, snapshot: store.snapshot, delta: delta)
        else { return }
        store.moveCards(targetIDs, toLane: target.laneID, at: target.index)
    }

    // MARK: - Title row

    private var titleRow: some View {
        HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
            Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
                .foregroundStyle(iconTint)
                .imageScale(.medium)
            titleOrEditor
                // The title takes the row's width so the chips sit hard against the trailing
                // edge — and so the rename field fills the same span the title occupied.
                .frame(maxWidth: .infinity, alignment: .leading)
            attachmentsIndicator
            commentsIndicator
        }
    }

    /// The title, or the rename editor when this card is the rename target. The four exits and their
    /// store calls are 04-interactions.md ▸ Grammar's, stated once in `InlineTitleField`.
    @ViewBuilder
    private var titleOrEditor: some View {
        if isRenaming {
            InlineTitleField(
                text: draft,
                prompt: "Card title",
                onCommit: { store.commitRename() },
                onAbandon: { store.transient.discardRename() },
                // **Click-away commits** — a rename's rule, and the deliberate opposite of the
                // placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
                // the card window's title field").
                onFocusLoss: { store.commitRename() },
                onCommitAndOpen: {
                    let id = card.id
                    store.commitRename()
                    if case let .board(openCard) = role { openCard(id) }
                }
            )
            .boardFont(.body)
        } else {
            Text(card.title.value ?? "Untitled")
                .boardFont(.body)
                .foregroundStyle(card.title.value == nil ? .secondary : .primary)
                .lineLimit(4)
        }
    }

    /// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
    /// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
    /// exists to make a *hand-coloured* icon stand out from its neighbours.
    private var iconTint: AnyShapeStyle {
        if let color = Palette.color(for: card.iconColor) {
            AnyShapeStyle(color)
        } else {
            AnyShapeStyle(.secondary)
        }
    }

    /// One of the two face chips in scope — shown only when the card actually has files, and quiet
    /// enough that the title still dominates (03-board-ui.md § Card face). The count goes to the
    /// accessibility *value* rather than onto the face: it is useful to know, not to look at.
    ///
    /// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are
    /// decorative — folded into the element, never separately focusable … the flattened element
    /// carries the attachment count in its value". The flattening above would drop a label here
    /// anyway; saying it explicitly is what keeps the chip inert in the replica too, which is drawn
    /// outside the flattened face.
    @ViewBuilder
    private var attachmentsIndicator: some View {
        if !card.attachments.isEmpty {
            Image(systemName: "paperclip")
                .boardFont(.caption)
                .foregroundStyle(.secondary)
                .accessibilityHidden(true)
        }
    }

    /// The comments chip — attachments' twin, joined 2026-08-09 (design ruling, card e729e30a):
    /// shown only when the card has one comment or more, same secondary-tinted, decorative,
    /// present-only vocabulary as `attachmentsIndicator`. **Carries a visible count**, unlike the
    /// paperclip, because the ruling asks for "a quiet indicator (bubble-style SF Symbol + count)" —
    /// still quiet (caption size, secondary tint, no pill background), just not icon-only; the count
    /// answers "how many" the way a lane's own card-count badge does one level up, without spending a
    /// tap to find out.
    ///
    /// **`card.commentCount` is the one and only source** — a snapshot field the loader fills with a
    /// readdir (`BoardLoader.commentCount(in:)`, `Card.commentCount`'s own doc comment), never a
    /// per-face parse — so drawing this chip costs nothing beyond reading a field already on the
    /// compared `card` parameter (RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies:
    /// no new Observable read joins this body, and no new `CardFaceView` parameter was needed either,
    /// since the count already rides inside `card`).
    ///
    /// **Decorative and hidden outright**, `attachmentsIndicator`'s exact reasons: the flattened
    /// element carries the comment count in its value (`AccessibilityPhrases.cardValue`), so a label
    /// here would be redundant even before the flattening drops it.
    @ViewBuilder
    private var commentsIndicator: some View {
        if card.commentCount > 0 {
            HStack(spacing: BoardMetrics.chipGlyphSpacing(bodyPointSize: pointSize)) {
                Image(systemName: "bubble")
                Text("\(card.commentCount)")
            }
            .boardFont(.caption)
            .foregroundStyle(.secondary)
            .accessibilityHidden(true)
        }
    }

    /// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
    /// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
    /// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
    ///
    /// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
    /// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
    /// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
    /// content does — a title wrapping across its full four lines included.
    @ViewBuilder
    private var accentStripe: some View {
        if let color = Palette.color(for: card.background) {
            UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
                .fill(color)
                .frame(width: stripeWidth)
                // Decoration only: the whole plate is one click target for selection.
                .allowsHitTesting(false)
        }
    }

    // MARK: - Selection and rename plumbing

    /// What the plate's edge is painted with: the accent when this card is selected or a Finder file
    /// drag is hovering it, a separator hairline under Increase Contrast, and nothing otherwise.
    ///
    /// The three-way branch is the whole of "state is never colour-alone, and Increase Contrast
    /// strengthens borders" meeting on one shape: the resting border is *chrome* (every card gets
    /// one, so it says nothing), and the accent ring stays the only thing that means "this one".
    private var plateStroke: AnyShapeStyle {
        if isSelected || isFileHovered {
            AnyShapeStyle(Color.accentColor)
        } else if Accommodations.drawsRestingBorder(contrast: contrast) {
            AnyShapeStyle(.separator)
        } else {
            AnyShapeStyle(.clear)
        }
    }

    private var plateStrokeWidth: CGFloat {
        guard isSelected || isFileHovered else {
            return Accommodations.borderWidth(1, contrast: contrast)
        }
        return Accommodations.borderWidth(isFileHovered ? 2.5 : 1.5, contrast: contrast)
    }

    /// Whether an external Finder file drag is hovering **this** card — the attach highlight
    /// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
    /// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
    /// to one implementation (`BoardDrops`).
    ///
    /// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md
    /// ▸ The trash), and the trash column's own delegate clears the file highlight rather than
    /// proposing one — so this is a second, structural statement of the same rule.
    private var isFileHovered: Bool {
        role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootKey) == card.id
    }

    /// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename"
    /// (04-interactions.md ▸ The trash). No path opens a rename on a trashed card, and this makes a
    /// stray one render nothing rather than putting a live field over a card that cannot be edited.
    private var isRenaming: Bool {
        role.container == .board && store.transient.renameEditor?.targetID == card.id
    }

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