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.
///
+200 -13
View File
@@ -2056,8 +2056,27 @@ public final class BoardStore: HealHost {
/// alternative a temp copy, a versioned name buys byte-perfect undo of a picture nobody asked
/// to keep at the price of litter in a folder the user owns.
///
/// ### The prior `.backgrounds/` file is trimmed, not just orphaned
///
/// **The replace-in-place half of the orphan tidy** (01-storage-format.md § Validation and
/// healing, ruled 2026-08-09): once the new picture and the field both land, `snapshot`'s own prior
/// image read before this write, the same value the undo step above closes over is trimmed
/// from `.backgrounds/` when it named a file of ours different from the one this write just landed
/// on (`tidyReplacedBackgroundImage`). Switching producers (a settled paste, then a generate) no
/// longer leaves the earlier picture behind forever; a regeneration over the *same* name is
/// unaffected, because there is no "old" file distinct from the new one to trim.
///
/// **This sharpens the undo note above, not contradicts it**: an undo that restores a prior
/// `.backgrounds/` reference now restores a field that may point at bytes the Trash holds rather
/// than the board folder the missing-file degrade `BoardBackdropImage` already has for a
/// hand-deleted file, and recoverable exactly as one, on the ruling's own "temp-file posture". A
/// board's own `snapshot` is what decides whether the trim fires at all: back-to-back gestures
/// inside one reload's echo window (`theTwoProducersStayApart`) see a `nil` prior and trim nothing,
/// the same staleness that already gates the echo naming above.
///
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
/// like every other gesture with no second thing to do about it.
/// like every other gesture with no second thing to do about it. The trim's own failure is
/// swallowed independently and never reaches this banner at all see `tidyReplacedBackgroundImage`.
///
/// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`).
/// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`)
@@ -2088,6 +2107,10 @@ public final class BoardStore: HealHost {
) { document in
Self.pointBackground(at: target.reference, color: colorHex, in: &document)
}
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see its own note.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder
)
}
guard landed != nil else { return false }
generatedBackgroundEcho = (
@@ -2133,15 +2156,21 @@ public final class BoardStore: HealHost {
/// undo-restores-both-fields shape, the same `WriteOperation.setBoardBackground`, the same
/// swallowed failure.
///
/// ### `facets.png` survives on disk
/// ### `facets.png` is trimmed when the switch has settled
///
/// Choosing a solid colour over a generated background does **not** delete the picture the
/// generator wrote. Undo restores the `image` *field*, and a field cannot point an undo back at
/// bytes this gesture just erased so the file has to survive for the same reason
/// `applyGeneratedBackground`'s own overwrite-in-place does. A board that regenerates after
/// choosing solid still finds `facets.png` free to overwrite in place; the file becomes an orphan
/// only when nothing in the frontmatter ever points at it again, which is the same quiet leftover
/// a hand-deleted `image:` line already leaves.
/// **Superseded by the ruling below** (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09): earlier, choosing a solid colour over a generated background never deleted the
/// picture the generator wrote, on exactly the reasoning `applyGeneratedBackground`'s own overwrite-
/// in-place note still gives for a same-name regeneration undo restores the `image` *field*, and
/// a field cannot point an undo back at bytes this gesture erased. The ruling reads "unsets" as one
/// more shape of "repoints away from a `.backgrounds/` file", so this gesture now trims that file
/// too, the same `tidyReplacedBackgroundImage` call `applyGeneratedBackground` and
/// `applyPastedBackground` make see that method's own note, including the accepted cost: an undo
/// back to the picture this gesture unset now restores a field that may point at bytes the Trash
/// holds rather than the board folder, gracefully, the same missing-file degrade a hand-deleted file
/// already has. The trim only fires once `snapshot` has caught up with the write it is trimming
/// two solid-colour gestures back to back inside one reload's echo window trim nothing on the
/// second, `applyGeneratedBackground`'s own staleness gate, restated here.
///
/// - Parameter colorHex: the solid colour to write, `#RRGGBB` one of the Theme tab's Solid color
/// swatches, which read `FacetsRecipe.primaryColorHex` at the filters' tone/saturation level.
@@ -2152,6 +2181,9 @@ public final class BoardStore: HealHost {
let root = rootURL
let priorImage = snapshot.backgroundImage
let priorColor = snapshot.background
let backgroundsFolder = root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.updateIndex(
@@ -2159,6 +2191,11 @@ public final class BoardStore: HealHost {
) { document in
Self.pointSolidBackground(color: colorHex, in: &document)
}
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see this method's own
// note. `newReference: nil`, the unset case: solid never writes an `image` subkey.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: nil, inFolder: backgroundsFolder
)
}
guard landed != nil else { return false }
@@ -2257,6 +2294,140 @@ public final class BoardStore: HealHost {
return (fresh, "\(BoardBackdrop.backgroundsFolderName)/\(fresh)")
}
// MARK: - The orphan tidy replace-in-place
/// **Trims the `.backgrounds/` file a background apply just repointed or unset away from** the
/// replace-in-place half of the orphan tidy (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "when the app's own write repoints or unsets the board background away from a
/// `.backgrounds/` file, remove the now-unreferenced old file as part of the operation"), shared by
/// every gesture that can move `background.image` off one of the app's own files
/// `applyGeneratedBackground`, `applyPastedBackground`, `applySolidBackground` each of which calls
/// this as the last line inside its own write bracket, after both of its own writes have landed.
///
/// ### Silent and best-effort, the ruling's own "temp-file posture"
///
/// This rides inside a bracket whose actual job is landing the *new* state, so a failure to trim
/// the *old* file must never fail that write or surface a banner about it the user pressed
/// Generate, Paste, or a colour well, and what failed, as far as they are concerned, is whichever of
/// those the surrounding bracket's own banner already names. `try?` swallows whatever
/// `BoardWriter.trashBoardImage` throws; a file that could not be trimmed is simply left for the
/// scheduled sweep (`tidyBackgroundImages`) to find on the next open the same self-healing shape
/// every heal in this file has when a single attempt does not land.
///
/// `healMarked: false`: this is not a heal discovering work on its own, it is part of the very
/// gesture the user is watching succeed `relocateLooseFiles`' own distinction between its
/// scheduled caller and its inline one, applied here.
///
/// ### A no-op in three shapes, each deliberate
///
/// - `priorImage` is `nil` no field to have named an old file, or (`theTwoProducersStayApart`'s
/// echo window) `snapshot` has not yet caught up with the very write this trim would be reacting
/// to.
/// - `priorImage` names the file the write just landed on (`newReference` unchanged)
/// `applyGeneratedBackground`'s own overwrite-in-place case; there is no "old" file distinct from
/// the new one.
/// - `BoardBackdrop.qualifiedBareName(of:)` answers `nil` `priorImage` is not one of the app's own
/// `.backgrounds/` files at all: unqualified (a legacy reference, ruled "stays where it is no
/// migration"), a hand-placed image elsewhere in the board, or a path this tidy is not scoped to
/// touch (01's own "Tidy scopes to `.backgrounds/` ONLY").
private static func tidyReplacedBackgroundImage(
priorImage: String?, newReference: String?, inFolder backgroundsFolder: URL
) {
guard let priorImage, priorImage != newReference,
let bareName = BoardBackdrop.qualifiedBareName(of: priorImage)
else { return }
try? BoardWriter.trashBoardImage(
named: bareName, inFolder: backgroundsFolder, healMarked: false,
operation: .tidyBackgroundImage(filename: bareName)
)
}
// MARK: - The orphan tidy the open-time sweep
/// **Sweeps `.backgrounds/` for a file the board's current background no longer names** the
/// open-time half of the orphan tidy (01-storage-format.md § Validation and healing, ruled
/// 2026-08-09: "any file in `.backgrounds/` not referenced by the board's current
/// `background.image` is swept"), the safety net behind the replace-in-place trim
/// (`tidyReplacedBackgroundImage`): every case that trim declines the echo window, a foreign
/// write that repointed the field outside the app, a trim that failed and was swallowed leaves
/// exactly the picture this sweep is built to notice and clean up on the next open.
///
/// **Scheduling is the engine's** (`HealScheduler`), on every other scheduled heal's own terms: the
/// resting-clear, the lock-and-writability gate, the signature compare, the armed-before-attempt
/// memo, the one bracket, the banner posture and the clear-on-success are its six steps, and this
/// method is only what is genuinely this heal's which files are candidates, and what the notice
/// names.
///
/// **No `Defect` case** (`IntegrityRules.Defect.Class.orphanedBackgroundImages`'s own note): what is
/// orphaned is two board-root readings taken together at the moment of healing, not something a
/// tree walk over lanes and cards could carry forward `seedGitignore`'s and `refreshAgentGuide`'s
/// shape, not `relocateLooseCardFiles`'s.
///
/// **Announced, unlike the trim** (`HealScheduler.HealNotice.tidiedBackgroundImages`): a heal
/// discovered on open is news the ruling asks to be said out loud, the loose-file relocation's own
/// posture "state what was tidied" where the in-flow trim rides silently inside a gesture the
/// user already watches succeed.
///
/// **The write half re-verifies against disk**: `BoardWriter.trashBoardImage` re-checks each name's
/// existence at write time and no-ops when it is gone, so a file that vanished under the write
/// (a hand delete, a losing race with a foreign write) contributes no line to the notice.
public func tidyBackgroundImages() {
let root = rootURL
let backgroundsFolder = root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let referenced = BoardBackdrop.qualifiedBareName(of: snapshot.backgroundImage.value)
let candidates = Self.orphanedBackgroundFileNames(inFolder: backgroundsFolder, keeping: referenced)
var tidied: [String] = []
heals.run(
.orphanedBackgroundImages,
signature: Set(candidates.map { "background:\($0)" }),
on: self
) { () throws(BoardWriteError) -> Void in
for name in candidates {
let landed = try BoardWriter.trashBoardImage(
named: name, inFolder: backgroundsFolder, healMarked: true,
operation: .tidyBackgroundImage(filename: name)
)
// Vanished under the write the reload is the authority on what is there, and this
// contributes no line, `relocateLooseCardFiles`'s own rule.
guard landed != nil else { continue }
tidied.append(name)
}
} posting: {
.tidiedBackgroundImages(tidied)
}
}
/// The `.backgrounds/` files nothing currently names `tidyBackgroundImages`'s candidate list, read
/// fresh at the moment of healing rather than carried from a tree walk (see that method's own
/// note).
///
/// A **missing** `.backgrounds/` lists as `[]`, not an error: nothing has ever been written there,
/// an ordinary state on a board that has never generated or pasted a background. `.skipsHiddenFiles`
/// is what keeps this from ever proposing the app's own crashed-write residue
/// (`.facets.png.lanework-<uuid>`, `BoardWriter.atomicWrite`'s dot-temp) or a stray `.DS_Store`
/// the same hidden-entry exclusion `BoardLoader.directoryCandidates` uses at every other level.
/// Sub-folders are excluded the same way `relocateLooseFiles` excludes them from a card: this sweep
/// only ever considers `.backgrounds/`'s own direct, regular-file contents, never anything nested.
///
/// - Parameter keeping: the bare name `background.image` currently resolves to inside this folder
/// (`BoardBackdrop.qualifiedBareName(of:)`), `nil` when the board's current reference is not one
/// of ours every file in the folder is then a candidate.
nonisolated static func orphanedBackgroundFileNames(inFolder backgroundsFolder: URL, keeping referenced: String?) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: backgroundsFolder, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]
) else { return [] }
return entries
.filter { url in
url.lastPathComponent != referenced
&& (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
}
.map(\.lastPathComponent)
.sorted()
}
// MARK: - Pasted background
/// **Applies a pasted picture as this board's backdrop** Edit Paste as Board Background
@@ -2275,15 +2446,22 @@ public final class BoardStore: HealHost {
/// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract.
/// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, ),
/// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted
/// twice in two formats therefore leaves the first file behind the same quiet leftover
/// choosing a solid colour over a generated background already leaves, and for the same reason:
/// undo restores the *field*, and a field cannot point an undo back at bytes this gesture
/// deleted.
/// twice in two formats no longer leaves the first file behind once `snapshot` has caught up
/// with the first paste see the trim note below, which is `applyGeneratedBackground`'s own,
/// restated.
///
/// **The undo restores the field, not the bytes** `applyGeneratedBackground`'s own note,
/// unchanged and for its reason: a re-paste over this board's own `.backgrounds/Pasted
/// Background.png` overwrites pixels nothing kept a copy of.
///
/// **The prior `.backgrounds/` file is trimmed once landed, on the same terms** the
/// replace-in-place half of the orphan tidy (ruled 2026-08-09): once both writes land,
/// `tidyReplacedBackgroundImage` trims `snapshot`'s prior image when it names a file of ours other
/// than the one this write just landed on. `theTwoProducersStayApart`'s echo window is unaffected
/// by construction a `nil` prior (the snapshot has not caught up with the write it would be
/// trimming) trims nothing, so a paste landing right after a generate leaves that generate's file
/// exactly where `applyGeneratedBackground`'s own memo says it should stay findable.
///
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
/// coming".
@discardableResult
@@ -2305,6 +2483,11 @@ public final class BoardStore: HealHost {
) { document in
document.setBackgroundImage(target.reference)
}
// The replace-in-place half of the orphan tidy (ruled 2026-08-09) see this method's own
// note.
Self.tidyReplacedBackgroundImage(
priorImage: priorImage.value, newReference: target.reference, inFolder: backgroundsFolder
)
}
guard landed != nil else { return false }
generatedBackgroundEcho = (
@@ -4952,6 +5135,9 @@ public final class BoardStore: HealHost {
/// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a
/// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
/// **The background-image sweep joins this immaterial tail** (ruled 2026-08-09): it reads
/// `background.image` and writes only inside `.backgrounds/`, a file and a folder none of the
/// other five ever touch, so it has nothing to race and nothing to be raced by.
public func runScheduledHeals() {
displaceClaimedNames()
seedGitignore()
@@ -4959,6 +5145,7 @@ public final class BoardStore: HealHost {
migrateLegacyTombstones()
remintDuplicateIdentities()
refreshAgentGuide()
tidyBackgroundImages()
}
// MARK: - Selection (delegated)
+7
View File
@@ -171,6 +171,11 @@ public final class HealScheduler {
/// This row is what replaced the retired user-gated Repair banner: the same information, none
/// of the waiting.
case remintedDuplicateIDs(titles: [String?])
/// Loss row, warning tone: `.backgrounds/` files nothing on the board named any more were
/// moved to the system Trash, named by filename (ruled 2026-08-09 the open-time heal half
/// of the orphan tidy; the in-flow trim rides silently inside the gesture that triggers it and
/// posts nothing here).
case tidiedBackgroundImages([String])
}
private static func post(_ notice: HealNotice, to banners: BannerCenter) {
@@ -185,6 +190,8 @@ public final class HealScheduler {
banners.postDisplacedClaimedNames(displacements)
case let .remintedDuplicateIDs(titles):
banners.postRemintedDuplicateIDs(titles)
case let .tidiedBackgroundImages(names):
banners.postTidiedBackgroundImages(names)
}
}