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:
@@ -306,11 +306,17 @@ public final class BoardStore: HealHost {
|
||||
/// following a reroll inside one reload window would read `facets.png` as its own and overwrite a
|
||||
/// file it never wrote.
|
||||
///
|
||||
/// **`name` and `bareName` differ since the `.backgrounds/` ruling** (2026-08-09): `name` is the
|
||||
/// relative reference `background.image` stores (`.backgrounds/facets.png`), and `bareName` is
|
||||
/// the file name it was written under inside that folder (`facets.png`) — the two calls
|
||||
/// `boardImageName` feeds (`BoardWriter.writeBoardImage`'s `named:` and
|
||||
/// `FrontmatterDocument.setBackgroundImage`'s value) no longer take the same string.
|
||||
///
|
||||
/// `@ObservationIgnored` because nothing renders it: it is bookkeeping about a file name, and a
|
||||
/// view that redrew when it changed would be redrawing for the write it is already going to be
|
||||
/// told about by the reload.
|
||||
@ObservationIgnored
|
||||
var generatedBackgroundEcho: (name: String, base: String, reloads: Int)?
|
||||
var generatedBackgroundEcho: (name: String, bareName: String, base: String, reloads: Int)?
|
||||
|
||||
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
||||
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
||||
@@ -2009,9 +2015,10 @@ public final class BoardStore: HealHost {
|
||||
|
||||
// MARK: - Generated background
|
||||
|
||||
/// **Applies a generated background to this board** — the picture into the board folder and the
|
||||
/// `background` mapping pointed at it, in one bracket (03-board-ui.md § Styling ▸ Capabilities;
|
||||
/// DESIGN/explorations/board-backgrounds.md; `FacetsGenerator`).
|
||||
/// **Applies a generated background to this board** — the picture into `.backgrounds/`
|
||||
/// (`BoardBackdrop.backgroundsFolderName`, ruled 2026-08-09) and the `background` mapping pointed
|
||||
/// at it, in one bracket (03-board-ui.md § Styling ▸ Capabilities; DESIGN/explorations/
|
||||
/// board-backgrounds.md; `FacetsGenerator`).
|
||||
///
|
||||
/// **The pixels are the caller's**, and that is the isolation contract: rendering a 3072 px mesh
|
||||
/// and PNG-encoding it is tens of milliseconds of pure computation, so it belongs on a detached
|
||||
@@ -2032,9 +2039,11 @@ public final class BoardStore: HealHost {
|
||||
///
|
||||
/// Regenerating is the common gesture — the user rerolls until they like it — so a board must not
|
||||
/// accumulate a PNG per roll. The board's own generated file is therefore **overwritten in place**
|
||||
/// whenever `background.image` already names it, and the Finder ladder is used only when the name
|
||||
/// belongs to somebody else (`BoardWriter.freshName`): a hand-placed `facets.png` in the board
|
||||
/// folder is the user's file and is never written through.
|
||||
/// whenever `background.image` already names the `.backgrounds/facets.png` this store writes, and
|
||||
/// the Finder ladder — scoped to `.backgrounds/`'s own contents — is used only when the name
|
||||
/// belongs to somebody else (`BoardWriter.freshName`): a hand-placed `facets.png` inside
|
||||
/// `.backgrounds/` is the user's file and is never written through. A **legacy** bare `facets.png`
|
||||
/// at board root is not "ours" by this reading either — see `boardImageName`'s own note.
|
||||
///
|
||||
/// ### The undo restores the fields, not the bytes
|
||||
///
|
||||
@@ -2061,30 +2070,36 @@ public final class BoardStore: HealHost {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let priorColor = snapshot.background
|
||||
let name = boardImageName(
|
||||
let target = boardImageName(
|
||||
base: FacetsGenerator.fileName, replacing: priorImage.value, inRoot: root
|
||||
)
|
||||
let backgroundsFolder = root.appendingPathComponent(
|
||||
BoardBackdrop.backgroundsFolderName, isDirectory: true
|
||||
)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: name, inRoot: root, operation: .setBoardBackground
|
||||
data: png, named: target.bareName, inRoot: backgroundsFolder, operation: .setBoardBackground
|
||||
)
|
||||
// `kind: .board` for the one subject whose position nothing can infer — the board root
|
||||
// (`BoardWriter.updateIndex`'s on-touch backfill), exactly as `applyStyle` passes it.
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointBackground(at: name, color: colorHex, in: &document)
|
||||
Self.pointBackground(at: target.reference, color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
generatedBackgroundEcho = (name: name, base: FacetsGenerator.fileName, reloads: landedReloads)
|
||||
generatedBackgroundEcho = (
|
||||
name: target.reference, bareName: target.bareName, base: FacetsGenerator.fileName,
|
||||
reloads: landedReloads
|
||||
)
|
||||
|
||||
// restyle → prior style (13-native-undo.md ▸ Rules). The board's own stack, never a window's:
|
||||
// there is no card here to have a session.
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: .board),
|
||||
undoExpects: [.present(root, .background(colorHex), .backgroundImage(name))],
|
||||
undoExpects: [.present(root, .background(colorHex), .backgroundImage(target.reference))],
|
||||
redoExpects: [.present(root, .background(priorColor.value), .backgroundImage(priorImage.value))]
|
||||
) { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
@@ -2099,7 +2114,7 @@ public final class BoardStore: HealHost {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointBackground(at: name, color: colorHex, in: &document)
|
||||
Self.pointBackground(at: target.reference, color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -2180,14 +2195,30 @@ public final class BoardStore: HealHost {
|
||||
document.setBackgroundImage(nil)
|
||||
}
|
||||
|
||||
/// The name a board image is written under: **ours to overwrite**, or the next free one.
|
||||
/// The name a board image is written under: **ours to overwrite**, or the next free one — always
|
||||
/// inside `.backgrounds/` (`BoardBackdrop.backgroundsFolderName`, ruled 2026-08-09: "new
|
||||
/// app-written background images go into an app-claimed folder", the `.trash/` pattern applied
|
||||
/// one concept over).
|
||||
///
|
||||
/// `base` is the producer's own file name — `facets.png` for the generator, `Pasted
|
||||
/// Background.<ext>` for a paste — and `current` is what `background.image` says now. When that
|
||||
/// is already `base` the file is this board's own output and is replaced in place — including
|
||||
/// when it has been deleted from the folder by hand, which is a board whose backdrop is broken
|
||||
/// and is exactly what regenerating (or re-pasting) fixes. Otherwise the Finder ladder decides,
|
||||
/// which yields the plain name when nothing holds it and `facets 2.png` when something does.
|
||||
/// `base` is the producer's own bare file name — `facets.png` for the generator, `Pasted
|
||||
/// Background.<ext>` for a paste — and `current` is what `background.image` says now, in whatever
|
||||
/// form it happens to be spelled. Only the **qualified** form (`.backgrounds/facets.png`) reads as
|
||||
/// "ours to overwrite in place" — including when the file has been deleted from `.backgrounds/` by
|
||||
/// hand, which is a board whose backdrop is broken and is exactly what regenerating (or
|
||||
/// re-pasting) fixes. Otherwise the Finder ladder — scoped to `.backgrounds/`'s own contents —
|
||||
/// decides, which yields the plain name when nothing there holds it and `facets 2.png` when
|
||||
/// something does.
|
||||
///
|
||||
/// ### A legacy bare reference is not "ours"
|
||||
///
|
||||
/// A board whose `background.image` still names a bare `facets.png` — written before this ruling,
|
||||
/// at board root — does **not** match the qualified form, so it is read the same as a hand-placed
|
||||
/// image belonging to nobody: this write proceeds into `.backgrounds/facets.png` (laddering only
|
||||
/// if `.backgrounds/` itself already holds one) rather than overwriting the legacy file in place.
|
||||
/// That is the deliberate reading of "legacy referenced images stay where they are — no
|
||||
/// migration": the old file is left exactly as it was, at the price of becoming an orphan the
|
||||
/// moment the field starts pointing at the new one — the same leftover an ordinary regeneration
|
||||
/// already produces (`applySolidBackground`'s own note).
|
||||
///
|
||||
/// ### The reroll's echo
|
||||
///
|
||||
@@ -2207,12 +2238,23 @@ public final class BoardStore: HealHost {
|
||||
/// **The echo is only consulted for its own family** (`base`), which is what keeps two producers
|
||||
/// off each other: a paste landing inside the reroll's echo window must not read `facets.png` as
|
||||
/// a name it owns.
|
||||
private func boardImageName(base: String, replacing current: String?, inRoot root: URL) -> String {
|
||||
if current == base { return base }
|
||||
///
|
||||
/// - Returns: the bare name to write the bytes under (`BoardWriter.writeBoardImage`'s `named:`)
|
||||
/// and the qualified relative reference to store in `background.image` — no longer the same
|
||||
/// string now that every write lives one folder down from the board root.
|
||||
private func boardImageName(
|
||||
base: String, replacing current: String?, inRoot root: URL
|
||||
) -> (bareName: String, reference: String) {
|
||||
let backgroundsFolder = root.appendingPathComponent(
|
||||
BoardBackdrop.backgroundsFolderName, isDirectory: true
|
||||
)
|
||||
let qualified = "\(BoardBackdrop.backgroundsFolderName)/\(base)"
|
||||
if current == qualified { return (base, qualified) }
|
||||
if let echo = generatedBackgroundEcho, echo.base == base, echo.reloads == landedReloads {
|
||||
return echo.name
|
||||
return (echo.bareName, echo.name)
|
||||
}
|
||||
return BoardWriter.freshName(for: base, in: root)
|
||||
let fresh = BoardWriter.freshName(for: base, in: backgroundsFolder)
|
||||
return (fresh, "\(BoardBackdrop.backgroundsFolderName)/\(fresh)")
|
||||
}
|
||||
|
||||
// MARK: - Pasted background
|
||||
@@ -2239,8 +2281,8 @@ public final class BoardStore: HealHost {
|
||||
/// deleted.
|
||||
///
|
||||
/// **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 `Pasted Background.png`
|
||||
/// overwrites pixels nothing kept a copy of.
|
||||
/// unchanged and for its reason: a re-paste over this board's own `.backgrounds/Pasted
|
||||
/// Background.png` overwrites pixels nothing kept a copy of.
|
||||
///
|
||||
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
|
||||
/// coming".
|
||||
@@ -2249,20 +2291,25 @@ public final class BoardStore: HealHost {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let base = "\(PastedImage.backgroundBaseName).\(fileExtension)"
|
||||
let name = boardImageName(base: base, replacing: priorImage.value, inRoot: root)
|
||||
let target = boardImageName(base: base, replacing: priorImage.value, inRoot: root)
|
||||
let backgroundsFolder = root.appendingPathComponent(
|
||||
BoardBackdrop.backgroundsFolderName, isDirectory: true
|
||||
)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: data, named: name, inRoot: root, operation: .setBoardBackground
|
||||
data: data, named: target.bareName, inRoot: backgroundsFolder, operation: .setBoardBackground
|
||||
)
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
document.setBackgroundImage(name)
|
||||
document.setBackgroundImage(target.reference)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
generatedBackgroundEcho = (name: name, base: base, reloads: landedReloads)
|
||||
generatedBackgroundEcho = (
|
||||
name: target.reference, bareName: target.bareName, base: base, reloads: landedReloads
|
||||
)
|
||||
|
||||
// restyle → prior image (13-native-undo.md ▸ Rules). The board's own stack, never a window's:
|
||||
// there is no card here to have a session. **Only the image subkey is declared**, which is
|
||||
@@ -2270,7 +2317,7 @@ public final class BoardStore: HealHost {
|
||||
// the step, because this gesture never wrote a colour.
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: .board),
|
||||
undoExpects: [.present(root, .backgroundImage(name))],
|
||||
undoExpects: [.present(root, .backgroundImage(target.reference))],
|
||||
redoExpects: [.present(root, .backgroundImage(priorImage.value))]
|
||||
) { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
@@ -2284,7 +2331,7 @@ public final class BoardStore: HealHost {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
document.setBackgroundImage(name)
|
||||
document.setBackgroundImage(target.reference)
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -145,7 +145,15 @@ struct BoardThemeTabView: View {
|
||||
// Pattern, since that is the surface that made it; every other board — no image, or a
|
||||
// hand-placed one the app does not own — opens on Solid color, which is also the mode that
|
||||
// never overwrites a foreign picture by accident.
|
||||
_mode = State(initialValue: store.snapshot.backgroundImage.value == FacetsGenerator.fileName ? .pattern : .solid)
|
||||
//
|
||||
// Two spellings answer "is it ours" since the `.backgrounds/` ruling (2026-08-09): the
|
||||
// qualified reference every current write lands at (`BoardStore.boardImageName`), and the
|
||||
// legacy bare name a board written before that ruling still carries at board root — a board
|
||||
// moved from neither location by this check, so both keep opening on Pattern.
|
||||
let image = store.snapshot.backgroundImage.value
|
||||
let generatesThisImage = image == FacetsGenerator.fileName
|
||||
|| image == "\(BoardBackdrop.backgroundsFolderName)/\(FacetsGenerator.fileName)"
|
||||
_mode = State(initialValue: generatesThisImage ? .pattern : .solid)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -69,6 +69,33 @@ enum BoardBackdrop {
|
||||
return imageURL(named: path, inBoardRoot: root)
|
||||
}
|
||||
|
||||
/// **Where the app writes its own background images** — a hidden dot-name at board root, the
|
||||
/// `.trash/` pattern applied one concept over (03-board-ui.md § Styling ▸ Capabilities; ruled
|
||||
/// 2026-08-09: "New app-written background images go into an app-claimed folder").
|
||||
///
|
||||
/// `image:` still names a path *relative to the board root*, unchanged: a generated or pasted
|
||||
/// picture is written under this folder and referenced by the qualified relative path
|
||||
/// (`.backgrounds/facets.png`), which `imageURL(named:inBoardRoot:)` above already resolves
|
||||
/// without any change of its own — a subfolder was always a legal `image:` target
|
||||
/// (`art/sunset.jpg` resolves exactly the same way, and the read-side coverage for it already
|
||||
/// existed). The folder buys tidiness, not new resolution machinery.
|
||||
///
|
||||
/// **Not a claimed name, and deliberately so.** Nothing in the board loader ever has to be told
|
||||
/// about it: `BoardLoader.directoryCandidates` skips hidden entries at every level it walks
|
||||
/// (`.skipsHiddenFiles`) — the same mechanism that already keeps `.trash` out of the lane walk
|
||||
/// before that folder's own name-based exclusion is ever consulted — so a hidden, non-UUID-shaped
|
||||
/// `.backgrounds` is invisible to the tree walk by construction, with nothing further to add. A
|
||||
/// file squatting the name simply fails the next background write like any other I/O error (the
|
||||
/// same "the app degrades to correctness without it" posture `.gitignore` has); nothing needs the
|
||||
/// name to exist for the app to keep working.
|
||||
///
|
||||
/// **Legacy images are not moved here.** A `background.image` written before this folder existed
|
||||
/// — or a hand-placed one anywhere else in the board — keeps resolving exactly where it sits; only
|
||||
/// a *new* app write (`BoardStore.boardImageName`) chooses this folder, and a regeneration over a
|
||||
/// legacy reference leaves the old file orphaned in place rather than migrating it, the same
|
||||
/// leftover an overwritten `facets.png` already could.
|
||||
static let backgroundsFolderName = ".backgrounds"
|
||||
|
||||
/// Whether this board paints a background of its own — **the window-chrome predicate**
|
||||
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
|
||||
/// runs its content under a transparent title bar, and a board without one keeps the standard
|
||||
|
||||
Reference in New Issue
Block a user