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).
///
/// **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.
enum CardFaceRole {

    /// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
    /// (04-interactions.md ▸ Selection).
    case board(openCard: (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), a quiet trailing attachments indicator, and a left-edge colour accent stripe
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
///
/// ### 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, "the face stays title-only … the old 'iterate on the card face
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
/// indicator when the card has files — the title dominates", which is why the paperclip is a
/// secondary-tinted caption and not a count pill: the eye should land on the title.
///
/// ### Two lenient fields, two different fallbacks
///
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### One presentation, selection styling only
///
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
///
/// ### 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

    /// 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 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 { BoardMetrics.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:)`), 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.
    ///
    /// **What the gate does not suppress is the point.** Everything this body reads through
    /// Observation — `store.selection`, `store.searchFilter`, `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.
    ///
    /// 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.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)
                }
        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 {
        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)
        .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
        .overlay(alignment: .leading) { accentStripe }
        // The selection treatment, which a hovering Finder file drag borrows outright: "the card
        // highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
        // already this face's vocabulary for "this one". The hover draws it a touch heavier so a
        // hovered card that is *also* selected still reads as the target.
        //
        // **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 deferred cut's "cut, pending paste", or both — and the empty
        // string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
        // it is not a conditional modifier).
        .accessibilityValue(AccessibilityPhrases.cardValue(
            attachments: card.attachments.count,
            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)
    }

    // 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] ?? LaneDropRegistry.nominalCardHeight },
            container: .board,
            source: store
        )
        return payload.itemProvider()
    }

    /// 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] ?? LaneDropRegistry.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).
    private var dragReplica: some View {
        let count = store.selection.container == role.container && store.selection.ids.contains(card.id)
            ? max(1, store.selection.ids.count)
            : 1
        return ZStack {
            if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
            if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
            replicaFace
        }
        .overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
        .padding(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 {
        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")
                .font(.body)
                .lineLimit(4)
                .frame(maxWidth: .infinity, alignment: .leading)
            attachmentsIndicator
        }
        .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 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(.background.secondary))
        .overlay(alignment: .leading) { accentStripe }
        .dragReplicaShadow()
    }

    // MARK: - Context menus

    /// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
    /// menus' Card row, in its order.
    @ViewBuilder
    private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
        // Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
        // alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and
        // Delete below it, this row never widens to the selection; Open never opens multiple, even
        // when the clicked card is part of one. It calls the very `openCard` closure the double-click
        // gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
        // a focused inline editor to *this* card's context menu, so there is nothing here to commit
        // first — only the plain open.
        Button("Open") {
            openCard(card.id)
        }

        Divider()

        // Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
        // currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
        // this card to be the *sole* selection; a context menu already names its target by where it
        // was invoked, so — standard macOS practice — it acts on the clicked card outright.
        Button("Rename") { beginRename() }
            .disabled(!store.acceptsBoardMutations)

        StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)

        Divider()

        // Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
        // widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own,
        // so this row gets it for free.
        Button("Delete") { deleteTargets() }
            .disabled(!store.acceptsBoardMutations)
    }

    /// `boardMenu`'s plain 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.
    @ViewBuilder
    private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
        Button("Open") { openCard(card.id) }
        Button("Rename") { beginRename() }
            .disabled(!store.acceptsBoardMutations)
        Button("Delete") { 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)
    }

    /// 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: - 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 indicator sits hard against the trailing
                // edge — and so the rename field fills the same span the title occupied.
                .frame(maxWidth: .infinity, alignment: .leading)
            attachmentsIndicator
        }
    }

    /// The title, or the rename editor when this card is the rename target. 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) }
                }
            )
            .font(.body)
        } else {
            Text(card.title.value ?? "Untitled")
                .font(.body)
                .foregroundStyle(card.title.value == nil ? .secondary : .primary)
                .lineLimit(4)
        }
    }

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

    /// The one face chip in scope — shown only when the card actually has files, and quiet enough
    /// that the title still dominates (03-board-ui.md § Card face). The count goes to the
    /// accessibility *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")
                .font(.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

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

    /// 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) }
        )
    }
}
