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
+3 -1
View File
@@ -271,6 +271,8 @@ public final class CardWindowUndo {
return .path(url.standardizedFileURL)
}
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
private static let fieldOrder: [ExpectedField.Kind] = [
.title, .order, .width, .background, .backgroundImage, .icon, .body,
]
}
}
+12
View File
@@ -30,6 +30,15 @@ public enum ExpectedField: Sendable, Equatable {
/// `background` the styling gesture's colour dimension.
case background(String?)
/// The `background` mapping's **`image` subkey** the generated-background gesture's other half
/// (`BoardStore.applyGeneratedBackground`).
///
/// Its own case rather than a second reading of `.background`, because they are two independent
/// values under one key: a board can have its colour changed from the wells while its image
/// stays, and the step that wrote the image must not stale because somebody picked a colour
/// afterwards. `nil` is the absent subkey, exactly as everywhere else here.
case backgroundImage(String?)
/// `icon` the styling gesture's symbol dimension.
case icon(String?)
@@ -46,6 +55,7 @@ public enum ExpectedField: Sendable, Equatable {
case .order: .order
case .width: .width
case .background: .background
case .backgroundImage: .backgroundImage
case .icon: .icon
case .body: .body
}
@@ -58,6 +68,7 @@ public enum ExpectedField: Sendable, Equatable {
case order
case width
case background
case backgroundImage
case icon
case body
}
@@ -319,6 +330,7 @@ public enum HistoryStaleness {
case let .order(expected): document.order.value == expected
case let .width(expected): equal(document.width, expected)
case let .background(expected): equal(document.background, expected)
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
case let .icon(expected): equal(document.icon, expected)
case let .body(expected): document.body == expected
}
+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).
+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)
@@ -0,0 +1,342 @@
import AppKit
import SwiftUI
/// **The board popover's Background tab** (03-board-ui.md § Board popover Background tab, settled
/// 2026-08-07) two surfaces sharing one tab, one manual and one generated.
///
/// ### Color
///
/// The `StyleEditorView` embed, re-homed here unchanged from the pre-tab popover body: same target
/// (`.board`), same `showsSymbols: false` (the inline `SymbolPicker` beside the rename field owns the
/// board's glyph, so a second symbol grid here would make the glyph read as two settings), same write
/// path (`StyleCommand.apply` `BoardStore.applyStyle` + `StyleRecents.record`). It brings its own
/// header ("Background") and its own inset (`StyleEditorLayout.popover(...).padding`), so nothing
/// here pads around it the anti-double-pad rule `BoardInfoView.inset`'s doc names.
///
/// ### Generated
///
/// A `FacetsRecipe` names a picture (`Backgrounds/FacetsRecipe.swift`); this section is the four
/// filters that narrow one (tone, hue strategy, mesh density, saturation) plus one seed per hue,
/// minted fresh on appear and re-minted by Reroll. Every swatch previews the exact recipe a click
/// would apply same filters, same seed, only the pixel width differs (384 for the strip, 3072 for
/// the file) so "what's clicked is what lands" (`FacetsGenerator`'s own claim).
// MARK: - Filters
/// **The generated picker's filter state**, and the pure mapping from it (plus a hue and a seed) to a
/// `FacetsRecipe` pulled out of the view so the default-tone rule and the recipe assembly are each
/// assertable without a popover on screen (`BoardBackgroundFiltersTests`).
struct BoardBackgroundFilters: Equatable {
var tone: FacetsRecipe.Tone
var colors: FacetsRecipe.Strategy
var mesh: FacetsRecipe.Density
var saturation: FacetsRecipe.Saturation
/// The picker's opening state: mono colours, medium mesh, mid saturation always only tone
/// follows the system, which is `defaultTone(colorScheme:)`'s own job.
static func initial(colorScheme: ColorScheme) -> BoardBackgroundFilters {
BoardBackgroundFilters(
tone: defaultTone(colorScheme: colorScheme), colors: .mono, mesh: .medium, saturation: .mid
)
}
/// Light appearance opens on Tone Light, dark on Tone Dark read once, at first appearance, so a
/// picker opened on a dark-mode Mac starts on swatches that read correctly against the popover
/// around them rather than ones chosen for the other appearance. `.light` covers every
/// `ColorScheme` case but `.dark` there is no third case today.
static func defaultTone(colorScheme: ColorScheme) -> FacetsRecipe.Tone {
colorScheme == .dark ? .dark : .light
}
/// One hue's recipe under these filters and a given seed the whole of "click a swatch, get a
/// board".
func recipe(hue: FacetsRecipe.Hue, seed: UInt64) -> FacetsRecipe {
FacetsRecipe(hue: hue, strategy: colors, density: mesh, tone: tone, saturation: saturation, seed: seed)
}
}
// MARK: - The tab
struct BoardBackgroundTabView: View {
let store: BoardStore
let recents: StyleRecents
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s own
/// parameter everything here that does not already carry its own inset (the Generated section)
/// pads by this amount instead of restating the derivation.
let inset: CGFloat
@Environment(\.colorScheme) private var colorScheme
@Environment(\.colorSchemeContrast) private var contrast
@State private var filters = BoardBackgroundFilters.initial(colorScheme: .light)
/// One seed per hue, in wheel order minted on appear and re-minted by Reroll. A filter change
/// leaves these alone (same geometry, new treatment); Reroll is the one gesture that changes them
/// (new geometry).
@State private var seeds: [FacetsRecipe.Hue: UInt64] = [:]
/// The carousel's previews, keyed by hue absent until the render for the current
/// `(filters, seeds)` pair lands, which is what the placeholder chip is for.
@State private var previews: [FacetsRecipe.Hue: CGImage] = [:]
/// Whether a swatch's 3072px render is in flight every swatch disables and the header grows a
/// small spinner for the duration, so a second click cannot race the first.
@State private var isApplying = false
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private let swatchWidth: CGFloat = 96
private var swatchHeight: CGFloat { (swatchWidth * 10 / 16).rounded() }
private let swatchCornerRadius: CGFloat = 6
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Always the board, whatever is selected. The S anchor is the selection-aware one
// ("nothing selected = the board"); this embed is the surface that exists *because* the
// board is a style target, so it can have no other target (§ Styling Controls: "the
// board popover's target is the board itself"). No symbol section the inline
// `SymbolPicker` beside the rename field above owns the board glyph.
StyleEditorView(store: store, recents: recents, target: .board, showsSymbols: false)
Divider()
generatedSection
.padding(inset)
}
.onAppear {
filters.tone = BoardBackgroundFilters.defaultTone(colorScheme: colorScheme)
if seeds.isEmpty { seeds = Self.mintSeeds() }
}
.task(id: previewKey) {
await renderPreviews()
}
}
// MARK: - Generated section
private var generatedSection: some View {
VStack(alignment: .leading, spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
HStack(spacing: 6) {
sectionHeader("Generated")
if isApplying {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Applying")
}
Spacer()
Button {
reroll()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.accessibilityLabel("New variations")
}
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 6) {
filterRow("Tone", selection: $filters.tone) { $0 == .light ? "Light" : "Dark" }
filterRow("Colors", selection: $filters.colors, label: colorsLabel)
filterRow("Mesh", selection: $filters.mesh, label: meshLabel)
filterRow("Saturation", selection: $filters.saturation, label: saturationLabel)
}
carousel
}
.font(.callout)
// The whole section disables as one surface under the read-only lock the same coarse rule
// `StyleEditorView` applies to itself just above: an editor whose gestures would be refused
// should not look available, and there is nothing here worth half-enabling (a filter nobody
// can commit is not a useful control to leave live).
.disabled(!store.acceptsBoardMutations)
}
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.subheadline.weight(.semibold))
}
/// One filter: a trailing-aligned label matching the Info tab's row shape beside a compact
/// segmented picker. The label is hidden from VoiceOver; the picker's own title (identical text)
/// is what it announces, so nothing is read twice.
private func filterRow<Value>(
_ title: String,
selection: Binding<Value>,
label: @escaping (Value) -> String
) -> some View where Value: Hashable, Value: CaseIterable, Value.AllCases: RandomAccessCollection {
GridRow {
Text(title)
.foregroundStyle(.secondary)
.gridColumnAlignment(.trailing)
.accessibilityHidden(true)
Picker(title, selection: selection) {
ForEach(Array(Value.allCases), id: \.self) { value in
Text(label(value)).tag(value)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.controlSize(.small)
}
}
private func colorsLabel(_ value: FacetsRecipe.Strategy) -> String {
switch value {
case .mono: "Mono"
case .duo: "Duo"
case .trio: "Trio"
}
}
private func meshLabel(_ value: FacetsRecipe.Density) -> String {
switch value {
case .coarse: "Coarse"
case .medium: "Medium"
case .fine: "Fine"
}
}
private func saturationLabel(_ value: FacetsRecipe.Saturation) -> String {
switch value {
case .soft: "Soft"
case .mid: "Mid"
case .rich: "Rich"
}
}
// MARK: - Carousel
private var carousel: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
ForEach(FacetsRecipe.Hue.allCases, id: \.self) { hue in
swatch(hue)
}
}
}
}
private func swatch(_ hue: FacetsRecipe.Hue) -> some View {
Button {
apply(hue)
} label: {
swatchFace(hue)
}
.buttonStyle(.plain)
// Layered on top of the section's own disabling: this half additionally freezes every swatch
// for the one gesture already running, so a second click cannot race the first's write.
.disabled(isApplying)
.opacity(isApplying ? 0.6 : 1)
.help(hue.displayName)
.accessibilityLabel("\(hue.displayName) — set generated background")
}
@ViewBuilder
private func swatchFace(_ hue: FacetsRecipe.Hue) -> some View {
Group {
if let image = previews[hue] {
Image(decorative: image, scale: 1)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
// The pending placeholder: the recipe's own primary colour, so the strip reads as
// "still painting this picture" rather than as a hole and is already the right
// colour if the render never manages to beat a quick reroll.
chip(hue)
}
}
.frame(width: swatchWidth, height: swatchHeight)
.clipShape(RoundedRectangle(cornerRadius: swatchCornerRadius))
.overlay(
RoundedRectangle(cornerRadius: swatchCornerRadius)
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
)
}
private func chip(_ hue: FacetsRecipe.Hue) -> Color {
guard let seed = seeds[hue] else { return Color(nsColor: .textBackgroundColor) }
let components = filters.recipe(hue: hue, seed: seed).primaryColor.components
return Color(red: components.red, green: components.green, blue: components.blue)
}
// MARK: - Rendering and applying
private struct PreviewKey: Equatable {
var filters: BoardBackgroundFilters
var seeds: [FacetsRecipe.Hue: UInt64]
}
private var previewKey: PreviewKey { PreviewKey(filters: filters, seeds: seeds) }
/// The strip's eight previews, off the main actor `FacetsGenerator.render` is pure, so this is
/// exactly the render `apply(_:)` below would do at 3072px, just smaller and for every hue at
/// once. Re-runs whenever `previewKey` changes (`.task(id:)`), which a filter edit or a Reroll
/// both do the same detach-and-await shape `BoardInfoTabView`'s disk walk uses.
private func renderPreviews() async {
previews = [:]
let filters = self.filters
let seeds = self.seeds
let rendered = await Task.detached(priority: .utility) { () -> [FacetsRecipe.Hue: CGImage] in
var rendered: [FacetsRecipe.Hue: CGImage] = [:]
for hue in FacetsRecipe.Hue.allCases {
guard let seed = seeds[hue] else { continue }
if let image = FacetsGenerator.render(recipe: filters.recipe(hue: hue, seed: seed), pixelWidth: 384) {
rendered[hue] = image
}
}
return rendered
}.value
// `.task(id:)` cancels this task when the key moves on, but cancellation is cooperative and
// the detached render finishes regardless without this gate a slow stale strip could land
// *after* the newer key's own renders and quietly replace them.
guard !Task.isCancelled else { return }
previews = rendered
}
/// A swatch, clicked: the same recipe the preview showed, rendered at the file's own width and
/// written through the one gesture every generated background lands through
/// (`BoardStore.applyGeneratedBackground`). Failures a `nil` render, a refused write under the
/// lock leave the board exactly as it was; the write path's own banners cover the write half.
private func apply(_ hue: FacetsRecipe.Hue) {
guard let seed = seeds[hue] else { return }
let recipe = filters.recipe(hue: hue, seed: seed)
isApplying = true
Task {
let data = await Task.detached(priority: .userInitiated) {
FacetsGenerator.pngData(recipe: recipe, pixelWidth: 3072)
}.value
if let data {
_ = store.applyGeneratedBackground(png: data, colorHex: recipe.primaryColorHex)
}
isApplying = false
}
}
private static func mintSeeds() -> [FacetsRecipe.Hue: UInt64] {
Dictionary(uniqueKeysWithValues: FacetsRecipe.Hue.allCases.map { ($0, UInt64.random(in: UInt64.min...UInt64.max)) })
}
private func reroll() {
seeds = Self.mintSeeds()
}
}
// MARK: - Hue display names
private extension FacetsRecipe.Hue {
/// The wheel's own names, capitalized for the carousel's `.help` and accessibility label the
/// same eight words 03-board-ui.md's faceted-gallery notes use.
var displayName: String {
switch self {
case .clay: "Clay"
case .amber: "Amber"
case .olive: "Olive"
case .forest: "Forest"
case .teal: "Teal"
case .sky: "Sky"
case .iris: "Iris"
case .rose: "Rose"
}
}
}
@@ -0,0 +1,441 @@
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
// MARK: - FacetsGenerator
/// **The faceted background, rendered** the Swift port of `board-backgrounds-faceted.html`'s
/// `genFacets` (DESIGN/explorations/board-backgrounds.md Faceted gallery, reviewed 2026-08-07).
///
/// A jittered point scatter over a frame slightly larger than the picture, Delaunay-triangulated,
/// every triangle filled with its own colour off the recipe's narrow lightness band.
///
/// ### Pure, and deliberately nothing else
///
/// No SwiftUI, no `NSColor`, no actor, no filesystem: recipe in, pixels out. That is what lets the
/// picker render a dozen previews off the main actor and the write path render one at 3072 px in the
/// same call, from the same code, with no risk that the preview and the file are different pictures.
///
/// ### Normalized space, so one seed means one composition at every size
///
/// The gallery works in a 480×300 SVG viewBox; this works in a 16:10 **unit** frame x 01,
/// y 00.625 with every constant divided by 480. A seed therefore names a composition rather than
/// a composition-at-a-size: the 240 px preview in the picker and the 3072 px file written to the
/// board folder are the same mesh, scaled.
///
/// ### The scatter runs past the frame on purpose
///
/// Points are laid over a region inset **outward** on all four sides by a margin the density
/// chooses (`FacetsRecipe.Density.scatterMargin`, which carries the whole reasoning). Delaunay
/// triangulation only covers the convex hull of its points, so a scatter that stopped at the frame
/// edge would leave the ground colour showing in a ragged border. The margin is sized so that even
/// the worst jitter draw puts every boundary-cell point at or beyond the frame edge the picture is
/// therefore entirely interior to the hull, and the ragged hull is cropped away.
public enum FacetsGenerator: Sendable {
/// **The name a generated background is written under** (`BoardStore.applyGeneratedBackground`).
///
/// One fixed name rather than a minted one, because regenerating is the overwhelmingly common
/// gesture the user rerolls until they like it and a fresh UUID per roll would leave a board
/// folder full of abandoned PNGs the app never offers to clean up. The collision ladder handles
/// the one case a fixed name cannot: somebody else's `facets.png` already sitting there.
public static let fileName = "facets.png"
/// The normalized frame: 1 wide, 10/16 tall.
static let frameHeight = 0.625
/// **The fraction of a cell a jittered point can be pushed toward the cell's far corner** the
/// upper end of the 0.080.92 placement band, and therefore the number the boundary ring has to
/// beat (`FacetsRecipe.Density.scatterMargin`).
static let jitterReach = 0.92
/// **The anti-seam stroke**, 0.7 px at the gallery's 480-wide scale. Each triangle is stroked in
/// its *own* fill colour, which is the whole trick: adjacent antialiased edges otherwise leave a
/// hairline of the ground colour between every pair of faces, and a mesh full of those reads as a
/// wireframe rather than a surface.
static let strokeWidth = 0.7 / 480
// MARK: Rendering
/// The pixel height that goes with `width` the 16:10 frame, rounded.
public static func pixelHeight(forWidth width: Int) -> Int {
max(1, Int((Double(width) * frameHeight).rounded()))
}
/// This recipe's mesh, drawn at `pixelWidth` opaque sRGB, no alpha to composite and none to
/// store.
///
/// `nil` only when CoreGraphics declines to make the bitmap at all (an allocation failure at an
/// absurd size); every recipe renders. Callers treat it the way `BoardBackdrop.decode` is
/// treated no image, no banner, nothing written.
public static func render(recipe: FacetsRecipe, pixelWidth: Int) -> CGImage? {
let width = max(1, pixelWidth)
let height = pixelHeight(forWidth: width)
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: space,
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
)
else { return nil }
let mesh = facets(recipe: recipe)
// The ground, painted in device pixels before the transform goes on: the frame's rounded
// height and `frameHeight × width` differ by up to half a pixel, and a rect stated in
// normalized units could leave that sliver unpainted along one edge.
if let ground = mesh.ground.cgColor(in: space) {
context.setFillColor(ground)
context.fill(CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
}
// **SVG's y-down frame, kept.** The mesh is a scatter, so a flip would cost nothing visually
// but keeping the axis the gallery drew in means a swatch reviewed there and a file written
// here are the same picture rather than its mirror, which is the only claim the port makes.
context.translateBy(x: 0, y: CGFloat(height))
context.scaleBy(x: CGFloat(width), y: -CGFloat(width))
context.setLineWidth(strokeWidth)
for face in mesh.faces {
guard let color = face.color.cgColor(in: space) else { continue }
context.setFillColor(color)
context.setStrokeColor(color)
context.beginPath()
context.move(to: face.a)
context.addLine(to: face.b)
context.addLine(to: face.c)
context.closePath()
context.drawPath(using: .fillStroke)
}
return context.makeImage()
}
/// The same render, encoded as PNG the bytes `BoardWriter.writeBoardImage` puts in the board
/// folder.
///
/// **PNG rather than JPEG**, and not for the usual reason: a flat-shaded triangle mesh is exactly
/// the content JPEG is worst at (hard edges become ringing, and the anti-seam stroke's whole
/// point is that there is no gap at those edges), while it is exactly what PNG's filters
/// compress well.
public static func pngData(recipe: FacetsRecipe, pixelWidth: Int) -> Data? {
guard let image = render(recipe: recipe, pixelWidth: pixelWidth) else { return nil }
let data = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
data, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else { return nil }
return data as Data
}
// MARK: The mesh
/// One triangle, in normalized coordinates, with the colour it is both filled and stroked in.
struct Face: Sendable, Equatable {
var a: CGPoint
var b: CGPoint
var c: CGPoint
var color: FacetsColor
/// The unsigned area of the triangle, in normalized units what the coverage check adds up.
var area: Double {
let abx = Double(b.x - a.x), aby = Double(b.y - a.y)
let acx = Double(c.x - a.x), acy = Double(c.y - a.y)
return abs(abx * acy - acx * aby) / 2
}
}
/// A whole composition: the ground and the faces over it, in draw order.
struct Mesh: Sendable, Equatable {
var ground: FacetsColor
var faces: [Face]
}
/// **The mesh, and the whole of the random consumption order.**
///
/// Fixed and documented because it is the only thing keeping a seed meaningful across versions:
/// **every point first** column-major, x before y within a point, matching the HTML's
/// `c`-outer/`r`-inner loops **then four draws per triangle in triangle order** (the weighted
/// hue pick, the hue jitter, the saturation factor, the lightness offset). Triangulation itself
/// consumes nothing. Inserting a draw anywhere in that sequence re-rolls every board that ever
/// stored this seed.
///
/// **Nothing stores one yet**, which is why the grid could be resized under it (the full-bleed
/// ring, `FacetsRecipe.Density`): a board carries the rendered PNG, never the recipe that made
/// it, so re-rolling every composition costs exactly nothing today. The moment a seed is written
/// to disk a `background.recipe` subkey, a preset library that stops being true and this
/// sequence, the density grid and the scatter margins all become format.
static func facets(recipe: FacetsRecipe) -> Mesh {
var random = FacetsRandom(seed: recipe.seed)
let points = scatter(recipe.density, using: &random)
let indices = triangulate(points)
let hues = recipe.hues
let level = recipe.level
var faces: [Face] = []
faces.reserveCapacity(indices.count)
for index in indices {
faces.append(Face(
a: points[index.a],
b: points[index.b],
c: points[index.c],
color: color(hues: hues, level: level, using: &random)
))
}
return Mesh(ground: recipe.primaryColor, faces: faces)
}
/// The grid-jittered scatter over the outset region. One point per cell, placed anywhere in the
/// middle 84% of it the 0.080.92 inset is what stops two points in neighbouring cells from
/// landing on top of each other and producing a sliver triangle, and its upper end is what the
/// density's margin is sized against (`FacetsRecipe.Density.scatterMargin`).
static func scatter(_ density: FacetsRecipe.Density, using random: inout FacetsRandom) -> [CGPoint] {
let margin = density.scatterMargin
let x0 = -margin
let x1 = 1 + margin
let y0 = -margin
let y1 = frameHeight + margin
let columns = density.columns
let rows = density.rows
let cellWidth = (x1 - x0) / Double(columns)
let cellHeight = (y1 - y0) / Double(rows)
var points: [CGPoint] = []
points.reserveCapacity(columns * rows)
for column in 0..<columns {
for row in 0..<rows {
// Two draws, x then y the order the JS array literal evaluates in.
let x = x0 + (Double(column) + random.uniform(0.08, 0.92)) * cellWidth
let y = y0 + (Double(row) + random.uniform(0.08, 0.92)) * cellHeight
points.append(CGPoint(x: x, y: y))
}
}
return points
}
/// One triangle's colour: a weighted hue pick, then the three jitters.
///
/// The gallery rounds each channel to one decimal on its way into a CSS string; that is a
/// serialization artefact of emitting text, not part of the recipe, so nothing rounds here.
static func color(
hues: [FacetsRecipe.WeightedHue],
level: FacetsRecipe.Level,
using random: inout FacetsRandom
) -> FacetsColor {
let picked = pickHue(hues, using: &random)
let hue = picked + random.uniform(-3, 3)
let saturation = level.saturation * random.uniform(0.85, 1.15)
let lightness = level.lightness
+ random.uniform(-FacetsRecipe.lightnessJitter, FacetsRecipe.lightnessJitter)
return FacetsColor(hue: hue, saturation: saturation, lightness: lightness)
}
/// The weighted pick, off **one** draw: walk the list subtracting weights until the draw falls
/// inside one. The trailing return is the floating-point safety net for a draw that survives every
/// subtraction the weights sum to 1, but they sum to 1 in binary.
static func pickHue(_ hues: [FacetsRecipe.WeightedHue], using random: inout FacetsRandom) -> Double {
var x = random.next()
for entry in hues {
if x < entry.weight { return entry.degrees }
x -= entry.weight
}
return hues.last?.degrees ?? 0
}
// MARK: Triangulation
/// Three indices into the point array.
struct IndexedTriangle: Sendable, Equatable {
var a: Int
var b: Int
var c: Int
}
/// **BowyerWatson**, exactly as the gallery does it: a super-triangle enclosing everything,
/// points inserted one at a time, the triangles whose circumcircle contains the new point
/// removed, and the cavity they leave retriangulated against its own boundary the edges that
/// were not shared by two of the removed triangles.
///
/// Triangles touching the super-triangle are dropped at the end, which is what leaves a
/// triangulation of the input points alone.
///
/// ### The one deliberate departure from the gallery
///
/// The super-triangle is the HTML's shape at **100× its size**. BowyerWatson only produces a
/// true triangulation when the scaffold is large enough that no real point's circumcircle can
/// reach past it; the gallery's is about 11× the point cloud, which is not, and the cost is a
/// handful of sliver triangles quietly missing near the hull the count comes out 14 short of
/// the 2n 2 h every triangulation must satisfy.
///
/// It is a departure from the *scaffold*, not from the picture: the scaffold is deleted before
/// anything is drawn, and the two versions were measured against each other over 36 meshes total
/// covered area differs by 0.2%, entirely in slivers outside the visible frame. What is bought is
/// an invariant a test can hold the port to exactly, instead of a tolerance around a defect.
static func triangulate(_ points: [CGPoint]) -> [IndexedTriangle] {
let count = points.count
guard count >= 3 else { return [] }
var vertices = points
vertices.append(CGPoint(x: -300_000.0 / 480, y: -300_000.0 / 480))
vertices.append(CGPoint(x: 350_000.0 / 480, y: -300_000.0 / 480))
vertices.append(CGPoint(x: 240.0 / 480, y: 360_000.0 / 480))
struct Working {
var triangle: IndexedTriangle
var circle: Circumcircle?
}
var working = [Working(
triangle: IndexedTriangle(a: count, b: count + 1, c: count + 2),
circle: circumcircle(vertices[count], vertices[count + 1], vertices[count + 2])
)]
for index in 0..<count {
let point = vertices[index]
var bad: [IndexedTriangle] = []
var kept: [Working] = []
kept.reserveCapacity(working.count)
for entry in working {
if let circle = entry.circle, circle.contains(point) {
bad.append(entry.triangle)
} else {
kept.append(entry)
}
}
working = kept
// The cavity's boundary: an edge shared by two removed triangles is interior and dies
// with them; one held by a single triangle is the hole's rim and gets a new face.
//
// The rim is walked in the removed triangles' own vertex order **not** the map's, and
// not a normalized one. Insertion order here is the order the faces come out in, and the
// face order is the order the colour draws are consumed in, so a tidier walk would be a
// different picture from the same seed.
var edgeCounts: [Edge: Int] = [:]
for triangle in bad {
for edge in triangle.orderedEdges { edgeCounts[Edge(edge.from, edge.to), default: 0] += 1 }
}
for triangle in bad {
for edge in triangle.orderedEdges where edgeCounts[Edge(edge.from, edge.to)] == 1 {
guard let circle = circumcircle(vertices[edge.from], vertices[edge.to], point) else {
continue
}
working.append(Working(
triangle: IndexedTriangle(a: edge.from, b: edge.to, c: index),
circle: circle
))
}
}
}
return working.map(\.triangle).filter { $0.a < count && $0.b < count && $0.c < count }
}
/// An undirected edge, keyed the way the HTML keys its `edgeCount` map: low index first, so the
/// same edge seen from either of its triangles is one key.
struct Edge: Hashable {
var low: Int
var high: Int
init(_ first: Int, _ second: Int) {
low = min(first, second)
high = max(first, second)
}
}
struct Circumcircle {
var x: Double
var y: Double
var radiusSquared: Double
func contains(_ point: CGPoint) -> Bool {
let dx = Double(point.x) - x
let dy = Double(point.y) - y
return dx * dx + dy * dy < radiusSquared
}
}
/// The circle through three points, or `nil` when they are collinear.
///
/// The guard is `1e-12` **in normalized units**, which is the HTML's `1e-9` at 480 scale carried
/// across with room to spare: the determinant is quadratic in the coordinates, so the same
/// degeneracy reads about 2×10 times smaller here. Three jittered grid points are never actually
/// collinear; this exists so a hand-built or pathological point set degrades to a missing face
/// instead of an infinity.
static func circumcircle(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> Circumcircle? {
let ax = Double(a.x), ay = Double(a.y)
let bx = Double(b.x), by = Double(b.y)
let cx = Double(c.x), cy = Double(c.y)
let d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
guard abs(d) >= 1e-12 else { return nil }
let a2 = ax * ax + ay * ay
let b2 = bx * bx + by * by
let c2 = cx * cx + cy * cy
let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d
let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d
let dx = ax - ux
let dy = ay - uy
return Circumcircle(x: ux, y: uy, radiusSquared: dx * dx + dy * dy)
}
}
extension FacetsGenerator.IndexedTriangle {
/// The three edges as **directed** pairs, in the HTML's own `e`/`(e+1)%3` order see the walk in
/// `triangulate` for why the direction is kept.
var orderedEdges: [(from: Int, to: Int)] {
[(a, b), (b, c), (c, a)]
}
}
// MARK: - FacetsRandom
/// **mulberry32**, ported bit-for-bit from the gallery's own PRNG.
///
/// Not `SystemRandomNumberGenerator` and not `SeededRandomNumberGenerator`-of-the-week: the seed's
/// entire job is to be a *stable name for a picture*, so the sequence has to be reproducible across
/// machines, OS versions and Swift releases. A 32-bit LCG-ish mixer with wrapping arithmetic is
/// reproducible by definition; the standard library's generators explicitly are not.
///
/// Swift's `&*`/`&+` on `UInt32` are exactly JavaScript's `Math.imul` and its `| 0` truncation, so
/// this produces the same doubles in the same order as the reviewed gallery does.
struct FacetsRandom: Sendable {
private var state: UInt32
/// The 64-bit seed folded to the generator's 32-bit state through **xmur3's finalizer** the
/// avalanche half of the gallery's string hash. Folding rather than truncating matters: seeds
/// minted from a counter differ only in their low bits, and a raw truncation would hand
/// neighbouring seeds neighbouring first draws.
init(seed: UInt64) {
var h = UInt32(truncatingIfNeeded: seed ^ (seed >> 32))
h ^= h >> 16
h = h &* 2_246_822_507
h ^= h >> 13
h = h &* 3_266_489_909
h ^= h >> 16
state = h
}
/// The next draw in 0..<1.
mutating func next() -> Double {
state = state &+ 0x6D2B_79F5
var t = state
t = (t ^ (t >> 15)) &* (t | 1)
t ^= t &+ ((t ^ (t >> 7)) &* (t | 61))
return Double(t ^ (t >> 14)) / 4_294_967_296
}
/// A draw scaled into `lower..<upper`.
mutating func uniform(_ lower: Double, _ upper: Double) -> Double {
lower + next() * (upper - lower)
}
}
@@ -0,0 +1,323 @@
import CoreGraphics
import Foundation
// MARK: - FacetsRecipe
/// **What a generated board background is made of** the axis set the faceted gallery swept and the
/// review settled (DESIGN/explorations/board-backgrounds.md Faceted gallery, 2026-08-07).
///
/// The recipe plus its `seed` is the whole of the picture: `FacetsGenerator` is pure, so the same
/// pair renders the same mesh at any output size, on any machine, forever. That is what makes the
/// recipe worth being a value a board can carry one, a picker can offer one, and neither has to
/// hold a bitmap to mean something.
///
/// ### The colour model is HSL, not HSB
///
/// The gallery is a web page and its swatches are CSS `hsl()`, so the numbers below saturations of
/// 20/42/68, lightnesses of 90/88/85 are HSL numbers and only mean what the reviewer saw when they
/// are read as HSL. `FacetsColor` converts; nothing here reaches for `NSColor`, whose `saturation`
/// and `brightness` are the other model's and would land somewhere else entirely.
///
/// ### The generator source is authoritative
///
/// Every constant here restates one in `board-backgrounds-faceted.html`'s `genFacets`/`triangleColor`
/// pair. Where the two ever disagree the HTML is the reviewed artefact and this is the port.
public struct FacetsRecipe: Sendable, Equatable, Hashable {
/// The base hue the one every strategy below builds its list from.
public var hue: Hue
/// How many hues the mesh draws from, and in what proportion.
public var strategy: Strategy
/// How finely the frame is diced.
public var density: Density
/// Which end of the lightness range the whole swatch sits at.
public var tone: Tone
/// How much colour there is at that lightness.
public var saturation: Saturation
/// The composition's identity. Two renders of the same recipe under the same seed are the same
/// picture; changing it alone is the gallery's Reroll button.
public var seed: UInt64
public init(
hue: Hue,
strategy: Strategy,
density: Density,
tone: Tone,
saturation: Saturation,
seed: UInt64
) {
self.hue = hue
self.strategy = strategy
self.density = density
self.tone = tone
self.saturation = saturation
self.seed = seed
}
// MARK: Axes
/// The eight-hue wheel sweep 1 established and the finals return to (the faceted round narrowed
/// the *gallery* to four representatives to keep 216 swatches reviewable it never narrowed the
/// wheel).
public enum Hue: Sendable, Equatable, Hashable, CaseIterable {
case clay
case amber
case olive
case forest
case teal
case sky
case iris
case rose
/// Degrees on the colour wheel.
public var degrees: Double {
switch self {
case .clay: 8
case .amber: 38
case .olive: 80
case .forest: 140
case .teal: 175
case .sky: 215
case .iris: 262
case .rose: 335
}
}
}
/// How many hues a swatch draws from. The weights are the reviewed ones and they sum to 1 by
/// construction, which is what `FacetsGenerator`'s single-draw weighted pick assumes.
public enum Strategy: Sendable, Equatable, Hashable, CaseIterable {
/// One hue; only the lightness jitter draws the mesh.
case mono
/// The base plus its complement, 65/35 a dominant field with contrasting inclusions.
case duo
/// The contrasting triad H, H+120°, H120°, weighted 50/30/20.
case trio
}
/// Vertex count, as a grid of jittered cells laid over a region **larger than the picture**
/// and the size of that overhang, which is per-density for the reason below.
///
/// ### The cell size is the gallery's; the ring is new
///
/// What a viewer reads as "coarse" or "fine" is the size of a facet, not the number of points,
/// so the numbers preserved from the reviewed gallery are the **cell dimensions** 0.23 for
/// coarse, 0.128 for medium, 0.0827 for fine (its 1.15/5, 1.15/9, 1.15/14 in unit terms). The
/// grid then simply has however many cells it takes to cover the frame *plus* a boundary ring,
/// which is where the extra columns and rows come from: 5×3 7×5, 9×6 10×7, 14×9 15×10.
///
/// ### The ring is the full-bleed guarantee
///
/// The gallery used one margin for all three densities (0.075 in unit terms) and got away with
/// it: a mesh only covers its points' convex hull, and at medium and fine that margin left the
/// frame covered often enough that nobody looking at swatches would notice. It is not a
/// guarantee, though a boundary point is placed anywhere in the middle 84% of its cell, so the
/// worst draw puts it 0.92 of a cell *inward* of the region's edge, and against a margin of only
/// 0.075 every density could land inside the picture: coarse by 0.163, medium by 0.044, fine by
/// 0.0042. Each of those is a notch of flat ground colour on the frame edge, and coarse's a
/// sixth of the frame's height is one anybody would see.
///
/// So the margin is sized against the cell instead of fixed: **margin 0.92 × cell** on both
/// axes, which is exactly the statement "even the worst jitter draw leaves every boundary-cell
/// point at or beyond the frame edge". The whole frame is then interior to the hull and the mesh
/// is full-bleed by construction rather than by luck. `FacetsGeneratorTests` holds the inequality.
///
/// The ring's own triangles are drawn and then cropped away, which is what an oversized canvas
/// costs: a third of coarse's faces are never seen. That is the trade the gallery was already
/// making, made big enough to be a promise.
public enum Density: Sendable, Equatable, Hashable, CaseIterable {
case coarse
case medium
case fine
public var columns: Int {
switch self {
case .coarse: 7
case .medium: 10
case .fine: 15
}
}
public var rows: Int {
switch self {
case .coarse: 5
case .medium: 7
case .fine: 10
}
}
/// How far the scatter runs past the frame on every side, in width units the sacrificial
/// ring. Symmetric on both axes because the frame is, and the cells are very nearly square.
public var scatterMargin: Double {
switch self {
case .coarse: 0.305
case .medium: 0.14
case .fine: 0.12
}
}
}
/// Which end of the lightness range the swatch sits at. **Not a light/dark *pair*** a board
/// carries one background image and the app has no appearance-conditional backdrop, so this is a
/// choice the author makes once, like choosing a photograph.
public enum Tone: Sendable, Equatable, Hashable, CaseIterable {
case light
case dark
}
/// The saturation band. Rich rows get a little lightness headroom so the saturation actually
/// shows which is why the level below carries both numbers rather than a saturation alone.
public enum Saturation: Sendable, Equatable, Hashable, CaseIterable {
case soft
case mid
case rich
}
// MARK: The derived numbers
/// One tone × saturation cell: the base saturation and lightness every triangle jitters around.
public struct Level: Sendable, Equatable, Hashable {
public var saturation: Double
public var lightness: Double
}
/// **The per-triangle lightness jitter, ±4.5** the same for both tones, because the narrow
/// band *is* the recipe: "brightness stays a narrow per-triangle jitter around the tone base".
/// Widening it on either end would stop the mesh reading as one surface catching light.
public static let lightnessJitter: Double = 4.5
/// This recipe's saturation/lightness cell.
public var level: Level {
switch (tone, saturation) {
case (.light, .soft): Level(saturation: 20, lightness: 90)
case (.light, .mid): Level(saturation: 42, lightness: 88)
case (.light, .rich): Level(saturation: 68, lightness: 85)
case (.dark, .soft): Level(saturation: 16, lightness: 17)
case (.dark, .mid): Level(saturation: 34, lightness: 19)
case (.dark, .rich): Level(saturation: 52, lightness: 21)
}
}
/// The hues a triangle is picked from, with the weights that pick it. First entry is always the
/// base hue, which is also the ground the mesh is painted over.
public var hues: [WeightedHue] {
let base = hue.degrees
switch strategy {
case .mono:
return [WeightedHue(degrees: base, weight: 1)]
case .duo:
return [
WeightedHue(degrees: base, weight: 0.65),
WeightedHue(degrees: base + 180, weight: 0.35),
]
case .trio:
return [
WeightedHue(degrees: base, weight: 0.5),
WeightedHue(degrees: base + 120, weight: 0.3),
WeightedHue(degrees: base - 120, weight: 0.2),
]
}
}
/// One entry of the weighted hue list.
public struct WeightedHue: Sendable, Equatable, Hashable {
public var degrees: Double
public var weight: Double
}
/// **The ground the mesh is painted over** the base hue at the level's own saturation and
/// lightness, un-jittered. The rect the generator fills before the first triangle lands.
public var primaryColor: FacetsColor {
FacetsColor(hue: hue.degrees, saturation: level.saturation, lightness: level.lightness)
}
/// The same colour as `#RRGGBB`, uppercase **what the board's `background.color` gets set to**
/// when a generated image is applied (`BoardStore.applyGeneratedBackground`).
///
/// It is the honest fallback rather than a decoration: the colour underlay is what shows while
/// the backdrop decodes, what shows if the file is later deleted from the folder by hand, and
/// what a board copied without its image degrades to. Picking the mesh's own ground means all
/// three land on the picture's average rather than on white.
public var primaryColorHex: String { primaryColor.hexString }
}
// MARK: - FacetsColor
/// **One colour in the gallery's own model** HSL, in degrees and percent, converted to sRGB on
/// demand (see `FacetsRecipe`'s note on why this is not HSB).
///
/// Stored as it was computed rather than as components, so a colour can be compared, hashed and
/// printed in the numbers the recipe is written in.
public struct FacetsColor: Sendable, Equatable, Hashable {
/// Degrees, wrapped into 0..<360 the `mod360` the generator applies before every emission.
public var hue: Double
/// Percent, clamped 0100.
public var saturation: Double
/// Percent, clamped 0100.
public var lightness: Double
public init(hue: Double, saturation: Double, lightness: Double) {
self.hue = Self.wrapped(hue)
self.saturation = min(max(saturation, 0), 100)
self.lightness = min(max(lightness, 0), 100)
}
/// CSS's own `hsl()` sRGB, component-wise in 01. The chroma/secondary/match-lightness form,
/// which is the one the specification is written in and the one every browser implements.
public var components: (red: Double, green: Double, blue: Double) {
let saturation = saturation / 100
let lightness = lightness / 100
let chroma = (1 - abs(2 * lightness - 1)) * saturation
let sextant = hue / 60
let secondary = chroma * (1 - abs(sextant.truncatingRemainder(dividingBy: 2) - 1))
let match = lightness - chroma / 2
let (red, green, blue): (Double, Double, Double) = switch sextant {
case ..<1: (chroma, secondary, 0)
case ..<2: (secondary, chroma, 0)
case ..<3: (0, chroma, secondary)
case ..<4: (0, secondary, chroma)
case ..<5: (secondary, 0, chroma)
default: (chroma, 0, secondary)
}
return (red + match, green + match, blue + match)
}
/// `#RRGGBB`, uppercase the spelling `Palette`'s hex reader and the colour panel's round trip
/// both already speak (`NSColor.paletteHexString`), so a generated colour is indistinguishable
/// from a hand-written one on disk.
public var hexString: String {
let (red, green, blue) = components
return String(
format: "#%02X%02X%02X",
Self.byte(red), Self.byte(green), Self.byte(blue)
)
}
/// The colour as CoreGraphics wants it, in the space the digits name. **`space` is passed in
/// rather than made here** so a render creates one sRGB space for a whole mesh instead of one
/// per triangle.
func cgColor(in space: CGColorSpace) -> CGColor? {
let (red, green, blue) = components
return CGColor(colorSpace: space, components: [CGFloat(red), CGFloat(green), CGFloat(blue), 1])
}
private static func byte(_ component: Double) -> Int {
min(max(Int((component * 255).rounded()), 0), 255)
}
/// Degrees into 0..<360, negatives included `trio`'s third hue is `H 120`, which is negative
/// for every hue below clay's 8°.
private static func wrapped(_ degrees: Double) -> Double {
let wrapped = degrees.truncatingRemainder(dividingBy: 360)
return wrapped < 0 ? wrapped + 360 : wrapped
}
}
+24 -24
View File
@@ -7,12 +7,12 @@ import SwiftUI
/// **Restructuring in progress (2026-08-07): the popover is going tabbed.** The symbol/name header
/// stays at the top; below it sit three tabs **Info**, **Background**, **Git** each a settings
/// surface for one aspect of board configuration, each settled in its own dedicated design session.
/// **Info is settled** (same day `BoardInfoTabView`, the metrics dossier); Background and Git
/// remain deliberately empty until theirs. The former body the embedded style editor and the
/// mode-aware git section is unrendered for the interim but parked in this file (see the
/// "Parked" marks below), because its pure seams (`BoardGitSection`, the posture notes,
/// `BoardSettingsAvailability`'s caller) are settled design and will rehome into the tabs as those
/// sessions rule.
/// **Info and Background are settled** (both 2026-08-07 `BoardInfoTabView`, the metrics dossier;
/// `BoardBackgroundTabView`, the re-homed style editor plus the generated-background picker); Git
/// remains deliberately empty until its own session. The former body's mode-aware git section is
/// unrendered for the interim but parked in this file (see the "Parked" mark below), because its
/// pure seams (`BoardGitSection`, the posture notes, `BoardSettingsAvailability`'s caller) are
/// settled design and will rehome into the Git tab once that session rules.
///
/// ### One home, deliberately
///
@@ -257,9 +257,9 @@ func boardInfoTitlebarAccessory(
// MARK: - Tabs
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure): **Info**, **Background**, **Git**. Info is settled (`BoardInfoTabView`);
/// Background and Git are placeholders empty on purpose until each gets its dedicated design
/// session, which then only has to fill its case in.
/// restructure): **Info**, **Background**, **Git**. Info and Background are settled
/// (`BoardInfoTabView`, `BoardBackgroundTabView`); Git is a placeholder empty on purpose until
/// its own dedicated design session, which then only has to fill its case in.
enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info"
@@ -275,9 +275,9 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
/// (03-board-ui.md § Board popover).
///
/// Width is the style editor's the number that keeps the Style popover narrow enough to sit
/// beside a card kept through the restructure so the popover's footprint doesn't wander while the
/// tabs are placeholders; whether the tabbed surface wants its own width is each tab session's
/// question to raise.
/// beside a card kept through the restructure so the popover's footprint doesn't wander while Git
/// is still a placeholder; both tabs settled so far (Info, Background) kept it too, so whether the
/// tabbed surface ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View {
let store: BoardStore
@@ -375,15 +375,16 @@ struct BoardInfoView: View {
.padding(.horizontal, inset)
.padding(.top, inset)
// The selected tab's surface. Info is settled (2026-08-07 `BoardInfoTabView`);
// Background and Git stay placeholders until their own sessions, holding a fixed
// height so an empty tab reads as a surface awaiting content rather than a collapsed
// sliver `Color.clear`, because an `EmptyView` inside a frame renders nothing at all.
// The selected tab's surface. Info and Background are settled (2026-08-07
// `BoardInfoTabView`, `BoardBackgroundTabView`); Git stays a placeholder until its own
// session, holding a fixed height so an empty tab reads as a surface awaiting content
// rather than a collapsed sliver `Color.clear`, because an `EmptyView` inside a frame
// renders nothing at all.
switch tab {
case .info:
BoardInfoTabView(store: store, inset: inset)
case .background:
Color.clear.frame(height: 120)
BoardBackgroundTabView(store: store, recents: recents, inset: inset)
case .git:
Color.clear.frame(height: 120)
}
@@ -394,14 +395,13 @@ struct BoardInfoView: View {
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
// MARK: Parked pending the tab sessions (2026-08-07)
// MARK: Parked pending the Git tab session (2026-08-07)
//
// Nothing below this mark renders today. The style-editor embed left the body with the tab
// restructure (its Background-tab fate is that session's), and the git section postures,
// notes, and the Board Settings row waits here for the Git tab's session. Parked rather
// than deleted because every seam it hangs on is settled, test-pinned design
// (`BoardGitSectionTests`, `BoardSettingsAvailabilityTests`), and the tab sessions rehome
// surfaces, not rulings.
// Nothing below this mark renders today. The style-editor embed that once lived here has
// rehomed to `BoardBackgroundTabView`; what is left is the git section postures, notes, and
// the Board Settings row waiting for the Git tab's own session. Parked rather than deleted
// because every seam it hangs on is settled, test-pinned design (`BoardGitSectionTests`,
// `BoardSettingsAvailabilityTests`), and the tab sessions rehome surfaces, not rulings.
/// The popover's closing section, whichever of the six postures this board is in see
/// `BoardGitSection`.