import Foundation
import Observation

// MARK: - Tone

/// The three kinds a banner row can be (02-architecture.md § The banner surface, "Tones, not
/// components"): one layout, one accessibility path, three colorings. The card window's
/// remote-change signpost (07-sync-collab.md) is this same component in `.info` — visually calm,
/// no error color — which is precisely why the distinction is a *tone* on a shared row rather
/// than three view types that would drift apart.
public enum BannerTone: Sendable, Equatable {
    case error
    case warning
    case info
}

// MARK: - Row payloads

/// A one-shot write failure: it happened once, it is over, and it waits to be read
/// (02-architecture.md § The banner surface, "One-shots dismiss, conditions heal").
///
/// **No timeout, ever** — "an error never evaporates unread". The row leaves the strip only when
/// the user dismisses it, which is why it carries an id of its own rather than being identified by
/// its content: two identical failures a minute apart are two rows, and dismissing one must not
/// take the other with it.
public struct OneShotBanner: Identifiable, Sendable, Equatable {
    public let id: UUID
    public let error: BoardWriteError
    /// When the failure happened — the sort key for "newest first within a class".
    public let occurredAt: Date

    public init(id: UUID = UUID(), error: BoardWriteError, occurredAt: Date = Date()) {
        self.id = id
        self.error = error
        self.occurredAt = occurredAt
    }
}

/// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner
/// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28) —
/// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin.
///
/// **It takes the one-shot's lifecycle**, deliberately: "a loss the user didn't notice is the harm,
/// so it never auto-expires" is `OneShotBanner`'s "an error never evaporates unread", read for a row
/// that reports something incomplete rather than something failed. Carrying its own `id` is the same
/// consequence: two identical losses a minute apart are two rows, and dismissing one must not take
/// the other with it.
///
/// **It ranks below the true failures and above the ambient notices** — "an action that didn't
/// happen outranks one that partially did" — which is why it is its own `BannerRow` case rather than
/// an `OneShotBanner` with a `nil` error or a `InfoSignpost` with a heavier tone: neither of those
/// vocabularies has a slot at loss's precedence, and bending one to fit would blur the reading that
/// the class exists to make precise.
public struct LossBanner: Identifiable, Sendable, Equatable {
    public let id: UUID
    public let message: String
    /// When the loss happened — the sort key for "newest first within a class".
    public let occurredAt: Date

    /// **What this row can show the user in Finder**, empty for every loss row that has nothing to
    /// point at — which is all of them but one.
    ///
    /// It exists for the skip notice (01-storage-format.md § Malformed input, ruled 2026-07-31: "the
    /// opened board carries a warning-tone notice naming the skipped items, **each with Reveal in
    /// Finder**"). The affordance is per *item* while the row is one line, so the targets ride the
    /// row's data and the strip renders one control over them (`BannerRowControl.reveal`) — a button
    /// for a sole item, a menu for several. Carrying them here rather than in a row case of their own
    /// keeps the skip notice in the loss class the ruling puts it in.
    public let reveals: [RevealTarget]

    public init(
        id: UUID = UUID(),
        message: String,
        occurredAt: Date = Date(),
        reveals: [RevealTarget] = []
    ) {
        self.id = id
        self.message = message
        self.occurredAt = occurredAt
        self.reveals = reveals
    }
}

/// One file a banner row can reveal in Finder.
///
/// `path` is what the user reads — the board-root-relative spelling `BoardLoadError.path` carries and
/// the decision surface's row already showed them — and `url` is what Finder selects. The two are
/// carried together rather than derived from each other because only the producer holds the board
/// root, and a row that rebuilt a URL from a string would be a second answer to where the board is.
public struct RevealTarget: Identifiable, Sendable, Equatable {
    public let path: String
    public let url: URL

    public var id: String { path }

    public init(path: String, url: URL) {
        self.path = path
        self.url = url
    }
}

/// The standing condition "changes aren't being recorded to history" (02-architecture.md §
/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention").
///
/// `reason` is **diagnostic English, not the banner's verb** — the free-form tail the phrasing
/// rules allow ("disk full", "the repository is corrupt"). The user-facing sentence is
/// `BannerCenter.headline(for:)`'s, like every other row's.
///
/// `since` exists because this is a condition, not an event: it started at some point and has been
/// true ever since. Nothing renders it today; it is what a later "suspended for 4 minutes" reading
/// would be built from, and recording it costs a `Date`.
public struct HistorySuspension: Sendable, Equatable {
    public let reason: String
    public let since: Date

    public init(reason: String, since: Date = Date()) {
        self.reason = reason
        self.since = since
    }
}

/// A calm notice that something happened elsewhere — the info tone's *passive* half.
///
/// Its one producer-to-be is the card window's **remote-change signpost** (07-sync-collab.md): a
/// pull landed commits touching the very card being edited, the dirty buffer stays put and wins,
/// and the window says so without a modal or a merge UI. 02-architecture.md calls that "this same
/// component in the info tone", which is exactly what this is.
///
/// **Ranks last and may collapse** (settled, § The banner surface): "calm by design, nothing gated
/// on seeing them instantly" — the opposite end of the strip from an in-progress row, whose spinner
/// may never hide.
///
/// Dismissable, like a one-shot and unlike a condition: it reports something that already happened,
/// so there is nothing for it to heal into.
public struct InfoSignpost: Identifiable, Sendable, Equatable {
    public let id: UUID
    public let message: String
    public let occurredAt: Date

    public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
        self.id = id
        self.message = message
        self.occurredAt = occurredAt
    }
}

/// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface,
/// "In-progress operations are info rows"): long copy-shaped work — big-board Duplicate, template
/// instantiation, large attachment imports — and whatever wholesale operation joins them.
///
/// `label` is the caller's, deliberately: unlike a failure — whose vocabulary is the closed
/// `WriteOperation` enum precisely so the banner can own every word — an in-progress row names an
/// operation the banner layer has no enum for, and inventing one would mean a new verb could not
/// describe itself without touching this file. The tradeoff is stated rather than hidden.
///
/// **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports,
/// Duplicate, template instantiation — carries `cancel`, meaning "remove the partial copy, nothing
/// lost". An operation that cannot be abandoned halfway passes `nil`: unwinding it is a repair job,
/// not a cancel.
public struct InProgressOperation: Identifiable, Sendable {
    public let id: UUID
    public let label: String
    public var isCancelable: Bool { cancel != nil }
    /// `@MainActor @Sendable` rather than the plainer `@MainActor () -> Void` the sketch carried:
    /// this value is stored in a `Sendable` struct, and a non-`Sendable` function type would make
    /// that conformance a lie under strict concurrency. The isolation is unchanged — cancelling
    /// runs on the main actor, where the operation's own state lives.
    public let cancel: (@MainActor @Sendable () -> Void)?

    public init(id: UUID = UUID(), label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) {
        self.id = id
        self.label = label
        self.cancel = cancel
    }
}

// MARK: - Rows

/// One row in a window's banner strip.
///
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// divide into three lifecycles that the view renders differently and that the ordering rule
/// treats as classes:
///
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
///   ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin,
///   "a condition is never dismissed while it is still true". Each leaves when the thing it
///   describes stops being true.
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something
///   that already happened, so only the user can clear it. `loss` shares the lifecycle deliberately
///   (settled 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the
///   harm, so it never auto-expires" is the same reasoning that keeps a one-shot from evaporating
///   unread, aimed at a row that isn't an error at all.
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
///   it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
///
/// **`Identifiable` by a synthetic string id**, not by content: the condition rows are singletons
/// per window (there is one lock, one breakage, one history suspension), so their ids are constant
/// and a changing *reason* updates the row rather than replacing it — no view churn, no lost
/// animation, and no diffing surprise when a lock's cause changes underneath a standing row.
public enum BannerRow: Identifiable, Sendable {
    /// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload, the
    /// vanished root, and the writability probe — at open and, symmetrically, on every reconciling
    /// reload thereafter.
    case readOnlyLock(ReadOnlyLockReason)
    /// A reload failed and the last good snapshot is still on screen. Condition, error tone. Carries
    /// the **whole** aggregate — one row either way, but its headline names the first defect and
    /// counts the rest rather than pretending the walk found only one.
    case reloadBreakage(BoardLoadFailure)
    /// A write that did not happen. Dismissable, error tone.
    case oneShot(OneShotBanner)
    /// Content that didn't arrive though nothing failed — folders skipped from a Finder drop, the
    /// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it,
    /// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
    case loss(LossBanner)
    /// History has stopped advancing. Condition, warning tone — the files are safe, only the undo
    /// trail is degraded, which is a warning rather than an error. No producer today; the row waits
    /// for whatever substrate can stall.
    case historySuspended(HistorySuspension)
    /// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse.
    /// (m5's copy-shaped work drives it.)
    case inProgress(InProgressOperation)
    /// A calm notice — the passive half of the info tone, ranking last of all. (m6's card window
    /// drives it, as 07-sync-collab.md's remote-change signpost.)
    case signpost(InfoSignpost)

