Generated and pasted board backgrounds move into .backgrounds/ — the board root stops collecting the app's own pictures

New app-written background images (Theme tab ▸ Pattern, and Edit ▸ Paste
as Board Background) now land in a hidden `.backgrounds/` folder at
board root instead of beside index.md, matching the `.trash/` app-managed
pattern. `background.image` stores the qualified relative reference
(`.backgrounds/facets.png`); the resolver needed no change at all, since
it already accepted any relative path inside the board root — the same
mechanism that already resolved `art/backdrops/sunset.png` resolves the
new location for free. `BoardWriter.writeBoardImage` now creates its
destination folder if missing, since `.backgrounds/` won't exist until a
board's first generated or pasted background.

The Finder collision-ladder (`BoardStore.boardImageName`) is rescoped to
`.backgrounds/`'s own contents, and its overwrite-in-place check now
recognizes only the qualified form as "ours" — a legacy bare
`background.image: facets.png` from before this change is read as a
foreign reference rather than migrated, so a regeneration writes a fresh
`.backgrounds/` file and orphans the old one in place, per the no-migration
ruling. The Theme tab's Pattern/Solid mode-detection was updated to
recognize both the legacy and current spellings as the generator's own
output.

The board loader needed no change: `.backgrounds/` is a hidden,
non-UUID-shaped name, and `.skipsHiddenFiles` already keeps every hidden
entry off the lane walk before any name-based exclusion is consulted —
pinned with a new loader test. Deliberately did not add `.backgrounds` to
IntegrityRules' claimed-name/squatter-displacement table: that table
mirrors a specific existing DESIGN ruling this card doesn't amend.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 08:38:07 -04:00
parent 0a01405ced
commit d0c546179f
9 changed files with 270 additions and 88 deletions
+5 -3
View File
@@ -67,9 +67,11 @@ extension FrontmatterDocument {
///
/// The doc comment above says "There is no image picker and none is planned"; that sentence held
/// until generated backgrounds (DESIGN/explorations/board-backgrounds.md), which do not make one
/// either. What the generator writes is a *file it just created in the board folder* and the name
/// it wrote it under the app is not browsing the user's pictures, it is naming its own output
/// so the hand-written path stays the escape hatch it always was, and this write preserves it the
/// either. What the generator writes is a *file it just created* in `.backgrounds/`
/// (`BoardBackdrop.backgroundsFolderName`, ruled 2026-08-09), never the board root, for every new
/// write and the relative reference to it, which is still simply a path the reader was already
/// able to resolve; the app is not browsing the user's pictures, it is naming its own output so
/// the hand-written path stays the escape hatch it always was, and this write preserves it the
/// same way the colour write preserves an image: by subkey.
///
/// Everything else is `setStyleValue`'s, deliberately shared rather than restated: the same
+23 -4
View File
@@ -300,10 +300,15 @@ public enum BoardWriter: Sendable {
// MARK: - Generated board artwork
/// **Writes a generated background image into the board folder** the one path in the app that
/// puts *bytes the app composed* on disk rather than text it edited (03-board-ui.md § Styling
/// **Writes a generated background image into `root`** the one path in the app that puts
/// *bytes the app composed* on disk rather than text it edited (03-board-ui.md § Styling
/// Capabilities, the `background.image` half; `FacetsGenerator`).
///
/// `root` is the board root for a legacy-shaped write and `.backgrounds/` for every current one
/// (`BoardBackdrop.backgroundsFolderName`, ruled 2026-08-09 `BoardStore.boardImageName` decides
/// which); either way this call does not care, it just writes `name` under whatever folder it is
/// given.
///
/// It is `atomicReplace` with a different payload and the same four properties, which is the
/// point of it existing here rather than at the store: hidden dot-temp in the **same folder**, a
/// POSIX rename over the destination, best-effort cleanup on failure, and a receipt so the churn
@@ -311,12 +316,17 @@ public enum BoardWriter: Sendable {
/// regenerated is a board whose renderer may be mid-decode on the old file, and a rename is the
/// only way to hand it either the old bytes or the new ones and never a truncated file.
///
/// **`root` is created if it is not there yet.** The board root always is, so this is a no-op on
/// that call; `.backgrounds/` is not, the first time any board writes to it, and asking every
/// caller to remember the `mkdir` would just be one more way to get it wrong.
///
/// **Overwriting is the caller's decision, expressed as a name.** This writes whatever name it is
/// given, so the policy reuse ours, or step aside from somebody else's file lives in one
/// place at the store (`BoardStore.applyGeneratedBackground`) rather than being half here and
/// half there. `name` must be a bare filename; a path is refused rather than resolved, because a
/// background that could be written outside the board folder is the mirror of the containment
/// rule `BoardBackdrop.imageURL(named:inBoardRoot:)` already enforces on the read side.
/// background that could be written outside `root` is the mirror of the containment rule
/// `BoardBackdrop.imageURL(named:inBoardRoot:)` already enforces on the read side `root` itself
/// is the caller's to keep inside the board.
///
/// - Returns: the name written, so a caller can chain straight into the frontmatter write
/// without restating it.
@@ -334,6 +344,15 @@ public enum BoardWriter: Sendable {
reason: .io(message: "'\(name)' is not a file name a board image can be written under")
)
}
do {
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: operation,
path: root.path,
reason: .io(message: "could not create '\(root.lastPathComponent)': \(error.localizedDescription)")
)
}
let fileURL = root.appendingPathComponent(name)
try atomicWrite(data, at: fileURL, operation: operation)
// The bytes are already in hand, so this is the hash-what-you-wrote form rather than