From d0c546179f7b384d5a89ea99a18a4918a18aad1d Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 08:00:55 -0400 Subject: [PATCH] =?UTF-8?q?Generated=20and=20pasted=20board=20backgrounds?= =?UTF-8?q?=20move=20into=20.backgrounds/=20=E2=80=94=20the=20board=20root?= =?UTF-8?q?=20stops=20collecting=20the=20app's=20own=20pictures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/LiveStore/BoardStore.swift | 111 +++++++++++++----- Kanban/Storage/BackgroundField.swift | 8 +- Kanban/Storage/BoardWriter.swift | 27 ++++- .../Board/Backgrounds/BoardThemeTabView.swift | 10 +- Kanban/UI/Board/BoardBackdropImage.swift | 27 +++++ KanbanTests/BoardBackgroundTests.swift | 14 +++ KanbanTests/BoardLoaderTests.swift | 20 ++++ KanbanTests/GeneratedBackgroundTests.swift | 108 ++++++++++++----- KanbanTests/PasteImageTests.swift | 33 +++--- 9 files changed, 270 insertions(+), 88 deletions(-) diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index ea5dc0e..ce15175 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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.` 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.` 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 diff --git a/Kanban/Storage/BackgroundField.swift b/Kanban/Storage/BackgroundField.swift index b4487f2..a37d82c 100644 --- a/Kanban/Storage/BackgroundField.swift +++ b/Kanban/Storage/BackgroundField.swift @@ -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 diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index a6e5e14..e9d87f7 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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 diff --git a/Kanban/UI/Board/Backgrounds/BoardThemeTabView.swift b/Kanban/UI/Board/Backgrounds/BoardThemeTabView.swift index 2aae7e0..e3b3196 100644 --- a/Kanban/UI/Board/Backgrounds/BoardThemeTabView.swift +++ b/Kanban/UI/Board/Backgrounds/BoardThemeTabView.swift @@ -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 { diff --git a/Kanban/UI/Board/BoardBackdropImage.swift b/Kanban/UI/Board/BoardBackdropImage.swift index 122f784..1f38af5 100644 --- a/Kanban/UI/Board/BoardBackdropImage.swift +++ b/Kanban/UI/Board/BoardBackdropImage.swift @@ -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 diff --git a/KanbanTests/BoardBackgroundTests.swift b/KanbanTests/BoardBackgroundTests.swift index 3cb8a8a..933da0e 100644 --- a/KanbanTests/BoardBackgroundTests.swift +++ b/KanbanTests/BoardBackgroundTests.swift @@ -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") diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 23d4418..5b11b4f 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -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() } diff --git a/KanbanTests/GeneratedBackgroundTests.swift b/KanbanTests/GeneratedBackgroundTests.swift index c750b0b..b6036b3 100644 --- a/KanbanTests/GeneratedBackgroundTests.swift +++ b/KanbanTests/GeneratedBackgroundTests.swift @@ -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") } diff --git a/KanbanTests/PasteImageTests.swift b/KanbanTests/PasteImageTests.swift index dc1725f..af3428d 100644 --- a/KanbanTests/PasteImageTests.swift +++ b/KanbanTests/PasteImageTests.swift @@ -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") } }