Files
lanework/Kanban/LiveStore/BannerCenter.swift
T
rzen b0c134a896 A board leaves as one file and comes back as one — headings are lanes, rows are cards, position is the order
File ▸ Export ▸ writes the frontmost board as Obsidian Kanban Markdown, a
plain Markdown outline, or RFC 4180 CSV; File ▸ Import Board… reads any of
the three back into a fresh board, format detected rather than asked. Every
format encodes order as document position, so an export writes no ranks and
an import mints them in parse order on the ordinary create path. Lossy
exports post a warning-tone loss row naming the comments and attachments the
destination cannot carry. Convert-once: nothing watches, nothing merges.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 08:38:06 -04:00

1378 lines
81 KiB
Swift

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 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 .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 .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 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 }
}
}