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
+14
View File
@@ -326,6 +326,20 @@ struct BackgroundImagePathTests {
== "/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
/// inside the board is an ordinary file in it.
@Test("A path that climbs and returns is still inside")
+20
View File
@@ -225,6 +225,26 @@ struct BoardLoaderStrayTests {
#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 {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
+76 -32
View File
@@ -146,6 +146,25 @@ struct WriteBoardImageTests {
let receipts = ledger.outstandingEntries()
#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
@@ -154,7 +173,7 @@ struct WriteBoardImageTests {
@Suite("BoardStore ▸ applyGeneratedBackground")
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 {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -162,11 +181,12 @@ struct GeneratedBackgroundWriteTests {
#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)
#expect(after.background == .valid("#E0E5EB"))
#expect(after.backgroundImage == .valid("facets.png"))
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\", image: \"facets.png\"}"))
#expect(after.backgroundImage == .valid(".backgrounds/facets.png"))
#expect(try fixture.indexText("")
.contains("background: {color: \"#E0E5EB\", image: \".backgrounds/facets.png\"}"))
#expect(store.banners.oneShots.isEmpty)
}
@@ -198,7 +218,7 @@ struct GeneratedBackgroundWriteTests {
store.applyGeneratedBackground(png: png, colorHex: "#513D1A")
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("created: 2026-01-01T09:00:00Z"))
#expect(text.contains("Board description."))
@@ -216,11 +236,10 @@ struct GeneratedBackgroundWriteTests {
await reload(store)
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == otherPNG)
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
// No ladder: the reload in between also seeds this board's `.gitignore`, so the listing is
// filtered to the pictures rather than compared whole.
#expect(try fixture.entryNames("").filter { $0.hasSuffix(".png") } == ["facets.png"])
#expect(try fixture.data(".backgrounds/facets.png") == otherPNG)
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
// No ladder: `.backgrounds/` holds exactly the one picture this store ever wrote to it.
#expect(try fixture.entryNames(".backgrounds") == ["facets.png"])
}
/// **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: otherPNG, colorHex: "#513D1A")
#expect(try fixture.data("facets.png") == otherPNG)
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"])
#expect(try fixture.data(".backgrounds/facets.png") == otherPNG)
#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
/// folder is theirs, and the Finder ladder is how the app steps aside from a name it does not own.
@Test("A foreign file on the name pushes the generation to 'facets 2.png'")
/// **Somebody else's `.backgrounds/facets.png` is never written through** a file the user put in
/// that folder is theirs, and the Finder ladder is how the app steps aside from a name it does not
/// 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 {
let fixture = try makeBoard()
defer { fixture.tearDown() }
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)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("facets.png") == mine, "the user's file is untouched")
#expect(try fixture.data("facets 2.png") == png)
#expect(try document(fixture).backgroundImage == .valid("facets 2.png"))
#expect(try fixture.data(".backgrounds/facets.png") == mine, "the user's file is untouched")
#expect(try fixture.data(".backgrounds/facets 2.png") == 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 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")
func keepsAHandWrittenImage() throws {
let fixture = try makeBoard(background: "{image: sunset.jpg}")
defer { fixture.tearDown() }
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)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#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
/// regenerating is exactly the repair so the name is reused rather than laddered.
@Test("A missing file under our own name is rewritten, not laddered")
/// regenerating is exactly the repair so the name is reused rather than laddered, **when the
/// 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 {
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}")
defer { fixture.tearDown() }
let (store, _) = try makeStore(fixture)
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
#expect(try fixture.data("facets.png") == png)
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
#expect(try fixture.data(".backgrounds/facets.png") == 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.
@@ -295,7 +339,7 @@ struct GeneratedBackgroundWriteTests {
store.enterVanishedRootLock()
#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)
}
}
@@ -327,7 +371,7 @@ struct GeneratedBackgroundUndoTests {
history.redo()
let redone = try document(fixture)
#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
@@ -362,8 +406,8 @@ struct GeneratedBackgroundUndoTests {
history.undo()
#expect(try document(fixture).background == .valid("#E0E5EB"))
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
#expect(try fixture.data("facets.png") == otherPNG, "the first generation's bytes are gone")
#expect(try document(fixture).backgroundImage == .valid(".backgrounds/facets.png"))
#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
@@ -437,7 +481,7 @@ struct SolidBackgroundWriteTests {
/// 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
/// 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 {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -446,7 +490,7 @@ struct SolidBackgroundWriteTests {
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")
}
+17 -16
View File
@@ -665,7 +665,7 @@ struct PasteBoardBackgroundTests {
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 {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
@@ -674,8 +674,8 @@ struct PasteBoardBackgroundTests {
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data("Pasted Background.png") == png)
#expect(try background(harness.fixture).backgroundImage.value == "Pasted Background.png")
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == png)
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
#expect(harness.store.banners.oneShots.isEmpty)
}
@@ -693,7 +693,7 @@ struct PasteBoardBackgroundTests {
let document = try background(harness.fixture)
#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
@@ -710,24 +710,25 @@ struct PasteBoardBackgroundTests {
harness.pasteboard.seed([(UTType.png.identifier, second)])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data("Pasted Background.png") == second)
#expect(!harness.fixture.exists("Pasted Background 2.png"))
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == second)
#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
/// rule, the same one the generator follows.
/// A hand-placed file of that name **inside `.backgrounds/`** is the user's, and is never written
/// 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")
func anExistingNameIsRespected() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
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))])
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data("Pasted Background.png") == mine)
#expect(try background(harness.fixture).backgroundImage.value == "Pasted Background 2.png")
#expect(try harness.fixture.data(".backgrounds/Pasted Background.png") == mine)
#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
@@ -743,8 +744,8 @@ struct PasteBoardBackgroundTests {
#expect(harness.clipboard.pasteBoardBackground(into: harness.store))
#expect(try harness.fixture.data(FacetsGenerator.fileName) == generated, "untouched")
#expect(try background(harness.fixture).backgroundImage.value == "Pasted Background.png")
#expect(try harness.fixture.data(".backgrounds/\(FacetsGenerator.fileName)") == generated, "untouched")
#expect(try background(harness.fixture).backgroundImage.value == ".backgrounds/Pasted Background.png")
}
@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(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
== "Pasted Background.png")
== ".backgrounds/Pasted Background.png")
#expect(history.undoActionName == "Restyle Board")
history.undo()
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.isMissing)
// 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()
#expect(try FrontmatterDocument.parse(fixture.indexText("")).backgroundImage.value
== "Pasted Background.png")
== ".backgrounds/Pasted Background.png")
}
}