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
+79 -32
View File
@@ -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 /// following a reroll inside one reload window would read `facets.png` as its own and overwrite a
/// file it never wrote. /// 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 /// `@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 /// view that redrew when it changed would be redrawing for the write it is already going to be
/// told about by the reload. /// told about by the reload.
@ObservationIgnored @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 /// 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 /// 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 // MARK: - Generated background
/// **Applies a generated background to this board** the picture into the board folder and the /// **Applies a generated background to this board** the picture into `.backgrounds/`
/// `background` mapping pointed at it, in one bracket (03-board-ui.md § Styling Capabilities; /// (`BoardBackdrop.backgroundsFolderName`, ruled 2026-08-09) and the `background` mapping pointed
/// DESIGN/explorations/board-backgrounds.md; `FacetsGenerator`). /// 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 /// **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 /// 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 /// 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** /// 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 /// whenever `background.image` already names the `.backgrounds/facets.png` this store writes, and
/// belongs to somebody else (`BoardWriter.freshName`): a hand-placed `facets.png` in the board /// the Finder ladder scoped to `.backgrounds/`'s own contents is used only when the name
/// folder is the user's file and is never written through. /// 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 /// ### The undo restores the fields, not the bytes
/// ///
@@ -2061,30 +2070,36 @@ public final class BoardStore: HealHost {
let root = rootURL let root = rootURL
let priorImage = snapshot.backgroundImage let priorImage = snapshot.backgroundImage
let priorColor = snapshot.background let priorColor = snapshot.background
let name = boardImageName( let target = boardImageName(
base: FacetsGenerator.fileName, replacing: priorImage.value, inRoot: root 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 let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.writeBoardImage( 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 // `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. // (`BoardWriter.updateIndex`'s on-touch backfill), exactly as `applyStyle` passes it.
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in ) { 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 } 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: // 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. // there is no card here to have a session.
registerStep( registerStep(
HistoryPhrase.name(.restyle, kind: .board), 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))] redoExpects: [.present(root, .background(priorColor.value), .backgroundImage(priorImage.value))]
) { _ in ) { _ in
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
@@ -2099,7 +2114,7 @@ public final class BoardStore: HealHost {
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in ) { document in
Self.pointBackground(at: name, color: colorHex, in: &document) Self.pointBackground(at: target.reference, color: colorHex, in: &document)
} }
} }
return true return true
@@ -2180,14 +2195,30 @@ public final class BoardStore: HealHost {
document.setBackgroundImage(nil) 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 /// `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. When that /// Background.<ext>` for a paste and `current` is what `background.image` says now, in whatever
/// is already `base` the file is this board's own output and is replaced in place including /// form it happens to be spelled. Only the **qualified** form (`.backgrounds/facets.png`) reads as
/// when it has been deleted from the folder by hand, which is a board whose backdrop is broken /// "ours to overwrite in place" including when the file has been deleted from `.backgrounds/` by
/// and is exactly what regenerating (or re-pasting) fixes. Otherwise the Finder ladder decides, /// hand, which is a board whose backdrop is broken and is exactly what regenerating (or
/// which yields the plain name when nothing holds it and `facets 2.png` when something does. /// 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 /// ### 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 /// **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 /// off each other: a paste landing inside the reroll's echo window must not read `facets.png` as
/// a name it owns. /// 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 { 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 // MARK: - Pasted background
@@ -2239,8 +2281,8 @@ public final class BoardStore: HealHost {
/// deleted. /// deleted.
/// ///
/// **The undo restores the field, not the bytes** `applyGeneratedBackground`'s own note, /// **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` /// unchanged and for its reason: a re-paste over this board's own `.backgrounds/Pasted
/// overwrites pixels nothing kept a copy of. /// Background.png` overwrites pixels nothing kept a copy of.
/// ///
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload /// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
/// coming". /// coming".
@@ -2249,20 +2291,25 @@ public final class BoardStore: HealHost {
let root = rootURL let root = rootURL
let priorImage = snapshot.backgroundImage let priorImage = snapshot.backgroundImage
let base = "\(PastedImage.backgroundBaseName).\(fileExtension)" 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 let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.writeBoardImage( try BoardWriter.writeBoardImage(
data: data, named: name, inRoot: root, operation: .setBoardBackground data: data, named: target.bareName, inRoot: backgroundsFolder, operation: .setBoardBackground
) )
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in ) { document in
document.setBackgroundImage(name) document.setBackgroundImage(target.reference)
} }
} }
guard landed != nil else { return false } 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: // 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 // 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. // the step, because this gesture never wrote a colour.
registerStep( registerStep(
HistoryPhrase.name(.restyle, kind: .board), HistoryPhrase.name(.restyle, kind: .board),
undoExpects: [.present(root, .backgroundImage(name))], undoExpects: [.present(root, .backgroundImage(target.reference))],
redoExpects: [.present(root, .backgroundImage(priorImage.value))] redoExpects: [.present(root, .backgroundImage(priorImage.value))]
) { _ in ) { _ in
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
@@ -2284,7 +2331,7 @@ public final class BoardStore: HealHost {
try BoardWriter.updateIndex( try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in ) { document in
document.setBackgroundImage(name) document.setBackgroundImage(target.reference)
} }
} }
return true return true
+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 /// 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 /// 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 /// either. What the generator writes is a *file it just created* in `.backgrounds/`
/// it wrote it under the app is not browsing the user's pictures, it is naming its own output /// (`BoardBackdrop.backgroundsFolderName`, ruled 2026-08-09), never the board root, for every new
/// so the hand-written path stays the escape hatch it always was, and this write preserves it the /// 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. /// same way the colour write preserves an image: by subkey.
/// ///
/// Everything else is `setStyleValue`'s, deliberately shared rather than restated: the same /// 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 // MARK: - Generated board artwork
/// **Writes a generated background image into the board folder** the one path in the app that /// **Writes a generated background image into `root`** the one path in the app that puts
/// puts *bytes the app composed* on disk rather than text it edited (03-board-ui.md § Styling /// *bytes the app composed* on disk rather than text it edited (03-board-ui.md § Styling
/// Capabilities, the `background.image` half; `FacetsGenerator`). /// 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 /// 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 /// 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 /// 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 /// 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. /// 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 /// **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 /// 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 /// 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 /// 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 /// background that could be written outside `root` is the mirror of the containment rule
/// rule `BoardBackdrop.imageURL(named:inBoardRoot:)` already enforces on the read side. /// `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 /// - Returns: the name written, so a caller can chain straight into the frontmatter write
/// without restating it. /// 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") 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) let fileURL = root.appendingPathComponent(name)
try atomicWrite(data, at: fileURL, operation: operation) try atomicWrite(data, at: fileURL, operation: operation)
// The bytes are already in hand, so this is the hash-what-you-wrote form rather than // 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 // 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 // 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. // 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 { var body: some View {
+27
View File
@@ -69,6 +69,33 @@ enum BoardBackdrop {
return imageURL(named: path, inBoardRoot: root) 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** /// Whether this board paints a background of its own **the window-chrome predicate**
/// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one /// (`BoardWindowHost`, `HostedWindowController.setExtendsContentUnderTitlebar`): a board with one
/// runs its content under a transparent title bar, and a board without one keeps the standard /// runs its content under a transparent title bar, and a board without one keeps the standard
+14
View File
@@ -326,6 +326,20 @@ struct BackgroundImagePathTests {
== "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png") == "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png")
} }
/// **Both locations resolve through the one rule, unchanged** (ruled 2026-08-09): a legacy board
/// naming a bare `facets.png` at board root and a current one naming the qualified
/// `.backgrounds/facets.png` both land as ordinary nested-path resolutions `.backgrounds/` is
/// simply a subfolder name to this check, exactly as `art/backdrops/` already was above. No new
/// resolver machinery exists for the app-claimed folder; this is the "minimal change" the ruling
/// chose, pinned as its own test.
@Test("A legacy root-level image and a current .backgrounds/ one both resolve")
func resolvesBothLegacyAndCurrentLocations() {
#expect(BoardBackdrop.imageURL(named: "facets.png", inBoardRoot: root)?.path
== "/Users/someone/Boards/Work.kanban/facets.png")
#expect(BoardBackdrop.imageURL(named: ".backgrounds/facets.png", inBoardRoot: root)?.path
== "/Users/someone/Boards/Work.kanban/.backgrounds/facets.png")
}
/// The check is about where the path *ends up*, not how it is spelled: a climb that lands back /// The check is about where the path *ends up*, not how it is spelled: a climb that lands back
/// inside the board is an ordinary file in it. /// inside the board is an ordinary file in it.
@Test("A path that climbs and returns is still inside") @Test("A path that climbs and returns is still inside")
+20
View File
@@ -225,6 +225,26 @@ struct BoardLoaderStrayTests {
#expect(result.warnings.isEmpty) #expect(result.warnings.isEmpty)
} }
/// **`.backgrounds/` is invisible to the loader** (03-board-ui.md § Styling Capabilities; ruled
/// 2026-08-09) no code change earns this: it is a hidden, non-UUID-shaped name, exactly the
/// stray shape `.trash` and `.DS_Store` already exercise above. Its contents (a generated PNG, in
/// this case) never appear as a lane candidate and never draw a `nonUUIDFolderIgnored` warning,
/// because the directory walk that would notice never descends into it at all.
@Test func backgroundsFolderAndItsContentsAreIgnoredWithoutWarning() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let lane = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.strayFile(".backgrounds/facets.png", contents: "not really a png")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
#expect(result.warnings.isEmpty)
}
@Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws { @Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws {
let fixture = try BoardFixture() let fixture = try BoardFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
+76 -32
View File
@@ -146,6 +146,25 @@ struct WriteBoardImageTests {
let receipts = ledger.outstandingEntries() let receipts = ledger.outstandingEntries()
#expect(receipts.contains { $0.key.hasSuffix("/facets.png") }) #expect(receipts.contains { $0.key.hasSuffix("/facets.png") })
} }
/// **`.backgrounds/` did not exist before this ruling** (2026-08-09), so the first board on a
/// Mac to ever generate or paste a background hands this call a folder nothing has made yet the
/// call has to make it rather than fail, or every board's very first background write would.
@Test("The destination folder is created when it does not exist yet")
func createsTheDestinationFolderWhenMissing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let backgroundsFolder = fixture.root.appendingPathComponent(
BoardBackdrop.backgroundsFolderName, isDirectory: true
)
let name = try BoardWriter.writeBoardImage(
data: png, named: "facets.png", inRoot: backgroundsFolder, operation: .setBoardBackground
)
#expect(name == "facets.png")
#expect(try fixture.data("\(BoardBackdrop.backgroundsFolderName)/facets.png") == png)
}
} }
// MARK: - The gesture // MARK: - The gesture
@@ -154,7 +173,7 @@ struct WriteBoardImageTests {
@Suite("BoardStore ▸ applyGeneratedBackground") @Suite("BoardStore ▸ applyGeneratedBackground")
struct GeneratedBackgroundWriteTests { struct GeneratedBackgroundWriteTests {
@Test("The picture lands in the folder and both subkeys point at it") @Test("The picture lands in .backgrounds/ and both subkeys point at it")
func writesTheFileAndTheFields() throws { func writesTheFileAndTheFields() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -162,11 +181,12 @@ struct GeneratedBackgroundWriteTests {
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")) #expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB"))
#expect(try fixture.data("facets.png") == png) #expect(try fixture.data(".backgrounds/facets.png") == png)
let after = try document(fixture) let after = try document(fixture)
#expect(after.background == .valid("#E0E5EB")) #expect(after.background == .valid("#E0E5EB"))
#expect(after.backgroundImage == .valid("facets.png")) #expect(after.backgroundImage == .valid(".backgrounds/facets.png"))
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\", image: \"facets.png\"}")) #expect(try fixture.indexText("")
.contains("background: {color: \"#E0E5EB\", image: \".backgrounds/facets.png\"}"))
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
@@ -198,7 +218,7 @@ struct GeneratedBackgroundWriteTests {
store.applyGeneratedBackground(png: png, colorHex: "#513D1A") store.applyGeneratedBackground(png: png, colorHex: "#513D1A")
let text = try fixture.indexText("") let text = try fixture.indexText("")
#expect(text.contains("background: {color: \"#513D1A\", image: \"facets.png\"}")) #expect(text.contains("background: {color: \"#513D1A\", image: \".backgrounds/facets.png\"}"))
#expect(text.contains("project: lanework # agent overlay")) #expect(text.contains("project: lanework # agent overlay"))
#expect(text.contains("created: 2026-01-01T09:00:00Z")) #expect(text.contains("created: 2026-01-01T09:00:00Z"))
#expect(text.contains("Board description.")) #expect(text.contains("Board description."))
@@ -216,11 +236,10 @@ struct GeneratedBackgroundWriteTests {
await reload(store) await reload(store)
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A") store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == otherPNG) #expect(try fixture.data(".backgrounds/facets.png") == otherPNG)
#expect(try document(fixture).backgroundImage == .valid("facets.png")) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
// No ladder: the reload in between also seeds this board's `.gitignore`, so the listing is // No ladder: `.backgrounds/` holds exactly the one picture this store ever wrote to it.
// filtered to the pictures rather than compared whole. #expect(try fixture.entryNames(".backgrounds") == ["facets.png"])
#expect(try fixture.entryNames("").filter { $0.hasSuffix(".png") } == ["facets.png"])
} }
/// **The reroll's echo**: rolling again before the watcher has rounded the first write back must /// **The reroll's echo**: rolling again before the watcher has rounded the first write back must
@@ -235,55 +254,80 @@ struct GeneratedBackgroundWriteTests {
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A") store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == otherPNG) #expect(try fixture.data(".backgrounds/facets.png") == otherPNG)
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"]) #expect(try fixture.entryNames("") == [".backgrounds", Ident.lane1, "index.md"])
} }
/// **Somebody else's `facets.png` is never written through** a file the user put in the board /// **Somebody else's `.backgrounds/facets.png` is never written through** a file the user put in
/// folder is theirs, and the Finder ladder is how the app steps aside from a name it does not own. /// that folder is theirs, and the Finder ladder is how the app steps aside from a name it does not
@Test("A foreign file on the name pushes the generation to 'facets 2.png'") /// own. A same-named file at board root the legacy location is a different question entirely
/// (`aLegacyBareReferenceIsNotOverwrittenInPlace`, below): it no longer sits anywhere this write
/// ever looks.
@Test("A foreign file on the name pushes the generation to '.backgrounds/facets 2.png'")
func stepsAsideFromAForeignFile() throws { func stepsAsideFromAForeignFile() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let mine = Data("not the app's".utf8) let mine = Data("not the app's".utf8)
try fixture.file("facets.png", mine) try fixture.file(".backgrounds/facets.png", mine)
let (store, _) = try makeStore(fixture) let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("facets.png") == mine, "the user's file is untouched") #expect(try fixture.data(".backgrounds/facets.png") == mine, "the user's file is untouched")
#expect(try fixture.data("facets 2.png") == png) #expect(try fixture.data(".backgrounds/facets 2.png") == png)
#expect(try document(fixture).backgroundImage == .valid("facets 2.png")) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets 2.png"))
} }
/// The same ladder when the board already names a *different* image: the hand-written path is /// The same ladder when the board already names a *different* image: the hand-written path is
/// the escape hatch and stays on disk, and the generation lands beside it. /// the escape hatch and stays on disk, and the generation lands beside it inside `.backgrounds/`,
/// composing with the collision-ladder test above.
@Test("A board naming another image keeps it and generates alongside") @Test("A board naming another image keeps it and generates alongside")
func keepsAHandWrittenImage() throws { func keepsAHandWrittenImage() throws {
let fixture = try makeBoard(background: "{image: sunset.jpg}") let fixture = try makeBoard(background: "{image: sunset.jpg}")
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.file("sunset.jpg", Data("photo".utf8)) try fixture.file("sunset.jpg", Data("photo".utf8))
try fixture.file("facets.png", Data("someone else's".utf8)) try fixture.file(".backgrounds/facets.png", Data("someone else's".utf8))
let (store, _) = try makeStore(fixture) let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("sunset.jpg") == Data("photo".utf8)) #expect(try fixture.data("sunset.jpg") == Data("photo".utf8))
#expect(try document(fixture).backgroundImage == .valid("facets 2.png")) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets 2.png"))
} }
/// A board whose generated file was deleted in Finder is a board with a broken backdrop, and /// A board whose generated file was deleted in Finder is a board with a broken backdrop, and
/// regenerating is exactly the repair so the name is reused rather than laddered. /// regenerating is exactly the repair so the name is reused rather than laddered, **when the
@Test("A missing file under our own name is rewritten, not laddered") /// reference is already the qualified `.backgrounds/` one this scheme writes**.
@Test("A missing file under our own qualified name is rewritten, not laddered")
func rewritesAMissingFile() throws { func rewritesAMissingFile() throws {
let fixture = try makeBoard(background: "{color: fern, image: .backgrounds/facets.png}")
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data(".backgrounds/facets.png") == png)
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
}
/// **Legacy stays where it is no migration** (ruled 2026-08-09). A board whose `background.image`
/// still names a bare `facets.png` at board root the shape every board carried before this
/// ruling does not read as "ours to overwrite in place": only the qualified `.backgrounds/`
/// spelling does (`rewritesAMissingFile`, above). So a regeneration on a legacy board writes a
/// fresh `.backgrounds/facets.png` rather than touching the root-level file the field used to name
/// even though nothing is actually there to protect in this case (the field names a file that
/// was never created), the point is the *reference's shape* decides, not disk contents.
@Test("A legacy bare reference is left alone; the regeneration writes a fresh .backgrounds/ file")
func aLegacyBareReferenceIsNotOverwrittenInPlace() throws {
let fixture = try makeBoard(background: "{color: fern, image: facets.png}") let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture) let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("facets.png") == png) #expect(try fixture.data(".backgrounds/facets.png") == png)
#expect(try document(fixture).backgroundImage == .valid("facets.png")) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
#expect(!fixture.exists("facets.png"), "no legacy file was ever created — nothing to leave behind")
} }
/// A locked board writes nothing at all not the picture, not the fields. /// A locked board writes nothing at all not the picture, not the fields.
@@ -295,7 +339,7 @@ struct GeneratedBackgroundWriteTests {
store.enterVanishedRootLock() store.enterVanishedRootLock()
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") == false) #expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") == false)
#expect(!fixture.exists("facets.png")) #expect(!fixture.exists(".backgrounds/facets.png"))
#expect(try document(fixture).backgroundImage == .missing) #expect(try document(fixture).backgroundImage == .missing)
} }
} }
@@ -327,7 +371,7 @@ struct GeneratedBackgroundUndoTests {
history.redo() history.redo()
let redone = try document(fixture) let redone = try document(fixture)
#expect(redone.background == .valid("#E0E5EB")) #expect(redone.background == .valid("#E0E5EB"))
#expect(redone.backgroundImage == .valid("facets.png")) #expect(redone.backgroundImage == .valid(".backgrounds/facets.png"))
} }
/// A prior colour comes back as itself rather than as an absence the same reading `applyStyle`'s /// A prior colour comes back as itself rather than as an absence the same reading `applyStyle`'s
@@ -362,8 +406,8 @@ struct GeneratedBackgroundUndoTests {
history.undo() history.undo()
#expect(try document(fixture).background == .valid("#E0E5EB")) #expect(try document(fixture).background == .valid("#E0E5EB"))
#expect(try document(fixture).backgroundImage == .valid("facets.png")) #expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
#expect(try fixture.data("facets.png") == otherPNG, "the first generation's bytes are gone") #expect(try fixture.data(".backgrounds/facets.png") == otherPNG, "the first generation's bytes are gone")
} }
/// A foreign edit to the field the step wrote stales it the field-level predicate, applied to /// A foreign edit to the field the step wrote stales it the field-level predicate, applied to
@@ -437,7 +481,7 @@ struct SolidBackgroundWriteTests {
/// does not delete the picture on disk only the field that pointed at it. Undo has to have /// does not delete the picture on disk only the field that pointed at it. Undo has to have
/// something to point back to (`SolidBackgroundUndoTests.restoresAPriorGeneratedImage`), and even /// something to point back to (`SolidBackgroundUndoTests.restoresAPriorGeneratedImage`), and even
/// without undo the file is the user's now, not litter the app cleans up on its own. /// without undo the file is the user's now, not litter the app cleans up on its own.
@Test("facets.png stays on disk when the board had one") @Test(".backgrounds/facets.png stays on disk when the board had one")
func leavesTheGeneratedFileOnDisk() throws { func leavesTheGeneratedFileOnDisk() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -446,7 +490,7 @@ struct SolidBackgroundWriteTests {
store.applySolidBackground(colorHex: "#513D1A") store.applySolidBackground(colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == png, "the bytes are untouched") #expect(try fixture.data(".backgrounds/facets.png") == png, "the bytes are untouched")
#expect(try document(fixture).backgroundImage == .missing, "only the field is gone") #expect(try document(fixture).backgroundImage == .missing, "only the field is gone")
} }
+17 -16
View File
@@ -665,7 +665,7 @@ struct PasteBoardBackgroundTests {
try FrontmatterDocument.parse(fixture.indexText("")) try FrontmatterDocument.parse(fixture.indexText(""))
} }
@Test("The picture lands in the board folder and `background.image` names it") @Test("The picture lands in .backgrounds/ and `background.image` names it")
func thePictureLands() throws { func thePictureLands() throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
@@ -674,8 +674,8 @@ struct PasteBoardBackgroundTests {
#expect(harness.clipboard.pasteBoardBackground(into: harness.store)) #expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data("Pasted Background.png") == png) #expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == png)
#expect(try background(harness.fixture).backgroundImage.value == "Pasted Background.png") #expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
#expect(harness.store.banners.oneShots.isEmpty) #expect(harness.store.banners.oneShots.isEmpty)
} }
@@ -693,7 +693,7 @@ struct PasteBoardBackgroundTests {
let document = try background(harness.fixture) let document = try background(harness.fixture)
#expect(document.background.value == "#112233") #expect(document.background.value == "#112233")
#expect(document.backgroundImage.value == "Pasted Background.png") #expect(document.backgroundImage.value == ".backgrounds/Pasted Background.png")
} }
/// The generator's overwrite-in-place rule, inherited: re-pasting must not leave a folder full of /// The generator's overwrite-in-place rule, inherited: re-pasting must not leave a folder full of
@@ -710,24 +710,25 @@ struct PasteBoardBackgroundTests {
harness.pasteboard.seed([(UTType.png.identifier, second)]) harness.pasteboard.seed([(UTType.png.identifier, second)])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store)) #expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data("Pasted Background.png") == second) #expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == second)
#expect(!harness.fixture.exists("Pasted Background 2.png")) #expect(!harness.fixture.exists(".backgrounds/Pasted Background 2.png"))
} }
/// A hand-placed file of that name is the user's, and is never written through the ladder's /// A hand-placed file of that name **inside `.backgrounds/`** is the user's, and is never written
/// rule, the same one the generator follows. /// through the ladder's rule, the same one the generator follows. A file of that name at board
/// root would not collide at all any more: it simply is not where this write ever looks.
@Test("A file already holding the name is stepped around") @Test("A file already holding the name is stepped around")
func anExistingNameIsRespected() throws { func anExistingNameIsRespected() throws {
let harness = try makeClipboardHarness() let harness = try makeClipboardHarness()
defer { harness.tearDown() } defer { harness.tearDown() }
let mine = Data("hand placed".utf8) let mine = Data("hand placed".utf8)
try harness.fixture.file("Pasted Background.png", mine) try harness.fixture.file(".backgrounds/Pasted Background.png", mine)
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))]) harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store)) #expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data("Pasted Background.png") == mine) #expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == mine)
#expect(try background(harness.fixture).backgroundImage.value == "Pasted Background 2.png") #expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background 2.png")
} }
/// The two producers must not read each other's echo: a paste landing inside the reroll's window /// The two producers must not read each other's echo: a paste landing inside the reroll's window
@@ -743,8 +744,8 @@ struct PasteBoardBackgroundTests {
#expect(harness.clipboard.pasteBoardBackground(into: harness.store)) #expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data(FacetsGenerator.fileName) == generated, "untouched") #expect(try harness.fixture.data(".backgrounds/\(FacetsGenerator.fileName)") == generated, "untouched")
#expect(try background(harness.fixture).backgroundImage.value == "Pasted Background.png") #expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
} }
@Test("⌘Z puts the image subkey back and leaves the colour alone") @Test("⌘Z puts the image subkey back and leaves the colour alone")
@@ -757,17 +758,17 @@ struct PasteBoardBackgroundTests {
#expect(store.applyPastedBackground(data: encodedImage(.png), fileExtension: "png")) #expect(store.applyPastedBackground(data: encodedImage(.png), fileExtension: "png"))
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value #expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
== "Pasted Background.png") == ".backgrounds/Pasted Background.png")
#expect(history.undoActionName == "Restyle Board") #expect(history.undoActionName == "Restyle Board")
history.undo() history.undo()
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.isMissing) #expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.isMissing)
// The file survives the undo "the undo restores the field, not the bytes". // The file survives the undo "the undo restores the field, not the bytes".
#expect(fixture.exists("Pasted Background.png")) #expect(fixture.exists(".backgrounds/Pasted Background.png"))
history.redo() history.redo()
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value #expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
== "Pasted Background.png") == ".backgrounds/Pasted Background.png")
} }
} }