Orphaned .backgrounds/ files get tidied — a repoint trims the one it leaves behind, and every open sweeps what got away

Follow-up to d0c5461's .backgrounds/ folder: generating, pasting, or
choosing solid now trims the app's own prior file in .backgrounds/ as
part of the same write when it repoints or unsets background.image
away from it — silent, best-effort, never blocking the gesture that
triggered it. A scheduled heal at every board open sweeps whatever
that trim declined or missed: any .backgrounds/ file the board's
current background.image no longer names, announced with a loss-row
notice in the loose-file relocation's own voice. Legacy root-level
references are untouched by both paths — the tidy scopes to
.backgrounds/ only, since that is the one folder the app can prove it
wrote into.

Removal is via FileManager.trashItem, matching the attachment-removal
precedent (recoverable, never a hard delete).

Supersedes applySolidBackground's earlier "facets.png survives on
disk" contract for the settled case: the ruling reads "unsets" as one
more shape of "repoints away from a .backgrounds/ file", so a settled
solid choice now trims the generated picture it displaces, same as
switching producers does. The superseded test and doc comment are
updated to the new behavior; the echo-window (no-reload-yet) case is
unaffected and still leaves the file in place.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent d905e73960
commit ca0328be2e
9 changed files with 858 additions and 26 deletions
+48
View File
@@ -602,6 +602,27 @@ public final class BannerCenter {
postLoss(message)
}
/// **The orphaned background-image sweep** (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "the open-time heal posts the calm notice-family banner the loose-file relocation
/// heal uses state what was tidied"): a file in `.backgrounds/` that nothing on the board named
/// any more was moved to the system Trash, and this is the row that says so.
///
/// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**: the app removed a file on its own
/// initiative, on a board it opened rather than on a gesture the user made that must be said out
/// loud, must not evaporate unread, and must not rank as an error, because nothing failed.
///
/// **Only the sweep posts this.** The in-flow replace-in-place trim a background apply makes on
/// its own prior file is deliberately silent the ruling's "temp-file posture": the app
/// superseding its own file inside a gesture the user already sees succeed (a fresh picture lands)
/// is not news the way a heal discovered on open is.
///
/// `names` are the bare filenames the sweep actually removed, in the order it removed them. A
/// sweep that removed nothing posts nothing.
public func postTidiedBackgroundImages(_ names: [String]) {
guard let message = Self.tidiedBackgroundImagesMessage(for: names) else { return }
postLoss(message)
}
/// **The skip notice** (01-storage-format.md § Malformed input, ruled 2026-07-31): the decision
/// surface offered Skip on a defect the app has no honest repair for, the user consented, the
/// board opened without that item "the file stays on disk untouched, tolerated-invisible like
@@ -973,6 +994,15 @@ public final class BannerCenter {
// failure the user did not provoke is exactly the one they have no other way to learn
// about.
"Couldn't move '\(filename)' into attachments"
case let .tidyBackgroundImage(filename):
// **The successful sweep's own verb, negated** `postTidiedBackgroundImages`' "Removed
// '\(filename)' " said the other way round, `.relocateLooseFile`'s own precedent: the
// failure reads as the same sentence's negation rather than a different event. It stays
// in the ordinary one-shot precedence class for `.relocateLooseFile`'s reason too this
// is work the app started on its own, and a failure the user did not provoke is exactly
// the one they have no other way to learn about. In practice only the sweep's own failure
// ever reaches here; the in-flow trim swallows its own (the ruling's "temp-file posture").
"Couldn't remove '\(filename)'"
case .agentGuide:
// **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and
// most users will never have opened it a filename here would name something they have
@@ -1284,6 +1314,24 @@ public final class BannerCenter {
return "Repaired duplicate id — \(sole(only))"
}
/// The orphaned background-image sweep's line, `migratedTombstonesMessage`'s voice: the act
/// first, the subject after an em dash, plurals folded, a tail naming the cause without it the
/// sentence would read as something that happened *to* the board rather than the app's own
/// housekeeping.
///
/// - **One**: "Removed 'facets 2.png' it was no longer the board's background".
/// - **Several**: "Removed 3 background images they were no longer the board's background".
///
/// `nil` when nothing was removed a sweep that swept nothing is not news.
public nonisolated static func tidiedBackgroundImagesMessage(for names: [String]) -> String? {
guard !names.isEmpty else { return nil }
let subject = names.count == 1 ? "'\(names[0])'" : "\(names.count) background images"
let tail = names.count == 1
? "it was no longer the board's background"
: "they were no longer the board's background"
return "Removed \(subject)\(tail)"
}
/// The lossy export's line, in the relocation family's voice the act first, the cause after an
/// em dash, plurals folded into their counts.
///