The Background tab fills in — facets rendered to order, eight hues in a carousel

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 16:08:29 -04:00
parent 56e37be158
commit fb96e30df0
19 changed files with 3031 additions and 38 deletions
+7
View File
@@ -1136,6 +1136,13 @@ public final class BannerCenter {
if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
case let .style(title):
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
case .setBoardBackground:
// **"generate", because that is the button they pressed**, and no title because there is
// one board and they are looking at it. It deliberately says nothing about the *file*
// the picture and the colour under it land in one bracket, and a user who has never seen
// the PNG has no model of a half-written one; what failed, as far as they are concerned,
// is that the board still looks the way it did.
"Couldn't generate this board's background"
case let .resize(title):
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
case let .rename(title):
+144
View File
@@ -297,6 +297,16 @@ public final class BoardStore: HealHost {
/// refreshes nothing the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
public private(set) var landedReloads: Int = 0
/// **The generated background this store wrote, and the reload count it was written at** the
/// reroll's echo (`generatedBackgroundName(replacing:inRoot:)`, which is the only reader and
/// carries the whole reasoning).
///
/// `@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, 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
/// describe the tree currently on screen.
@@ -1944,6 +1954,140 @@ 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`).
///
/// **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
/// task, and `FacetsGenerator` is `Sendable` and main-actor-free precisely so it can go there.
/// What arrives here is finished `Data`. This method is synchronous for `applyStyle`'s reason: the
/// write rides one `performWrite` bracket, which suspends the watcher a suspension that must not
/// span an `await`.
///
/// ### One bracket, two files
///
/// The image lands first and the frontmatter second, so a failure to write the picture never
/// leaves the board naming one that is not there. The reverse order would; the two are not atomic
/// together, and this is the ordering that makes the non-atomic half harmless. Both are inside the
/// same bracket, so the churn rounds back as one app-mediated reload and mints one commit on git
/// boards the style batch's rule, one gesture one commit.
///
/// ### The name is chosen, not minted
///
/// 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.
///
/// ### The undo restores the fields, not the bytes
///
/// Stated plainly because it is the one place in the app where an inverse is not a full return:
/// Z puts `background.image` and `background.color` back to what they said, and if this gesture
/// **overwrote** a previous generation's PNG, those pixels are gone nothing in the app kept a
/// copy. The consequence is confined to regenerating over the app's own output (the image name is
/// unchanged, so the fields come back pointing at a file whose contents are the new picture); an
/// undo of the *first* generation removes the subkey and the board looks exactly as it did. Every
/// alternative a temp copy, a versioned name buys byte-perfect undo of a picture nobody asked
/// to keep at the price of litter in a folder the user owns.
///
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
/// like every other gesture with no second thing to do about it.
///
/// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`).
/// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`)
/// written as `background.color` so the underlay, and a board copied without its picture,
/// degrade to the image's own average rather than to nothing.
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
/// coming" (`setLaneWidth`'s rule). Discardable: the popover has nothing to do with the answer.
@discardableResult
public func applyGeneratedBackground(png: Data, colorHex: String) -> Bool {
let root = rootURL
let priorImage = snapshot.backgroundImage
let priorColor = snapshot.background
let name = generatedBackgroundName(replacing: priorImage.value, inRoot: root)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.writeBoardImage(
data: png, named: name, inRoot: root, 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)
}
}
guard landed != nil else { return false }
generatedBackgroundEcho = (name: name, 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))],
redoExpects: [.present(root, .background(priorColor.value), .backgroundImage(priorImage.value))]
) { _ in
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
// A malformed prior reads as a removal on both subkeys, which is `restore(_:to:in:)`'s
// own rule and the one the redo expectation above is written against.
document.setBackgroundImage(priorImage.value)
Self.restore(priorColor, to: FrontmatterKeys.background, in: &document)
}
} redo: { _ in
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
Self.pointBackground(at: name, color: colorHex, in: &document)
}
}
return true
}
/// The name a generated background is written under: **ours to overwrite**, or the next free one.
///
/// `current` is what `background.image` says now. When that is already the generated name 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 fixes. Otherwise the Finder ladder decides, which yields the plain name when
/// nothing holds it and `facets 2.png` when something does.
///
/// ### The reroll's echo
///
/// The snapshot is by construction one reload behind every write the app makes (the one-way
/// flow), and rerolling is a gesture people repeat *fast* faster than FSEvents rounds a write
/// back. Read from the snapshot alone, the second roll would see no `background.image` yet, find
/// its own first roll's file sitting on the name, and step aside to `facets 2.png`: a folder full
/// of abandoned pictures, which is the exact outcome the fixed name exists to prevent.
///
/// So a name this store wrote **since the last landed reload** counts as ours. The gate is the
/// reload count rather than a timer or a flag, because it is the honest statement of the problem:
/// while it has not moved, the snapshot *cannot* know about the write, so the store's own memory
/// is the better authority. Once a reload lands, the snapshot's `background.image` takes over and
/// this memory stops being consulted including when a hand edit pointed the board somewhere
/// else in the meantime.
private func generatedBackgroundName(replacing current: String?, inRoot root: URL) -> String {
if current == FacetsGenerator.fileName { return FacetsGenerator.fileName }
if let echo = generatedBackgroundEcho, echo.reloads == landedReloads { return echo.name }
return BoardWriter.freshName(for: FacetsGenerator.fileName, in: root)
}
/// Both subkeys, written into the mapping rather than over it (BackgroundField.swift) spelled
/// once so the gesture and its redo cannot drift apart on the order they land in.
///
/// Colour first, so a board that had no `background` key at all comes out spelled the way
/// 01-storage-format.md § Frontmatter writes it: `{color: , image: }`.
private static func pointBackground(at name: String, color: String, in document: inout FrontmatterDocument) {
document.setStyleValue(color, for: FrontmatterKeys.background)
document.setBackgroundImage(name)
}
// MARK: - Creation
/// Creates a lane at the board's right end File New Lane N (11-command-nexus.md).