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
+24 -1
View File
@@ -59,12 +59,35 @@ extension FrontmatterDocument {
}
return
}
setBackgroundSubkey(FrontmatterKeys.Background.color, to: value)
}
/// **Writes the `image` subkey** `setStyleValue`'s colour write, one subkey over
/// (03-board-ui.md § Styling Capabilities).
///
/// 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
/// same way the colour write preserves an image: by subkey.
///
/// Everything else is `setStyleValue`'s, deliberately shared rather than restated: the same
/// in-place merge, the same flow-mapping emission, the same removal-empties-the-key rule, and the
/// same replacement of a non-mapping shape the schema could never read.
public mutating func setBackgroundImage(_ value: String?) {
setBackgroundSubkey(FrontmatterKeys.Background.image, to: value)
}
/// The one merge both subkey writes go through see `setStyleValue` for every rule it applies.
private mutating func setBackgroundSubkey(_ subkey: String, to value: String?) {
let key = FrontmatterKeys.background
// Only a mapping has subkeys worth carrying; every other shape absent, the retired scalar,
// a sequence starts empty and is replaced outright by what the app writes.
var existing: [YAMLValue.Pair] = []
if case let .mapping(pairs)? = self.value(for: key) { existing = pairs }
let merged = Self.merged(existing, subkey: FrontmatterKeys.Background.color, value: value)
let merged = Self.merged(existing, subkey: subkey, value: value)
if merged.isEmpty {
remove(key)
} else {
+81 -8
View File
@@ -250,11 +250,26 @@ public enum BoardWriter: Sendable {
/// removed best-effort and `.io` is thrown: the destination is either the old bytes or the
/// new ones, never a mix, and never a directory littered with half-written files.
static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
try atomicWrite(Data(text.utf8), at: fileURL, operation: operation)
// **The receipt, dropped after the bytes land and before the call returns** (the
// EchoLedger's contract, 02-architecture.md Components). This one line covers every
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
// recreate, the task-marker flip, the body save and the raw-source Apply.
EchoLedger.current?.recordWrite(at: fileURL, text: text)
}
/// The temp-and-rename itself, with no opinion about what the bytes are shared by the text
/// path above and by `writeBoardImage`, so there is one atomic write in the app rather than two
/// that could drift on the temp name, the cleanup or the `rename(2)`.
///
/// It drops **no receipt**: what a write means to the echo ledger differs between an `index.md`
/// and a generated image, so each caller records its own.
private static func atomicWrite(_ data: Data, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
let directory = fileURL.deletingLastPathComponent()
let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")
do {
try Data(text.utf8).write(to: tempURL)
try data.write(to: tempURL)
} catch {
try? FileManager.default.removeItem(at: tempURL)
throw BoardWriteError(
@@ -281,11 +296,51 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
)
}
// **The receipt, dropped after the bytes land and before the call returns** (the
// EchoLedger's contract, 02-architecture.md Components). This one line covers every
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
// recreate, the task-marker flip, the body save and the raw-source Apply.
EchoLedger.current?.recordWrite(at: fileURL, text: text)
}
// 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
/// Capabilities, the `background.image` half; `FacetsGenerator`).
///
/// 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
/// classifies as the app's rather than as a foreign write. A board whose backdrop is being
/// 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.
///
/// **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.
///
/// - Returns: the name written, so a caller can chain straight into the frontmatter write
/// without restating it.
@discardableResult
public static func writeBoardImage(
data: Data,
named name: String,
inRoot root: URL,
operation: WriteOperation
) throws(BoardWriteError) -> String {
guard !name.isEmpty, !name.contains("/"), name != ".", name != ".." else {
throw BoardWriteError(
operation: operation,
path: root.appendingPathComponent(name).path,
reason: .io(message: "'\(name)' is not a file name a board image can be written under")
)
}
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
// `recordImport`'s read-it-back-and-hope see `EchoLedger.recordImport(at:)` for the
// difference and why the app prefers this side of it wherever it can.
EchoLedger.current?.recordWrite(at: fileURL, data: data)
return name
}
// MARK: - Create
@@ -2883,6 +2938,21 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// delete 'Fix login'" would name a gesture they never made.
case migrateTombstone(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
/// **A generated board background landing** the PNG written into the board folder and the
/// `background` mapping's two subkeys pointed at it, one bracket
/// (`BoardStore.applyGeneratedBackground`; `FacetsGenerator`).
///
/// Its own case rather than a fold into `.style`, on the vocabulary's standing reasoning: a
/// restyle picks a value out of a grid of wells, while this **writes a file into the user's board
/// folder** a different act with a different failure ("the disk is full" means something else
/// when a megabyte of picture is involved), and the one styling gesture whose undo cannot put
/// everything back (see the store's own note on the overwritten bytes).
///
/// **No payload**, for `.mintBoardIndex`'s reason: there is one background per board, the user is
/// looking at the board while they press the control, and the board's title would name a thing
/// nobody could confuse for another.
case setBoardBackground
case resize(title: String?) // a lane's `width` the edge drag and the stepper alike (03-board-ui.md § Lane)
/// An inline title editor's commit the third inline editor's write (04-interactions.md
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
@@ -3117,9 +3187,11 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// `.seedGitignore`'s reasons at once: neither carries a title slot, and the board they
// repair has no readable title to enrich from a root with no `index.md` has no document
// at all, and one with no `schema` is the file the walk just refused.
// `.setBoardBackground` joins them on `.mintBoardIndex`'s reasoning: it carries no title
// slot, and the board it writes to is the one the user is looking at.
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema,
.mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
self
@@ -3183,7 +3255,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
.mintBoardIndex, .stampSchema,
.mintBoardIndex, .stampSchema, .setBoardBackground,
.displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
@@ -3210,6 +3282,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .purge(title): Self.phrase("purge", title)
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
case let .style(title): Self.phrase("style", title)
case .setBoardBackground: "set this board's background"
case let .resize(title): Self.phrase("resize", title)
case let .rename(title): Self.phrase("rename", title)
case let .duplicateBoard(title): Self.phrase("duplicate board", title)