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:
@@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 0…1,
|
||||
/// y 0…0.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.08…0.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.08…0.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
|
||||
}
|
||||
|
||||
/// **Bowyer–Watson**, 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**. Bowyer–Watson 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 1–4 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°, H−120°, 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 0…100.
|
||||
public var saturation: Double
|
||||
|
||||
/// Percent, clamped 0…100.
|
||||
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 0…1. 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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user