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:
@@ -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