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
@@ -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`.