    public var id: String {
        switch self {
        case .readOnlyLock: "read-only-lock"
        case .reloadBreakage: "reload-breakage"
        case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
        case let .loss(loss): "loss:\(loss.id.uuidString)"
        case .historySuspended: "history-suspension"
        case let .inProgress(operation): "operation:\(operation.id.uuidString)"
        case let .signpost(signpost): "signpost:\(signpost.id.uuidString)"
        }
    }

    public var tone: BannerTone {
        switch self {
        case .readOnlyLock, .reloadBreakage, .oneShot: .error
        // A suspension is history failing to advance and retrying, which is degraded rather than
        // broken — the files are safe either way, so it takes the warning tone the loss row does.
        case .historySuspended, .loss: .warning
        case .inProgress, .signpost: .info
        }
    }

    /// Whether this row is pinned above the strip's collapse — true for in-progress rows and
    /// nothing else (settled, 02-architecture.md § The banner surface: "a spinner may never hide
    /// behind '+N more'"). The view reads this rather than re-deriving the case.
    public var isPinned: Bool {
        if case .inProgress = self { true } else { false }
    }

    /// The user-facing line. Every word of it comes from `BannerCenter`'s phrasing statics except
    /// an in-progress row's, whose label is its caller's (see `InProgressOperation`).
    public var headline: String {
        switch self {
        case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
        case let .reloadBreakage(error): BannerCenter.headline(for: error)
        case let .oneShot(banner): BannerCenter.headline(for: banner.error)
        case let .loss(loss): loss.message
        case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
        case let .inProgress(operation): operation.label
        case let .signpost(signpost): signpost.message
        }
    }

    /// Only the rows reporting something that already happened carry a dismiss control —
    /// one-shot failures, loss rows, and signposts. Conditions stand until they heal; in-progress
    /// rows complete, fail, or are cancelled — neither is something a user can wave away.
    public var dismissID: UUID? {
        switch self {
        case let .oneShot(banner): banner.id
        case let .loss(loss): loss.id
        case let .signpost(signpost): signpost.id
        case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
        }
    }

    /// **This row's buttons, in the order Tab visits them** — Cancel, then Reveal, then Dismiss.
    ///
    /// It exists because 10-accessibility.md ▸ Full Keyboard Access rules the banner's buttons in by
    /// name (2026-07-29): "'Every control' is literal and includes banner-row buttons — a Dismiss or
    /// Cancel on a banner must be a Tab stop … Cancel on an in-progress operation is exactly the
    /// control that cannot require a pointer". A claim about *which* controls a row has is then a
    /// fact about the row's data rather than about a view's `if` ladder, so it can be pinned
    /// headlessly and the strip can render straight from it (`BannerStripView`) — which is the same
    /// posture the rest of this type already takes ("the per-kind affordances hang off the row's
    /// data, not off separate views").
    ///
    /// Cancel and Dismiss are disjoint by construction (only an in-progress row cancels, and an
    /// in-progress row is never dismissable), so the pair that actually co-occurs is **Reveal then
    /// Dismiss** — the skip notice's shape. Reveal comes first because it is the row's *content*
    /// affordance and Dismiss is its lifecycle one: the same reason Cancel precedes Dismiss.
    public var controls: [BannerRowControl] {
        var controls: [BannerRowControl] = []
        if case let .inProgress(operation) = self, let cancel = operation.cancel {
            controls.append(.cancel(cancel))
        }
        if case let .loss(loss) = self, !loss.reveals.isEmpty {
            controls.append(.reveal(loss.reveals))
        }
        if let dismissID {
            controls.append(.dismiss(dismissID))
        }
        return controls
    }
}

// MARK: - A row's buttons

/// One button on a banner row — the strip's whole vocabulary of per-row controls, as data.
///
/// **Identified by its label**, which is legitimate rather than lazy here: a row carries at most one
/// of each kind, the label is the user-facing name of exactly that kind, and it is what both
/// surfaces the ruling cares about need — the button's title (Cancel) or its accessibility label
/// (Dismiss, whose face is an ✕ glyph).
public enum BannerRowControl: Identifiable, Sendable {

    /// Stop the operation this row is reporting and remove its partial work — carried by cancelable
    /// in-progress rows only (`InProgressOperation`: "safe copies only").
    case cancel(@MainActor @Sendable () -> Void)

    /// Clear this row, by the id `BannerCenter.dismiss(_:)` takes.
    case dismiss(UUID)

    /// **Show the files this row is about in Finder** — the skip notice's per-item affordance
    /// (01-storage-format.md § Malformed input: "each with Reveal in Finder").
    ///
    /// **One control over N targets, not N controls**, and the reason is the strip's own shape: a
    /// banner row is one line, three collapsible rows are all the strip shows, and a notice that grew
    /// a button per skipped item would push the rows below it behind "+N more" on the very board that
    /// just told the user something went wrong. So the row stays one row, the control stays one Tab
    /// stop (10-accessibility.md ▸ Full Keyboard Access), and the plurality lives *inside* it — the
    /// strip renders a plain button for a sole target and a menu naming each path for two or more.
    ///
    /// Never empty: `BannerRow.controls` only produces it where there is something to reveal.
    case reveal([RevealTarget])

    public var label: String {
        switch self {
        case .cancel: "Cancel"
        case .dismiss: "Dismiss"
        // One label for both renderings — it is the button's title *and* the menu's, and it is what
        // 01 calls the affordance by name.
        case .reveal: "Reveal in Finder"
        }
    }

    public var id: String { label }
}

// MARK: - BannerCenter

/// The model behind one window's banner strip: the rows a window owns outright, plus the ordering
/// and phrasing rules the whole app renders through (02-architecture.md § The banner surface).
///
/// ### What lives here and what does not
///
/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the
/// history suspension, in-progress operations, and passive signposts. It deliberately does **not**
/// hold the read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying them
/// here would create a second place for them to be stale. `BoardStore.bannerRows` composes both
/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a
/// *pure function* precisely so the precedence rule can be tested without a store, a window, or a
/// filesystem.
///
/// ### Phrasing lives here too
///
/// "The banner owns all user-facing phrasing and localization from that vocabulary" (02 §
/// Write-failure surfacing). `BoardWriteError` carries a closed `WriteOperation` enum and a
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
/// suspension: their user-facing lines are here, not on the error types.
///
/// ### One center per window, not per board
///
/// The strip is "hosted by the window of origin": a card window's own save and attachment failures
/// belong to that window's center, and re-home to the board window's when it closes (m6's job).
/// `BoardStore` owns *the board window's* center, which is why it is a stored `let` there rather
/// than something injected — a board window's strip has exactly one lifetime, the store's.
@MainActor
@Observable
public final class BannerCenter {

    // MARK: State

    /// Newest first, which is the order the class renders in. Kept sorted on insertion rather than
    /// at render time so that two failures sharing a timestamp — plausible inside one run loop
    /// turn — still order deterministically.
    public private(set) var oneShots: [OneShotBanner] = []

    /// Newest first, like `oneShots` — content that didn't arrive though nothing failed,
    /// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see
    /// `LossBanner`).
    public private(set) var losses: [LossBanner] = []

    /// The standing "history isn't advancing" condition, or `nil` when history is keeping up.
    public private(set) var historySuspension: HistorySuspension?

    /// Work in flight, newest first for the same reason `oneShots` is.
    public private(set) var operations: [InProgressOperation] = []

    /// Passive notices, newest first. Empty until m6's card window starts posting the
    /// remote-change signpost.
    public private(set) var signposts: [InfoSignpost] = []

    public init() {}

    // MARK: One-shots

    /// Records a write failure. **Every failed write lands here before it reaches its caller** —
    /// `BoardStore.performWrite` posts before it rethrows — because the banner is how the one-way
    /// flow stays honest: the action visibly did not happen, and the strip is the only place that
    /// says why (02 § Write-failure surfacing).
    public func post(_ error: BoardWriteError) {
        oneShots.insert(OneShotBanner(error: error), at: 0)
    }

    /// Posts a loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see
    /// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
    ///
    /// - Parameter reveals: the files this row can show in Finder, empty for every producer but the
    ///   skip notice (`LossBanner.reveals`).
    public func postLoss(_ message: String, reveals: [RevealTarget] = []) {
        losses.insert(LossBanner(message: message, reveals: reveals), at: 0)
    }

    /// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
    /// like the one-shots it shares a lifecycle with.
    public func postSignpost(_ message: String) {
        signposts.insert(InfoSignpost(message: message), at: 0)
    }

    /// **The skipped undo step** (13-native-undo.md ▸ Rules ▸ staleness validation): an inverse found
    /// its target holding somebody else's newer value, so it was popped rather than applied and ⌘Z
    /// fell through to the next step. This is the row that says so.
    ///
    /// **A signpost, and no new class** — the vocabulary's answer rather than a compromise. 13 asks
    /// for an "info-tone banner", and 02-architecture.md § The banner surface gives the info tone
    /// exactly two halves: the pinned in-progress row with its spinner, and the passive signpost.
    /// Nothing is in flight here, so the passive half is the whole of the choice. It also reads
    /// right: unlike a `loss` row (warning tone, "content that didn't arrive though nothing failed"),
    /// **nothing was lost and nothing failed** — the file holds exactly what its most recent writer
    /// meant it to, the stack moved on to a step that did apply, and the user's ⌘Z did something. A
    /// row that ranks last and may collapse behind "+N more" is the honest weight for that: calm by
    /// design, nothing gated on seeing it instantly. And it is emphatically not a `oneShot`, which
    /// carries a `BoardWriteError` — a *failed* inverse posts one of those instead, and the two rows
    /// must stay distinguishable (`HistoryStepOutcome`).
    public func postSkippedStep(_ direction: HistoryDirection, subject: String) {
        postSignpost(Self.skippedStepMessage(direction, subject: subject))
    }

    /// **The refused paste** (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29): the staged
    /// snapshot was missing or unreadable, so the paste produced **nothing**, and this is the row that
    /// says so — "the paste produces nothing, and a one-shot failure banner names it from the
    /// manifest's metadata".
    ///
    /// **A `oneShot`, not a loss row** — which is the pivot, and it is the vocabulary reading the
    /// event correctly rather than a reclassification for its own sake. The degraded paste *was* a loss
    /// row because the items landed and only their attachments did not: content that didn't arrive
    /// though nothing failed. Under refuse-don't-degrade nothing lands at all, which is exactly
    /// 02-architecture.md's definition of a one-shot — a write that did not happen — so the row
    /// carries a `BoardWriteError` like every other failure, ranks with the true failures, and says
    /// "Couldn't paste" rather than "Pasted … without".
    ///
    /// The retired member is `postDegradedPaste(_:)` and its `AttachmentLoss` payload: with the
    /// degraded materialization gone there is no partial arrival to account for, and the
    /// loss-accounting problem it existed to report — what didn't arrive, and whether the totals were
    /// honest — dissolves rather than being solved. **The loss class itself is untouched**: folder-drop
    /// skips still post one (`postSkippedFolders`), and the relocation, migration, displacement and
    /// remint notices are all still its.
    ///
    /// `title` is the offending entry's, from the manifest's own metadata, `nil` for an untitled item —
    /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so `actionPhrase(for:)`
    /// says "the item" instead. The staging path is what the error names as its `path`: it is the file
    /// that was not there, and naming it is what makes a bug report about this actionable.
    public func postRefusedPaste(title: String?, stagedAt path: String) {
        post(BoardWriteError(
            operation: .paste(title: title),
            path: path,
            reason: .clipboardContentGone
        ))
    }

    /// One card whose loose files were relocated into `attachments/` — what
    /// `relocatedLooseFilesMessage(for:)` names.
    ///
    /// `fileNames` are the names the files had **beside `index.md`**, not the Finder-renamed ones
    /// they may have landed under: those are the names the user or their agent wrote, and the one
    /// they would recognize in a sentence (`WriteOperation.importAttachment`'s own rule, read for
    /// the relocation). `title` is the card's as written, `nil` for an untitled one — "Untitled" is
    /// a rendering, never a value (03-board-ui.md § Card face).
    public struct Relocation: Sendable, Equatable {
        public let title: String?
        public let fileNames: [String]

        public init(title: String?, fileNames: [String]) {
            self.title = title
            self.fileNames = fileNames
        }
    }

    /// **The loose-file relocation** (01-storage-format.md § Fractal layout ▸ Rules, settled
    /// 2026-07-28): a file was sitting beside a card's `index.md`, the app moved it into that card's
    /// `attachments/`, and this is the row that says so — "surfacing a graceful warning-tone notice
    /// naming the card and files".
    ///
    /// **A loss row, though nothing was lost.** The class is the vocabulary's warning-tone,
    /// user-dismissed, never-expiring one — `LossBanner`'s "their future kin" — and this is exactly
    /// that shape read once more: the app did something to the user's files that they did not ask
    /// for, so it must be said out loud, it must not evaporate unread, and it must not rank as an
    /// error, because no action failed. `signpost` would be too quiet (it ranks last and may
    /// collapse behind "+N more"); `oneShot` would be a lie (it carries a `BoardWriteError`, and
    /// the write succeeded). The name of the class is about its *lifecycle and tone*, not about
    /// loss being the only thing it can report.
    ///
    /// A relocation that moved nothing posts nothing.
    public func postRelocatedLooseFiles(_ relocations: [Relocation]) {
        guard let message = Self.relocatedLooseFilesMessage(for: relocations) else { return }
        postLoss(message)
    }

    /// **The legacy tombstone migration** (01-storage-format.md § Deletion, resettled 2026-07-28:
    /// "Legacy `deleted:` keys migrate on load-and-write, never destroy … a graceful warning-tone
    /// notice"): a board written by an older version carried `deleted:` keys, the app moved the
    /// cards those keys named into `.trash/` and returned the lanes live, and this is the row that
    /// says so.
    ///
    /// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event:
    /// the app moved the user's folders on its own initiative, on a board it opened rather than on a
    /// gesture they made. That must be said out loud, must not evaporate unread, and must not rank
    /// as an error, because no action failed.
    ///
    /// **Cards only** (01-storage-format.md § Deletion, lane clause re-ruled 2026-07-29): the lane
    /// half of this notice retired with the lane migration itself — a lane's `deleted:` is inert now,
    /// nothing is written for it, and a row announcing an act the app did not perform would be worse
    /// than silence.
    ///
    /// `cards` holds the migrated cards' titles, in the order they were written, `nil` for an
    /// untitled one — "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so the
    /// phrasing layer decides what to call it. A migration that migrated nothing posts nothing.
    public func postMigratedTombstones(cards: [String?]) {
        guard let message = Self.migratedTombstonesMessage(cards: cards) else { return }
        postLoss(message)
    }

    /// One claimed board-root name whose squatter was moved aside — what
    /// `displacedClaimedNamesMessage(for:)` names.
    ///
    /// Both names are carried because the notice owes **old and new** (01-storage-format.md
    /// § Fractal layout ▸ Rules, ruled 2026-07-29: "with the relocation-style warning-tone notice
    /// naming old and new"): the user needs to know which of their files moved *and* where to find
    /// it, and a sentence naming only one of the two would be half an answer.
    public struct Displacement: Sendable, Equatable {
        /// The claimed name the app took back — `.trash`, `CLAUDE.md`.
        public let name: String
        /// The Finder-ladder name the displaced node now has — `.trash 2`.
        public let movedTo: String

        public init(name: String, movedTo: String) {
            self.name = name
            self.movedTo = movedTo
        }
    }

    /// **The claimed-name displacement** (01-storage-format.md § Fractal layout ▸ Rules, ruled
    /// 2026-07-29): a folder, file or symlink was sitting on a name the app owns, the app moved it
    /// aside — preserved verbatim, never destroyed — and this is the row that says so.
    ///
    /// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, which is also what the ruling
    /// asks for by name ("the relocation-style warning-tone notice"): the app moved something of the
    /// user's that they did not ask it to move, so it must be said out loud, must not evaporate
    /// unread, and must not rank as an error, because nothing failed.
    ///
    /// A displacement that displaced nothing posts nothing.
    public func postDisplacedClaimedNames(_ displacements: [Displacement]) {
        guard let message = Self.displacedClaimedNamesMessage(for: displacements) else { return }
        postLoss(message)
    }

