Add the loss-row banner class and retone degraded paste

02 ratified a warning-tone class for non-failure losses — content that
didn't arrive though nothing failed: a degraded paste, folders skipped
from a Finder drop, their future kin. Loss rows take the one-shot's
dismissable-untimed lifecycle (a loss the user didn't notice is the
harm) and rank below the true failures, above commit and attachment
notices. BannerCenter grows LossBanner, postLoss, and the
skipped-folders phrasing; the degraded-paste notice moves off its
signpost onto the new class, ending its too-quiet ranking.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 07:17:27 -04:00
parent 15006ad233
commit 756e936291
6 changed files with 312 additions and 46 deletions
+134 -30
View File
@@ -36,6 +36,34 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
}
}
/// 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) a
/// degraded paste, folders skipped from a Finder drop, 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
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
self.id = id
self.message = message
self.occurredAt = occurredAt
}
}
/// The standing condition "changes aren't being recorded to history" (02-architecture.md §
/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention").
///
@@ -116,7 +144,7 @@ public struct InProgressOperation: Identifiable, Sendable {
/// One row in a window's banner strip.
///
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// The seven 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:
///
@@ -124,8 +152,11 @@ public struct InProgressOperation: Identifiable, Sendable {
/// 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` and `signpost`. Each reports something that already happened,
/// so only the user can clear it.
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something that already
/// happened, so only the user can clear it. `loss` shares this 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(_:)`).
///
@@ -141,6 +172,10 @@ public enum BannerRow: Identifiable, Sendable {
case reloadBreakage(BoardLoadError)
/// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner)
/// Content that didn't arrive though nothing failed a degraded paste, folders skipped from a
/// Finder drop, their future kin. 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. (m7's committer drives it.)
case historySuspended(HistorySuspension)
@@ -156,6 +191,7 @@ public enum BannerRow: Identifiable, Sendable {
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)"
@@ -165,7 +201,7 @@ public enum BannerRow: Identifiable, Sendable {
public var tone: BannerTone {
switch self {
case .readOnlyLock, .reloadBreakage, .oneShot: .error
case .historySuspended: .warning
case .historySuspended, .loss: .warning
case .inProgress, .signpost: .info
}
}
@@ -184,18 +220,20 @@ public enum BannerRow: Identifiable, Sendable {
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 and signposts. Conditions stand until they heal; in-progress rows complete, fail, or
/// are cancelled neither is something a user can wave away.
/// 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
}
@@ -209,12 +247,12 @@ public enum BannerRow: Identifiable, Sendable {
///
/// ### What lives here and what does not
///
/// A center holds the state nothing else does: dismissable one-shot write failures, 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:suspension:operations:signposts:)`, which is a *pure
/// function* precisely so the precedence rule can be tested without a store, a window, or a
/// 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
@@ -243,6 +281,11 @@ public final class BannerCenter {
/// 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 commits are landing.
public private(set) var historySuspension: HistorySuspension?
@@ -265,6 +308,12 @@ public final class BannerCenter {
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.
public func postLoss(_ message: String) {
losses.insert(LossBanner(message: message), 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) {
@@ -292,34 +341,52 @@ public final class BannerCenter {
/// intact, attachments absent and this is the row that says so. "A degraded paste is loud,
/// never silent the user never discovers an empty `attachments/` later."
///
/// **A signpost, not a `oneShot`**, and the choice is the vocabulary's rather than a compromise:
/// 02-architecture.md's `oneShot` is *a write that did not happen*, carrying a `BoardWriteError`,
/// and nothing here failed the items landed, whole but for files that were never on the
/// pasteboard's side of the transfer. A signpost is the other member of the same lifecycle class
/// ("one-shots dismiss"): it reports something that already happened, it has no timeout, and only
/// the user clears it, which is the whole of "never evaporates unread". What it costs is
/// precedence a signpost ranks last and may collapse behind "+N more" which is the one place
/// this row reads quieter than 04's "loud" deserves. See the report's design-gap note.
/// **A loss row, not a `oneShot` and not a signpost** the vocabulary's answer rather than a
/// compromise (settled 2026-07-28). 02-architecture.md's `oneShot` is *a write that did not
/// happen*, carrying a `BoardWriteError`, and nothing here failed the items landed, whole but
/// for files that were never on the pasteboard's side of the transfer. This row first shipped as
/// a signpost, the vocabulary's other one-shot-lifecycle member at the time, and it read quieter
/// than 04's "loud" deserved: a signpost ranks last and may collapse behind "+N more", exactly
/// where a board already showing real trouble would bury it. The loss class exists to close that
/// gap content that didn't arrive though nothing failed ranks below the true failures and
/// above the ambient notices, keeping the signpost's dismissable, untimed lifecycle without
/// inheriting its bottom-of-the-strip precedence.
///
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a
/// banner announcing that would be noise.
public func postDegradedPaste(_ losses: [AttachmentLoss]) {
guard let message = Self.degradedPasteMessage(for: losses) else { return }
postSignpost(message)
postLoss(message)
}
/// Removes a dismissable row: a one-shot failure 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.
/// 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, for the same reason the degraded paste is
/// one (settled 2026-07-28): folders that never arrived are a non-failure loss, not a write
/// failure.
///
/// 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))
}
/// 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 one-shots 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).
/// Removes every dismissable row one-shots, 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()
}
@@ -371,8 +438,8 @@ public final class BannerCenter {
/// function** (02 § The banner surface, "Concurrent conditions stack").
///
/// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage >
/// one-shot write failures > commit and attachment failures > **passive info rows**. Three
/// readings of it are worth stating because the code depends on them:
/// 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
@@ -380,6 +447,13 @@ public final class BannerCenter {
/// "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
@@ -393,11 +467,13 @@ public final class BannerCenter {
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
///
/// `signposts` carries a default because its producer is m6's card window and nothing posts one
/// today; every other class has a live producer and is spelled out at every call site.
/// today; every other class has a live producer and is spelled out at every call site `losses`
/// included, since a degraded paste already posts one (`postDegradedPaste`).
public nonisolated static func rows(
lock: ReadOnlyLockReason?,
breakage: BoardLoadError?,
oneShots: [OneShotBanner],
losses: [LossBanner],
suspension: HistorySuspension?,
operations: [InProgressOperation],
signposts: [InfoSignpost] = []
@@ -416,6 +492,8 @@ public final class BannerCenter {
let ordered = newestFirst(oneShots)
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
rows.append(contentsOf: newestFirst(losses).map(BannerRow.loss))
if let suspension {
rows.append(.historySuspended(suspension))
}
@@ -440,6 +518,20 @@ public final class BannerCenter {
.map(\.element)
}
/// The same stable newest-first ordering as the overload above, for loss rows the two classes
/// share a lifecycle, and `postLoss` already maintains newest-first on insertion the way
/// `post(_:)` does.
private nonisolated static func newestFirst(_ losses: [LossBanner]) -> [LossBanner] {
losses
.enumerated()
.sorted { lhs, rhs in
lhs.element.occurredAt == rhs.element.occurredAt
? lhs.offset < rhs.offset
: lhs.element.occurredAt > rhs.element.occurredAt
}
.map(\.element)
}
// MARK: - Phrasing
/// The user-facing line for a failed write: what the app could not do, then why.
@@ -575,6 +667,18 @@ public final class BannerCenter {
return "Pasted \(subject) without \(tail)"
}
/// 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 suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// the diagnosis as its tail.
+4 -3
View File
@@ -196,9 +196,9 @@ public final class BoardStore {
/// failures, and re-homes those rows here when it closes; injecting a shared center would
/// erase precisely that distinction.
///
/// It holds only what nothing else does one-shot write failures, the history suspension,
/// in-progress operations, passive signposts. The lock and the reload breakage stay this
/// store's own state and are composed in at render time by `bannerRows`.
/// It holds only what nothing else does one-shot write failures, loss rows, the history
/// suspension, in-progress operations, passive signposts. The lock and the reload breakage stay
/// this store's own state and are composed in at render time by `bannerRows`.
public let banners = BannerCenter()
/// The rows the board window's strip renders, in precedence order.
@@ -211,6 +211,7 @@ public final class BoardStore {
lock: readOnlyLock,
breakage: reloadFailure,
oneShots: banners.oneShots,
losses: banners.losses,
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts