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 -10
View File
@@ -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)",
])