From 756e936291be0cb180077e6fca10232c4abcbaf5 Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 07:17:27 -0400 Subject: [PATCH] Add the loss-row banner class and retone degraded paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/LiveStore/BannerCenter.swift | 164 +++++++++++++++++++++++----- Kanban/LiveStore/BoardStore.swift | 7 +- Kanban/UI/BannerStripView.swift | 14 +++ KanbanTests/BannerCenterTests.swift | 144 ++++++++++++++++++++++-- KanbanTests/ClipboardTests.swift | 23 ++++ KanbanTests/PasteWriteTests.swift | 6 +- 6 files changed, 312 insertions(+), 46 deletions(-) diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index ca0a6b4..ac85715 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -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. diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index ce41ba5..7ff924d 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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 diff --git a/Kanban/UI/BannerStripView.swift b/Kanban/UI/BannerStripView.swift index 2f6215f..d8767bc 100644 --- a/Kanban/UI/BannerStripView.swift +++ b/Kanban/UI/BannerStripView.swift @@ -245,6 +245,7 @@ private func previewError( .inProgress(InProgressOperation(label: "Pulling…")), .readOnlyLock(.vanishedRoot), .oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))), + .loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")), .historySuspended(HistorySuspension(reason: "the repository is corrupt")), .signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")), ], @@ -253,6 +254,19 @@ private func previewError( .frame(width: 520) } +/// A loss row on its own: warning tone, but — unlike the standing `historySuspended` condition +/// beside it in "Stacked tones" — dismissable, since a loss reports something that already +/// happened rather than an ongoing state (settled 2026-07-28, BannerCenter's `LossBanner`). +#Preview("Loss row") { + BannerStripView( + rows: [ + .loss(LossBanner(message: BannerCenter.skippedFoldersMessage(count: 2))), + ], + onDismiss: { _ in } + ) + .frame(width: 520) +} + /// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest, /// and "+3 more" counts only what actually collapsed. #Preview("Collapse") { diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index 534651d..2ae8658 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -83,6 +83,10 @@ struct BannerCenterOrderingTests { error: error(.importAttachment(filename: "photo.png")), occurredAt: Date(timeIntervalSince1970: 200) ) + let loss = LossBanner( + message: "Pasted 'Fix login' without its 3 attachments", + occurredAt: Date(timeIntervalSince1970: 150) + ) let operation = InProgressOperation(label: "Pulling…") let signpost = InfoSignpost(message: "This card changed on the remote") @@ -90,25 +94,27 @@ struct BannerCenterOrderingTests { lock: .vanishedRoot, breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder), oneShots: [attachment, move], + losses: [loss], suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)), operations: [operation], signposts: [signpost] ) // in-progress (pinned) > read-only lock > reload breakage > one-shot write failures > - // commit and attachment failures > passive info rows. The two info classes sit at opposite - // ends of the strip. + // loss rows > commit and attachment failures > passive info rows. The two info classes + // sit at opposite ends of the strip. #expect(rows.map(\.id) == [ "operation:\(operation.id.uuidString)", "read-only-lock", "reload-breakage", "one-shot:\(move.id.uuidString)", + "loss:\(loss.id.uuidString)", "history-suspension", "one-shot:\(attachment.id.uuidString)", "signpost:\(signpost.id.uuidString)", ]) - #expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .error, .info]) - #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false], + #expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info]) + #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false], "a spinner may never hide behind '+N more' — nothing else is pinned") } @@ -120,12 +126,43 @@ struct BannerCenterOrderingTests { ) let style = OneShotBanner(error: error(.style(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1)) - let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [attachment, style], suspension: nil, operations: []) + let rows = BannerCenter.rows( + lock: nil, breakage: nil, oneShots: [attachment, style], losses: [], suspension: nil, operations: [] + ) #expect(rows.map(\.id) == ["one-shot:\(style.id.uuidString)", "one-shot:\(attachment.id.uuidString)"], "precedence class outranks recency; recency only orders within a class") } + @Test("A loss row ranks below one-shot write failures and above history suspension, attachment one-shots, and signposts") + func lossRankBetweenFailuresAndAmbientNotices() { + let center = BannerCenter() + center.post(error(.move(title: "Fix login"))) + center.post(error(.importAttachment(filename: "photo.png"))) + center.postLoss("Pasted 'Old ticket' without its 2 attachments") + center.postSignpost("This card changed on the remote") + + let rows = BannerCenter.rows( + lock: nil, + breakage: nil, + oneShots: center.oneShots, + losses: center.losses, + suspension: HistorySuspension(reason: "disk full"), + operations: [], + signposts: center.signposts + ) + + // move (non-attachment one-shot) > loss > history suspension > attachment one-shot > signpost. + #expect(rows.map(\.id) == [ + "one-shot:\(center.oneShots[1].id.uuidString)", // "Fix login" move, posted first, listed second (newest-first) + "loss:\(center.losses[0].id.uuidString)", + "history-suspension", + "one-shot:\(center.oneShots[0].id.uuidString)", // the attachment import + "signpost:\(center.signposts[0].id.uuidString)", + ]) + #expect(rows[1].tone == .warning) + } + @Test("One-shots order newest first within their class") func oneShotsAreNewestFirst() { let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1)) @@ -136,6 +173,7 @@ struct BannerCenterOrderingTests { lock: nil, breakage: nil, oneShots: [middle, oldest, newest], + losses: [], suspension: nil, operations: [] ) @@ -147,6 +185,28 @@ struct BannerCenterOrderingTests { ]) } + @Test("Loss rows order newest first within their class") + func lossesAreNewestFirst() { + let oldest = LossBanner(message: "A", occurredAt: Date(timeIntervalSince1970: 1)) + let middle = LossBanner(message: "B", occurredAt: Date(timeIntervalSince1970: 2)) + let newest = LossBanner(message: "C", occurredAt: Date(timeIntervalSince1970: 3)) + + let rows = BannerCenter.rows( + lock: nil, + breakage: nil, + oneShots: [], + losses: [middle, oldest, newest], + suspension: nil, + operations: [] + ) + + #expect(rows.map(\.id) == [ + "loss:\(newest.id.uuidString)", + "loss:\(middle.id.uuidString)", + "loss:\(oldest.id.uuidString)", + ]) + } + @Test("Failures sharing a timestamp keep the order they were posted in") func tiesAreStable() { let center = BannerCenter() @@ -160,6 +220,7 @@ struct BannerCenterOrderingTests { lock: nil, breakage: nil, oneShots: center.oneShots, + losses: [], suspension: nil, operations: [] ) @@ -169,7 +230,7 @@ struct BannerCenterOrderingTests { @Test("Nothing standing is an empty strip") func quietBoardHasNoRows() { - #expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: []).isEmpty) + #expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: []).isEmpty) } } @@ -198,6 +259,7 @@ struct BannerCenterLifecycleTests { lock: .bracketedReloadFailed, breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex), oneShots: center.oneShots, + losses: [], suspension: HistorySuspension(reason: "disk full"), operations: [InProgressOperation(label: "Pulling…")] ) @@ -212,6 +274,47 @@ struct BannerCenterLifecycleTests { #expect(center.operations.count == 1) } + @Test("A loss row dismisses individually and is untimed — nothing but its own dismissal clears it") + func lossRowsDismissByIDAndNeverExpire() throws { + let center = BannerCenter() + center.postLoss("Pasted 'Fix login' without its 3 attachments") + center.postLoss("Folders can't be attached — 2 skipped") + #expect(center.losses.count == 2) + + let doomed = try #require(center.losses.first) + center.dismiss(doomed.id) + + #expect(center.losses.count == 1) + #expect(center.losses.first?.id != doomed.id, "dismissing one must not take its neighbour") + + // No timer, no auto-expiry: everything else the center does — ending an in-progress + // operation, raising and clearing the history suspension — leaves a standing loss alone. + let id = center.beginOperation(label: "Pulling…", cancel: nil) + center.endOperation(id) + center.suspendHistory(reason: "disk full") + center.clearHistorySuspension() + #expect(center.losses.count == 1, "a loss survives everything except its own dismissal") + } + + @Test("Dismissing all dismissable rows clears losses along with one-shots and signposts") + func dismissAllClearsLosses() { + let center = BannerCenter() + center.postLoss("Pasted 'Fix login' without its 3 attachments") + center.dismissAllDismissableRows() + #expect(center.losses.isEmpty) + } + + @Test("postSkippedFolders no-ops when nothing was skipped") + func postSkippedFoldersNoOpsAtZero() { + let center = BannerCenter() + center.postSkippedFolders(count: 0) + #expect(center.losses.isEmpty) + + center.postSkippedFolders(count: 2) + #expect(center.losses.count == 1) + #expect(center.losses[0].message == "Folders can't be attached — 2 skipped") + } + @Test("Suspending history raises a warning row; clearing it takes the row away") func historySuspensionIsAHealingCondition() throws { let center = BannerCenter() @@ -221,7 +324,9 @@ struct BannerCenterLifecycleTests { let suspension = try #require(center.historySuspension) #expect(suspension.reason == "the repository is corrupt") - let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []) + let rows = BannerCenter.rows( + lock: nil, breakage: nil, oneShots: [], losses: [], suspension: center.historySuspension, operations: [] + ) #expect(rows.count == 1) #expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded") #expect(rows[0].headline.contains("history")) @@ -229,7 +334,9 @@ struct BannerCenterLifecycleTests { center.clearHistorySuspension() #expect(center.historySuspension == nil) - #expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []).isEmpty) + #expect(BannerCenter.rows( + lock: nil, breakage: nil, oneShots: [], losses: [], suspension: center.historySuspension, operations: [] + ).isEmpty) } @Test("Re-suspending keeps the original start and takes the newer diagnosis") @@ -250,7 +357,9 @@ struct BannerCenterLifecycleTests { let center = BannerCenter() let id = center.beginOperation(label: "Pulling…", cancel: nil) - var rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations) + var rows = BannerCenter.rows( + lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: center.operations + ) #expect(rows.count == 1) #expect(rows[0].tone == .info) #expect(rows[0].headline == "Pulling…") @@ -261,7 +370,9 @@ struct BannerCenterLifecycleTests { #expect(!operation.isCancelable, "git brackets get no Cancel — settled") center.endOperation(id) - rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations) + rows = BannerCenter.rows( + lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: center.operations + ) #expect(rows.isEmpty) } @@ -290,6 +401,7 @@ struct BannerCenterLifecycleTests { lock: nil, breakage: nil, oneShots: center.oneShots, + losses: [], suspension: nil, operations: center.operations, signposts: center.signposts @@ -315,6 +427,7 @@ struct BannerCenterLifecycleTests { lock: nil, breakage: nil, oneShots: center.oneShots, + losses: [], suspension: nil, operations: center.operations ) @@ -444,6 +557,15 @@ struct BannerCenterPhrasingTests { #expect(BannerCenter.headline(for: HistorySuspension(reason: "")) == "Changes aren't being recorded to history") } + + @Test("The skipped-folders line matches 04's own example, plural and singular") + func skippedFoldersMessageMatchesTheDesignExample() { + // 04-interactions.md's own example sentence, verbatim. + #expect(BannerCenter.skippedFoldersMessage(count: 2) == "Folders can't be attached — 2 skipped") + // Singular keeps the same leading claim rather than recasting it as "A folder can't be + // attached" — one sentence shape for every count (see the type's own doc comment). + #expect(BannerCenter.skippedFoldersMessage(count: 1) == "Folders can't be attached — 1 skipped") + } } // MARK: - Store integration @@ -514,6 +636,7 @@ struct BannerCenterStoreTests { store.enterUnwritableLock() store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full"))) + store.banners.postLoss("Pasted 'Fix login' without its 3 attachments") store.banners.suspendHistory(reason: "the disk is full") store.banners.beginOperation(label: "Duplicating…", cancel: nil) store.banners.postSignpost("This card changed on the remote") @@ -522,6 +645,7 @@ struct BannerCenterStoreTests { "operation:\(store.banners.operations[0].id.uuidString)", "read-only-lock", "one-shot:\(store.banners.oneShots[0].id.uuidString)", + "loss:\(store.banners.losses[0].id.uuidString)", "history-suspension", "signpost:\(store.banners.signposts[0].id.uuidString)", ]) diff --git a/KanbanTests/ClipboardTests.swift b/KanbanTests/ClipboardTests.swift index 36b9d1f..3fd47c3 100644 --- a/KanbanTests/ClipboardTests.swift +++ b/KanbanTests/ClipboardTests.swift @@ -764,6 +764,29 @@ struct DegradedPasteBannerTests { func postingNothing() { let center = BannerCenter() center.postDegradedPaste([]) + #expect(center.losses.isEmpty) #expect(center.signposts.isEmpty) } + + @Test("A degraded paste lands in the loss class, not the signpost class") + @MainActor + func postingLandsAsALossRow() { + // Settled 2026-07-28 (DESIGN/02-architecture.md § The banner surface, "Loss rows"): the + // degraded paste retoned from a signpost onto the new warning-tone loss class — content + // that didn't arrive though nothing failed, ranking below the true failures and above the + // ambient notices rather than at the bottom of the strip. + let center = BannerCenter() + center.postDegradedPaste([.init(title: "Fix login", attachments: 3)]) + + #expect(center.losses.count == 1) + #expect(center.losses.first?.message == "Pasted 'Fix login' without its 3 attachments") + #expect(center.signposts.isEmpty, "the degraded paste no longer posts a signpost") + + let rows = BannerCenter.rows( + lock: nil, breakage: nil, oneShots: [], losses: center.losses, suspension: nil, operations: [] + ) + #expect(rows.count == 1) + #expect(rows[0].tone == .warning) + #expect(rows[0].dismissID == center.losses.first?.id) + } } diff --git a/KanbanTests/PasteWriteTests.swift b/KanbanTests/PasteWriteTests.swift index 3221df9..fb4e00d 100644 --- a/KanbanTests/PasteWriteTests.swift +++ b/KanbanTests/PasteWriteTests.swift @@ -494,7 +494,7 @@ struct PasteFallbackTests { target.select([destinationLane], liveness: .live) await harness.clipboard.paste(into: target)?.value - #expect(target.banners.signposts.map(\.message) == ["Pasted 'First' without its 2 attachments"]) + #expect(target.banners.losses.map(\.message) == ["Pasted 'First' without its 2 attachments"]) } @Test("A fallback that lost nothing says nothing") @@ -518,7 +518,7 @@ struct PasteFallbackTests { await harness.clipboard.paste(into: target)?.value #expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "Second"]) - #expect(target.banners.signposts.isEmpty) + #expect(target.banners.losses.isEmpty) } @Test("A lane's fallback materializes its embedded cards") @@ -544,7 +544,7 @@ struct PasteFallbackTests { // The two live cards, and not the tombstoned third. #expect(arrived.cards.count == 2) #expect(Set(arrived.cards.compactMap(\.title.value)) == ["First", "Second"]) - #expect(target.banners.signposts.map(\.message) == ["Pasted 'Todo' without its 2 attachments"]) + #expect(target.banners.losses.map(\.message) == ["Pasted 'Todo' without its 2 attachments"]) } @Test("A trash-sourced fallback still strips `deleted:` at materialization")