    /// **The duplicate-id remint** (01-storage-format.md § Fractal layout ▸ Rules, re-ruled
    /// 2026-07-29): two folders were carrying one id, the app gave the later one the fresh identity a
    /// copy should have had, and this is the row that says so — "Announced, not invisible: the
    /// relocation-style warning-tone notice names the repair … identity changed, so a line says so,
    /// but nothing waits on consent".
    ///
    /// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, and the ruling asks for that
    /// class by name: the app renamed a folder of the user's that they did not ask it to rename, so it
    /// must be said out loud, must not evaporate unread, and must not rank as an error, because
    /// nothing failed. It is emphatically **not** a condition banner with a button — the user-gated
    /// Repair retired on 2026-07-29 and this row is what replaced it.
    ///
    /// `titles` are the reminted items' as the load found them, `nil` for an untitled one — "Untitled"
    /// is a rendering, never a value (03-board-ui.md § Card face). A remint that reminted nothing
    /// posts nothing.
    public func postRemintedDuplicateIDs(_ titles: [String?]) {
        guard let message = Self.remintedDuplicateIDsMessage(for: titles) else { return }
        postLoss(message)
    }

    /// **The orphaned background-image sweep** (01-storage-format.md § Validation and healing, ruled
    /// 2026-08-09: "the open-time heal posts the calm notice-family banner the loose-file relocation
    /// heal uses — state what was tidied"): a file in `.backgrounds/` that nothing on the board named
    /// any more was moved to the system Trash, and this is the row that says so.
    ///
    /// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**: the app removed a file on its own
    /// initiative, on a board it opened rather than on a gesture the user made — that must be said out
    /// loud, must not evaporate unread, and must not rank as an error, because nothing failed.
    ///
    /// **Only the sweep posts this.** The in-flow replace-in-place trim a background apply makes on
    /// its own prior file is deliberately silent — the ruling's "temp-file posture": the app
    /// superseding its own file inside a gesture the user already sees succeed (a fresh picture lands)
    /// is not news the way a heal discovered on open is.
    ///
    /// `names` are the bare filenames the sweep actually removed, in the order it removed them. A
    /// sweep that removed nothing posts nothing.
    public func postTidiedBackgroundImages(_ names: [String]) {
        guard let message = Self.tidiedBackgroundImagesMessage(for: names) else { return }
        postLoss(message)
    }

    /// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
    /// surface offered Skip on a defect the app has no honest repair for, the user consented, the
    /// board opened without that item — "the file stays on disk untouched, tolerated-invisible like
    /// strays" — and this is the row that says so.
    ///
    /// > a skipped item loads the board without it … and the opened board carries a warning-tone
    /// > notice naming the skipped items, each with Reveal in Finder. Skips are per-open decisions,
    /// > never persisted: the next open of a still-broken board presents the surface again — the
    /// > notice is the honest residue of this open, not a stored preference.
    ///
    /// **A loss row, and the ruling names the tone**: the board on screen is not the whole board, and
    /// that is exactly "content that didn't arrive though nothing failed". It must not evaporate
    /// unread (the class's untimed lifecycle) and it must not rank as an error, because nothing
    /// failed — the user chose this.
    ///
    /// An open that skipped nothing posts nothing: a notice about no skips is not news, and it is
    /// what every ordinary open passes here.
    public func postSkippedOnOpen(_ items: [RevealTarget]) {
        guard let message = Self.skippedOnOpenMessage(for: items) else { return }
        postLoss(message, reveals: items)
    }

    /// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
    /// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
    /// mixed drag proposes for its files only, and the drop imports the files while a one-shot
    /// banner names the skipped folders" — now a loss row (settled 2026-07-28): folders that never
    /// arrived are a non-failure loss, not a write failure. With the degraded paste retired
    /// (`postRefusedPaste`) this is the loss class's clearest remaining instance: the drop *did* land,
    /// and only the payload the attachment model cannot hold stayed behind.
    ///
    /// A drop with no skipped folders posts nothing — nothing was lost, so there is nothing to say.
    public func postSkippedFolders(count: Int) {
        guard count > 0 else { return }
        postLoss(Self.skippedFoldersMessage(count: count))
    }

    /// **The mixed-kind drag that tried to leave the trash** (04-interactions.md ▸ The trash, ruled
    /// 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal — but
    /// every out-of-trash drop target refuses the mixed payload, and the release surfaces a notice
    /// explaining the rule … the refused drag ends like any refusal, rows staying put".
    ///
    /// **A loss row**, with the relocation family and `postSkippedFolders`: nothing failed — no write
    /// was attempted — and nothing is wrong with the board, but the gesture the user made did not
    /// happen, which is exactly the warning-tone "didn't arrive" register. The wording is 04's own,
    /// verbatim, and lives here because BannerCenter owns the phrasing (▸ Clipboard).
    ///
    /// It is the *teaching* half of the guard: ⌘C/⌘X grey out silently (menu validation says no
    /// before the gesture starts), while the drag has no such surface to say it in advance — so the
    /// drop-time explanation "is where the rule teaches itself".
    public func postMixedTrashDrag() {
        postLoss(Self.mixedTrashDragMessage)
    }

    /// 04-interactions.md ▸ The trash's own sentence.
    nonisolated static let mixedTrashDragMessage =
        "Cards and lanes leave the trash separately \u{2014} restore one kind at a time"

    /// **The lossy export's notice** (15-import-export.md ▸ Lossy exports say so): the document landed
    /// where the user asked, and this is the row naming what none of the three v1 formats can carry.
    ///
    /// **A loss row, on `postSkippedFolders`' exact reasoning** — the clearest existing member of the
    /// class, and the same shape of event: the operation succeeded and only the payload the destination
    /// cannot hold stayed behind. It must be said out loud (an export the user believes is complete is
    /// the harm), it must not evaporate unread, and it must not rank as an error, because nothing
    /// failed. A `signpost` would be too quiet — it ranks last and may collapse behind "+N more" — and
    /// a `oneShot` would be a lie, since it carries a `BoardWriteError` and the write succeeded.
    ///
    /// **An export that left nothing behind says nothing at all.** A board with no comments and no
    /// attachments exports losslessly, and a row confirming that would be noise on top of a file the
    /// user is already looking at in Finder.
    public func postExportOmissions(_ omissions: InterchangeOmissions, format: InterchangeFormat) {
        guard let message = Self.exportOmissionsMessage(omissions, format: format) else { return }
        postLoss(message)
    }

    /// Removes a dismissable row: a one-shot failure, a loss row, or a signpost.
    /// **An id that names an in-progress operation is ignored** rather than ending it, because
    /// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do
    /// the other.
    public func dismiss(_ id: UUID) {
        oneShots.removeAll { $0.id == id }
        losses.removeAll { $0.id == id }
        signposts.removeAll { $0.id == id }
    }

    /// Removes every dismissable row — failures, losses, and signposts alike. The strip's
    /// own "clear all" affordance later; today it is what a window uses when it re-homes its rows
    /// elsewhere (m6).
    public func dismissAllDismissableRows() {
        oneShots.removeAll()
        losses.removeAll()
        signposts.removeAll()
    }

    // MARK: History suspension

    /// Raises (or refreshes) the "changes aren't being recorded to history" condition — for whatever
    /// substrate can stall while the files themselves are landing fine. No producer today.
    ///
    /// A second call while already suspended keeps the original `since` and takes the newer
    /// `reason`: the condition never stopped being true, so restarting its clock would misreport
    /// how long history has been stalled, while the newest diagnosis is the useful one.
    public func suspendHistory(reason: String) {
        historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date())
    }

    /// Clears it, the moment history catches up. Idempotent — clearing a condition that is not
    /// standing is not an error, it is the ordinary shape of "it worked".
    public func clearHistorySuspension() {
        historySuspension = nil
    }

    // MARK: In-progress operations

    /// Starts an info row with a spinner and hands back its id.
    ///
    /// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the
    ///   partial copy, nothing lost" (02, settled). Everything else passes `nil`.
    @discardableResult
    public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID {
        let operation = InProgressOperation(label: label, cancel: cancel)
        operations.insert(operation, at: 0)
        return operation.id
    }

    /// **Relabels a running operation** — the same row, still spinning, now saying something else.
    ///
    /// It exists for the waiting state a long operation can fall into — contention outlasting a brief
    /// retry surfaces in the operation's *own* in-progress row ("waiting for another writer"),
    /// retrying on its cadence.
    /// The waiting state is explicitly *the operation's own row*, not a second row and not a
    /// replacement — the operation has not restarted, it is explaining itself — so the id is stable
    /// and the view neither churns nor re-animates.
    ///
    /// An unknown id is a no-op: a completion racing a relabel is ordinary, not a bug.
    public func updateOperation(_ id: UUID, label: String) {
        guard let index = operations.firstIndex(where: { $0.id == id }) else { return }
        let existing = operations[index]
        operations[index] = InProgressOperation(id: existing.id, label: label, cancel: existing.cancel)
    }

    /// Ends one — the row leaves the strip.
    ///
    /// **Completion and failure both come through here.** There is no `failOperation`: a failure is
    /// `endOperation(id)` followed by `post(error)`, which is exactly 02's "failure swaps it for
    /// the error row" expressed as two calls that already exist. Ending an unknown id is a no-op;
    /// a close racing a completion is ordinary, not a bug.
    public func endOperation(_ id: UUID) {
        operations.removeAll { $0.id == id }
    }

    // MARK: - Ordering

    /// The strip's rows, precedence-ordered — **the whole of the ordering rule, in one pure
    /// function** (02 § The banner surface, "Concurrent conditions stack").
    ///
    /// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage >
    /// one-shot write failures > **loss rows** > commit and attachment failures > **passive info
    /// rows**. Four readings of it are worth stating because the code depends on them:
    ///
    /// - **The two info classes sit at opposite ends of the strip.** An in-progress row is pinned
    ///   above everything: it is the only explanation the strip offers for a bracket's write lock
    ///   and for a close or quit deferring teardown, and a copy row's Cancel has to stay reachable
    ///   — "a spinner may never hide behind '+N more'". A passive signpost ranks last and may
    ///   collapse: calm by design, nothing gated on seeing it instantly. Same tone, opposite
    ///   urgency.
    /// - **Loss rows slot between the true failures and the ambient notices** (settled 2026-07-28):
    ///   "an action that didn't happen outranks one that partially did" is why a loss ranks below
    ///   every one-shot write failure, while "content that didn't arrive though nothing failed" is
    ///   still more consequential than a condition or a signpost that only reports ambient state —
    ///   so a loss also ranks above `historySuspended` and the attachment one-shots that follow it.
    ///   Concretely: non-attachment one-shots, then loss rows, then the commit-and-attachment class,
    ///   then signposts.
    /// - **An attachment failure is a one-shot**, not a separate kind — its `WriteOperation` is
    ///   `.importAttachment`. It ranks in the commit-failure class rather than with its fellow
    ///   one-shots, so a fresh failed import sits below an older failed move. That is the design's
    ///   ordering read literally, and it is defensible: a failed import is the least destructive of
    ///   the failures (the drop was accepted, the partial copy was removed), so it yields its place
    ///   to failures that stopped the user's actual work.
    /// - **Within the commit-and-attachment class the standing suspension leads**, then the
    ///   attachment one-shots newest first. A deliberate reading of "newest first within a class":
    ///   a condition and a one-shot are not comparable by recency in any way a user would read as
    ///   order — the condition's `since` is when it *started* being true, not when it happened —
    ///   so they are ordered by kind, and recency orders only the one-shots among themselves.
    /// - **The failure rank is one class, ordered by recency** (settled 2026-07-31): "failures rank
    ///   by what they are, not by which error vocabulary threw them". The rank held two row shapes
    ///   while the git stack was wired, interleaved by `occurredAt`; the merge went with the second
    ///   shape and the rank is ready to take another the day one arrives.
    ///
    /// `signposts` carries a default because its producer is m6's card window. Every other class is
    /// spelled out at every call site — `losses` included, since a Finder drop that skipped folders
    /// already posts one (`postSkippedFolders`).
    public nonisolated static func rows(
        lock: ReadOnlyLockReason?,
        breakage: BoardLoadFailure?,
        oneShots: [OneShotBanner],
        losses: [LossBanner],
        suspension: HistorySuspension?,
        operations: [InProgressOperation],
        signposts: [InfoSignpost] = []
    ) -> [BannerRow] {
        var rows: [BannerRow] = []

        rows.append(contentsOf: operations.map(BannerRow.inProgress))

        if let lock {
            rows.append(.readOnlyLock(lock))
        }
        if let breakage {
            rows.append(.reloadBreakage(breakage))
        }

        let ordered = newestFirst(oneShots, by: \.occurredAt)
        rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))

        rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss))

        if let suspension {
            rows.append(.historySuspended(suspension))
        }
        rows.append(contentsOf: ordered.lazy.filter(\.isAttachmentImport).map(BannerRow.oneShot))

        rows.append(contentsOf: signposts.map(BannerRow.signpost))
        return rows
    }

    /// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop
    /// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every
    /// `post…` maintains newest-first on insertion — so a tie renders in the order it was posted
    /// rather than in whatever order the sort happened to leave.
    ///
    /// One function over a date key rather than one per class: the dismissable classes order
    /// by exactly the same rule, and a copy per class was a place for it to drift.
    private nonisolated static func newestFirst<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [Row] {
        rows
            .enumerated()
            .sorted { lhs, rhs in
                lhs.element[keyPath: occurredAt] == rhs.element[keyPath: occurredAt]
                    ? lhs.offset < rhs.offset
                    : lhs.element[keyPath: occurredAt] > rhs.element[keyPath: occurredAt]
            }
            .map(\.element)
    }

    // MARK: - Phrasing

    /// The user-facing line for a failed write: what the app could not do, then why.
    ///
    /// "Couldn't move 'Fix login' — disk full" is the design's own example and the shape every
    /// case takes: an action clause the banner owns, an em dash, and the diagnostic cause from the
    /// error's `reason` — the one place free-form English is allowed to survive.
    public nonisolated static func headline(for error: BoardWriteError) -> String {
        let action = actionPhrase(for: error.operation)
        let cause = causePhrase(for: error.reason)
        return cause.isEmpty ? action : "\(action) — \(cause)"
    }

    /// **Exhaustive by construction — no `default`.** A `WriteOperation` case added without a
    /// sentence here fails to compile, which is the settled contract ("a new Writer operation
    /// without a banner rendering is a compile-time hole, not a silent default").
    ///
    /// Titles are quoted where the operation carries one and the phrasing stays graceful where it
    /// does not: the enum knows an item's title, never its *kind*, so an untitled failure says
    /// "the item" rather than guessing "card" and being wrong about a lane.
    ///
    /// The trash verbs match the commands the user pressed — **Delete**, Empty Trash — which is
    /// 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: "Finder's 'Move to
    /// Trash' phrasing is reserved for the system Trash; board deletion says 'Delete'". A banner
    /// saying a card could not be *moved to the trash* would name the wrong one of the app's two
    /// trashes (the card window's attachment Remove is the other).
    private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
        switch operation {
        case .createBoard:
            "Couldn't create the board"
        case .createLane:
            "Couldn't create a lane"
        case .createCard:
            "Couldn't create a card"
        case let .move(title):
            if let title { "Couldn't move '\(title)'" } else { "Couldn't move the item" }
        case let .reorder(title):
            if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" }
        case let .copy(title):
            if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
        case let .paste(title):
            // **The command's own verb** (04-interactions.md ▸ Clipboard's own example sentence,
            // "Couldn't paste 'Fix login' — the copied content is gone"). The user pressed ⌘V, and
            // "Couldn't copy" — the operation the Writer would have run — would name a gesture they
            // never made. The item is named from the manifest's metadata, which is what the embedded
            // `index.md` is kept for now that it is never a materialization source.
            if let title { "Couldn't paste '\(title)'" } else { "Couldn't paste the item" }
        case let .delete(title):
            if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" }
        case let .purge(title):
            if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
        case let .migrateTombstone(title):
            // One sentence covering both shapes of the migration, because the user's mental model
            // of either is the same non-event: a board written by an older version being brought
            // up to date. It deliberately names neither "delete" nor "trash" — a card's migration
            // moves it into the trash and a lane's brings it *back*, so any verb specific enough
            // to describe one would be actively wrong about the other.
            if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
        case let .style(title):
            if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
        case let .relabel(title):
            // **"relabel", in both directions** — `.collapse`/`.expand`'s two-word split turned down
            // for the reason `WriteOperation.relabel` gives: adding and removing a label are the same
            // control pressed twice, so one verb is the honest one. The word is 06-history-undo.md's
            // own for a change to this key ("Relabel card 'X'"), which the commit composer has spoken
            // since before there was a control to press. It says "the card" rather than "the item"
            // where every other row here says the latter, because labels are a card field and nothing
            // else can reach this sentence.
            if let title { "Couldn't relabel '\(title)'" } else { "Couldn't relabel the card" }
        case .setBoardBackground:
            // **"generate", because that is the button they pressed**, and no title because there is
            // one board and they are looking at it. It deliberately says nothing about the *file* —
            // the picture and the colour under it land in one bracket, and a user who has never seen
            // the PNG has no model of a half-written one; what failed, as far as they are concerned,
            // is that the board still looks the way it did.
            "Couldn't generate this board's background"
        case let .resize(title):
            if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
        case let .collapse(title):
            // **The menu row's own word, in both directions** (03-board-ui.md § Lane ▸ Collapsed
            // lanes): the user pressed Collapse Lane or Expand Lane, and a shared sentence would tell
            // half of them about a gesture they did not make. It says "lane" rather than "the item"
            // when untitled, which is the one place in this switch that can: only a lane has the key.
            if let title { "Couldn't collapse '\(title)'" } else { "Couldn't collapse the lane" }
        case let .expand(title):
            if let title { "Couldn't expand '\(title)'" } else { "Couldn't expand the lane" }
        case let .rename(title):
            // The title here is the item's name *before* the edit — the one the user is still
            // looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example
            // sentence) identify the right row rather than a name that never landed.
            if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" }
        case let .duplicateBoard(title):
            if let title { "Couldn't duplicate '\(title)'" } else { "Couldn't duplicate the board" }
        case let .saveAsTemplate(title):
            // The command's own words (File ▸ Save as Template), because that is the button they
            // pressed and the board is still exactly as it was: nothing was saved *over*, and the
            // failure is about the copy in the templates folder, not about this board's own files.
            if let title { "Couldn't save '\(title)' as a template" } else { "Couldn't save the board as a template" }
        case let .shareBoard(title):
            // The command's own word (File ▸ Share…), on `.saveAsTemplate`'s reasoning: the board
            // itself is untouched by a share that fails partway — nothing here was saved *over* —
            // so the sentence is about the staged copy that never reached the picker, not about
            // this board's own files.
            if let title { "Couldn't share '\(title)'" } else { "Couldn't share the board" }
        case let .exportBoard(title):
            // The command's own word (File ▸ Export ▸ …), on `.shareBoard`'s reasoning exactly: the
            // board is untouched by an export that failed to write, so the sentence is about the
            // document that never landed. It names no format — the user chose one row out of three a
            // moment ago and does not need telling which.
            if let title { "Couldn't export '\(title)'" } else { "Couldn't export the board" }
        case let .importBoard(fileName):
            // **The file, not a board**: there is no board yet, and naming one would name something
            // that does not exist. This sentence covers the read and the parse only — once the tree
            // starts being written, the ordinary create operations speak for themselves.
            "Couldn't import '\(fileName)'"
        case let .importAttachment(filename):
            "Couldn't import '\(filename)'"
        case .listAttachments:
            "Couldn't read this card's attachments"
        case let .removeAttachment(filename):
            // **Finder's phrasing, deliberately** — and the one place in this app that is allowed
            // it. The naming constraint above reserves "move to the Trash" for the *system* Trash,
            // and this is the operation that uses it: the file really did go (or fail to go) where
            // Finder's own ⌘⌫ sends things, so saying anything else — "remove", "delete" — would
            // describe the board's own trash instead and promise the wrong recovery.
            "Couldn't move '\(filename)' to the Trash"
        case .renumberChildren:
            "Couldn't renumber cards"
        case let .relocateLooseFile(filename):
            // The verb matches the successful notice's ("Moved 'notes.txt' into attachments"), so
            // the failure reads as the same sentence negated rather than as a different event.
            // It stays in the ordinary one-shot precedence class rather than joining the attachment
            // imports at the bottom: the relocation is work the *app* started on its own, and a
            // failure the user did not provoke is exactly the one they have no other way to learn
            // about.
            "Couldn't move '\(filename)' into attachments"
        case let .tidyBackgroundImage(filename):
            // **The successful sweep's own verb, negated** — `postTidiedBackgroundImages`' "Removed
            // '\(filename)' …" said the other way round, `.relocateLooseFile`'s own precedent: the
            // failure reads as the same sentence's negation rather than a different event. It stays
            // in the ordinary one-shot precedence class for `.relocateLooseFile`'s reason too — this
            // is work the app started on its own, and a failure the user did not provoke is exactly
            // the one they have no other way to learn about. In practice only the sweep's own failure
            // ever reaches here; the in-flow trim swallows its own (the ruling's "temp-file posture").
            "Couldn't remove '\(filename)'"
        case .agentGuide:
            // **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and
            // most users will never have opened it — a filename here would name something they have
            // no relationship with. It says nothing about the board's own files because none were
            // touched, and nothing is lost: the board works exactly as well without the guide, which
            // is why every *refusal* to write it is a log line and only a real I/O failure gets here.
            "Couldn't update the agent guide"
        case .seedGitignore:
            // **The agent guide's sentence, one file over**, and for its reasons: `.gitignore` is a
            // courtesy the app writes once, most users will never open it, and nothing about the
            // board's own files is at stake — a board without one simply excludes nothing, which is
            // how every board behaved until 2026-07-31. It names the file rather than a role because
            // this one *does* have a name users know from git, and "the ignore list" would be the
            // app inventing a word for something already called something.
            "Couldn't write this board's .gitignore"
        case .mintBoardIndex:
            // **Not "couldn't create the board"** — the board is on screen behind the surface, with
            // its lanes and its cards; what could not be written is the one file that says the
            // folder is a board. It names `index.md` rather than a role because the decision surface
            // the user is looking at has just named that file itself, twice: in the class's own
            // sentence and on the row's Reveal.
            "Couldn't create this board's index.md"
        case .stampSchema:
            // The repair in the user's own words — the surface's choice reads "Stamp schema: 1", so
            // its failure says the same thing negated. It names no file for `.mintBoardIndex`'s
            // reason inverted: the file is right there and the surface just showed its path.
            "Couldn't stamp this board's schema"
        case let .displaceClaimedName(name):
            // **The name, quoted, and what the app wanted with it** — the failure's mirror of the
            // success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names
            // the *consequence* the user can act on rather than the mechanics: while the name is
            // held, the feature that needs it does not work, and the fix is theirs (move or rename
            // the thing sitting there) because the app has just demonstrated it cannot.
            "Couldn't move '\(name)' aside — Lanework needs that name"
        case let .repairDuplicateID(title):
            // **The failure's mirror of the success row** ("Repaired duplicate id — 'Fix login'"), in
            // the same words, so the two read as one sentence and its negation. It names the *defect*
            // rather than the mechanics ("couldn't rename a folder" would describe an act the user has
            // no model of) and stays graceful when the item is untitled, because the enum knows a
            // title and never a kind.
            if let title { "Couldn't repair the duplicate id of '\(title)'" } else { "Couldn't repair a duplicate id" }
        case let .toggleTask(title):
            // The user's word for it, not the file's: they ticked a box. The card is named where
            // the read that preceded the flip learned its title, so a body write that refused says
            // *which* card refused it — a card window is not always the frontmost thing on screen.
            if let title { "Couldn't tick the checkbox in '\(title)'" } else { "Couldn't tick the checkbox" }
        case let .editBody(title):
            // **Save**, because that is the word for what just failed: the Edit→Preview flip is the
            // effective Save button (05-card-window.md ▸ Edit), and the debounced tick is the same
            // act happening on its own. The keystrokes are still in the buffer — the banner says the
            // app could not put them on disk, not that they are gone.
            if let title { "Couldn't save '\(title)'" } else { "Couldn't save the card" }
        case let .rawSource(title):
            // **Apply**, because that is the button they pressed (05-card-window.md ▸ Raw source
            // outlet), and "source changes" because what failed to land is the whole file as they
            // typed it — not a save of the card's body, which is what "Couldn't save" would claim.
            // The buffer is still on screen: the banner says the app could not put those bytes on
            // disk, not that they are gone.
            if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" }

        // **The comment family names the card, because that is the title these operations carry** —
        // a comment has none of its own (01-storage-format.md § Enhanced schema: "No `title`, no
        // `order`"), and the card is the thing on the window in front of them. Each sentence is the
        // gesture's own word, the vocabulary's standing rule: they pressed ⌘↩, or Delete, or nothing
        // at all in the draft's case.
        case let .saveCommentDraft(title):
            // **"draft", never "save the comment"**: nothing has been posted, and a banner claiming a
            // comment could not be saved would name one that does not exist yet. The typed text is
            // still in the composer — this says the app could not put it on disk.
            if let title { "Couldn't save your draft comment on '\(title)'" } else { "Couldn't save your draft comment" }
        case let .postComment(title):
            // The Comment button's own word (05-card-window.md ▸ The comments column, "⌘↩ posts").
            // The draft is untouched on disk, which is what makes this a retry rather than a loss.
            if let title { "Couldn't post your comment on '\(title)'" } else { "Couldn't post your comment" }
        case let .editComment(title):
            if let title { "Couldn't save your edit to a comment on '\(title)'" } else { "Couldn't save your edit to the comment" }
        case let .deleteComment(title):
            if let title { "Couldn't delete a comment on '\(title)'" } else { "Couldn't delete the comment" }
        case .purgeCommentTrash:
            // No title, and no mention of a trash the user has never seen: `comments/.trash/` is
            // "never a UI surface", so the sentence is about the *card's* files being tidied — the
            // agent guide's posture, one level down. Nothing is lost either way; the next window
            // open sweeps again.
            "Couldn't tidy up deleted comments"
        }
    }

    /// The cause tail. `reason`'s own English is diagnostic and already reads as a phrase ("disk
    /// full", "no such file or directory"); the uneditable-frontmatter case is the one that needs
    /// translating, because its `description` is a fragment written for a developer.
    private nonisolated static func causePhrase(for reason: BoardWriteError.Reason) -> String {
        let text = switch reason {
        case let .unreadable(message):
            message
        case let .uneditableFrontmatter(shape):
            "this file's frontmatter can't be edited in place (\(shape.description))"
        case let .io(message):
            message
        case let .staleTarget(message):
            message
        case let .invalidSource(error):
            // The loader's own reason, without its path: the path is always the card's own
            // `index.md`, and the banner has already named the card. In practice the raw-source
            // outlet raises this in its alert and never here — the store validates before it opens a
            // write bracket — so this line exists for a caller that reached the Writer directly.
            error.reason.description
        case .clipboardContentGone:
            // **04's own words** ("the copied content is gone"), and the whole of what can honestly be
            // said: the snapshot the pasteboard promised is not on disk, so there is no cause to
            // diagnose past that. It deliberately says nothing about *why* — a sweep that ran early, a
            // container the system reclaimed, an unmounted volume — because the user's recovery is the
            // same in every case, and 04 names it: ⌘C again.
            "the copied content is gone"
        }
        return trimmed(text)
    }

    /// The read-only lock's line. Each cause says the same two things — what is wrong, and that
    /// what is on screen is still the last good view — because the lock's whole promise is that
    /// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
    /// lock's scope").
    ///
    /// **The unwritable location gets two lines, not one shared one** (02 § Write-failure
    /// surfacing, settled): "which specific cause, not a shared line … the fixes being different
    /// acts". Ejecting a DMG or copying the board off it is not the same repair as a `chmod` or a
    /// Get Info panel, and a line that covered both would name neither.
    public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String {
        switch lock {
        case .bracketedReloadFailed:
            "This board couldn't be re-read after the last operation — showing the last good view, read-only"
        case .vanishedRoot:
            "This board's folder is gone — showing the last good view, read-only"
        case .unwritableLocation(.readOnlyVolume):
            "This board's volume is read-only — showing the last good view, read-only"
        case .unwritableLocation(.permissionDenied):
            "You don't have permission to change this folder — showing the last good view, read-only"
        }
    }

    /// Reload breakage: fail-fast's specifics (the offending path and what is wrong with it), plus
    /// the reassurance that the board is still the board.
    ///
    /// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
    /// `index.md` — is spelled as "This board" rather than shown as a lone dot.
    ///
    /// **One defect is named, the rest are counted** (01-storage-format.md § Malformed input: the
    /// loader collects every fail-fast defect in a walk). A banner is one line and a list of paths
    /// is the first thing that would truncate, so the sentence stays the sentence it always was —
    /// the walk's first defect, said in full — with ", and N more" between the reason and the
    /// reassurance. The full list is not lost: it is the decision surface's to show on the next
    /// attended open, which is where a repair is actually made.
    ///
    /// A single-defect failure reads **exactly** as it did before the aggregate existed.
    public nonisolated static func headline(for breakage: BoardLoadFailure) -> String {
        breakageHeadline(breakage.primary, others: breakage.defects.count - 1)
    }

    /// One defect's own sentence — the same rule, for the surfaces that hold exactly one and know
    /// it: the template chooser's unloadable row, whose folder is picked from rather than opened.
    public nonisolated static func headline(for defect: BoardLoadError) -> String {
        breakageHeadline(defect, others: 0)
    }

    private nonisolated static func breakageHeadline(_ defect: BoardLoadError, others: Int) -> String {
        let subject = defect.path == "." || defect.path.isEmpty
            ? "This board isn't loading"
            : "'\(defect.path)' isn't loading"
        let more = others > 0 ? ", and \(others) more" : ""
        return "\(subject): \(trimmed(defect.reason.description))\(more) — showing the last good view"
    }

    /// The skipped-folders line — 04-interactions.md's own example, "Folders can't be attached — 2
    /// skipped", generalized over the count. `postSkippedFolders` never calls this at `count == 0`,
    /// so every real call already has something to report.
    ///
    /// **Singular stays "Folders can't be attached — 1 skipped"** rather than recasting the leading
    /// clause to "A folder can't be attached": the claim is always about the drag as a whole — *its*
    /// folders didn't make it in — and only the trailing count varies, which is one sentence shape
    /// for every count instead of two that would have to be kept in agreement with each other.
    public nonisolated static func skippedFoldersMessage(count: Int) -> String {
        "Folders can't be attached — \(count) skipped"
    }

    /// The skip notice's line, in the relocation family's voice — the act first, the subject after an
    /// em dash, plurals folded, a sole item named.
    ///
    /// - **One**: "Opened without 'todo/index.md' — you chose to skip it".
    /// - **Several**: "Opened without 3 items — you chose to skip them".
    ///
    /// **The plural fold is safe here in a way it is not elsewhere**, and that is the whole reason
    /// the count is allowed to stand in for the names: the row carries a Reveal target per item
    /// (`LossBanner.reveals`), so "which ones" is one click away rather than lost — which is what
    /// 01's "each with Reveal in Finder" buys. The sole case still names its path, because it fits
    /// and because a one-item row that said "1 item" would be the app declining to say what it knows.
    ///
    /// **The tail names the cause**, the migration notice's rule: without it the sentence would read
    /// as something that happened *to* the user, when it is the choice they just made on the surface.
    ///
    /// `nil` when nothing was skipped — the ordinary open, and not news.
    public nonisolated static func skippedOnOpenMessage(for items: [RevealTarget]) -> String? {
        guard let only = items.first else { return nil }
        guard items.count == 1 else {
            return "Opened without \(items.count) items — you chose to skip them"
        }
        return "Opened without '\(only.path)' — you chose to skip it"
    }

    /// The loose-file relocation's line — 01-storage-format.md's own example sentence, "Moved
    /// 'notes.txt' into attachments — 'Fix login'", generalized over the two axes it varies on.
    ///
    /// **Plurals fold twice**, which is the design's word for it ("plurals fold"; "multiple files
    /// one card → 'Moved 3 files into attachments — Fix login'"):
    ///
    /// - **One card, one file** names the file *and* the card, which is the sentence the design
    ///   wrote: both facts fit, so both are said.
    /// - **One card, several files** drops the filenames for their count. A banner is one line, and
    ///   a list of names would be the first thing to truncate; the card is still named, which is
    ///   what makes the notice actionable — the user knows exactly which `attachments/` to look in.
    /// - **Several cards** folds again, to two counts: "Moved 5 files into attachments — 3 cards"
    ///   (settled here, the judgment 01 leaves to the implementation): the true total plus the true
    ///   item count is the honest summary where a truncated list of titles would not
    ///   be. This case is the whole-board sweep (a board opened after an agent scattered files
    ///   across it), where naming three cards of eleven would read as a bug.
    ///
    /// The multi-card branch never has to spell a singular: two cards carry at least two files.
    ///
    /// `nil` when nothing moved — a relocation that relocated nothing is not news. Entries with no
    /// filenames are dropped first, so a caller need not filter its own list.
    public nonisolated static func relocatedLooseFilesMessage(for relocations: [Relocation]) -> String? {
        let cards = relocations.filter { !$0.fileNames.isEmpty }
        guard let only = cards.first else { return nil }

        let total = cards.reduce(0) { $0 + $1.fileNames.count }
        guard cards.count == 1 else {
            return "Moved \(total) files into attachments — \(cards.count) cards"
        }

        let subject = only.title.map { "'\($0)'" } ?? "an untitled card"
        guard total == 1, let name = only.fileNames.first else {
            return "Moved \(total) files into attachments — \(subject)"
        }
        return "Moved '\(name)' into attachments — \(subject)"
    }

    /// The legacy tombstone migration's line, written in `relocatedLooseFilesMessage`'s voice
    /// because it is the same kind of notice: the act first, the subject after an em dash, plurals
    /// folded, a sole item named.
    ///
    /// **One clause, since the lane half retired** (01-storage-format.md § Deletion, re-ruled
    /// 2026-07-29 — lanes ignore the key rather than migrating). The shapes:
    ///
    /// - **One card**: "Moved 'Fix login' to the trash — it carried an old deleted marker".
    /// - **Several**: "Moved 3 cards to the trash — they carried old deleted markers".
    ///
    /// **The tail names the cause**, and it is the whole explanation the row owes: the user did not
    /// delete anything just now, and without the clause the sentence would read as an action they
    /// had somehow just taken.
    ///
    /// `nil` when nothing migrated — a migration that migrated nothing is not news.
    public nonisolated static func migratedTombstonesMessage(cards: [String?]) -> String? {
        guard !cards.isEmpty else { return nil }
        let subject = cards.count == 1 ? sole(cards[0]) : "\(cards.count) cards"
        let tail = cards.count == 1
            ? "it carried an old deleted marker"
            : "they carried old deleted markers"
        return "Moved \(subject) to the trash — \(tail)"
    }

    /// The claimed-name displacement's line — the relocation's own voice (the act first, the subject
    /// after an em dash), naming **old and new** as the ruling requires.
    ///
    /// - **One name**: "Renamed '.trash' to '.trash 2' — Lanework needs that name". The tail is the
    ///   whole explanation the row owes: the user did not rename anything, and without it the
    ///   sentence would read as an act they had somehow just taken. It says *needs the name* rather
    ///   than anything about what was there, because what was there is the user's business and
    ///   still exists, under the name the row just gave them.
    /// - **Several**: folded to a count in the relocation's idiom — "Renamed 2 items — Lanework
    ///   needs those names". Two claimed names can be squatted at once (a board somebody unpacked
    ///   over an old one), and a two-clause sentence would be longer than the row.
    ///
    /// `nil` when nothing moved — a displacement that displaced nothing is not news.
    public nonisolated static func displacedClaimedNamesMessage(for displacements: [Displacement]) -> String? {
        guard let only = displacements.first else { return nil }
        guard displacements.count == 1 else {
            return "Renamed \(displacements.count) items — Lanework needs those names"
        }
        return "Renamed '\(only.name)' to '\(only.movedTo)' — Lanework needs that name"
    }

    /// The duplicate-id remint's line — **the design's own sentence**, verbatim
    /// (01-storage-format.md § Fractal layout ▸ Rules, re-ruled 2026-07-29: "posts 'Repaired duplicate
    /// id — 'Fix login''").
    ///
    /// - **One**: "Repaired duplicate id — 'Fix login'". The relocation's idiom exactly — the act
    ///   first, the subject after an em dash — and it names the *id* rather than the folder, because
    ///   the folder name is a UUID no user has a relationship with. There is no explanatory tail: the
    ///   act is its own explanation, and nothing about the user's content changed.
    /// - **Several**: folded to a count, the idiom's plural — "Repaired 3 duplicate ids". Several
    ///   arrive together routinely (a hand-copied lane's cards all collide at once), and a sentence
    ///   naming each would be longer than the row.
    ///
    /// `nil` when nothing was reminted — a heal that healed nothing is not news, which is also the
    /// vanished-duplicate race's whole outward appearance: silence.
    public nonisolated static func remintedDuplicateIDsMessage(for titles: [String?]) -> String? {
        guard let only = titles.first else { return nil }
        guard titles.count == 1 else {
            return "Repaired \(titles.count) duplicate ids"
        }
        return "Repaired duplicate id — \(sole(only))"
    }

    /// The orphaned background-image sweep's line, `migratedTombstonesMessage`'s voice: the act
    /// first, the subject after an em dash, plurals folded, a tail naming the cause — without it the
    /// sentence would read as something that happened *to* the board rather than the app's own
    /// housekeeping.
    ///
    /// - **One**: "Removed 'facets 2.png' — it was no longer the board's background".
    /// - **Several**: "Removed 3 background images — they were no longer the board's background".
    ///
    /// `nil` when nothing was removed — a sweep that swept nothing is not news.
    public nonisolated static func tidiedBackgroundImagesMessage(for names: [String]) -> String? {
        guard !names.isEmpty else { return nil }
        let subject = names.count == 1 ? "'\(names[0])'" : "\(names.count) background images"
        let tail = names.count == 1
            ? "it was no longer the board's background"
            : "they were no longer the board's background"
        return "Removed \(subject) — \(tail)"
    }

    /// The lossy export's line, in the relocation family's voice — the act first, the cause after an
    /// em dash, plurals folded into their counts.
    ///
    /// - **Both**: "Exported without 12 comments and 3 attachments — CSV carries neither".
    /// - **One kind**: "Exported without 12 comments — CSV can't carry them".
    ///
    /// **The tail names the format**, which is the whole explanation the row owes: nothing is wrong with
    /// the board and nothing failed — the destination simply has no place to put these — and a sentence
    /// without it would read as something the app decided to leave out. The format is named rather than
    /// described because the user picked it by name one dialog ago.
    ///
    /// The counts carry their own plurality ("1 comment", "12 comments") while the tail stays invariant:
    /// "them" reads correctly after either count, and one sentence shape is one thing to keep true.
    ///
    /// `nil` when nothing was left behind — a lossless export is not news.
    public nonisolated static func exportOmissionsMessage(
        _ omissions: InterchangeOmissions,
        format: InterchangeFormat
    ) -> String? {
        guard !omissions.isEmpty else { return nil }
        let comments = omissions.comments == 1 ? "1 comment" : "\(omissions.comments) comments"
        let attachments = omissions.attachments == 1 ? "1 attachment" : "\(omissions.attachments) attachments"

        if omissions.comments > 0, omissions.attachments > 0 {
            return "Exported without \(comments) and \(attachments) — \(format.displayName) carries neither"
        }
        let subject = omissions.comments > 0 ? comments : attachments
        return "Exported without \(subject) — \(format.displayName) can't carry them"
    }

    /// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
    /// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
    /// clause it sits in already says which level it is).
    private nonisolated static func sole(_ title: String?) -> String {
        guard let title else { return "an untitled item" }
        return "'\(title)'"
    }

    /// The skipped-step line — 13-native-undo.md ▸ Rules' own example sentence, "Undo skipped — 'Fix
    /// login' changed outside Lanework", with ⇧⌘Z's mirror ("Redo skipped — …").
    ///
    /// **The verb is the command the user pressed**, not the half of the step that declined: a step
    /// already undone sits on the redo stack reversed, so the closure ⇧⌘Z crosses is the one
    /// registered as `redo`, and a sentence naming the half would tell the user they pressed the
    /// other key (`HistoryDirection`).
    ///
    /// **`subject` is quoted whatever it names** — the item's title for a step with one target
    /// ("'Fix login'"), the step's own 06 phrase for a batch or an untitled item ("'Move 3 Cards'").
    /// One sentence shape for both readings, chosen where the step is registered
    /// (`BoardStore.registerStep`) because that is the only place that knows how many items it named.
    ///
    /// **"changed outside Lanework"** is the design's own wording and stays literal: it is the whole
    /// explanation the row owes — the app did not decline out of caution, somebody else wrote to that
    /// item, and the reason the step is gone is that applying it would have thrown their edit away.
    public nonisolated static func skippedStepMessage(_ direction: HistoryDirection, subject: String) -> String {
        let verb = switch direction {
        case .undo: "Undo"
        case .redo: "Redo"
        }
        return "\(verb) skipped — '\(subject)' changed outside Lanework"
    }

    /// The suspended-history line. It names the *consequence* the user cares about — undo and the
    /// flush-before-overwrite guarantee are degraded — rather than the mechanics, and carries
    /// the diagnosis as its tail.
    public nonisolated static func headline(for suspension: HistorySuspension) -> String {
        let reason = trimmed(suspension.reason)
        let line = "Changes aren't being recorded to history"
        return reason.isEmpty ? line : "\(line) — \(reason)"
    }

    /// Trims whitespace and a trailing period: these fragments are tails inside a longer line, and
    /// a stray full stop mid-sentence is the tell of machine-assembled text.
    private nonisolated static func trimmed(_ text: String) -> String {
        var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
        while value.hasSuffix(".") {
            value.removeLast()
        }
        return value
    }
}

// MARK: - Helpers

private extension OneShotBanner {
    /// Whether this failure is an attachment import — the one `WriteOperation` whose one-shot
    /// ranks in the last precedence class rather than with the other one-shots.
    var isAttachmentImport: Bool {
        if case .importAttachment = error.operation { true } else { false }
    }
}
