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:
@@ -1,5 +1,9 @@
|
||||
**August 2026**
|
||||
|
||||
The board popover's new Background tab generates board backgrounds: filter by light or dark, color count, mesh density and saturation, then pick from eight hues.
|
||||
|
||||
The board popover is now organized into three tabs — Info with the board's vital statistics, Background for styling, and Git.
|
||||
|
||||
The rubber band now highlights cards the moment it touches them, instead of lagging behind on large boards.
|
||||
|
||||
The app's appearance can now be set to Light, Dark, or Auto from the View menu or the toolbar's new Appearance item.
|
||||
|
||||
@@ -53,7 +53,9 @@ One **style editor** component — a background palette grid and a curated symbo
|
||||
|
||||
## Board popover
|
||||
|
||||
**Restructure 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 the settings surface for one aspect of board configuration, each designed in its own dedicated session (Background and Git are empty placeholders until theirs). The bullets below describe the popover's pre-tab content; what of it rehomes into which tab — and where the embedded style editor lands — is those sessions' to settle. The rulings inside the bullets (rename semantics, the git postures, the popover/sheet split) stand; only their placement is in motion.
|
||||
**Restructure 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 the settings surface for one aspect of board configuration, each designed in its own dedicated session (Git is an empty placeholder until its; Info and Background are settled). The bullets below describe the popover's pre-tab content; what of it rehomes into which tab — and where the git section lands — is the Git session's to settle. The rulings inside the bullets (rename semantics, the git postures, the popover/sheet split) stand; only their placement is in motion.
|
||||
|
||||
**The Background tab** (settled 2026-08-07, its dedicated session): two surfaces, manual then generated. The **Color** section is the re-homed style-editor embed — background half only, `showsSymbols: false`, exactly the pre-tab composition rule (§ Styling ▸ Controls: the popover's symbol picker beside the rename field owns the board glyph) — same write path, same recents, so the embed's re-homing changed its address and nothing else. The **Generated** section is the faceted-background picker (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, the reviewed recipe): four segmented filters — Tone (defaults to the current appearance), Colors mono/duo/trio, Mesh coarse/medium/fine, Saturation soft/mid/rich — over a horizontal carousel of the eight wheel hues, one seed per hue, plus a Reroll button that re-mints the seeds (filters change the treatment, Reroll changes the geometry; the preview and the applied file share a seed, so what's clicked is what lands). Clicking a swatch renders the recipe at the 3072 px decode ceiling off-main and lands it in one write: the PNG into the board root as `facets.png` (Finder-ladder rename only when a foreign file owns the name), `background.image` pointed at it, and `background.color` set to the recipe's primary color — the underlay that stands in while the image decodes or if the file ever goes missing. **Backgrounds ship as static pixels, never live-rendered views** (the perf/sync ruling, 2026-08-07): the generator runs at pick time, the render loop only ever composites a decoded bitmap. Native undo restores the two fields, not the overwritten bytes — regenerating over our own PNG is destructive, documented, and accepted (the escape hatch remains the raw file). The whole tab disables under the read-only lock as one surface.
|
||||
|
||||
### Info tab (settled 2026-08-07)
|
||||
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
<title>Board Backgrounds — Facets</title>
|
||||
<style>
|
||||
:root{
|
||||
--paper:#FAFAF7;
|
||||
--ink:#1D1C1A;
|
||||
--ink-2:#6E6A63;
|
||||
--line:#E4E1DA;
|
||||
--chip:#F0EEE8;
|
||||
--accent:#5B6E8C;
|
||||
--paper-a85: rgba(250,250,247,.85);
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--paper:#181715;
|
||||
--ink:#ECEAE5;
|
||||
--ink-2:#98938A;
|
||||
--line:#2E2C28;
|
||||
--chip:#242220;
|
||||
--accent:#8FA3C0;
|
||||
--paper-a85: rgba(24,23,21,.85);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--paper:#181715;
|
||||
--ink:#ECEAE5;
|
||||
--ink-2:#98938A;
|
||||
--line:#2E2C28;
|
||||
--chip:#242220;
|
||||
--accent:#8FA3C0;
|
||||
--paper-a85: rgba(24,23,21,.85);
|
||||
}
|
||||
|
||||
*, *::before, *::after{ box-sizing:border-box; }
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
background:var(--paper);
|
||||
color:var(--ink);
|
||||
font-family:-apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
|
||||
.wrap{ max-width:1240px; margin:0 auto; padding:0 32px 64px; }
|
||||
|
||||
.page-head{ padding:32px 0 16px; }
|
||||
.page-head h1{ font-size:22px; font-weight:600; margin:0 0 6px; text-wrap:balance; }
|
||||
.page-head .subtitle{ font-size:13px; color:var(--ink-2); margin:0; max-width:760px; }
|
||||
|
||||
.controlbar{
|
||||
position:sticky; top:0; z-index:10;
|
||||
display:flex; align-items:center; gap:20px; row-gap:10px; flex-wrap:wrap;
|
||||
padding:12px 0;
|
||||
background:var(--paper-a85);
|
||||
-webkit-backdrop-filter:blur(8px);
|
||||
backdrop-filter:blur(8px);
|
||||
border-bottom:1px solid var(--line);
|
||||
margin-bottom:28px;
|
||||
}
|
||||
.chipgroup{ display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.chip{
|
||||
font:inherit; font-size:12px; padding:5px 11px; border-radius:999px;
|
||||
border:1px solid var(--line); background:var(--chip); color:var(--ink);
|
||||
cursor:pointer; line-height:1.3;
|
||||
}
|
||||
.chip:hover{ border-color:var(--accent); }
|
||||
.chip.active{ background:var(--accent); border-color:var(--accent); color:var(--paper); }
|
||||
.chip:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
|
||||
|
||||
.toggle{ display:flex; align-items:center; gap:6px; font-size:12px; color:var(--ink); cursor:pointer; user-select:none; }
|
||||
.toggle input{ width:14px; height:14px; accent-color:var(--accent); }
|
||||
.toggle input:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
|
||||
|
||||
.gen-note{ font-size:12px; color:var(--ink-2); font-family:ui-monospace,"SF Mono",Menlo,monospace; }
|
||||
|
||||
.recipe-box{ margin-bottom:40px; }
|
||||
.section-label{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-2); margin:0 0 10px; }
|
||||
.recipe-list{ display:grid; grid-template-columns:1fr 1fr; gap:7px 32px; margin:0; }
|
||||
.recipe-item{ display:flex; gap:6px; font-size:13px; line-height:1.5; }
|
||||
.recipe-item dt{ margin:0; font-weight:600; color:var(--ink-2); flex:0 0 auto; }
|
||||
.recipe-item dd{ margin:0; color:var(--ink-2); }
|
||||
@media (max-width:720px){ .recipe-list{ grid-template-columns:1fr; } }
|
||||
|
||||
.family{ margin-bottom:44px; }
|
||||
.family h2{ font-size:15px; font-weight:600; margin:0 0 4px; }
|
||||
.family .recipe{ font-size:13px; color:var(--ink-2); margin:0 0 14px; max-width:820px; }
|
||||
|
||||
.colheads{
|
||||
display:grid; grid-template-columns:repeat(3, 1fr); gap:14px;
|
||||
margin-bottom:8px;
|
||||
position:sticky; top:56px; z-index:5;
|
||||
background:var(--paper-a85);
|
||||
-webkit-backdrop-filter:blur(8px);
|
||||
backdrop-filter:blur(8px);
|
||||
padding:4px 0;
|
||||
}
|
||||
.colheads span{
|
||||
font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase;
|
||||
color:var(--ink-2);
|
||||
}
|
||||
.grid{ display:grid; grid-template-columns:repeat(3, 1fr); gap:14px; }
|
||||
@media (max-width:720px){
|
||||
.colheads{ display:none; }
|
||||
.grid{ grid-template-columns:1fr; }
|
||||
}
|
||||
|
||||
.card{ cursor:pointer; }
|
||||
.frame{
|
||||
aspect-ratio:16/10; border-radius:8px; border:1px solid var(--line);
|
||||
overflow:hidden; background:var(--chip);
|
||||
}
|
||||
.frame svg, .lightbox-frame svg{ display:block; width:100%; height:100%; }
|
||||
.caption{ display:flex; justify-content:space-between; align-items:baseline; margin-top:6px; font-size:11px; gap:8px; }
|
||||
.caption .swatch-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; color:var(--ink); }
|
||||
.caption .note{ color:var(--ink-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
.board-overlay{ display:none; }
|
||||
body.show-overlay .board-overlay{ display:inline; }
|
||||
|
||||
.lightbox{
|
||||
position:fixed; inset:0; z-index:100;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
|
||||
background:rgba(0,0,0,.55);
|
||||
opacity:0; pointer-events:none;
|
||||
transition:opacity .12s ease;
|
||||
}
|
||||
.lightbox.open{ opacity:1; pointer-events:auto; }
|
||||
.lightbox-frame{
|
||||
width:min(92vw, 1100px); aspect-ratio:16/10; border-radius:10px; overflow:hidden;
|
||||
box-shadow:0 24px 60px rgba(0,0,0,.45);
|
||||
background:var(--chip);
|
||||
}
|
||||
.lightbox-id{ font-family:ui-monospace,"SF Mono",Menlo,monospace; font-size:12px; color:#fff; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.lightbox{ transition:none; }
|
||||
}
|
||||
</style>
|
||||
<div class="wrap">
|
||||
<header class="page-head">
|
||||
<h1>Board Backgrounds — Facets, round 2</h1>
|
||||
<p class="subtitle">The triangle mesh swept on the full axis set: vertex count (columns), color count (mono · complementary duo · contrasting trio), tone, and saturation. Brightness stays a narrow per-triangle jitter around the tone base. Hue wheel narrowed to four representatives this round — amber, forest, sky, rose — the full wheel returns for finals. 3 color counts × 2 tones × 4 hues × 3 saturations × 3 densities = 216 swatches. Reroll to reseed geometry and color assignment under the same recipes.</p>
|
||||
</header>
|
||||
|
||||
<div class="controlbar">
|
||||
<div class="chipgroup" id="strategy-chips" role="group" aria-label="Color-count filter">
|
||||
<button type="button" class="chip active" data-strategy="all" aria-pressed="true">All</button>
|
||||
<button type="button" class="chip" data-strategy="mono" aria-pressed="false">Mono</button>
|
||||
<button type="button" class="chip" data-strategy="duo" aria-pressed="false">Duo</button>
|
||||
<button type="button" class="chip" data-strategy="trio" aria-pressed="false">Trio</button>
|
||||
</div>
|
||||
<div class="chipgroup" id="tone-chips" role="group" aria-label="Tone filter">
|
||||
<button type="button" class="chip active" data-tone="all" aria-pressed="true">All</button>
|
||||
<button type="button" class="chip" data-tone="light" aria-pressed="false">Light</button>
|
||||
<button type="button" class="chip" data-tone="dark" aria-pressed="false">Dark</button>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="overlay-toggle">
|
||||
<span>Board overlay</span>
|
||||
</label>
|
||||
<button type="button" class="chip" id="reroll">↻ Reroll geometry</button>
|
||||
<span class="gen-note" id="gen-note">gen 1</span>
|
||||
</div>
|
||||
|
||||
<section class="recipe-box">
|
||||
<p class="section-label">Recipe parameters</p>
|
||||
<dl class="recipe-list">
|
||||
<div class="recipe-item"><dt>Vertices</dt><dd>coarse 5×3 cells (~20 triangles) · medium 9×6 (~97) · fine 14×9 (~230), grid-jittered then Delaunay-triangulated</dd></div>
|
||||
<div class="recipe-item"><dt>Colors</dt><dd>mono · duo = complement H+180° weighted 65/35 · trio = triad H±120° weighted 50/30/20, picked per triangle</dd></div>
|
||||
<div class="recipe-item"><dt>Saturation</dt><dd>soft · mid · rich row bands, jittered ±15% per triangle</dd></div>
|
||||
<div class="recipe-item"><dt>Brightness</dt><dd>narrow band — tone base (light ≈85–90%, dark ≈17–21%) ±4.5 per triangle; all hues in a swatch share it</dd></div>
|
||||
<div class="recipe-item"><dt>Hue</dt><dd>amber 38° · forest 140° · sky 215° · rose 335° (±3° per triangle)</dd></div>
|
||||
<div class="recipe-item"><dt>Randomness</dt><dd>geometry and per-triangle color assignment reseed on reroll; the swatch ID names the recipe</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="mono" data-tone="light">
|
||||
<h2>Mono — Light</h2>
|
||||
<p class="recipe">One hue; only the brightness jitter draws the mesh. Rows: each hue at soft, mid, rich saturation.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-mono-light"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="duo" data-tone="light">
|
||||
<h2>Duo — Light</h2>
|
||||
<p class="recipe">Base hue plus its complement, weighted 65/35 — a dominant field with contrasting inclusions.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-duo-light"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="trio" data-tone="light">
|
||||
<h2>Trio — Light</h2>
|
||||
<p class="recipe">A contrasting triad (H, H+120°, H−120°) weighted 50/30/20 — closest in spirit to the low-poly reference.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-trio-light"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="mono" data-tone="dark">
|
||||
<h2>Mono — Dark</h2>
|
||||
<p class="recipe">The mono mesh on the dark base — facets catching light like slate.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-mono-dark"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="duo" data-tone="dark">
|
||||
<h2>Duo — Dark</h2>
|
||||
<p class="recipe">Complementary pair on the dark base; at low brightness the hue contrast turns ember-like.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-duo-dark"></div>
|
||||
</section>
|
||||
|
||||
<section class="family" data-strategy="trio" data-tone="dark">
|
||||
<h2>Trio — Dark</h2>
|
||||
<p class="recipe">The triad on the dark base — stained glass at dusk.</p>
|
||||
<div class="colheads"><span>Coarse</span><span>Medium</span><span>Fine</span></div>
|
||||
<div class="grid" id="grid-trio-dark"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="lightbox">
|
||||
<div class="lightbox-frame" id="lightbox-frame"></div>
|
||||
<div class="lightbox-id" id="lightbox-id"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
// ---------- seeded PRNG (same as sweep-1 gallery) ----------
|
||||
function xmur3(str){
|
||||
let h = 1779033703 ^ str.length;
|
||||
for(let i=0;i<str.length;i++){
|
||||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
return function(){
|
||||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||
h ^= h >>> 16;
|
||||
return h >>> 0;
|
||||
};
|
||||
}
|
||||
function mulberry32(a){
|
||||
return function(){
|
||||
let t = (a += 0x6D2B79F5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function seededRng(id){
|
||||
return mulberry32(xmur3(id)());
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
function mod360(h){ return ((h % 360) + 360) % 360; }
|
||||
function clamp(v,min,max){ return v < min ? min : (v > max ? max : v); }
|
||||
function hsl(h,s,l){ return "hsl(" + f1(mod360(h)) + "," + f1(clamp(s,0,100)) + "%," + f1(clamp(l,0,100)) + "%)"; }
|
||||
function rnd(rng,min,max){ return min + rng() * (max - min); }
|
||||
function rndInt(rng,min,max){ return Math.floor(rnd(rng,min,max+1)); }
|
||||
function f1(n){ return n.toFixed(1); }
|
||||
|
||||
// ---------- Delaunay (Bowyer–Watson) ----------
|
||||
function circumcircle(a,b,c){
|
||||
const ax=a[0],ay=a[1],bx=b[0],by=b[1],cx=c[0],cy=c[1];
|
||||
const d = 2*(ax*(by-cy)+bx*(cy-ay)+cx*(ay-by));
|
||||
if(Math.abs(d) < 1e-9) return null;
|
||||
const a2 = ax*ax+ay*ay, b2 = bx*bx+by*by, c2 = cx*cx+cy*cy;
|
||||
const ux = (a2*(by-cy)+b2*(cy-ay)+c2*(ay-by))/d;
|
||||
const uy = (a2*(cx-bx)+b2*(ax-cx)+c2*(bx-ax))/d;
|
||||
const dx = ax-ux, dy = ay-uy;
|
||||
return { x:ux, y:uy, r2:dx*dx+dy*dy };
|
||||
}
|
||||
function triangulate(points){
|
||||
const pts = points.slice();
|
||||
const st = pts.length;
|
||||
pts.push([-3000,-3000],[3500,-3000],[240,3600]);
|
||||
let tris = [{ i:[st,st+1,st+2], cc:circumcircle(pts[st],pts[st+1],pts[st+2]) }];
|
||||
for(let pi=0; pi<st; pi++){
|
||||
const p = pts[pi];
|
||||
const bad = [];
|
||||
for(let t=0;t<tris.length;t++){
|
||||
const cc = tris[t].cc;
|
||||
if(cc){
|
||||
const dx = p[0]-cc.x, dy = p[1]-cc.y;
|
||||
if(dx*dx+dy*dy < cc.r2) bad.push(tris[t]);
|
||||
}
|
||||
}
|
||||
const edgeCount = new Map();
|
||||
for(let b=0;b<bad.length;b++){
|
||||
const idx = bad[b].i;
|
||||
for(let e=0;e<3;e++){
|
||||
const u = idx[e], v = idx[(e+1)%3];
|
||||
const key = u < v ? u+"_"+v : v+"_"+u;
|
||||
edgeCount.set(key,(edgeCount.get(key)||0)+1);
|
||||
}
|
||||
}
|
||||
const badSet = new Set(bad);
|
||||
tris = tris.filter(function(t){ return !badSet.has(t); });
|
||||
for(let b=0;b<bad.length;b++){
|
||||
const idx = bad[b].i;
|
||||
for(let e=0;e<3;e++){
|
||||
const u = idx[e], v = idx[(e+1)%3];
|
||||
const key = u < v ? u+"_"+v : v+"_"+u;
|
||||
if(edgeCount.get(key) === 1){
|
||||
const cc = circumcircle(pts[u],pts[v],p);
|
||||
if(cc) tris.push({ i:[u,v,pi], cc:cc });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for(let t=0;t<tris.length;t++){
|
||||
const idx = tris[t].i;
|
||||
if(idx[0] < st && idx[1] < st && idx[2] < st) out.push(idx);
|
||||
}
|
||||
return { pts: pts, tris: out };
|
||||
}
|
||||
|
||||
// ---------- color ----------
|
||||
// Weighted per-triangle hue pick; weights sum to 1.
|
||||
function pickHue(rng, hueList){
|
||||
let x = rng();
|
||||
for(let i=0;i<hueList.length;i++){
|
||||
if(x < hueList[i].w) return hueList[i].h;
|
||||
x -= hueList[i].w;
|
||||
}
|
||||
return hueList[hueList.length-1].h;
|
||||
}
|
||||
function triangleColor(rng, hueList, spec){
|
||||
const h = pickHue(rng, hueList) + rnd(rng,-3,3);
|
||||
const s = spec.sat * rnd(rng,0.85,1.15);
|
||||
const l = spec.lBase + rnd(rng,-spec.lJit,spec.lJit);
|
||||
return hsl(h,s,l);
|
||||
}
|
||||
|
||||
// ---------- facets generator ----------
|
||||
// Points scatter beyond the 480x300 viewBox so the mesh covers the frame edge-to-edge.
|
||||
function genFacets(rng, hueList, spec, density){
|
||||
const pts = [];
|
||||
const x0 = -36, y0 = -36, x1 = 516, y1 = 336;
|
||||
const cols = density.cols, rows = density.rows;
|
||||
const cw = (x1-x0)/cols, ch = (y1-y0)/rows;
|
||||
for(let c=0;c<cols;c++){
|
||||
for(let r=0;r<rows;r++){
|
||||
pts.push([ x0 + (c + rnd(rng,0.08,0.92))*cw, y0 + (r + rnd(rng,0.08,0.92))*ch ]);
|
||||
}
|
||||
}
|
||||
const mesh = triangulate(pts);
|
||||
let s = '<rect width="480" height="300" fill="' + hsl(hueList[0].h, spec.sat, spec.lBase) + '"/>';
|
||||
for(let t=0;t<mesh.tris.length;t++){
|
||||
const idx = mesh.tris[t];
|
||||
const a = mesh.pts[idx[0]], b = mesh.pts[idx[1]], c = mesh.pts[idx[2]];
|
||||
const color = triangleColor(rng, hueList, spec);
|
||||
const ptsAttr = f1(a[0])+","+f1(a[1])+" "+f1(b[0])+","+f1(b[1])+" "+f1(c[0])+","+f1(c[1]);
|
||||
s += '<polygon points="' + ptsAttr + '" fill="' + color + '" stroke="' + color + '" stroke-width="0.7"/>';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- board overlay (same as sweep-1 gallery) ----------
|
||||
function buildOverlay(rng, tone){
|
||||
const laneFill = tone === "dark" ? "rgba(20,20,24,0.40)" : "rgba(255,255,255,0.38)";
|
||||
const cardFill = tone === "dark" ? "#26262B" : "#FFFFFF";
|
||||
const cardStroke = tone === "dark" ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)";
|
||||
const titleFill = tone === "dark" ? "rgba(22,22,26,0.55)" : "rgba(255,255,255,0.55)";
|
||||
let g = '<g class="board-overlay">';
|
||||
g += '<rect x="0" y="0" width="480" height="34" fill="' + titleFill + '"/>';
|
||||
const laneX = [16,168,320];
|
||||
for(let li=0; li<laneX.length; li++){
|
||||
const lx = laneX[li];
|
||||
g += '<rect x="' + lx + '" y="40" width="144" height="250" rx="8" fill="' + laneFill + '"/>';
|
||||
let y = 48;
|
||||
const count = rndInt(rng,2,4);
|
||||
for(let c=0;c<count;c++){
|
||||
const h = rnd(rng,26,44);
|
||||
if(y + h > 282) break;
|
||||
g += '<rect x="' + (lx+8) + '" y="' + f1(y) + '" width="128" height="' + f1(h) + '" rx="5" fill="' + cardFill + '" stroke="' + cardStroke + '"/>';
|
||||
y += h + 8;
|
||||
}
|
||||
}
|
||||
g += "</g>";
|
||||
return g;
|
||||
}
|
||||
|
||||
// ---------- registry ----------
|
||||
const HUES = [
|
||||
{ code:"am", name:"amber", h:38 },
|
||||
{ code:"fo", name:"forest", h:140 },
|
||||
{ code:"sk", name:"sky", h:215 },
|
||||
{ code:"ro", name:"rose", h:335 }
|
||||
];
|
||||
|
||||
const STRATEGIES = [
|
||||
{ id:"mono", hues:function(H){ return [{h:H, w:1}]; } },
|
||||
{ id:"duo", hues:function(H){ return [{h:H, w:0.65},{h:H+180, w:0.35}]; } },
|
||||
{ id:"trio", hues:function(H){ return [{h:H, w:0.5},{h:H+120, w:0.3},{h:H-120, w:0.2}]; } }
|
||||
];
|
||||
|
||||
const DENSITIES = [
|
||||
{ id:"c", label:"coarse", cols:5, rows:3 },
|
||||
{ id:"m", label:"medium", cols:9, rows:6 },
|
||||
{ id:"f", label:"fine", cols:14, rows:9 }
|
||||
];
|
||||
|
||||
// Saturation rows and brightness bases per tone. Rich rows get a touch of
|
||||
// brightness headroom so the saturation actually shows.
|
||||
const TONES = {
|
||||
light: { lJit:4.5, levels:[ {label:"soft", sat:20, lBase:90}, {label:"mid", sat:42, lBase:88}, {label:"rich", sat:68, lBase:85} ] },
|
||||
dark: { lJit:4.5, levels:[ {label:"soft", sat:16, lBase:17}, {label:"mid", sat:34, lBase:19}, {label:"rich", sat:52, lBase:21} ] }
|
||||
};
|
||||
|
||||
// ---------- lightbox ----------
|
||||
let openState = null;
|
||||
const lightbox = document.getElementById("lightbox");
|
||||
const lightboxFrame = document.getElementById("lightbox-frame");
|
||||
const lightboxId = document.getElementById("lightbox-id");
|
||||
|
||||
function openLightbox(svgEl, id){
|
||||
openState = { svgEl:svgEl, parent:svgEl.parentNode, next:svgEl.nextSibling };
|
||||
lightboxFrame.appendChild(svgEl);
|
||||
lightboxId.textContent = id;
|
||||
lightbox.classList.add("open");
|
||||
}
|
||||
function closeLightbox(){
|
||||
if(!openState) return;
|
||||
if(openState.next){
|
||||
openState.parent.insertBefore(openState.svgEl, openState.next);
|
||||
} else {
|
||||
openState.parent.appendChild(openState.svgEl);
|
||||
}
|
||||
openState = null;
|
||||
lightbox.classList.remove("open");
|
||||
}
|
||||
lightbox.addEventListener("click", function(e){
|
||||
if(e.target === lightbox) closeLightbox();
|
||||
});
|
||||
document.addEventListener("keydown", function(e){
|
||||
if(e.key === "Escape") closeLightbox();
|
||||
});
|
||||
|
||||
// ---------- render ----------
|
||||
let gen = 1;
|
||||
|
||||
function renderAll(){
|
||||
closeLightbox();
|
||||
const toneKeys = ["light","dark"];
|
||||
for(let si=0; si<STRATEGIES.length; si++){
|
||||
const strategy = STRATEGIES[si];
|
||||
for(let ti=0; ti<toneKeys.length; ti++){
|
||||
const tone = toneKeys[ti];
|
||||
const toneSpec = TONES[tone];
|
||||
const grid = document.getElementById("grid-" + strategy.id + "-" + tone);
|
||||
grid.innerHTML = "";
|
||||
// rows: hue × saturation; columns: density
|
||||
for(let hi=0; hi<HUES.length; hi++){
|
||||
for(let li=0; li<toneSpec.levels.length; li++){
|
||||
const level = toneSpec.levels[li];
|
||||
for(let di=0; di<DENSITIES.length; di++){
|
||||
const density = DENSITIES[di];
|
||||
const id = "facets-" + HUES[hi].code + "-" + strategy.id + "-" + density.id + "-" + tone.charAt(0) + (li+1);
|
||||
const rng = seededRng(id + "/g" + gen);
|
||||
const spec = { sat:level.sat, lBase:level.lBase, lJit:toneSpec.lJit };
|
||||
const hueList = strategy.hues(HUES[hi].h);
|
||||
const inner = genFacets(rng, hueList, spec, density);
|
||||
const overlay = buildOverlay(rng, tone);
|
||||
const svgMarkup = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 300" data-id="' + id + '">' + inner + overlay + "</svg>";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "card";
|
||||
|
||||
const frame = document.createElement("div");
|
||||
frame.className = "frame";
|
||||
frame.innerHTML = svgMarkup;
|
||||
const svgEl = frame.firstElementChild;
|
||||
|
||||
const caption = document.createElement("div");
|
||||
caption.className = "caption";
|
||||
const idSpan = document.createElement("span");
|
||||
idSpan.className = "swatch-id";
|
||||
idSpan.textContent = id;
|
||||
const noteSpan = document.createElement("span");
|
||||
noteSpan.className = "note";
|
||||
noteSpan.textContent = HUES[hi].name + " · " + level.label;
|
||||
caption.appendChild(idSpan);
|
||||
caption.appendChild(noteSpan);
|
||||
|
||||
card.appendChild(frame);
|
||||
card.appendChild(caption);
|
||||
card.addEventListener("click", function(){ openLightbox(svgEl, id); });
|
||||
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.getElementById("gen-note").textContent = "gen " + gen;
|
||||
}
|
||||
|
||||
// ---------- filters ----------
|
||||
let activeStrategy = "all", activeTone = "all";
|
||||
const strategyChips = document.querySelectorAll("#strategy-chips .chip");
|
||||
const toneChips = document.querySelectorAll("#tone-chips .chip");
|
||||
|
||||
function applyFilters(){
|
||||
const sections = document.querySelectorAll(".family");
|
||||
for(let i=0;i<sections.length;i++){
|
||||
const sec = sections[i];
|
||||
const strat = sec.dataset.strategy, tone = sec.dataset.tone;
|
||||
const visible = (activeStrategy === "all" || activeStrategy === strat) && (activeTone === "all" || activeTone === tone);
|
||||
sec.style.display = visible ? "" : "none";
|
||||
}
|
||||
}
|
||||
strategyChips.forEach(function(chip){
|
||||
chip.addEventListener("click", function(){
|
||||
strategyChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
|
||||
chip.classList.add("active");
|
||||
chip.setAttribute("aria-pressed","true");
|
||||
activeStrategy = chip.dataset.strategy;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
toneChips.forEach(function(chip){
|
||||
chip.addEventListener("click", function(){
|
||||
toneChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
|
||||
chip.classList.add("active");
|
||||
chip.setAttribute("aria-pressed","true");
|
||||
activeTone = chip.dataset.tone;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
document.getElementById("overlay-toggle").addEventListener("change", function(e){
|
||||
document.body.classList.toggle("show-overlay", e.target.checked);
|
||||
});
|
||||
document.getElementById("reroll").addEventListener("click", function(){
|
||||
gen++;
|
||||
renderAll();
|
||||
});
|
||||
|
||||
renderAll();
|
||||
})();
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
# Board background image set — exploration notes
|
||||
|
||||
Status: **first sweep published, awaiting swatch review** (2026-08-07). Not a numbered design doc — this is working material for producing a set of bundled background images; the schema and rendering side is already settled in 03-board-ui.md § Styling ▸ Capabilities.
|
||||
Status: **sweep 1 + faceted gallery published, awaiting swatch review** (2026-08-07). Not a numbered design doc — this is working material for producing a set of bundled background images; the schema and rendering side is already settled in 03-board-ui.md § Styling ▸ Capabilities.
|
||||
|
||||
## What this is
|
||||
|
||||
@@ -47,6 +47,24 @@ Families (1–9 light, 10–12 dark):
|
||||
|
||||
Rendering constraints baked into the generator (keep for sweep 2): no per-swatch SVG filters (all glow via radial gradients fading to alpha 0 — 96 filters would crawl), ≤~120 filler elements per swatch, gradient defs namespaced by swatch ID, light grounds ≥82% lightness / dark ≤20%.
|
||||
|
||||
## Faceted gallery — user-designed originals (2026-08-07)
|
||||
|
||||
Separate from the sweep-1 axes model: patterns designed to the user's own criteria — canvas tiled by shapes, per-shape **brightness jitter within a narrow band** doing the drawing, base brightness set by light/dark tone. Geometry is random on every render (that's part of the concept), so the gallery has a **Reroll** button; a swatch ID names the recipe, not a fixed layout.
|
||||
|
||||
- **Artifact**: https://claude.ai/code/artifact/d27ad77f-9298-4cf1-9093-19d13a186752 (republished in place each round)
|
||||
- **Generator source**: `board-backgrounds-faceted.html` (this folder) — same seeded-PRNG + board-overlay-toggle infrastructure as sweep 1; seed = swatch ID + generation counter.
|
||||
- **facets**: grid-jittered dot scatter over an extended canvas → Bowyer–Watson Delaunay triangulation, each triangle stroked with its own fill color to kill antialiasing seams. Reference image: low-poly example on the Redesign board.
|
||||
- **bubbles**: same color logic on ~64 opaque circles, power-law radii, drawn large-first. **Set aside after round 1** ("we'll need more work on that") — round-1 recipe kept in git history of the generator file.
|
||||
|
||||
**Round 2 (current, facets only)** — axes per the user: vertex count, color count, tone, saturation; brightness stays the narrow in-image jitter. 216 swatches = 3 color strategies × 2 tones × 4 hues × 3 saturations × 3 densities. Hue wheel narrowed to 4 representatives (amber/forest/sky/rose) to keep the cross reviewable; full wheel returns for finals.
|
||||
|
||||
- Densities (columns): coarse 5×3 cells ≈20 triangles · medium 9×6 ≈97 · fine 14×9 ≈230.
|
||||
- Color strategies (sections): mono · duo = complement H+180° weighted 65/35 · trio = triad H±120° weighted 50/30/20, hue picked per triangle.
|
||||
- IDs `facets-<hue>-<strategy>-<density>-<tone><sat>`, e.g. `facets-sk-duo-f-l2` = sky duo fine light mid.
|
||||
- Jitters: hue ±3°, sat ±15%, brightness ±4.5 L around tone base (light 85–90, dark 17–21, rich rows get slight headroom shifts).
|
||||
|
||||
**Shipped in-app (2026-08-07)**: the recipe is live in the board popover's Background tab (03-board-ui.md § Board popover ▸ Background tab) — Swift port in `Kanban/UI/Board/Backgrounds/`, rendered to a static `facets.png` at pick time (never a live view — the perf/sync ruling). One deliberate deviation from the gallery: the scatter grid gained a sacrificial boundary ring per density (coarse 7×5 @ margin 0.305, medium 10×7 @ 0.14, fine 15×10 @ 0.12 — interior cell size unchanged) because the gallery's fixed 0.075 margin let the ground notch the frame edge (coarse by up to 0.163 of the width — visible flat borders on the reviewed coarse swatches; medium 0.044, fine 0.004). Full-bleed coverage is now a tested invariant (`margin ≥ 0.92 × cell` both axes). Packaging question from sweep 1 is thereby answered for facets: generated on demand, not bundled.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Review with overlay on; pick surviving families / specific IDs and direction tweaks ("aurora but duskier", "contours denser").
|
||||
|
||||
@@ -271,6 +271,8 @@ public final class CardWindowUndo {
|
||||
return .path(url.standardizedFileURL)
|
||||
}
|
||||
|
||||
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
|
||||
private static let fieldOrder: [ExpectedField.Kind] = [
|
||||
.title, .order, .width, .background, .backgroundImage, .icon, .body,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
/// `background` — the styling gesture's colour dimension.
|
||||
case background(String?)
|
||||
|
||||
/// The `background` mapping's **`image` subkey** — the generated-background gesture's other half
|
||||
/// (`BoardStore.applyGeneratedBackground`).
|
||||
///
|
||||
/// Its own case rather than a second reading of `.background`, because they are two independent
|
||||
/// values under one key: a board can have its colour changed from the wells while its image
|
||||
/// stays, and the step that wrote the image must not stale because somebody picked a colour
|
||||
/// afterwards. `nil` is the absent subkey, exactly as everywhere else here.
|
||||
case backgroundImage(String?)
|
||||
|
||||
/// `icon` — the styling gesture's symbol dimension.
|
||||
case icon(String?)
|
||||
|
||||
@@ -46,6 +55,7 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
case .order: .order
|
||||
case .width: .width
|
||||
case .background: .background
|
||||
case .backgroundImage: .backgroundImage
|
||||
case .icon: .icon
|
||||
case .body: .body
|
||||
}
|
||||
@@ -58,6 +68,7 @@ public enum ExpectedField: Sendable, Equatable {
|
||||
case order
|
||||
case width
|
||||
case background
|
||||
case backgroundImage
|
||||
case icon
|
||||
case body
|
||||
}
|
||||
@@ -319,6 +330,7 @@ public enum HistoryStaleness {
|
||||
case let .order(expected): document.order.value == expected
|
||||
case let .width(expected): equal(document.width, expected)
|
||||
case let .background(expected): equal(document.background, expected)
|
||||
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
|
||||
case let .icon(expected): equal(document.icon, expected)
|
||||
case let .body(expected): document.body == expected
|
||||
}
|
||||
|
||||
@@ -1136,6 +1136,13 @@ public final class BannerCenter {
|
||||
if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
|
||||
case let .style(title):
|
||||
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
||||
case .setBoardBackground:
|
||||
// **"generate", because that is the button they pressed**, and no title because there is
|
||||
// one board and they are looking at it. It deliberately says nothing about the *file* —
|
||||
// the picture and the colour under it land in one bracket, and a user who has never seen
|
||||
// the PNG has no model of a half-written one; what failed, as far as they are concerned,
|
||||
// is that the board still looks the way it did.
|
||||
"Couldn't generate this board's background"
|
||||
case let .resize(title):
|
||||
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
|
||||
case let .rename(title):
|
||||
|
||||
@@ -297,6 +297,16 @@ public final class BoardStore: HealHost {
|
||||
/// refreshes nothing — the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
|
||||
public private(set) var landedReloads: Int = 0
|
||||
|
||||
/// **The generated background this store wrote, and the reload count it was written at** — the
|
||||
/// reroll's echo (`generatedBackgroundName(replacing:inRoot:)`, which is the only reader and
|
||||
/// carries the whole reasoning).
|
||||
///
|
||||
/// `@ObservationIgnored` because nothing renders it: it is bookkeeping about a file name, and a
|
||||
/// view that redrew when it changed would be redrawing for the write it is already going to be
|
||||
/// told about by the reload.
|
||||
@ObservationIgnored
|
||||
var generatedBackgroundEcho: (name: String, reloads: Int)?
|
||||
|
||||
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
||||
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
||||
/// describe the tree currently on screen.
|
||||
@@ -1944,6 +1954,140 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated background
|
||||
|
||||
/// **Applies a generated background to this board** — the picture into the board folder and the
|
||||
/// `background` mapping pointed at it, in one bracket (03-board-ui.md § Styling ▸ Capabilities;
|
||||
/// DESIGN/explorations/board-backgrounds.md; `FacetsGenerator`).
|
||||
///
|
||||
/// **The pixels are the caller's**, and that is the isolation contract: rendering a 3072 px mesh
|
||||
/// and PNG-encoding it is tens of milliseconds of pure computation, so it belongs on a detached
|
||||
/// task, and `FacetsGenerator` is `Sendable` and main-actor-free precisely so it can go there.
|
||||
/// What arrives here is finished `Data`. This method is synchronous for `applyStyle`'s reason: the
|
||||
/// write rides one `performWrite` bracket, which suspends the watcher — a suspension that must not
|
||||
/// span an `await`.
|
||||
///
|
||||
/// ### One bracket, two files
|
||||
///
|
||||
/// The image lands first and the frontmatter second, so a failure to write the picture never
|
||||
/// leaves the board naming one that is not there. The reverse order would; the two are not atomic
|
||||
/// together, and this is the ordering that makes the non-atomic half harmless. Both are inside the
|
||||
/// same bracket, so the churn rounds back as one app-mediated reload and mints one commit on git
|
||||
/// boards — the style batch's rule, one gesture one commit.
|
||||
///
|
||||
/// ### The name is chosen, not minted
|
||||
///
|
||||
/// Regenerating is the common gesture — the user rerolls until they like it — so a board must not
|
||||
/// accumulate a PNG per roll. The board's own generated file is therefore **overwritten in place**
|
||||
/// whenever `background.image` already names it, and the Finder ladder is used only when the name
|
||||
/// belongs to somebody else (`BoardWriter.freshName`): a hand-placed `facets.png` in the board
|
||||
/// folder is the user's file and is never written through.
|
||||
///
|
||||
/// ### The undo restores the fields, not the bytes
|
||||
///
|
||||
/// Stated plainly because it is the one place in the app where an inverse is not a full return:
|
||||
/// ⌘Z puts `background.image` and `background.color` back to what they said, and if this gesture
|
||||
/// **overwrote** a previous generation's PNG, those pixels are gone — nothing in the app kept a
|
||||
/// copy. The consequence is confined to regenerating over the app's own output (the image name is
|
||||
/// unchanged, so the fields come back pointing at a file whose contents are the new picture); an
|
||||
/// undo of the *first* generation removes the subkey and the board looks exactly as it did. Every
|
||||
/// alternative — a temp copy, a versioned name — buys byte-perfect undo of a picture nobody asked
|
||||
/// to keep at the price of litter in a folder the user owns.
|
||||
///
|
||||
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
|
||||
/// like every other gesture with no second thing to do about it.
|
||||
///
|
||||
/// - Parameter png: the encoded image, already rendered (`FacetsGenerator.pngData`).
|
||||
/// - Parameter colorHex: the ground colour of that render (`FacetsRecipe.primaryColorHex`) —
|
||||
/// written as `background.color` so the underlay, and a board copied without its picture,
|
||||
/// degrade to the image's own average rather than to nothing.
|
||||
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
|
||||
/// coming" (`setLaneWidth`'s rule). Discardable: the popover has nothing to do with the answer.
|
||||
@discardableResult
|
||||
public func applyGeneratedBackground(png: Data, colorHex: String) -> Bool {
|
||||
let root = rootURL
|
||||
let priorImage = snapshot.backgroundImage
|
||||
let priorColor = snapshot.background
|
||||
let name = generatedBackgroundName(replacing: priorImage.value, inRoot: root)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: name, inRoot: root, operation: .setBoardBackground
|
||||
)
|
||||
// `kind: .board` for the one subject whose position nothing can infer — the board root
|
||||
// (`BoardWriter.updateIndex`'s on-touch backfill), exactly as `applyStyle` passes it.
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointBackground(at: name, color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
generatedBackgroundEcho = (name: name, reloads: landedReloads)
|
||||
|
||||
// restyle → prior style (13-native-undo.md ▸ Rules). The board's own stack, never a window's:
|
||||
// there is no card here to have a session.
|
||||
registerStep(
|
||||
HistoryPhrase.name(.restyle, kind: .board),
|
||||
undoExpects: [.present(root, .background(colorHex), .backgroundImage(name))],
|
||||
redoExpects: [.present(root, .background(priorColor.value), .backgroundImage(priorImage.value))]
|
||||
) { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
// A malformed prior reads as a removal on both subkeys, which is `restore(_:to:in:)`'s
|
||||
// own rule and the one the redo expectation above is written against.
|
||||
document.setBackgroundImage(priorImage.value)
|
||||
Self.restore(priorColor, to: FrontmatterKeys.background, in: &document)
|
||||
}
|
||||
} redo: { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: root, kind: .board, operation: .setBoardBackground
|
||||
) { document in
|
||||
Self.pointBackground(at: name, color: colorHex, in: &document)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// The name a generated background is written under: **ours to overwrite**, or the next free one.
|
||||
///
|
||||
/// `current` is what `background.image` says now. When that is already the generated name the
|
||||
/// file is this board's own output and is replaced in place — including when it has been deleted
|
||||
/// from the folder by hand, which is a board whose backdrop is broken and is exactly what
|
||||
/// regenerating fixes. Otherwise the Finder ladder decides, which yields the plain name when
|
||||
/// nothing holds it and `facets 2.png` when something does.
|
||||
///
|
||||
/// ### The reroll's echo
|
||||
///
|
||||
/// The snapshot is by construction one reload behind every write the app makes (the one-way
|
||||
/// flow), and rerolling is a gesture people repeat *fast* — faster than FSEvents rounds a write
|
||||
/// back. Read from the snapshot alone, the second roll would see no `background.image` yet, find
|
||||
/// its own first roll's file sitting on the name, and step aside to `facets 2.png`: a folder full
|
||||
/// of abandoned pictures, which is the exact outcome the fixed name exists to prevent.
|
||||
///
|
||||
/// So a name this store wrote **since the last landed reload** counts as ours. The gate is the
|
||||
/// reload count rather than a timer or a flag, because it is the honest statement of the problem:
|
||||
/// while it has not moved, the snapshot *cannot* know about the write, so the store's own memory
|
||||
/// is the better authority. Once a reload lands, the snapshot's `background.image` takes over and
|
||||
/// this memory stops being consulted — including when a hand edit pointed the board somewhere
|
||||
/// else in the meantime.
|
||||
private func generatedBackgroundName(replacing current: String?, inRoot root: URL) -> String {
|
||||
if current == FacetsGenerator.fileName { return FacetsGenerator.fileName }
|
||||
if let echo = generatedBackgroundEcho, echo.reloads == landedReloads { return echo.name }
|
||||
return BoardWriter.freshName(for: FacetsGenerator.fileName, in: root)
|
||||
}
|
||||
|
||||
/// Both subkeys, written into the mapping rather than over it (BackgroundField.swift) — spelled
|
||||
/// once so the gesture and its redo cannot drift apart on the order they land in.
|
||||
///
|
||||
/// Colour first, so a board that had no `background` key at all comes out spelled the way
|
||||
/// 01-storage-format.md § Frontmatter writes it: `{color: …, image: …}`.
|
||||
private static func pointBackground(at name: String, color: String, in document: inout FrontmatterDocument) {
|
||||
document.setStyleValue(color, for: FrontmatterKeys.background)
|
||||
document.setBackgroundImage(name)
|
||||
}
|
||||
|
||||
// MARK: - Creation
|
||||
|
||||
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
|
||||
|
||||
@@ -59,12 +59,35 @@ extension FrontmatterDocument {
|
||||
}
|
||||
return
|
||||
}
|
||||
setBackgroundSubkey(FrontmatterKeys.Background.color, to: value)
|
||||
}
|
||||
|
||||
/// **Writes the `image` subkey** — `setStyleValue`'s colour write, one subkey over
|
||||
/// (03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// The doc comment above says "There is no image picker and none is planned"; that sentence held
|
||||
/// until generated backgrounds (DESIGN/explorations/board-backgrounds.md), which do not make one
|
||||
/// either. What the generator writes is a *file it just created in the board folder* and the name
|
||||
/// it wrote it under — the app is not browsing the user's pictures, it is naming its own output —
|
||||
/// so the hand-written path stays the escape hatch it always was, and this write preserves it the
|
||||
/// same way the colour write preserves an image: by subkey.
|
||||
///
|
||||
/// Everything else is `setStyleValue`'s, deliberately shared rather than restated: the same
|
||||
/// in-place merge, the same flow-mapping emission, the same removal-empties-the-key rule, and the
|
||||
/// same replacement of a non-mapping shape the schema could never read.
|
||||
public mutating func setBackgroundImage(_ value: String?) {
|
||||
setBackgroundSubkey(FrontmatterKeys.Background.image, to: value)
|
||||
}
|
||||
|
||||
/// The one merge both subkey writes go through — see `setStyleValue` for every rule it applies.
|
||||
private mutating func setBackgroundSubkey(_ subkey: String, to value: String?) {
|
||||
let key = FrontmatterKeys.background
|
||||
// Only a mapping has subkeys worth carrying; every other shape — absent, the retired scalar,
|
||||
// a sequence — starts empty and is replaced outright by what the app writes.
|
||||
var existing: [YAMLValue.Pair] = []
|
||||
if case let .mapping(pairs)? = self.value(for: key) { existing = pairs }
|
||||
|
||||
let merged = Self.merged(existing, subkey: FrontmatterKeys.Background.color, value: value)
|
||||
let merged = Self.merged(existing, subkey: subkey, value: value)
|
||||
if merged.isEmpty {
|
||||
remove(key)
|
||||
} else {
|
||||
|
||||
@@ -250,11 +250,26 @@ public enum BoardWriter: Sendable {
|
||||
/// removed best-effort and `.io` is thrown: the destination is either the old bytes or the
|
||||
/// new ones, never a mix, and never a directory littered with half-written files.
|
||||
static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
|
||||
try atomicWrite(Data(text.utf8), at: fileURL, operation: operation)
|
||||
// **The receipt, dropped after the bytes land and before the call returns** (the
|
||||
// EchoLedger's contract, 02-architecture.md ▸ Components). This one line covers every
|
||||
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
|
||||
// recreate, the task-marker flip, the body save and the raw-source Apply.
|
||||
EchoLedger.current?.recordWrite(at: fileURL, text: text)
|
||||
}
|
||||
|
||||
/// The temp-and-rename itself, with no opinion about what the bytes are — shared by the text
|
||||
/// path above and by `writeBoardImage`, so there is one atomic write in the app rather than two
|
||||
/// that could drift on the temp name, the cleanup or the `rename(2)`.
|
||||
///
|
||||
/// It drops **no receipt**: what a write means to the echo ledger differs between an `index.md`
|
||||
/// and a generated image, so each caller records its own.
|
||||
private static func atomicWrite(_ data: Data, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")
|
||||
|
||||
do {
|
||||
try Data(text.utf8).write(to: tempURL)
|
||||
try data.write(to: tempURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw BoardWriteError(
|
||||
@@ -281,11 +296,51 @@ public enum BoardWriter: Sendable {
|
||||
reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
|
||||
)
|
||||
}
|
||||
// **The receipt, dropped after the bytes land and before the call returns** (the
|
||||
// EchoLedger's contract, 02-architecture.md ▸ Components). This one line covers every
|
||||
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
|
||||
// recreate, the task-marker flip, the body save and the raw-source Apply.
|
||||
EchoLedger.current?.recordWrite(at: fileURL, text: text)
|
||||
}
|
||||
|
||||
// MARK: - Generated board artwork
|
||||
|
||||
/// **Writes a generated background image into the board folder** — the one path in the app that
|
||||
/// puts *bytes the app composed* on disk rather than text it edited (03-board-ui.md § Styling ▸
|
||||
/// Capabilities, the `background.image` half; `FacetsGenerator`).
|
||||
///
|
||||
/// It is `atomicReplace` with a different payload and the same four properties, which is the
|
||||
/// point of it existing here rather than at the store: hidden dot-temp in the **same folder**, a
|
||||
/// POSIX rename over the destination, best-effort cleanup on failure, and a receipt so the churn
|
||||
/// classifies as the app's rather than as a foreign write. A board whose backdrop is being
|
||||
/// regenerated is a board whose renderer may be mid-decode on the old file, and a rename is the
|
||||
/// only way to hand it either the old bytes or the new ones and never a truncated file.
|
||||
///
|
||||
/// **Overwriting is the caller's decision, expressed as a name.** This writes whatever name it is
|
||||
/// given, so the policy — reuse ours, or step aside from somebody else's file — lives in one
|
||||
/// place at the store (`BoardStore.applyGeneratedBackground`) rather than being half here and
|
||||
/// half there. `name` must be a bare filename; a path is refused rather than resolved, because a
|
||||
/// background that could be written outside the board folder is the mirror of the containment
|
||||
/// rule `BoardBackdrop.imageURL(named:inBoardRoot:)` already enforces on the read side.
|
||||
///
|
||||
/// - Returns: the name written, so a caller can chain straight into the frontmatter write
|
||||
/// without restating it.
|
||||
@discardableResult
|
||||
public static func writeBoardImage(
|
||||
data: Data,
|
||||
named name: String,
|
||||
inRoot root: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> String {
|
||||
guard !name.isEmpty, !name.contains("/"), name != ".", name != ".." else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: root.appendingPathComponent(name).path,
|
||||
reason: .io(message: "'\(name)' is not a file name a board image can be written under")
|
||||
)
|
||||
}
|
||||
let fileURL = root.appendingPathComponent(name)
|
||||
try atomicWrite(data, at: fileURL, operation: operation)
|
||||
// The bytes are already in hand, so this is the hash-what-you-wrote form rather than
|
||||
// `recordImport`'s read-it-back-and-hope — see `EchoLedger.recordImport(at:)` for the
|
||||
// difference and why the app prefers this side of it wherever it can.
|
||||
EchoLedger.current?.recordWrite(at: fileURL, data: data)
|
||||
return name
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
@@ -2883,6 +2938,21 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// delete 'Fix login'" would name a gesture they never made.
|
||||
case migrateTombstone(title: String?)
|
||||
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
||||
|
||||
/// **A generated board background landing** — the PNG written into the board folder and the
|
||||
/// `background` mapping's two subkeys pointed at it, one bracket
|
||||
/// (`BoardStore.applyGeneratedBackground`; `FacetsGenerator`).
|
||||
///
|
||||
/// Its own case rather than a fold into `.style`, on the vocabulary's standing reasoning: a
|
||||
/// restyle picks a value out of a grid of wells, while this **writes a file into the user's board
|
||||
/// folder** — a different act with a different failure ("the disk is full" means something else
|
||||
/// when a megabyte of picture is involved), and the one styling gesture whose undo cannot put
|
||||
/// everything back (see the store's own note on the overwritten bytes).
|
||||
///
|
||||
/// **No payload**, for `.mintBoardIndex`'s reason: there is one background per board, the user is
|
||||
/// looking at the board while they press the control, and the board's title would name a thing
|
||||
/// nobody could confuse for another.
|
||||
case setBoardBackground
|
||||
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
|
||||
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
|
||||
@@ -3117,9 +3187,11 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// `.seedGitignore`'s reasons at once: neither carries a title slot, and the board they
|
||||
// repair has no readable title to enrich from — a root with no `index.md` has no document
|
||||
// at all, and one with no `schema` is the file the walk just refused.
|
||||
// `.setBoardBackground` joins them on `.mintBoardIndex`'s reasoning: it carries no title
|
||||
// slot, and the board it writes to is the one the user is looking at.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
self
|
||||
@@ -3183,7 +3255,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
.displaceClaimedName,
|
||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
@@ -3210,6 +3282,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .purge(title): Self.phrase("purge", title)
|
||||
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
|
||||
case let .style(title): Self.phrase("style", title)
|
||||
case .setBoardBackground: "set this board's background"
|
||||
case let .resize(title): Self.phrase("resize", title)
|
||||
case let .rename(title): Self.phrase("rename", title)
|
||||
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// **The board popover's Background tab** (03-board-ui.md § Board popover ▸ Background tab, settled
|
||||
/// 2026-08-07) — two surfaces sharing one tab, one manual and one generated.
|
||||
///
|
||||
/// ### Color
|
||||
///
|
||||
/// The `StyleEditorView` embed, re-homed here unchanged from the pre-tab popover body: same target
|
||||
/// (`.board`), same `showsSymbols: false` (the inline `SymbolPicker` beside the rename field owns the
|
||||
/// board's glyph, so a second symbol grid here would make the glyph read as two settings), same write
|
||||
/// path (`StyleCommand.apply` → `BoardStore.applyStyle` + `StyleRecents.record`). It brings its own
|
||||
/// header ("Background") and its own inset (`StyleEditorLayout.popover(...).padding`), so nothing
|
||||
/// here pads around it — the anti-double-pad rule `BoardInfoView.inset`'s doc names.
|
||||
///
|
||||
/// ### Generated
|
||||
///
|
||||
/// A `FacetsRecipe` names a picture (`Backgrounds/FacetsRecipe.swift`); this section is the four
|
||||
/// filters that narrow one (tone, hue strategy, mesh density, saturation) plus one seed per hue,
|
||||
/// minted fresh on appear and re-minted by Reroll. Every swatch previews the exact recipe a click
|
||||
/// would apply — same filters, same seed, only the pixel width differs (384 for the strip, 3072 for
|
||||
/// the file) — so "what's clicked is what lands" (`FacetsGenerator`'s own claim).
|
||||
|
||||
// MARK: - Filters
|
||||
|
||||
/// **The generated picker's filter state**, and the pure mapping from it (plus a hue and a seed) to a
|
||||
/// `FacetsRecipe` — pulled out of the view so the default-tone rule and the recipe assembly are each
|
||||
/// assertable without a popover on screen (`BoardBackgroundFiltersTests`).
|
||||
struct BoardBackgroundFilters: Equatable {
|
||||
|
||||
var tone: FacetsRecipe.Tone
|
||||
var colors: FacetsRecipe.Strategy
|
||||
var mesh: FacetsRecipe.Density
|
||||
var saturation: FacetsRecipe.Saturation
|
||||
|
||||
/// The picker's opening state: mono colours, medium mesh, mid saturation always — only tone
|
||||
/// follows the system, which is `defaultTone(colorScheme:)`'s own job.
|
||||
static func initial(colorScheme: ColorScheme) -> BoardBackgroundFilters {
|
||||
BoardBackgroundFilters(
|
||||
tone: defaultTone(colorScheme: colorScheme), colors: .mono, mesh: .medium, saturation: .mid
|
||||
)
|
||||
}
|
||||
|
||||
/// Light appearance opens on Tone Light, dark on Tone Dark — read once, at first appearance, so a
|
||||
/// picker opened on a dark-mode Mac starts on swatches that read correctly against the popover
|
||||
/// around them rather than ones chosen for the other appearance. `.light` covers every
|
||||
/// `ColorScheme` case but `.dark` — there is no third case today.
|
||||
static func defaultTone(colorScheme: ColorScheme) -> FacetsRecipe.Tone {
|
||||
colorScheme == .dark ? .dark : .light
|
||||
}
|
||||
|
||||
/// One hue's recipe under these filters and a given seed — the whole of "click a swatch, get a
|
||||
/// board".
|
||||
func recipe(hue: FacetsRecipe.Hue, seed: UInt64) -> FacetsRecipe {
|
||||
FacetsRecipe(hue: hue, strategy: colors, density: mesh, tone: tone, saturation: saturation, seed: seed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The tab
|
||||
|
||||
struct BoardBackgroundTabView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
|
||||
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s own
|
||||
/// parameter — everything here that does not already carry its own inset (the Generated section)
|
||||
/// pads by this amount instead of restating the derivation.
|
||||
let inset: CGFloat
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
@State private var filters = BoardBackgroundFilters.initial(colorScheme: .light)
|
||||
|
||||
/// One seed per hue, in wheel order — minted on appear and re-minted by Reroll. A filter change
|
||||
/// leaves these alone (same geometry, new treatment); Reroll is the one gesture that changes them
|
||||
/// (new geometry).
|
||||
@State private var seeds: [FacetsRecipe.Hue: UInt64] = [:]
|
||||
|
||||
/// The carousel's previews, keyed by hue — absent until the render for the current
|
||||
/// `(filters, seeds)` pair lands, which is what the placeholder chip is for.
|
||||
@State private var previews: [FacetsRecipe.Hue: CGImage] = [:]
|
||||
|
||||
/// Whether a swatch's 3072px render is in flight — every swatch disables and the header grows a
|
||||
/// small spinner for the duration, so a second click cannot race the first.
|
||||
@State private var isApplying = false
|
||||
|
||||
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||
private let swatchWidth: CGFloat = 96
|
||||
private var swatchHeight: CGFloat { (swatchWidth * 10 / 16).rounded() }
|
||||
private let swatchCornerRadius: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one
|
||||
// ("nothing selected = the board"); this embed is the surface that exists *because* the
|
||||
// board is a style target, so it can have no other target (§ Styling ▸ Controls: "the
|
||||
// board popover's target is the board itself"). No symbol section — the inline
|
||||
// `SymbolPicker` beside the rename field above owns the board glyph.
|
||||
StyleEditorView(store: store, recents: recents, target: .board, showsSymbols: false)
|
||||
|
||||
Divider()
|
||||
|
||||
generatedSection
|
||||
.padding(inset)
|
||||
}
|
||||
.onAppear {
|
||||
filters.tone = BoardBackgroundFilters.defaultTone(colorScheme: colorScheme)
|
||||
if seeds.isEmpty { seeds = Self.mintSeeds() }
|
||||
}
|
||||
.task(id: previewKey) {
|
||||
await renderPreviews()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generated section
|
||||
|
||||
private var generatedSection: some View {
|
||||
VStack(alignment: .leading, spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
|
||||
HStack(spacing: 6) {
|
||||
sectionHeader("Generated")
|
||||
if isApplying {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Applying")
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
reroll()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel("New variations")
|
||||
}
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 6) {
|
||||
filterRow("Tone", selection: $filters.tone) { $0 == .light ? "Light" : "Dark" }
|
||||
filterRow("Colors", selection: $filters.colors, label: colorsLabel)
|
||||
filterRow("Mesh", selection: $filters.mesh, label: meshLabel)
|
||||
filterRow("Saturation", selection: $filters.saturation, label: saturationLabel)
|
||||
}
|
||||
|
||||
carousel
|
||||
}
|
||||
.font(.callout)
|
||||
// The whole section disables as one surface under the read-only lock — the same coarse rule
|
||||
// `StyleEditorView` applies to itself just above: an editor whose gestures would be refused
|
||||
// should not look available, and there is nothing here worth half-enabling (a filter nobody
|
||||
// can commit is not a useful control to leave live).
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
/// One filter: a trailing-aligned label — matching the Info tab's row shape — beside a compact
|
||||
/// segmented picker. The label is hidden from VoiceOver; the picker's own title (identical text)
|
||||
/// is what it announces, so nothing is read twice.
|
||||
private func filterRow<Value>(
|
||||
_ title: String,
|
||||
selection: Binding<Value>,
|
||||
label: @escaping (Value) -> String
|
||||
) -> some View where Value: Hashable, Value: CaseIterable, Value.AllCases: RandomAccessCollection {
|
||||
GridRow {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
.gridColumnAlignment(.trailing)
|
||||
.accessibilityHidden(true)
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(Array(Value.allCases), id: \.self) { value in
|
||||
Text(label(value)).tag(value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private func colorsLabel(_ value: FacetsRecipe.Strategy) -> String {
|
||||
switch value {
|
||||
case .mono: "Mono"
|
||||
case .duo: "Duo"
|
||||
case .trio: "Trio"
|
||||
}
|
||||
}
|
||||
|
||||
private func meshLabel(_ value: FacetsRecipe.Density) -> String {
|
||||
switch value {
|
||||
case .coarse: "Coarse"
|
||||
case .medium: "Medium"
|
||||
case .fine: "Fine"
|
||||
}
|
||||
}
|
||||
|
||||
private func saturationLabel(_ value: FacetsRecipe.Saturation) -> String {
|
||||
switch value {
|
||||
case .soft: "Soft"
|
||||
case .mid: "Mid"
|
||||
case .rich: "Rich"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carousel
|
||||
|
||||
private var carousel: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) {
|
||||
ForEach(FacetsRecipe.Hue.allCases, id: \.self) { hue in
|
||||
swatch(hue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func swatch(_ hue: FacetsRecipe.Hue) -> some View {
|
||||
Button {
|
||||
apply(hue)
|
||||
} label: {
|
||||
swatchFace(hue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Layered on top of the section's own disabling: this half additionally freezes every swatch
|
||||
// for the one gesture already running, so a second click cannot race the first's write.
|
||||
.disabled(isApplying)
|
||||
.opacity(isApplying ? 0.6 : 1)
|
||||
.help(hue.displayName)
|
||||
.accessibilityLabel("\(hue.displayName) — set generated background")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func swatchFace(_ hue: FacetsRecipe.Hue) -> some View {
|
||||
Group {
|
||||
if let image = previews[hue] {
|
||||
Image(decorative: image, scale: 1)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
// The pending placeholder: the recipe's own primary colour, so the strip reads as
|
||||
// "still painting this picture" rather than as a hole — and is already the right
|
||||
// colour if the render never manages to beat a quick reroll.
|
||||
chip(hue)
|
||||
}
|
||||
}
|
||||
.frame(width: swatchWidth, height: swatchHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: swatchCornerRadius))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: swatchCornerRadius)
|
||||
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
||||
)
|
||||
}
|
||||
|
||||
private func chip(_ hue: FacetsRecipe.Hue) -> Color {
|
||||
guard let seed = seeds[hue] else { return Color(nsColor: .textBackgroundColor) }
|
||||
let components = filters.recipe(hue: hue, seed: seed).primaryColor.components
|
||||
return Color(red: components.red, green: components.green, blue: components.blue)
|
||||
}
|
||||
|
||||
// MARK: - Rendering and applying
|
||||
|
||||
private struct PreviewKey: Equatable {
|
||||
var filters: BoardBackgroundFilters
|
||||
var seeds: [FacetsRecipe.Hue: UInt64]
|
||||
}
|
||||
|
||||
private var previewKey: PreviewKey { PreviewKey(filters: filters, seeds: seeds) }
|
||||
|
||||
/// The strip's eight previews, off the main actor — `FacetsGenerator.render` is pure, so this is
|
||||
/// exactly the render `apply(_:)` below would do at 3072px, just smaller and for every hue at
|
||||
/// once. Re-runs whenever `previewKey` changes (`.task(id:)`), which a filter edit or a Reroll
|
||||
/// both do — the same detach-and-await shape `BoardInfoTabView`'s disk walk uses.
|
||||
private func renderPreviews() async {
|
||||
previews = [:]
|
||||
let filters = self.filters
|
||||
let seeds = self.seeds
|
||||
let rendered = await Task.detached(priority: .utility) { () -> [FacetsRecipe.Hue: CGImage] in
|
||||
var rendered: [FacetsRecipe.Hue: CGImage] = [:]
|
||||
for hue in FacetsRecipe.Hue.allCases {
|
||||
guard let seed = seeds[hue] else { continue }
|
||||
if let image = FacetsGenerator.render(recipe: filters.recipe(hue: hue, seed: seed), pixelWidth: 384) {
|
||||
rendered[hue] = image
|
||||
}
|
||||
}
|
||||
return rendered
|
||||
}.value
|
||||
// `.task(id:)` cancels this task when the key moves on, but cancellation is cooperative and
|
||||
// the detached render finishes regardless — without this gate a slow stale strip could land
|
||||
// *after* the newer key's own renders and quietly replace them.
|
||||
guard !Task.isCancelled else { return }
|
||||
previews = rendered
|
||||
}
|
||||
|
||||
/// A swatch, clicked: the same recipe the preview showed, rendered at the file's own width and
|
||||
/// written through the one gesture every generated background lands through
|
||||
/// (`BoardStore.applyGeneratedBackground`). Failures — a `nil` render, a refused write under the
|
||||
/// lock — leave the board exactly as it was; the write path's own banners cover the write half.
|
||||
private func apply(_ hue: FacetsRecipe.Hue) {
|
||||
guard let seed = seeds[hue] else { return }
|
||||
let recipe = filters.recipe(hue: hue, seed: seed)
|
||||
isApplying = true
|
||||
Task {
|
||||
let data = await Task.detached(priority: .userInitiated) {
|
||||
FacetsGenerator.pngData(recipe: recipe, pixelWidth: 3072)
|
||||
}.value
|
||||
if let data {
|
||||
_ = store.applyGeneratedBackground(png: data, colorHex: recipe.primaryColorHex)
|
||||
}
|
||||
isApplying = false
|
||||
}
|
||||
}
|
||||
|
||||
private static func mintSeeds() -> [FacetsRecipe.Hue: UInt64] {
|
||||
Dictionary(uniqueKeysWithValues: FacetsRecipe.Hue.allCases.map { ($0, UInt64.random(in: UInt64.min...UInt64.max)) })
|
||||
}
|
||||
|
||||
private func reroll() {
|
||||
seeds = Self.mintSeeds()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hue display names
|
||||
|
||||
private extension FacetsRecipe.Hue {
|
||||
/// The wheel's own names, capitalized for the carousel's `.help` and accessibility label — the
|
||||
/// same eight words 03-board-ui.md's faceted-gallery notes use.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .clay: "Clay"
|
||||
case .amber: "Amber"
|
||||
case .olive: "Olive"
|
||||
case .forest: "Forest"
|
||||
case .teal: "Teal"
|
||||
case .sky: "Sky"
|
||||
case .iris: "Iris"
|
||||
case .rose: "Rose"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - FacetsGenerator
|
||||
|
||||
/// **The faceted background, rendered** — the Swift port of `board-backgrounds-faceted.html`'s
|
||||
/// `genFacets` (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, reviewed 2026-08-07).
|
||||
///
|
||||
/// A jittered point scatter over a frame slightly larger than the picture, Delaunay-triangulated,
|
||||
/// every triangle filled with its own colour off the recipe's narrow lightness band.
|
||||
///
|
||||
/// ### Pure, and deliberately nothing else
|
||||
///
|
||||
/// No SwiftUI, no `NSColor`, no actor, no filesystem: recipe in, pixels out. That is what lets the
|
||||
/// picker render a dozen previews off the main actor and the write path render one at 3072 px in the
|
||||
/// same call, from the same code, with no risk that the preview and the file are different pictures.
|
||||
///
|
||||
/// ### Normalized space, so one seed means one composition at every size
|
||||
///
|
||||
/// The gallery works in a 480×300 SVG viewBox; this works in a 16:10 **unit** frame — x 0…1,
|
||||
/// y 0…0.625 — with every constant divided by 480. A seed therefore names a composition rather than
|
||||
/// a composition-at-a-size: the 240 px preview in the picker and the 3072 px file written to the
|
||||
/// board folder are the same mesh, scaled.
|
||||
///
|
||||
/// ### The scatter runs past the frame on purpose
|
||||
///
|
||||
/// Points are laid over a region inset **outward** on all four sides by a margin the density
|
||||
/// chooses (`FacetsRecipe.Density.scatterMargin`, which carries the whole reasoning). Delaunay
|
||||
/// triangulation only covers the convex hull of its points, so a scatter that stopped at the frame
|
||||
/// edge would leave the ground colour showing in a ragged border. The margin is sized so that even
|
||||
/// the worst jitter draw puts every boundary-cell point at or beyond the frame edge — the picture is
|
||||
/// therefore entirely interior to the hull, and the ragged hull is cropped away.
|
||||
public enum FacetsGenerator: Sendable {
|
||||
|
||||
/// **The name a generated background is written under** (`BoardStore.applyGeneratedBackground`).
|
||||
///
|
||||
/// One fixed name rather than a minted one, because regenerating is the overwhelmingly common
|
||||
/// gesture — the user rerolls until they like it — and a fresh UUID per roll would leave a board
|
||||
/// folder full of abandoned PNGs the app never offers to clean up. The collision ladder handles
|
||||
/// the one case a fixed name cannot: somebody else's `facets.png` already sitting there.
|
||||
public static let fileName = "facets.png"
|
||||
|
||||
/// The normalized frame: 1 wide, 10/16 tall.
|
||||
static let frameHeight = 0.625
|
||||
|
||||
/// **The fraction of a cell a jittered point can be pushed toward the cell's far corner** — the
|
||||
/// upper end of the 0.08…0.92 placement band, and therefore the number the boundary ring has to
|
||||
/// beat (`FacetsRecipe.Density.scatterMargin`).
|
||||
static let jitterReach = 0.92
|
||||
|
||||
/// **The anti-seam stroke**, 0.7 px at the gallery's 480-wide scale. Each triangle is stroked in
|
||||
/// its *own* fill colour, which is the whole trick: adjacent antialiased edges otherwise leave a
|
||||
/// hairline of the ground colour between every pair of faces, and a mesh full of those reads as a
|
||||
/// wireframe rather than a surface.
|
||||
static let strokeWidth = 0.7 / 480
|
||||
|
||||
// MARK: Rendering
|
||||
|
||||
/// The pixel height that goes with `width` — the 16:10 frame, rounded.
|
||||
public static func pixelHeight(forWidth width: Int) -> Int {
|
||||
max(1, Int((Double(width) * frameHeight).rounded()))
|
||||
}
|
||||
|
||||
/// This recipe's mesh, drawn at `pixelWidth` — opaque sRGB, no alpha to composite and none to
|
||||
/// store.
|
||||
///
|
||||
/// `nil` only when CoreGraphics declines to make the bitmap at all (an allocation failure at an
|
||||
/// absurd size); every recipe renders. Callers treat it the way `BoardBackdrop.decode` is
|
||||
/// treated — no image, no banner, nothing written.
|
||||
public static func render(recipe: FacetsRecipe, pixelWidth: Int) -> CGImage? {
|
||||
let width = max(1, pixelWidth)
|
||||
let height = pixelHeight(forWidth: width)
|
||||
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: space,
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
|
||||
)
|
||||
else { return nil }
|
||||
|
||||
let mesh = facets(recipe: recipe)
|
||||
|
||||
// The ground, painted in device pixels before the transform goes on: the frame's rounded
|
||||
// height and `frameHeight × width` differ by up to half a pixel, and a rect stated in
|
||||
// normalized units could leave that sliver unpainted along one edge.
|
||||
if let ground = mesh.ground.cgColor(in: space) {
|
||||
context.setFillColor(ground)
|
||||
context.fill(CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
|
||||
}
|
||||
|
||||
// **SVG's y-down frame, kept.** The mesh is a scatter, so a flip would cost nothing visually
|
||||
// — but keeping the axis the gallery drew in means a swatch reviewed there and a file written
|
||||
// here are the same picture rather than its mirror, which is the only claim the port makes.
|
||||
context.translateBy(x: 0, y: CGFloat(height))
|
||||
context.scaleBy(x: CGFloat(width), y: -CGFloat(width))
|
||||
context.setLineWidth(strokeWidth)
|
||||
|
||||
for face in mesh.faces {
|
||||
guard let color = face.color.cgColor(in: space) else { continue }
|
||||
context.setFillColor(color)
|
||||
context.setStrokeColor(color)
|
||||
context.beginPath()
|
||||
context.move(to: face.a)
|
||||
context.addLine(to: face.b)
|
||||
context.addLine(to: face.c)
|
||||
context.closePath()
|
||||
context.drawPath(using: .fillStroke)
|
||||
}
|
||||
|
||||
return context.makeImage()
|
||||
}
|
||||
|
||||
/// The same render, encoded as PNG — the bytes `BoardWriter.writeBoardImage` puts in the board
|
||||
/// folder.
|
||||
///
|
||||
/// **PNG rather than JPEG**, and not for the usual reason: a flat-shaded triangle mesh is exactly
|
||||
/// the content JPEG is worst at (hard edges become ringing, and the anti-seam stroke's whole
|
||||
/// point is that there is no gap at those edges), while it is exactly what PNG's filters
|
||||
/// compress well.
|
||||
public static func pngData(recipe: FacetsRecipe, pixelWidth: Int) -> Data? {
|
||||
guard let image = render(recipe: recipe, pixelWidth: pixelWidth) else { return nil }
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(
|
||||
data, UTType.png.identifier as CFString, 1, nil
|
||||
) else { return nil }
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
guard CGImageDestinationFinalize(destination) else { return nil }
|
||||
return data as Data
|
||||
}
|
||||
|
||||
// MARK: The mesh
|
||||
|
||||
/// One triangle, in normalized coordinates, with the colour it is both filled and stroked in.
|
||||
struct Face: Sendable, Equatable {
|
||||
var a: CGPoint
|
||||
var b: CGPoint
|
||||
var c: CGPoint
|
||||
var color: FacetsColor
|
||||
|
||||
/// The unsigned area of the triangle, in normalized units — what the coverage check adds up.
|
||||
var area: Double {
|
||||
let abx = Double(b.x - a.x), aby = Double(b.y - a.y)
|
||||
let acx = Double(c.x - a.x), acy = Double(c.y - a.y)
|
||||
return abs(abx * acy - acx * aby) / 2
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole composition: the ground and the faces over it, in draw order.
|
||||
struct Mesh: Sendable, Equatable {
|
||||
var ground: FacetsColor
|
||||
var faces: [Face]
|
||||
}
|
||||
|
||||
/// **The mesh, and the whole of the random consumption order.**
|
||||
///
|
||||
/// Fixed and documented because it is the only thing keeping a seed meaningful across versions:
|
||||
/// **every point first** — column-major, x before y within a point, matching the HTML's
|
||||
/// `c`-outer/`r`-inner loops — **then four draws per triangle in triangle order** (the weighted
|
||||
/// hue pick, the hue jitter, the saturation factor, the lightness offset). Triangulation itself
|
||||
/// consumes nothing. Inserting a draw anywhere in that sequence re-rolls every board that ever
|
||||
/// stored this seed.
|
||||
///
|
||||
/// **Nothing stores one yet**, which is why the grid could be resized under it (the full-bleed
|
||||
/// ring, `FacetsRecipe.Density`): a board carries the rendered PNG, never the recipe that made
|
||||
/// it, so re-rolling every composition costs exactly nothing today. The moment a seed is written
|
||||
/// to disk — a `background.recipe` subkey, a preset library — that stops being true and this
|
||||
/// sequence, the density grid and the scatter margins all become format.
|
||||
static func facets(recipe: FacetsRecipe) -> Mesh {
|
||||
var random = FacetsRandom(seed: recipe.seed)
|
||||
let points = scatter(recipe.density, using: &random)
|
||||
let indices = triangulate(points)
|
||||
|
||||
let hues = recipe.hues
|
||||
let level = recipe.level
|
||||
var faces: [Face] = []
|
||||
faces.reserveCapacity(indices.count)
|
||||
for index in indices {
|
||||
faces.append(Face(
|
||||
a: points[index.a],
|
||||
b: points[index.b],
|
||||
c: points[index.c],
|
||||
color: color(hues: hues, level: level, using: &random)
|
||||
))
|
||||
}
|
||||
return Mesh(ground: recipe.primaryColor, faces: faces)
|
||||
}
|
||||
|
||||
/// The grid-jittered scatter over the outset region. One point per cell, placed anywhere in the
|
||||
/// middle 84% of it — the 0.08…0.92 inset is what stops two points in neighbouring cells from
|
||||
/// landing on top of each other and producing a sliver triangle, and its upper end is what the
|
||||
/// density's margin is sized against (`FacetsRecipe.Density.scatterMargin`).
|
||||
static func scatter(_ density: FacetsRecipe.Density, using random: inout FacetsRandom) -> [CGPoint] {
|
||||
let margin = density.scatterMargin
|
||||
let x0 = -margin
|
||||
let x1 = 1 + margin
|
||||
let y0 = -margin
|
||||
let y1 = frameHeight + margin
|
||||
let columns = density.columns
|
||||
let rows = density.rows
|
||||
let cellWidth = (x1 - x0) / Double(columns)
|
||||
let cellHeight = (y1 - y0) / Double(rows)
|
||||
|
||||
var points: [CGPoint] = []
|
||||
points.reserveCapacity(columns * rows)
|
||||
for column in 0..<columns {
|
||||
for row in 0..<rows {
|
||||
// Two draws, x then y — the order the JS array literal evaluates in.
|
||||
let x = x0 + (Double(column) + random.uniform(0.08, 0.92)) * cellWidth
|
||||
let y = y0 + (Double(row) + random.uniform(0.08, 0.92)) * cellHeight
|
||||
points.append(CGPoint(x: x, y: y))
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/// One triangle's colour: a weighted hue pick, then the three jitters.
|
||||
///
|
||||
/// The gallery rounds each channel to one decimal on its way into a CSS string; that is a
|
||||
/// serialization artefact of emitting text, not part of the recipe, so nothing rounds here.
|
||||
static func color(
|
||||
hues: [FacetsRecipe.WeightedHue],
|
||||
level: FacetsRecipe.Level,
|
||||
using random: inout FacetsRandom
|
||||
) -> FacetsColor {
|
||||
let picked = pickHue(hues, using: &random)
|
||||
let hue = picked + random.uniform(-3, 3)
|
||||
let saturation = level.saturation * random.uniform(0.85, 1.15)
|
||||
let lightness = level.lightness
|
||||
+ random.uniform(-FacetsRecipe.lightnessJitter, FacetsRecipe.lightnessJitter)
|
||||
return FacetsColor(hue: hue, saturation: saturation, lightness: lightness)
|
||||
}
|
||||
|
||||
/// The weighted pick, off **one** draw: walk the list subtracting weights until the draw falls
|
||||
/// inside one. The trailing return is the floating-point safety net for a draw that survives every
|
||||
/// subtraction — the weights sum to 1, but they sum to 1 in binary.
|
||||
static func pickHue(_ hues: [FacetsRecipe.WeightedHue], using random: inout FacetsRandom) -> Double {
|
||||
var x = random.next()
|
||||
for entry in hues {
|
||||
if x < entry.weight { return entry.degrees }
|
||||
x -= entry.weight
|
||||
}
|
||||
return hues.last?.degrees ?? 0
|
||||
}
|
||||
|
||||
// MARK: Triangulation
|
||||
|
||||
/// Three indices into the point array.
|
||||
struct IndexedTriangle: Sendable, Equatable {
|
||||
var a: Int
|
||||
var b: Int
|
||||
var c: Int
|
||||
}
|
||||
|
||||
/// **Bowyer–Watson**, exactly as the gallery does it: a super-triangle enclosing everything,
|
||||
/// points inserted one at a time, the triangles whose circumcircle contains the new point
|
||||
/// removed, and the cavity they leave retriangulated against its own boundary — the edges that
|
||||
/// were not shared by two of the removed triangles.
|
||||
///
|
||||
/// Triangles touching the super-triangle are dropped at the end, which is what leaves a
|
||||
/// triangulation of the input points alone.
|
||||
///
|
||||
/// ### The one deliberate departure from the gallery
|
||||
///
|
||||
/// The super-triangle is the HTML's shape at **100× its size**. Bowyer–Watson only produces a
|
||||
/// true triangulation when the scaffold is large enough that no real point's circumcircle can
|
||||
/// reach past it; the gallery's is about 11× the point cloud, which is not, and the cost is a
|
||||
/// handful of sliver triangles quietly missing near the hull — the count comes out 1–4 short of
|
||||
/// the 2n − 2 − h every triangulation must satisfy.
|
||||
///
|
||||
/// It is a departure from the *scaffold*, not from the picture: the scaffold is deleted before
|
||||
/// anything is drawn, and the two versions were measured against each other over 36 meshes — total
|
||||
/// covered area differs by 0.2%, entirely in slivers outside the visible frame. What is bought is
|
||||
/// an invariant a test can hold the port to exactly, instead of a tolerance around a defect.
|
||||
static func triangulate(_ points: [CGPoint]) -> [IndexedTriangle] {
|
||||
let count = points.count
|
||||
guard count >= 3 else { return [] }
|
||||
|
||||
var vertices = points
|
||||
vertices.append(CGPoint(x: -300_000.0 / 480, y: -300_000.0 / 480))
|
||||
vertices.append(CGPoint(x: 350_000.0 / 480, y: -300_000.0 / 480))
|
||||
vertices.append(CGPoint(x: 240.0 / 480, y: 360_000.0 / 480))
|
||||
|
||||
struct Working {
|
||||
var triangle: IndexedTriangle
|
||||
var circle: Circumcircle?
|
||||
}
|
||||
|
||||
var working = [Working(
|
||||
triangle: IndexedTriangle(a: count, b: count + 1, c: count + 2),
|
||||
circle: circumcircle(vertices[count], vertices[count + 1], vertices[count + 2])
|
||||
)]
|
||||
|
||||
for index in 0..<count {
|
||||
let point = vertices[index]
|
||||
|
||||
var bad: [IndexedTriangle] = []
|
||||
var kept: [Working] = []
|
||||
kept.reserveCapacity(working.count)
|
||||
for entry in working {
|
||||
if let circle = entry.circle, circle.contains(point) {
|
||||
bad.append(entry.triangle)
|
||||
} else {
|
||||
kept.append(entry)
|
||||
}
|
||||
}
|
||||
working = kept
|
||||
|
||||
// The cavity's boundary: an edge shared by two removed triangles is interior and dies
|
||||
// with them; one held by a single triangle is the hole's rim and gets a new face.
|
||||
//
|
||||
// The rim is walked in the removed triangles' own vertex order — **not** the map's, and
|
||||
// not a normalized one. Insertion order here is the order the faces come out in, and the
|
||||
// face order is the order the colour draws are consumed in, so a tidier walk would be a
|
||||
// different picture from the same seed.
|
||||
var edgeCounts: [Edge: Int] = [:]
|
||||
for triangle in bad {
|
||||
for edge in triangle.orderedEdges { edgeCounts[Edge(edge.from, edge.to), default: 0] += 1 }
|
||||
}
|
||||
for triangle in bad {
|
||||
for edge in triangle.orderedEdges where edgeCounts[Edge(edge.from, edge.to)] == 1 {
|
||||
guard let circle = circumcircle(vertices[edge.from], vertices[edge.to], point) else {
|
||||
continue
|
||||
}
|
||||
working.append(Working(
|
||||
triangle: IndexedTriangle(a: edge.from, b: edge.to, c: index),
|
||||
circle: circle
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return working.map(\.triangle).filter { $0.a < count && $0.b < count && $0.c < count }
|
||||
}
|
||||
|
||||
/// An undirected edge, keyed the way the HTML keys its `edgeCount` map: low index first, so the
|
||||
/// same edge seen from either of its triangles is one key.
|
||||
struct Edge: Hashable {
|
||||
var low: Int
|
||||
var high: Int
|
||||
|
||||
init(_ first: Int, _ second: Int) {
|
||||
low = min(first, second)
|
||||
high = max(first, second)
|
||||
}
|
||||
}
|
||||
|
||||
struct Circumcircle {
|
||||
var x: Double
|
||||
var y: Double
|
||||
var radiusSquared: Double
|
||||
|
||||
func contains(_ point: CGPoint) -> Bool {
|
||||
let dx = Double(point.x) - x
|
||||
let dy = Double(point.y) - y
|
||||
return dx * dx + dy * dy < radiusSquared
|
||||
}
|
||||
}
|
||||
|
||||
/// The circle through three points, or `nil` when they are collinear.
|
||||
///
|
||||
/// The guard is `1e-12` **in normalized units**, which is the HTML's `1e-9` at 480 scale carried
|
||||
/// across with room to spare: the determinant is quadratic in the coordinates, so the same
|
||||
/// degeneracy reads about 2×10⁵ times smaller here. Three jittered grid points are never actually
|
||||
/// collinear; this exists so a hand-built or pathological point set degrades to a missing face
|
||||
/// instead of an infinity.
|
||||
static func circumcircle(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> Circumcircle? {
|
||||
let ax = Double(a.x), ay = Double(a.y)
|
||||
let bx = Double(b.x), by = Double(b.y)
|
||||
let cx = Double(c.x), cy = Double(c.y)
|
||||
|
||||
let d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
|
||||
guard abs(d) >= 1e-12 else { return nil }
|
||||
|
||||
let a2 = ax * ax + ay * ay
|
||||
let b2 = bx * bx + by * by
|
||||
let c2 = cx * cx + cy * cy
|
||||
let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d
|
||||
let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d
|
||||
let dx = ax - ux
|
||||
let dy = ay - uy
|
||||
return Circumcircle(x: ux, y: uy, radiusSquared: dx * dx + dy * dy)
|
||||
}
|
||||
}
|
||||
|
||||
extension FacetsGenerator.IndexedTriangle {
|
||||
/// The three edges as **directed** pairs, in the HTML's own `e`/`(e+1)%3` order — see the walk in
|
||||
/// `triangulate` for why the direction is kept.
|
||||
var orderedEdges: [(from: Int, to: Int)] {
|
||||
[(a, b), (b, c), (c, a)]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FacetsRandom
|
||||
|
||||
/// **mulberry32**, ported bit-for-bit from the gallery's own PRNG.
|
||||
///
|
||||
/// Not `SystemRandomNumberGenerator` and not `SeededRandomNumberGenerator`-of-the-week: the seed's
|
||||
/// entire job is to be a *stable name for a picture*, so the sequence has to be reproducible across
|
||||
/// machines, OS versions and Swift releases. A 32-bit LCG-ish mixer with wrapping arithmetic is
|
||||
/// reproducible by definition; the standard library's generators explicitly are not.
|
||||
///
|
||||
/// Swift's `&*`/`&+` on `UInt32` are exactly JavaScript's `Math.imul` and its `| 0` truncation, so
|
||||
/// this produces the same doubles in the same order as the reviewed gallery does.
|
||||
struct FacetsRandom: Sendable {
|
||||
|
||||
private var state: UInt32
|
||||
|
||||
/// The 64-bit seed folded to the generator's 32-bit state through **xmur3's finalizer** — the
|
||||
/// avalanche half of the gallery's string hash. Folding rather than truncating matters: seeds
|
||||
/// minted from a counter differ only in their low bits, and a raw truncation would hand
|
||||
/// neighbouring seeds neighbouring first draws.
|
||||
init(seed: UInt64) {
|
||||
var h = UInt32(truncatingIfNeeded: seed ^ (seed >> 32))
|
||||
h ^= h >> 16
|
||||
h = h &* 2_246_822_507
|
||||
h ^= h >> 13
|
||||
h = h &* 3_266_489_909
|
||||
h ^= h >> 16
|
||||
state = h
|
||||
}
|
||||
|
||||
/// The next draw in 0..<1.
|
||||
mutating func next() -> Double {
|
||||
state = state &+ 0x6D2B_79F5
|
||||
var t = state
|
||||
t = (t ^ (t >> 15)) &* (t | 1)
|
||||
t ^= t &+ ((t ^ (t >> 7)) &* (t | 61))
|
||||
return Double(t ^ (t >> 14)) / 4_294_967_296
|
||||
}
|
||||
|
||||
/// A draw scaled into `lower..<upper`.
|
||||
mutating func uniform(_ lower: Double, _ upper: Double) -> Double {
|
||||
lower + next() * (upper - lower)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
// MARK: - FacetsRecipe
|
||||
|
||||
/// **What a generated board background is made of** — the axis set the faceted gallery swept and the
|
||||
/// review settled (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, 2026-08-07).
|
||||
///
|
||||
/// The recipe plus its `seed` is the whole of the picture: `FacetsGenerator` is pure, so the same
|
||||
/// pair renders the same mesh at any output size, on any machine, forever. That is what makes the
|
||||
/// recipe worth being a value — a board can carry one, a picker can offer one, and neither has to
|
||||
/// hold a bitmap to mean something.
|
||||
///
|
||||
/// ### The colour model is HSL, not HSB
|
||||
///
|
||||
/// The gallery is a web page and its swatches are CSS `hsl()`, so the numbers below — saturations of
|
||||
/// 20/42/68, lightnesses of 90/88/85 — are HSL numbers and only mean what the reviewer saw when they
|
||||
/// are read as HSL. `FacetsColor` converts; nothing here reaches for `NSColor`, whose `saturation`
|
||||
/// and `brightness` are the other model's and would land somewhere else entirely.
|
||||
///
|
||||
/// ### The generator source is authoritative
|
||||
///
|
||||
/// Every constant here restates one in `board-backgrounds-faceted.html`'s `genFacets`/`triangleColor`
|
||||
/// pair. Where the two ever disagree the HTML is the reviewed artefact and this is the port.
|
||||
public struct FacetsRecipe: Sendable, Equatable, Hashable {
|
||||
|
||||
/// The base hue — the one every strategy below builds its list from.
|
||||
public var hue: Hue
|
||||
|
||||
/// How many hues the mesh draws from, and in what proportion.
|
||||
public var strategy: Strategy
|
||||
|
||||
/// How finely the frame is diced.
|
||||
public var density: Density
|
||||
|
||||
/// Which end of the lightness range the whole swatch sits at.
|
||||
public var tone: Tone
|
||||
|
||||
/// How much colour there is at that lightness.
|
||||
public var saturation: Saturation
|
||||
|
||||
/// The composition's identity. Two renders of the same recipe under the same seed are the same
|
||||
/// picture; changing it alone is the gallery's Reroll button.
|
||||
public var seed: UInt64
|
||||
|
||||
public init(
|
||||
hue: Hue,
|
||||
strategy: Strategy,
|
||||
density: Density,
|
||||
tone: Tone,
|
||||
saturation: Saturation,
|
||||
seed: UInt64
|
||||
) {
|
||||
self.hue = hue
|
||||
self.strategy = strategy
|
||||
self.density = density
|
||||
self.tone = tone
|
||||
self.saturation = saturation
|
||||
self.seed = seed
|
||||
}
|
||||
|
||||
// MARK: Axes
|
||||
|
||||
/// The eight-hue wheel sweep 1 established and the finals return to (the faceted round narrowed
|
||||
/// the *gallery* to four representatives to keep 216 swatches reviewable — it never narrowed the
|
||||
/// wheel).
|
||||
public enum Hue: Sendable, Equatable, Hashable, CaseIterable {
|
||||
case clay
|
||||
case amber
|
||||
case olive
|
||||
case forest
|
||||
case teal
|
||||
case sky
|
||||
case iris
|
||||
case rose
|
||||
|
||||
/// Degrees on the colour wheel.
|
||||
public var degrees: Double {
|
||||
switch self {
|
||||
case .clay: 8
|
||||
case .amber: 38
|
||||
case .olive: 80
|
||||
case .forest: 140
|
||||
case .teal: 175
|
||||
case .sky: 215
|
||||
case .iris: 262
|
||||
case .rose: 335
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many hues a swatch draws from. The weights are the reviewed ones and they sum to 1 by
|
||||
/// construction, which is what `FacetsGenerator`'s single-draw weighted pick assumes.
|
||||
public enum Strategy: Sendable, Equatable, Hashable, CaseIterable {
|
||||
/// One hue; only the lightness jitter draws the mesh.
|
||||
case mono
|
||||
/// The base plus its complement, 65/35 — a dominant field with contrasting inclusions.
|
||||
case duo
|
||||
/// The contrasting triad H, H+120°, H−120°, weighted 50/30/20.
|
||||
case trio
|
||||
}
|
||||
|
||||
/// Vertex count, as a grid of jittered cells laid over a region **larger than the picture** —
|
||||
/// and the size of that overhang, which is per-density for the reason below.
|
||||
///
|
||||
/// ### The cell size is the gallery's; the ring is new
|
||||
///
|
||||
/// What a viewer reads as "coarse" or "fine" is the size of a facet, not the number of points,
|
||||
/// so the numbers preserved from the reviewed gallery are the **cell dimensions** — 0.23 for
|
||||
/// coarse, 0.128 for medium, 0.0827 for fine (its 1.15/5, 1.15/9, 1.15/14 in unit terms). The
|
||||
/// grid then simply has however many cells it takes to cover the frame *plus* a boundary ring,
|
||||
/// which is where the extra columns and rows come from: 5×3 → 7×5, 9×6 → 10×7, 14×9 → 15×10.
|
||||
///
|
||||
/// ### The ring is the full-bleed guarantee
|
||||
///
|
||||
/// The gallery used one margin for all three densities (0.075 in unit terms) and got away with
|
||||
/// it: a mesh only covers its points' convex hull, and at medium and fine that margin left the
|
||||
/// frame covered often enough that nobody looking at swatches would notice. It is not a
|
||||
/// guarantee, though — a boundary point is placed anywhere in the middle 84% of its cell, so the
|
||||
/// worst draw puts it 0.92 of a cell *inward* of the region's edge, and against a margin of only
|
||||
/// 0.075 every density could land inside the picture: coarse by 0.163, medium by 0.044, fine by
|
||||
/// 0.0042. Each of those is a notch of flat ground colour on the frame edge, and coarse's — a
|
||||
/// sixth of the frame's height — is one anybody would see.
|
||||
///
|
||||
/// So the margin is sized against the cell instead of fixed: **margin ≥ 0.92 × cell** on both
|
||||
/// axes, which is exactly the statement "even the worst jitter draw leaves every boundary-cell
|
||||
/// point at or beyond the frame edge". The whole frame is then interior to the hull and the mesh
|
||||
/// is full-bleed by construction rather than by luck. `FacetsGeneratorTests` holds the inequality.
|
||||
///
|
||||
/// The ring's own triangles are drawn and then cropped away, which is what an oversized canvas
|
||||
/// costs: a third of coarse's faces are never seen. That is the trade the gallery was already
|
||||
/// making, made big enough to be a promise.
|
||||
public enum Density: Sendable, Equatable, Hashable, CaseIterable {
|
||||
case coarse
|
||||
case medium
|
||||
case fine
|
||||
|
||||
public var columns: Int {
|
||||
switch self {
|
||||
case .coarse: 7
|
||||
case .medium: 10
|
||||
case .fine: 15
|
||||
}
|
||||
}
|
||||
|
||||
public var rows: Int {
|
||||
switch self {
|
||||
case .coarse: 5
|
||||
case .medium: 7
|
||||
case .fine: 10
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the scatter runs past the frame on every side, in width units — the sacrificial
|
||||
/// ring. Symmetric on both axes because the frame is, and the cells are very nearly square.
|
||||
public var scatterMargin: Double {
|
||||
switch self {
|
||||
case .coarse: 0.305
|
||||
case .medium: 0.14
|
||||
case .fine: 0.12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which end of the lightness range the swatch sits at. **Not a light/dark *pair*** — a board
|
||||
/// carries one background image and the app has no appearance-conditional backdrop, so this is a
|
||||
/// choice the author makes once, like choosing a photograph.
|
||||
public enum Tone: Sendable, Equatable, Hashable, CaseIterable {
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
/// The saturation band. Rich rows get a little lightness headroom so the saturation actually
|
||||
/// shows — which is why the level below carries both numbers rather than a saturation alone.
|
||||
public enum Saturation: Sendable, Equatable, Hashable, CaseIterable {
|
||||
case soft
|
||||
case mid
|
||||
case rich
|
||||
}
|
||||
|
||||
// MARK: The derived numbers
|
||||
|
||||
/// One tone × saturation cell: the base saturation and lightness every triangle jitters around.
|
||||
public struct Level: Sendable, Equatable, Hashable {
|
||||
public var saturation: Double
|
||||
public var lightness: Double
|
||||
}
|
||||
|
||||
/// **The per-triangle lightness jitter, ±4.5** — the same for both tones, because the narrow
|
||||
/// band *is* the recipe: "brightness stays a narrow per-triangle jitter around the tone base".
|
||||
/// Widening it on either end would stop the mesh reading as one surface catching light.
|
||||
public static let lightnessJitter: Double = 4.5
|
||||
|
||||
/// This recipe's saturation/lightness cell.
|
||||
public var level: Level {
|
||||
switch (tone, saturation) {
|
||||
case (.light, .soft): Level(saturation: 20, lightness: 90)
|
||||
case (.light, .mid): Level(saturation: 42, lightness: 88)
|
||||
case (.light, .rich): Level(saturation: 68, lightness: 85)
|
||||
case (.dark, .soft): Level(saturation: 16, lightness: 17)
|
||||
case (.dark, .mid): Level(saturation: 34, lightness: 19)
|
||||
case (.dark, .rich): Level(saturation: 52, lightness: 21)
|
||||
}
|
||||
}
|
||||
|
||||
/// The hues a triangle is picked from, with the weights that pick it. First entry is always the
|
||||
/// base hue, which is also the ground the mesh is painted over.
|
||||
public var hues: [WeightedHue] {
|
||||
let base = hue.degrees
|
||||
switch strategy {
|
||||
case .mono:
|
||||
return [WeightedHue(degrees: base, weight: 1)]
|
||||
case .duo:
|
||||
return [
|
||||
WeightedHue(degrees: base, weight: 0.65),
|
||||
WeightedHue(degrees: base + 180, weight: 0.35),
|
||||
]
|
||||
case .trio:
|
||||
return [
|
||||
WeightedHue(degrees: base, weight: 0.5),
|
||||
WeightedHue(degrees: base + 120, weight: 0.3),
|
||||
WeightedHue(degrees: base - 120, weight: 0.2),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the weighted hue list.
|
||||
public struct WeightedHue: Sendable, Equatable, Hashable {
|
||||
public var degrees: Double
|
||||
public var weight: Double
|
||||
}
|
||||
|
||||
/// **The ground the mesh is painted over** — the base hue at the level's own saturation and
|
||||
/// lightness, un-jittered. The rect the generator fills before the first triangle lands.
|
||||
public var primaryColor: FacetsColor {
|
||||
FacetsColor(hue: hue.degrees, saturation: level.saturation, lightness: level.lightness)
|
||||
}
|
||||
|
||||
/// The same colour as `#RRGGBB`, uppercase — **what the board's `background.color` gets set to**
|
||||
/// when a generated image is applied (`BoardStore.applyGeneratedBackground`).
|
||||
///
|
||||
/// It is the honest fallback rather than a decoration: the colour underlay is what shows while
|
||||
/// the backdrop decodes, what shows if the file is later deleted from the folder by hand, and
|
||||
/// what a board copied without its image degrades to. Picking the mesh's own ground means all
|
||||
/// three land on the picture's average rather than on white.
|
||||
public var primaryColorHex: String { primaryColor.hexString }
|
||||
}
|
||||
|
||||
// MARK: - FacetsColor
|
||||
|
||||
/// **One colour in the gallery's own model** — HSL, in degrees and percent, converted to sRGB on
|
||||
/// demand (see `FacetsRecipe`'s note on why this is not HSB).
|
||||
///
|
||||
/// Stored as it was computed rather than as components, so a colour can be compared, hashed and
|
||||
/// printed in the numbers the recipe is written in.
|
||||
public struct FacetsColor: Sendable, Equatable, Hashable {
|
||||
|
||||
/// Degrees, wrapped into 0..<360 — the `mod360` the generator applies before every emission.
|
||||
public var hue: Double
|
||||
|
||||
/// Percent, clamped 0…100.
|
||||
public var saturation: Double
|
||||
|
||||
/// Percent, clamped 0…100.
|
||||
public var lightness: Double
|
||||
|
||||
public init(hue: Double, saturation: Double, lightness: Double) {
|
||||
self.hue = Self.wrapped(hue)
|
||||
self.saturation = min(max(saturation, 0), 100)
|
||||
self.lightness = min(max(lightness, 0), 100)
|
||||
}
|
||||
|
||||
/// CSS's own `hsl()` → sRGB, component-wise in 0…1. The chroma/secondary/match-lightness form,
|
||||
/// which is the one the specification is written in and the one every browser implements.
|
||||
public var components: (red: Double, green: Double, blue: Double) {
|
||||
let saturation = saturation / 100
|
||||
let lightness = lightness / 100
|
||||
let chroma = (1 - abs(2 * lightness - 1)) * saturation
|
||||
let sextant = hue / 60
|
||||
let secondary = chroma * (1 - abs(sextant.truncatingRemainder(dividingBy: 2) - 1))
|
||||
let match = lightness - chroma / 2
|
||||
|
||||
let (red, green, blue): (Double, Double, Double) = switch sextant {
|
||||
case ..<1: (chroma, secondary, 0)
|
||||
case ..<2: (secondary, chroma, 0)
|
||||
case ..<3: (0, chroma, secondary)
|
||||
case ..<4: (0, secondary, chroma)
|
||||
case ..<5: (secondary, 0, chroma)
|
||||
default: (chroma, 0, secondary)
|
||||
}
|
||||
return (red + match, green + match, blue + match)
|
||||
}
|
||||
|
||||
/// `#RRGGBB`, uppercase — the spelling `Palette`'s hex reader and the colour panel's round trip
|
||||
/// both already speak (`NSColor.paletteHexString`), so a generated colour is indistinguishable
|
||||
/// from a hand-written one on disk.
|
||||
public var hexString: String {
|
||||
let (red, green, blue) = components
|
||||
return String(
|
||||
format: "#%02X%02X%02X",
|
||||
Self.byte(red), Self.byte(green), Self.byte(blue)
|
||||
)
|
||||
}
|
||||
|
||||
/// The colour as CoreGraphics wants it, in the space the digits name. **`space` is passed in
|
||||
/// rather than made here** so a render creates one sRGB space for a whole mesh instead of one
|
||||
/// per triangle.
|
||||
func cgColor(in space: CGColorSpace) -> CGColor? {
|
||||
let (red, green, blue) = components
|
||||
return CGColor(colorSpace: space, components: [CGFloat(red), CGFloat(green), CGFloat(blue), 1])
|
||||
}
|
||||
|
||||
private static func byte(_ component: Double) -> Int {
|
||||
min(max(Int((component * 255).rounded()), 0), 255)
|
||||
}
|
||||
|
||||
/// Degrees into 0..<360, negatives included — `trio`'s third hue is `H − 120`, which is negative
|
||||
/// for every hue below clay's 8°.
|
||||
private static func wrapped(_ degrees: Double) -> Double {
|
||||
let wrapped = degrees.truncatingRemainder(dividingBy: 360)
|
||||
return wrapped < 0 ? wrapped + 360 : wrapped
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import SwiftUI
|
||||
/// **Restructuring in progress (2026-08-07): the popover is going tabbed.** The symbol/name header
|
||||
/// stays at the top; below it sit three tabs — **Info**, **Background**, **Git** — each a settings
|
||||
/// surface for one aspect of board configuration, each settled in its own dedicated design session.
|
||||
/// **Info is settled** (same day — `BoardInfoTabView`, the metrics dossier); Background and Git
|
||||
/// remain deliberately empty until theirs. The former body — the embedded style editor and the
|
||||
/// mode-aware git section — is unrendered for the interim but parked in this file (see the
|
||||
/// "Parked" marks below), because its pure seams (`BoardGitSection`, the posture notes,
|
||||
/// `BoardSettingsAvailability`'s caller) are settled design and will rehome into the tabs as those
|
||||
/// sessions rule.
|
||||
/// **Info and Background are settled** (both 2026-08-07 — `BoardInfoTabView`, the metrics dossier;
|
||||
/// `BoardBackgroundTabView`, the re-homed style editor plus the generated-background picker); Git
|
||||
/// remains deliberately empty until its own session. The former body's mode-aware git section is
|
||||
/// unrendered for the interim but parked in this file (see the "Parked" mark below), because its
|
||||
/// pure seams (`BoardGitSection`, the posture notes, `BoardSettingsAvailability`'s caller) are
|
||||
/// settled design and will rehome into the Git tab once that session rules.
|
||||
///
|
||||
/// ### One home, deliberately
|
||||
///
|
||||
@@ -257,9 +257,9 @@ func boardInfoTitlebarAccessory(
|
||||
// MARK: - Tabs
|
||||
|
||||
/// The popover's three aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
||||
/// restructure): **Info**, **Background**, **Git**. Info is settled (`BoardInfoTabView`);
|
||||
/// Background and Git are placeholders — empty on purpose — until each gets its dedicated design
|
||||
/// session, which then only has to fill its case in.
|
||||
/// restructure): **Info**, **Background**, **Git**. Info and Background are settled
|
||||
/// (`BoardInfoTabView`, `BoardBackgroundTabView`); Git is a placeholder — empty on purpose — until
|
||||
/// its own dedicated design session, which then only has to fill its case in.
|
||||
enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||
|
||||
case info = "Info"
|
||||
@@ -275,9 +275,9 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
|
||||
/// (03-board-ui.md § Board popover).
|
||||
///
|
||||
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
|
||||
/// beside a card — kept through the restructure so the popover's footprint doesn't wander while the
|
||||
/// tabs are placeholders; whether the tabbed surface wants its own width is each tab session's
|
||||
/// question to raise.
|
||||
/// beside a card — kept through the restructure so the popover's footprint doesn't wander while Git
|
||||
/// is still a placeholder; both tabs settled so far (Info, Background) kept it too, so whether the
|
||||
/// tabbed surface ever wants its own width remains open, but nothing has needed one yet.
|
||||
struct BoardInfoView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -375,15 +375,16 @@ struct BoardInfoView: View {
|
||||
.padding(.horizontal, inset)
|
||||
.padding(.top, inset)
|
||||
|
||||
// The selected tab's surface. Info is settled (2026-08-07 — `BoardInfoTabView`);
|
||||
// Background and Git stay placeholders until their own sessions, holding a fixed
|
||||
// height so an empty tab reads as a surface awaiting content rather than a collapsed
|
||||
// sliver — `Color.clear`, because an `EmptyView` inside a frame renders nothing at all.
|
||||
// The selected tab's surface. Info and Background are settled (2026-08-07 —
|
||||
// `BoardInfoTabView`, `BoardBackgroundTabView`); Git stays a placeholder until its own
|
||||
// session, holding a fixed height so an empty tab reads as a surface awaiting content
|
||||
// rather than a collapsed sliver — `Color.clear`, because an `EmptyView` inside a frame
|
||||
// renders nothing at all.
|
||||
switch tab {
|
||||
case .info:
|
||||
BoardInfoTabView(store: store, inset: inset)
|
||||
case .background:
|
||||
Color.clear.frame(height: 120)
|
||||
BoardBackgroundTabView(store: store, recents: recents, inset: inset)
|
||||
case .git:
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
@@ -394,14 +395,13 @@ struct BoardInfoView: View {
|
||||
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
|
||||
}
|
||||
|
||||
// MARK: Parked pending the tab sessions (2026-08-07)
|
||||
// MARK: Parked pending the Git tab session (2026-08-07)
|
||||
//
|
||||
// Nothing below this mark renders today. The style-editor embed left the body with the tab
|
||||
// restructure (its Background-tab fate is that session's), and the git section — postures,
|
||||
// notes, and the Board Settings… row — waits here for the Git tab's session. Parked rather
|
||||
// than deleted because every seam it hangs on is settled, test-pinned design
|
||||
// (`BoardGitSectionTests`, `BoardSettingsAvailabilityTests`), and the tab sessions rehome
|
||||
// surfaces, not rulings.
|
||||
// Nothing below this mark renders today. The style-editor embed that once lived here has
|
||||
// rehomed to `BoardBackgroundTabView`; what is left is the git section — postures, notes, and
|
||||
// the Board Settings… row — waiting for the Git tab's own session. Parked rather than deleted
|
||||
// because every seam it hangs on is settled, test-pinned design (`BoardGitSectionTests`,
|
||||
// `BoardSettingsAvailabilityTests`), and the tab sessions rehome surfaces, not rulings.
|
||||
|
||||
/// The popover's closing section, whichever of the six postures this board is in — see
|
||||
/// `BoardGitSection`.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **`BoardBackgroundFilters`** — the Background tab's generated picker: the default-tone rule and
|
||||
/// the pure filters → `FacetsRecipe` assembly, pinned the way the popover's other pure seams are
|
||||
/// (`BoardInfoMetrics`, `BoardDiskFootprint` in `BoardInfoTabTests`). Everything else about the
|
||||
/// carousel — the strip's layout, the placeholder chip, the apply gesture — is SwiftUI and
|
||||
/// deliberately untested; `FacetsGeneratorTests` and `GeneratedBackgroundTests` already cover the
|
||||
/// generator and the write path this feeds.
|
||||
@Suite("Board popover ▸ Background tab filters")
|
||||
struct BoardBackgroundFiltersTests {
|
||||
|
||||
@Test("Dark system appearance opens on Tone Dark, light opens on Tone Light")
|
||||
func defaultToneFollowsTheSystem() {
|
||||
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .dark) == .dark)
|
||||
#expect(BoardBackgroundFilters.defaultTone(colorScheme: .light) == .light)
|
||||
}
|
||||
|
||||
@Test("The opening state is mono, medium, mid — only tone varies with the system")
|
||||
func initialStateIsTheReviewedDefaults() {
|
||||
let light = BoardBackgroundFilters.initial(colorScheme: .light)
|
||||
#expect(light.tone == .light)
|
||||
#expect(light.colors == .mono)
|
||||
#expect(light.mesh == .medium)
|
||||
#expect(light.saturation == .mid)
|
||||
|
||||
let dark = BoardBackgroundFilters.initial(colorScheme: .dark)
|
||||
#expect(dark.tone == .dark)
|
||||
#expect(dark.colors == .mono)
|
||||
#expect(dark.mesh == .medium)
|
||||
#expect(dark.saturation == .mid)
|
||||
}
|
||||
|
||||
@Test("A hue and a seed assemble the exact recipe the filters describe")
|
||||
func recipeAssemblesEveryAxis() {
|
||||
let filters = BoardBackgroundFilters(tone: .dark, colors: .trio, mesh: .fine, saturation: .rich)
|
||||
let recipe = filters.recipe(hue: .iris, seed: 0x5EED)
|
||||
|
||||
#expect(recipe.hue == .iris)
|
||||
#expect(recipe.strategy == .trio)
|
||||
#expect(recipe.density == .fine)
|
||||
#expect(recipe.tone == .dark)
|
||||
#expect(recipe.saturation == .rich)
|
||||
#expect(recipe.seed == 0x5EED)
|
||||
}
|
||||
|
||||
@Test("Two hues under the same filters and seed differ only in hue")
|
||||
func onlyHueChangesAcrossTheWheel() {
|
||||
let filters = BoardBackgroundFilters.initial(colorScheme: .light)
|
||||
let sky = filters.recipe(hue: .sky, seed: 42)
|
||||
let rose = filters.recipe(hue: .rose, seed: 42)
|
||||
|
||||
#expect(sky.hue == .sky)
|
||||
#expect(rose.hue == .rose)
|
||||
#expect(sky.strategy == rose.strategy)
|
||||
#expect(sky.density == rose.density)
|
||||
#expect(sky.tone == rose.tone)
|
||||
#expect(sky.saturation == rose.saturation)
|
||||
#expect(sky.seed == rose.seed)
|
||||
}
|
||||
|
||||
@Test("The same filters and seed recipe identically — the preview/apply agreement the carousel depends on")
|
||||
func sameInputsRecipeIdentically() {
|
||||
let filters = BoardBackgroundFilters(tone: .light, colors: .duo, mesh: .coarse, saturation: .soft)
|
||||
#expect(filters.recipe(hue: .forest, seed: 7) == filters.recipe(hue: .forest, seed: 7))
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,144 @@ struct BackgroundWriteTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Writing the image subkey
|
||||
|
||||
/// `FrontmatterDocument.setBackgroundImage` — the colour write's sibling, one subkey over
|
||||
/// (BackgroundField.swift), and what `BoardStore.applyGeneratedBackground` points at the PNG it just
|
||||
/// wrote. Every rule the colour write obeys, this one obeys too: that is the whole reason they share
|
||||
/// a merge.
|
||||
@Suite("Board background ▸ the image subkey")
|
||||
struct BackgroundImageWriteTests {
|
||||
|
||||
/// The mirror of `setKeepsTheImage`: the app now writes both subkeys, and neither may take the
|
||||
/// other with it.
|
||||
@Test("Setting an image replaces the subkey and keeps the colour")
|
||||
func setKeepsTheColour() throws {
|
||||
var document = try document("background: {color: fern, image: sunset.jpg}")
|
||||
document.setBackgroundImage("facets.png")
|
||||
|
||||
#expect(document.background == .valid("fern"))
|
||||
#expect(document.backgroundImage == .valid("facets.png"))
|
||||
#expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}")
|
||||
}
|
||||
|
||||
/// A colour-only board — every board styled from the wells — is the one the generator is most
|
||||
/// likely to be pointed at.
|
||||
@Test("Setting an image on a colour-only background adds the subkey")
|
||||
func setAddsTheSubkeyToAColourOnlyMapping() throws {
|
||||
var document = try document("background: {color: fern}")
|
||||
document.setBackgroundImage("facets.png")
|
||||
|
||||
#expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}")
|
||||
}
|
||||
|
||||
/// Unknown subkeys ride along here exactly as they do through a colour change — the merge is
|
||||
/// literally the same one.
|
||||
@Test("Unknown subkeys survive an image change, in their own positions")
|
||||
func setPreservesUnknownSubkeys() throws {
|
||||
var document = try document("background: {blend: multiply, image: old.png, opacity: 0.5}")
|
||||
document.setBackgroundImage("facets.png")
|
||||
|
||||
#expect(backgroundLine(document)
|
||||
== "background: {blend: \"multiply\", image: \"facets.png\", opacity: 0.5}")
|
||||
}
|
||||
|
||||
/// The undo of a first generation: the image subkey goes and the colour the board had — or did
|
||||
/// not have — is left to the colour write.
|
||||
@Test("Removing the image drops that subkey alone")
|
||||
func removeDropsOnlyTheImage() throws {
|
||||
var document = try document("background: {color: fern, image: facets.png}")
|
||||
document.setBackgroundImage(nil)
|
||||
|
||||
#expect(document.background == .valid("fern"))
|
||||
#expect(document.backgroundImage == .missing)
|
||||
#expect(backgroundLine(document) == "background: {color: \"fern\"}")
|
||||
}
|
||||
|
||||
/// `background: {}` is a key that says nothing — the removal's contract is that the field is
|
||||
/// gone, whichever subkey emptied it.
|
||||
@Test("A mapping emptied by the removal takes the key with it")
|
||||
func removeDropsAnEmptiedKey() throws {
|
||||
var document = try document("background: {image: facets.png}")
|
||||
document.setBackgroundImage(nil)
|
||||
|
||||
#expect(!document.contains(FrontmatterKeys.background))
|
||||
#expect(backgroundLine(document) == nil)
|
||||
}
|
||||
|
||||
/// The malformed-value-cleared posture, on this subkey: a shape the schema cannot read has no
|
||||
/// subkeys to preserve and is replaced by the mapping the app writes.
|
||||
@Test("An image written onto an absent or unreadable key lands as a mapping")
|
||||
func alwaysWritesTheMapping() throws {
|
||||
var absent = try document("schema: 1")
|
||||
absent.setBackgroundImage("facets.png")
|
||||
#expect(backgroundLine(absent) == "background: {image: \"facets.png\"}")
|
||||
|
||||
var scalar = try document("background: fern")
|
||||
scalar.setBackgroundImage("facets.png")
|
||||
#expect(backgroundLine(scalar) == "background: {image: \"facets.png\"}")
|
||||
|
||||
var sequence = try document("background: [a, b]")
|
||||
sequence.setBackgroundImage("facets.png")
|
||||
#expect(backgroundLine(sequence) == "background: {image: \"facets.png\"}")
|
||||
}
|
||||
|
||||
/// Both subkeys written in one edit, which is the shape every generated background lands in —
|
||||
/// and the order the schema spells it in, colour first.
|
||||
@Test("A colour and an image written together land as one mapping")
|
||||
func bothSubkeysTogether() throws {
|
||||
var document = try document("schema: 1")
|
||||
document.setStyleValue("#E0E5EB", for: FrontmatterKeys.background)
|
||||
document.setBackgroundImage("facets.png")
|
||||
|
||||
#expect(backgroundLine(document) == "background: {color: \"#E0E5EB\", image: \"facets.png\"}")
|
||||
#expect(document.background == .valid("#E0E5EB"))
|
||||
#expect(document.backgroundImage == .valid("facets.png"))
|
||||
}
|
||||
|
||||
/// Everything outside the key survives byte for byte, including the comment on the unknown key
|
||||
/// beside it — the verbatim promise, which yields on the one key being rewritten and nowhere else.
|
||||
@Test("Nothing but the background line moves")
|
||||
func leavesEverythingElseAlone() throws {
|
||||
var document = try FrontmatterDocument.parse("""
|
||||
---
|
||||
schema: 1
|
||||
title: Work
|
||||
project: lanework # agent overlay
|
||||
background: {color: fern}
|
||||
icon: tray
|
||||
---
|
||||
Board description.
|
||||
|
||||
""")
|
||||
document.setBackgroundImage("facets.png")
|
||||
|
||||
#expect(document.serialized() == """
|
||||
---
|
||||
schema: 1
|
||||
title: Work
|
||||
project: lanework # agent overlay
|
||||
background: {color: "fern", image: "facets.png"}
|
||||
icon: tray
|
||||
---
|
||||
Board description.
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
/// The emitted mapping is read back by the reader the app uses — a name with YAML-significant
|
||||
/// characters in it included, which is the reason strings are always quoted in flow context.
|
||||
@Test("An awkward file name round-trips through the emitted mapping")
|
||||
func awkwardNamesRoundTrip() throws {
|
||||
var document = try document("background: {color: fern}")
|
||||
document.setBackgroundImage("a, b}.png")
|
||||
|
||||
let reparsed = try FrontmatterDocument.parse(document.serialized())
|
||||
#expect(reparsed.backgroundImage == .valid("a, b}.png"))
|
||||
#expect(reparsed.background == .valid("fern"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Where an image may point
|
||||
|
||||
@Suite("Board background ▸ the image path stays inside the board")
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `FacetsGenerator` — the Swift port of the reviewed faceted gallery
|
||||
/// (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery; `board-backgrounds-faceted.html`).
|
||||
///
|
||||
/// What is worth pinning here is not "the picture looks nice", which no test can say, but the four
|
||||
/// properties the feature is built on:
|
||||
///
|
||||
/// - **A seed names a composition** — so the picker's preview and the file written from it are the
|
||||
/// same picture.
|
||||
/// - **The triangulation is a triangulation** — it tiles its points' convex hull exactly and every
|
||||
/// face is Delaunay, checked against the hull rather than against a remembered number.
|
||||
/// - **The mesh is full bleed** — proved twice over, once as arithmetic on the density constants
|
||||
/// (margin ≥ 0.92 × cell, which holds for every seed at once) and once on the meshes themselves
|
||||
/// (boundary points clear the frame, the frame's corners are covered). This is the property the
|
||||
/// reviewed gallery did *not* have; see `FacetsRecipe.Density`.
|
||||
/// - **The colour model is HSL** — the reason the swatches look like the ones that were reviewed.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func recipe(
|
||||
hue: FacetsRecipe.Hue = .sky,
|
||||
strategy: FacetsRecipe.Strategy = .duo,
|
||||
density: FacetsRecipe.Density = .medium,
|
||||
tone: FacetsRecipe.Tone = .light,
|
||||
saturation: FacetsRecipe.Saturation = .mid,
|
||||
seed: UInt64 = 0x5EED
|
||||
) -> FacetsRecipe {
|
||||
FacetsRecipe(
|
||||
hue: hue, strategy: strategy, density: density,
|
||||
tone: tone, saturation: saturation, seed: seed
|
||||
)
|
||||
}
|
||||
|
||||
/// The convex hull, by monotone chain — the region a Delaunay triangulation of these points must
|
||||
/// cover exactly, which is the sharpest thing that can be asked of the port.
|
||||
private func convexHull(_ points: [CGPoint]) -> [CGPoint] {
|
||||
let sorted = points.sorted { $0.x == $1.x ? $0.y < $1.y : $0.x < $1.x }
|
||||
guard sorted.count >= 3 else { return sorted }
|
||||
|
||||
func cross(_ o: CGPoint, _ a: CGPoint, _ b: CGPoint) -> Double {
|
||||
Double(a.x - o.x) * Double(b.y - o.y) - Double(a.y - o.y) * Double(b.x - o.x)
|
||||
}
|
||||
func chain(_ points: [CGPoint]) -> [CGPoint] {
|
||||
var hull: [CGPoint] = []
|
||||
for point in points {
|
||||
while hull.count >= 2, cross(hull[hull.count - 2], hull[hull.count - 1], point) <= 0 {
|
||||
hull.removeLast()
|
||||
}
|
||||
hull.append(point)
|
||||
}
|
||||
return hull
|
||||
}
|
||||
// Each half drops its own last point, which is the other half's first.
|
||||
return Array(chain(sorted).dropLast()) + Array(chain(sorted.reversed()).dropLast())
|
||||
}
|
||||
|
||||
/// The gallery's own cell dimensions: its 1.15 × 0.775 region (552 × 372 at the 480-wide scale, in
|
||||
/// unit terms) divided by its 5×3, 9×6 and 14×9 grids.
|
||||
private let reviewedCells: [(density: FacetsRecipe.Density, width: Double, height: Double)] = [
|
||||
(.coarse, 1.15 / 5, 0.775 / 3),
|
||||
(.medium, 1.15 / 9, 0.775 / 6),
|
||||
(.fine, 1.15 / 14, 0.775 / 9),
|
||||
]
|
||||
|
||||
/// Whether `point` is inside `face`, edges included — the three edge cross-products agreeing in
|
||||
/// sign. The tolerance admits a point exactly on an edge, which every frame corner shared by two
|
||||
/// faces is.
|
||||
private func contains(_ face: FacetsGenerator.Face, _ point: CGPoint) -> Bool {
|
||||
func side(_ a: CGPoint, _ b: CGPoint) -> Double {
|
||||
Double(b.x - a.x) * Double(point.y - a.y) - Double(b.y - a.y) * Double(point.x - a.x)
|
||||
}
|
||||
let first = side(face.a, face.b)
|
||||
let second = side(face.b, face.c)
|
||||
let third = side(face.c, face.a)
|
||||
let epsilon = 1e-12
|
||||
return (first >= -epsilon && second >= -epsilon && third >= -epsilon)
|
||||
|| (first <= epsilon && second <= epsilon && third <= epsilon)
|
||||
}
|
||||
|
||||
/// A simple polygon's area, by the shoelace formula.
|
||||
private func polygonArea(_ polygon: [CGPoint]) -> Double {
|
||||
guard polygon.count >= 3 else { return 0 }
|
||||
var total = 0.0
|
||||
for index in polygon.indices {
|
||||
let a = polygon[index]
|
||||
let b = polygon[(index + 1) % polygon.count]
|
||||
total += Double(a.x) * Double(b.y) - Double(b.x) * Double(a.y)
|
||||
}
|
||||
return abs(total) / 2
|
||||
}
|
||||
|
||||
/// The image a PNG payload decodes to — the only way to ask what was actually encoded rather than
|
||||
/// what was handed to the encoder.
|
||||
private func decoded(_ data: Data) -> CGImage? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
|
||||
return CGImageSourceCreateImageAtIndex(source, 0, nil)
|
||||
}
|
||||
|
||||
// MARK: - A seed names a composition
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ determinism")
|
||||
struct FacetsDeterminismTests {
|
||||
|
||||
/// The whole reason the generator is pure: the picker's preview, the file written to the board
|
||||
/// folder, and a re-render on another Mac next year are one picture.
|
||||
@Test("The same recipe and seed render identical bytes")
|
||||
func sameSeedSameBytes() throws {
|
||||
let first = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
|
||||
let second = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
/// Reroll's whole job.
|
||||
@Test("A different seed renders different bytes")
|
||||
func differentSeedDiffers() throws {
|
||||
let first = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 1), pixelWidth: 96))
|
||||
let second = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 2), pixelWidth: 96))
|
||||
#expect(first != second)
|
||||
}
|
||||
|
||||
/// **Normalized space, doing its job**: the composition is the same mesh at any output size, so
|
||||
/// the geometry a small preview shows is the geometry the 3072 px file has.
|
||||
@Test("Size changes the pixels, never the mesh")
|
||||
func sizeDoesNotChangeTheMesh() {
|
||||
let mesh = FacetsGenerator.facets(recipe: recipe())
|
||||
let again = FacetsGenerator.facets(recipe: recipe())
|
||||
#expect(mesh == again)
|
||||
|
||||
let small = FacetsGenerator.render(recipe: recipe(), pixelWidth: 64)
|
||||
let large = FacetsGenerator.render(recipe: recipe(), pixelWidth: 640)
|
||||
#expect(small?.width == 64)
|
||||
#expect(large?.width == 640)
|
||||
}
|
||||
|
||||
/// Every axis is part of the identity — a picker that changed one of them and got the same
|
||||
/// picture back would be a picker with a dead control.
|
||||
@Test("Each axis changes the picture")
|
||||
func everyAxisMatters() {
|
||||
let base = FacetsGenerator.facets(recipe: recipe())
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(hue: .rose)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(strategy: .trio)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(density: .fine)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(tone: .dark)) != base)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe(saturation: .rich)) != base)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The mesh
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ the mesh")
|
||||
struct FacetsMeshTests {
|
||||
|
||||
/// **The triangulation tiles its points' convex hull exactly** — no hole, no overlap, nothing
|
||||
/// left over. Total face area against the hull's own area is the whole claim in one number, and
|
||||
/// it is the claim that matters here: a hole is a patch of flat ground colour in the middle of
|
||||
/// the picture, which is exactly the artefact the small super-triangle in the gallery's own
|
||||
/// generator produces and this port's larger one does not (`FacetsGenerator.triangulate`).
|
||||
///
|
||||
/// Checked across every density and a dozen seeds rather than one, because a triangulator's
|
||||
/// failures are input-shaped.
|
||||
///
|
||||
/// The tolerance is what a *hull* can honestly promise: when a point lands essentially on the
|
||||
/// line between its two neighbours, the sliver between them has no circumcircle to speak of and
|
||||
/// is not made. Swept over 600 meshes the largest such gap is 5.5 × 10⁻⁵ of a unit square; the
|
||||
/// smallest hole a *missing face* could leave is a fraction of a cell, and the smallest cell in
|
||||
/// the table is fine's at 7.2 × 10⁻³. The threshold sits between the two.
|
||||
@Test("The mesh tiles the hull exactly", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
|
||||
func meshTilesTheHull(density: FacetsRecipe.Density) {
|
||||
for seed in UInt64(1)...12 {
|
||||
var random = FacetsRandom(seed: seed)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
#expect(points.count == density.columns * density.rows)
|
||||
|
||||
let hull = polygonArea(convexHull(points))
|
||||
let tiled = FacetsGenerator.triangulate(points).reduce(0.0) { total, triangle in
|
||||
total + FacetsGenerator.Face(
|
||||
a: points[triangle.a], b: points[triangle.b], c: points[triangle.c],
|
||||
color: FacetsColor(hue: 0, saturation: 0, lightness: 0)
|
||||
).area
|
||||
}
|
||||
#expect(abs(tiled - hull) < 3e-4, "density \(density), seed \(seed): \(tiled) vs hull \(hull)")
|
||||
}
|
||||
}
|
||||
|
||||
/// **The Delaunay property itself**: no point sits inside another triangle's circumcircle. A
|
||||
/// tiling alone could be any triangulation — this is the one the recipe names, and the one whose
|
||||
/// fat triangles make the mesh read as facets rather than as splinters.
|
||||
///
|
||||
/// The tolerance is relative and tiny; it exists because four points can be *nearly* cocircular,
|
||||
/// not because the predicate is soft.
|
||||
@Test("Every face is Delaunay", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
|
||||
func facesAreDelaunay(density: FacetsRecipe.Density) {
|
||||
for seed in UInt64(1)...12 {
|
||||
var random = FacetsRandom(seed: seed)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
for triangle in FacetsGenerator.triangulate(points) {
|
||||
guard let circle = FacetsGenerator.circumcircle(
|
||||
points[triangle.a], points[triangle.b], points[triangle.c]
|
||||
) else {
|
||||
Issue.record("a face with no circumcircle survived")
|
||||
continue
|
||||
}
|
||||
for (index, point) in points.enumerated()
|
||||
where index != triangle.a && index != triangle.b && index != triangle.c {
|
||||
let dx = Double(point.x) - circle.x
|
||||
let dy = Double(point.y) - circle.y
|
||||
#expect(dx * dx + dy * dy >= circle.radiusSquared * (1 - 1e-9),
|
||||
"density \(density), seed \(seed): point \(index) is inside a face's circumcircle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The full-bleed inequality** — the one line the whole boundary ring exists to satisfy
|
||||
/// (`FacetsRecipe.Density.scatterMargin`).
|
||||
///
|
||||
/// A boundary cell's point is placed anywhere in the middle 84% of it, so the worst draw pushes
|
||||
/// it 0.92 of a cell *inward* from the outer edge. `margin ≥ 0.92 × cell` on both axes is
|
||||
/// therefore exactly "no draw can put a boundary point inside the picture", which is what makes
|
||||
/// the frame interior to the hull for **every** seed rather than for most of them.
|
||||
///
|
||||
/// It is arithmetic on constants, so it holds for all seeds at once — the sharpest form the
|
||||
/// claim has, and the one that would catch a future density added with a margin copied from its
|
||||
/// neighbour.
|
||||
@Test("Every density's margin outruns its worst jitter draw", arguments: FacetsRecipe.Density.allCases)
|
||||
func marginOutrunsTheJitter(density: FacetsRecipe.Density) {
|
||||
let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
|
||||
let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
|
||||
#expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellWidth,
|
||||
"\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellWidth)")
|
||||
#expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellHeight,
|
||||
"\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellHeight)")
|
||||
}
|
||||
|
||||
/// **The reviewed facet size, preserved** — the number a viewer actually reads as "coarse" or
|
||||
/// "fine" (`FacetsRecipe.Density`).
|
||||
///
|
||||
/// The grid grew when the boundary ring went in (5×3 → 7×5, 9×6 → 10×7, 14×9 → 15×10), and this
|
||||
/// is the guard that says it grew *outward*: the cell is still the gallery's 1.15/5, 1.15/9 and
|
||||
/// 1.15/14 in unit terms, so the same number of facets falls inside the picture as did in the
|
||||
/// swatches that were reviewed. Stretching the cells to reach the edges instead would have kept
|
||||
/// the point counts and changed every density's character.
|
||||
@Test("The cell size is the gallery's", arguments: reviewedCells)
|
||||
func cellSizeMatchesTheGallery(density: FacetsRecipe.Density, width: Double, height: Double) {
|
||||
let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
|
||||
let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
|
||||
// 5%, which is what round margins cost: medium and fine land within half a percent on both
|
||||
// axes, and coarse's cell comes out 4% shorter — its ring is 1.3 cells deep, so squaring the
|
||||
// grid up moved the height and left the width exactly where it was.
|
||||
#expect(abs(cellWidth - width) / width < 0.05, "\(density) width \(cellWidth) vs \(width)")
|
||||
#expect(abs(cellHeight - height) / height < 0.05, "\(density) height \(cellHeight) vs \(height)")
|
||||
}
|
||||
|
||||
/// The facet counts that follow from those cells: how many faces land **inside the picture**,
|
||||
/// which is the number the gallery's "≈20 · ≈97 · ≈230" was describing. The ring's own faces are
|
||||
/// cropped away and are not part of what anyone judged.
|
||||
@Test("The visible facet count matches the reviewed density")
|
||||
func visibleDensityMatchesTheGallery() {
|
||||
func visible(_ density: FacetsRecipe.Density, seed: UInt64) -> Int {
|
||||
FacetsGenerator.facets(recipe: recipe(density: density, seed: seed)).faces.count { face in
|
||||
let x = Double(face.a.x + face.b.x + face.c.x) / 3
|
||||
let y = Double(face.a.y + face.b.y + face.c.y) / 3
|
||||
return x >= 0 && x <= 1 && y >= 0 && y <= FacetsGenerator.frameHeight
|
||||
}
|
||||
}
|
||||
for seed in UInt64(1)...12 {
|
||||
#expect((14...30).contains(visible(.coarse, seed: seed)), "coarse: \(visible(.coarse, seed: seed))")
|
||||
#expect((62...84).contains(visible(.medium, seed: seed)), "medium: \(visible(.medium, seed: seed))")
|
||||
#expect((160...185).contains(visible(.fine, seed: seed)), "fine: \(visible(.fine, seed: seed))")
|
||||
}
|
||||
}
|
||||
|
||||
/// **Full bleed, checked on the points** — the inequality above, arrived at from the other end.
|
||||
///
|
||||
/// Every point in the first and last column sits at or beyond the left and right frame edges,
|
||||
/// and every point in the first and last row at or beyond the top and bottom. That is what makes
|
||||
/// the picture interior to the convex hull: each of its four sides has a wall of points past it.
|
||||
@Test("Every boundary point lands outside the frame on its own side",
|
||||
arguments: FacetsRecipe.Density.allCases)
|
||||
func boundaryPointsClearTheFrame(density: FacetsRecipe.Density) {
|
||||
let rows = density.rows
|
||||
for seed in UInt64(1)...12 {
|
||||
var random = FacetsRandom(seed: seed)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
for (index, point) in points.enumerated() {
|
||||
// Column-major: index = column × rows + row (see `FacetsGenerator.scatter`).
|
||||
let column = index / rows
|
||||
let row = index % rows
|
||||
if column == 0 { #expect(Double(point.x) <= 0, "\(density)/\(seed): left \(point.x)") }
|
||||
if column == density.columns - 1 {
|
||||
#expect(Double(point.x) >= 1, "\(density)/\(seed): right \(point.x)")
|
||||
}
|
||||
if row == 0 { #expect(Double(point.y) <= 0, "\(density)/\(seed): top \(point.y)") }
|
||||
if row == rows - 1 {
|
||||
#expect(Double(point.y) >= FacetsGenerator.frameHeight,
|
||||
"\(density)/\(seed): bottom \(point.y)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **And full bleed, checked on the pixels that matter**: the four frame corners are each inside
|
||||
/// some face. A corner is where a triangulation's coverage fails first, and a corner showing flat
|
||||
/// ground colour is the artefact this whole ring was built to remove.
|
||||
///
|
||||
/// The total-area check rides along. Over 200 seeds a density the *worst* mesh still covers
|
||||
/// 2.15× the frame at coarse and 1.49× at medium and fine, so the arithmetic is never close —
|
||||
/// which is the point of a ring sized against the jitter rather than against a taste for how
|
||||
/// much overhang looks like enough.
|
||||
@Test("The frame's corners are covered", arguments: FacetsRecipe.Density.allCases)
|
||||
func frameCornersAreCovered(density: FacetsRecipe.Density) {
|
||||
let height = FacetsGenerator.frameHeight
|
||||
let corners = [
|
||||
CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0),
|
||||
CGPoint(x: 1, y: height), CGPoint(x: 0, y: height),
|
||||
]
|
||||
for seed in UInt64(1)...12 {
|
||||
let mesh = FacetsGenerator.facets(recipe: recipe(density: density, seed: seed))
|
||||
#expect(mesh.faces.reduce(0) { $0 + $1.area } >= height, "\(density)/\(seed): total area")
|
||||
for corner in corners {
|
||||
#expect(mesh.faces.contains { contains($0, corner) },
|
||||
"density \(density), seed \(seed): corner \(corner) shows ground colour")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No face may be degenerate: a zero-area triangle is a circumcircle the guard should have
|
||||
/// refused, and a stroked sliver is a visible scratch across the picture.
|
||||
@Test("No face is degenerate")
|
||||
func facesHaveArea() {
|
||||
for seed in UInt64(1)...12 {
|
||||
let mesh = FacetsGenerator.facets(recipe: recipe(density: .fine, seed: seed))
|
||||
#expect(mesh.faces.allSatisfy { $0.area > 0 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Points land inside their own cell's middle band, which is what keeps neighbours from
|
||||
/// coinciding — and inside the outset region, which everything above rests on.
|
||||
@Test("The scatter stays inside the outset region", arguments: FacetsRecipe.Density.allCases)
|
||||
func scatterStaysInTheRegion(density: FacetsRecipe.Density) {
|
||||
var random = FacetsRandom(seed: 7)
|
||||
let points = FacetsGenerator.scatter(density, using: &random)
|
||||
let margin = density.scatterMargin
|
||||
#expect(points.allSatisfy { Double($0.x) >= -margin && Double($0.x) <= 1 + margin })
|
||||
#expect(points.allSatisfy {
|
||||
Double($0.y) >= -margin && Double($0.y) <= FacetsGenerator.frameHeight + margin
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Colour
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ colour is HSL")
|
||||
struct FacetsColorTests {
|
||||
|
||||
/// **Hand-computed CSS `hsl()`**, which is the whole claim: the gallery's swatches are `hsl()`
|
||||
/// strings, so a port that reached for `NSColor`'s hue/saturation/**brightness** would render a
|
||||
/// different set of colours from the ones that were reviewed.
|
||||
@Test("primaryColorHex is the recipe's level as CSS reads it", arguments: [
|
||||
(FacetsRecipe.Hue.sky, FacetsRecipe.Tone.light, FacetsRecipe.Saturation.soft, "#E0E5EB"),
|
||||
(.amber, .dark, .rich, "#513D1A"),
|
||||
(.forest, .light, .rich, "#BFF3D0"),
|
||||
(.rose, .dark, .soft, "#32242A"),
|
||||
(.clay, .light, .mid, "#EDD7D4"),
|
||||
(.iris, .dark, .mid, "#2C2041"),
|
||||
])
|
||||
func primaryColorMatchesHSL(
|
||||
hue: FacetsRecipe.Hue,
|
||||
tone: FacetsRecipe.Tone,
|
||||
saturation: FacetsRecipe.Saturation,
|
||||
expected: String
|
||||
) {
|
||||
let recipe = recipe(hue: hue, tone: tone, saturation: saturation)
|
||||
#expect(recipe.primaryColorHex == expected)
|
||||
}
|
||||
|
||||
/// The ground the generator paints under the mesh is the recipe's own primary — the value the
|
||||
/// board's `background.color` is set to, so the underlay and the picture agree.
|
||||
@Test("The ground is the primary colour")
|
||||
func groundIsThePrimary() {
|
||||
let recipe = recipe(hue: .teal, tone: .light, saturation: .soft)
|
||||
#expect(FacetsGenerator.facets(recipe: recipe).ground == recipe.primaryColor)
|
||||
#expect(recipe.primaryColorHex == "#E0EBEA")
|
||||
}
|
||||
|
||||
/// The strategies' weighted lists, as the gallery states them — mono one hue, duo the complement
|
||||
/// at 65/35, trio the triad at 50/30/20 — each summing to 1, which is what the single-draw pick
|
||||
/// assumes.
|
||||
@Test("The hue lists are the reviewed ones")
|
||||
func hueListsMatchTheGallery() {
|
||||
#expect(recipe(hue: .sky, strategy: .mono).hues.map(\.degrees) == [215])
|
||||
#expect(recipe(hue: .sky, strategy: .duo).hues.map(\.degrees) == [215, 395])
|
||||
#expect(recipe(hue: .sky, strategy: .trio).hues.map(\.degrees) == [215, 335, 95])
|
||||
for strategy in FacetsRecipe.Strategy.allCases {
|
||||
let total = recipe(strategy: strategy).hues.reduce(0) { $0 + $1.weight }
|
||||
#expect(abs(total - 1) < 1e-12, "\(strategy) weights must sum to 1")
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightness stays in the narrow band the recipe names — "brightness stays a narrow per-triangle
|
||||
/// jitter around the tone base", which is the property that makes a whole swatch read as one
|
||||
/// surface. Hue and saturation jitter around theirs the same way.
|
||||
@Test("Every face jitters inside its recipe's bands")
|
||||
func facesStayInTheirBands() {
|
||||
let recipe = recipe(hue: .forest, strategy: .mono, density: .fine, tone: .dark, saturation: .rich)
|
||||
let level = recipe.level
|
||||
for face in FacetsGenerator.facets(recipe: recipe).faces {
|
||||
#expect(abs(face.color.lightness - level.lightness) <= FacetsRecipe.lightnessJitter)
|
||||
#expect(face.color.saturation >= level.saturation * 0.85)
|
||||
#expect(face.color.saturation <= level.saturation * 1.15)
|
||||
// Mono: one hue, ±3° — wrapped, so 140 ± 3 stays comfortably away from the seam.
|
||||
#expect(abs(face.color.hue - 140) <= 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrap that a triad needs: `H − 120` is negative for every hue below 120°, and a colour at
|
||||
/// −112° is a colour at 248°, not a colour at 0.
|
||||
@Test("A negative triad hue wraps rather than clamping")
|
||||
func negativeHuesWrap() {
|
||||
#expect(FacetsColor(hue: -112, saturation: 50, lightness: 50).hue == 248)
|
||||
#expect(FacetsColor(hue: 395, saturation: 50, lightness: 50).hue == 35)
|
||||
// Saturation and lightness clamp instead — they are percentages, not angles.
|
||||
#expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).saturation == 100)
|
||||
#expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).lightness == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The encoded file
|
||||
|
||||
@MainActor
|
||||
@Suite("Facets ▸ the PNG")
|
||||
struct FacetsPNGTests {
|
||||
|
||||
/// 16:10, rounded — the aspect the board window is judged at and the one every swatch was
|
||||
/// reviewed in.
|
||||
@Test("The payload decodes at the requested width and a 16:10 height", arguments: [64, 480, 1024])
|
||||
func decodesAtTheRequestedSize(width: Int) throws {
|
||||
let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: width))
|
||||
let image = try #require(decoded(data))
|
||||
#expect(image.width == width)
|
||||
#expect(image.height == FacetsGenerator.pixelHeight(forWidth: width))
|
||||
#expect(image.height == Int((Double(width) * 10 / 16).rounded()))
|
||||
}
|
||||
|
||||
/// It really is a PNG — the first eight bytes of the format's own signature — because the board
|
||||
/// frontmatter is about to name this file and `BoardBackdrop.decode` will be asked to read it.
|
||||
@Test("The payload is a PNG")
|
||||
func payloadIsPNG() throws {
|
||||
let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 64))
|
||||
#expect(Array(data.prefix(8)) == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
}
|
||||
|
||||
/// Opaque, and stated: the backdrop sits under lane plates and card faces, and an image carrying
|
||||
/// alpha would let the window's own background through in a way no swatch was reviewed with.
|
||||
@Test("The render is opaque")
|
||||
func renderIsOpaque() throws {
|
||||
let image = try #require(FacetsGenerator.render(recipe: recipe(), pixelWidth: 64))
|
||||
#expect(image.alphaInfo == .noneSkipLast)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The write half of generated board backgrounds: `BoardWriter.writeBoardImage` and
|
||||
/// `BoardStore.applyGeneratedBackground` (03-board-ui.md § Styling ▸ Capabilities;
|
||||
/// DESIGN/explorations/board-backgrounds.md).
|
||||
///
|
||||
/// Like every other write suite here these drive a real writer or a real store over a real temp
|
||||
/// board and read the **bytes on disk** back rather than the app's own read path: the claims are
|
||||
/// about the file — which name the picture landed under, what the frontmatter says afterwards, and
|
||||
/// what an undo leaves behind. `WriterFixture`, `Ident` and `Item` come from
|
||||
/// `WriterTestSupport.swift`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A board root carrying whatever background the test needs, plus the unowned baggage every write
|
||||
/// has to leave alone.
|
||||
private func boardIndex(background: String? = nil) -> String {
|
||||
let line = background.map { "background: \($0)\n" } ?? ""
|
||||
return """
|
||||
---
|
||||
schema: 1
|
||||
title: Work
|
||||
\(line)project: lanework # agent overlay
|
||||
created: 2026-01-01T09:00:00Z
|
||||
---
|
||||
Board description.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeBoard(background: String? = nil) throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", boardIndex(background: background))
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let history = NativeHistoryProvider()
|
||||
store.history = history
|
||||
return (store, history)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reload(_ store: BoardStore) async {
|
||||
store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
private func document(_ fixture: WriterFixture) throws -> FrontmatterDocument {
|
||||
try FrontmatterDocument.parse(fixture.indexText(""))
|
||||
}
|
||||
|
||||
/// Bytes that are not an image and do not need to be: nothing in the write path decodes them, which
|
||||
/// is itself worth pinning — the Writer moves a payload, it does not validate artwork.
|
||||
private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03])
|
||||
private let otherPNG = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x09, 0x09])
|
||||
|
||||
// MARK: - The writer primitive
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardWriter ▸ writeBoardImage")
|
||||
struct WriteBoardImageTests {
|
||||
|
||||
@Test("The bytes land under the given name, and the name comes back")
|
||||
func writesTheBytes() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let name = try BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
|
||||
#expect(name == "facets.png")
|
||||
#expect(try fixture.data("facets.png") == png)
|
||||
}
|
||||
|
||||
/// The caller decides the name, so the writer's own contract is simply that the same name is
|
||||
/// replaced rather than laddered — one board, one generated picture.
|
||||
@Test("A second write to the same name replaces it in place")
|
||||
func overwritesInPlace() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: otherPNG, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
|
||||
#expect(try fixture.data("facets.png") == otherPNG)
|
||||
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"])
|
||||
}
|
||||
|
||||
/// **Atomic, and no residue**: the temp file is hidden and in the same folder, so a listing that
|
||||
/// sees hidden entries is what proves the rename left nothing behind.
|
||||
@Test("No temp file survives the write")
|
||||
func leavesNoResidue() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
|
||||
#expect(try !fixture.entryNames("").contains { $0.hasPrefix(".") })
|
||||
}
|
||||
|
||||
/// The mirror of the read side's containment rule (`BoardBackdrop.imageURL(named:inBoardRoot:)`):
|
||||
/// a background that could be written outside the board folder is not a background.
|
||||
@Test("A path, an empty name and the dot names are refused", arguments: ["", "art/x.png", "../x.png", ".", ".."])
|
||||
func refusesAnythingThatIsNotABareName(name: String) throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
#expect(throws: BoardWriteError.self) {
|
||||
try BoardWriter.writeBoardImage(
|
||||
data: png, named: name, inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
}
|
||||
#expect(try fixture.entryNames("") == [Ident.lane1, "index.md"])
|
||||
}
|
||||
|
||||
/// The receipt: without one the churn the write produces classifies as somebody else's, and the
|
||||
/// auto-committer would name the commit for a foreign edit.
|
||||
@Test("The write leaves a content receipt in the ledger")
|
||||
func dropsAReceipt() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let ledger = EchoLedger()
|
||||
|
||||
EchoLedger.$current.withValue(ledger) {
|
||||
try? BoardWriter.writeBoardImage(
|
||||
data: png, named: "facets.png", inRoot: fixture.root, operation: .setBoardBackground
|
||||
)
|
||||
}
|
||||
|
||||
let receipts = ledger.outstandingEntries()
|
||||
#expect(receipts.contains { $0.key.hasSuffix("/facets.png") })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The gesture
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardStore ▸ applyGeneratedBackground")
|
||||
struct GeneratedBackgroundWriteTests {
|
||||
|
||||
@Test("The picture lands in the folder and both subkeys point at it")
|
||||
func writesTheFileAndTheFields() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB"))
|
||||
|
||||
#expect(try fixture.data("facets.png") == png)
|
||||
let after = try document(fixture)
|
||||
#expect(after.background == .valid("#E0E5EB"))
|
||||
#expect(after.backgroundImage == .valid("facets.png"))
|
||||
#expect(try fixture.indexText("").contains("background: {color: \"#E0E5EB\", image: \"facets.png\"}"))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
/// One gesture, one bracket — the style batch's rule, which is what makes one reroll one
|
||||
/// app-mediated reload and one commit on a git board, though it writes two files.
|
||||
@Test("Two files, one bracket")
|
||||
func oneBracketForBothFiles() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
var begins = 0
|
||||
var ends = 0
|
||||
store.watcherBrackets = (begin: { begins += 1 }, end: { ends += 1 })
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(begins == 1)
|
||||
#expect(ends == 1)
|
||||
}
|
||||
|
||||
/// A colour the wells wrote is replaced, and everything the app does not own comes through
|
||||
/// untouched — the unknown key with its comment, `created`, the body.
|
||||
@Test("An existing colour is replaced and the rest of the file survives")
|
||||
func replacesAnExistingColour() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern}")
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#513D1A")
|
||||
|
||||
let text = try fixture.indexText("")
|
||||
#expect(text.contains("background: {color: \"#513D1A\", image: \"facets.png\"}"))
|
||||
#expect(text.contains("project: lanework # agent overlay"))
|
||||
#expect(text.contains("created: 2026-01-01T09:00:00Z"))
|
||||
#expect(text.contains("Board description."))
|
||||
}
|
||||
|
||||
/// **Regenerating overwrites**: the whole reason the name is fixed rather than minted. The reload
|
||||
/// between the two rolls is the ordinary case — the snapshot has caught up and names the file.
|
||||
@Test("A second generation replaces the same file")
|
||||
func secondGenerationOverwrites() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
await reload(store)
|
||||
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
|
||||
|
||||
#expect(try fixture.data("facets.png") == otherPNG)
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
|
||||
// No ladder: the reload in between also seeds this board's `.gitignore`, so the listing is
|
||||
// filtered to the pictures rather than compared whole.
|
||||
#expect(try fixture.entryNames("").filter { $0.hasSuffix(".png") } == ["facets.png"])
|
||||
}
|
||||
|
||||
/// **The reroll's echo**: rolling again before the watcher has rounded the first write back must
|
||||
/// not ladder onto `facets 2.png`, because a fast reroll is the expected gesture and a folder of
|
||||
/// abandoned pictures is what the fixed name exists to prevent.
|
||||
@Test("A reroll before the reload lands still overwrites")
|
||||
func rerollBeforeTheReloadOverwrites() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
|
||||
|
||||
#expect(try fixture.data("facets.png") == otherPNG)
|
||||
#expect(try fixture.entryNames("") == [Ident.lane1, "facets.png", "index.md"])
|
||||
}
|
||||
|
||||
/// **Somebody else's `facets.png` is never written through** — a file the user put in the board
|
||||
/// folder is theirs, and the Finder ladder is how the app steps aside from a name it does not own.
|
||||
@Test("A foreign file on the name pushes the generation to 'facets 2.png'")
|
||||
func stepsAsideFromAForeignFile() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let mine = Data("not the app's".utf8)
|
||||
try fixture.file("facets.png", mine)
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(try fixture.data("facets.png") == mine, "the user's file is untouched")
|
||||
#expect(try fixture.data("facets 2.png") == png)
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets 2.png"))
|
||||
}
|
||||
|
||||
/// The same ladder when the board already names a *different* image: the hand-written path is
|
||||
/// the escape hatch and stays on disk, and the generation lands beside it.
|
||||
@Test("A board naming another image keeps it and generates alongside")
|
||||
func keepsAHandWrittenImage() throws {
|
||||
let fixture = try makeBoard(background: "{image: sunset.jpg}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("sunset.jpg", Data("photo".utf8))
|
||||
try fixture.file("facets.png", Data("someone else's".utf8))
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(try fixture.data("sunset.jpg") == Data("photo".utf8))
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets 2.png"))
|
||||
}
|
||||
|
||||
/// A board whose generated file was deleted in Finder is a board with a broken backdrop, and
|
||||
/// regenerating is exactly the repair — so the name is reused rather than laddered.
|
||||
@Test("A missing file under our own name is rewritten, not laddered")
|
||||
func rewritesAMissingFile() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern, image: facets.png}")
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
#expect(try fixture.data("facets.png") == png)
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
|
||||
}
|
||||
|
||||
/// A locked board writes nothing at all — not the picture, not the fields.
|
||||
@Test("A read-only board refuses before anything is written")
|
||||
func refusesUnderTheLock() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, _) = try makeStore(fixture)
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
#expect(store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB") == false)
|
||||
#expect(!fixture.exists("facets.png"))
|
||||
#expect(try document(fixture).backgroundImage == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Undo
|
||||
|
||||
@MainActor
|
||||
@Suite("Undo ▸ generated background")
|
||||
struct GeneratedBackgroundUndoTests {
|
||||
|
||||
/// The first generation's undo is a clean return: the board had no background, and afterwards it
|
||||
/// has none again. (The PNG stays in the folder — nothing in the app deletes the user's files —
|
||||
/// and nothing points at it.)
|
||||
@Test("Undo removes both subkeys and redo puts them back")
|
||||
func roundTrip() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
#expect(history.undoActionName == "Restyle Board")
|
||||
|
||||
history.undo()
|
||||
let undone = try document(fixture)
|
||||
#expect(undone.background == .missing)
|
||||
#expect(undone.backgroundImage == .missing)
|
||||
#expect(!undone.contains(FrontmatterKeys.background))
|
||||
|
||||
history.redo()
|
||||
let redone = try document(fixture)
|
||||
#expect(redone.background == .valid("#E0E5EB"))
|
||||
#expect(redone.backgroundImage == .valid("facets.png"))
|
||||
}
|
||||
|
||||
/// A prior colour comes back as itself rather than as an absence — the same reading `applyStyle`'s
|
||||
/// inverse has.
|
||||
@Test("A prior colour and image are restored, not removed")
|
||||
func priorValuesComeBack() throws {
|
||||
let fixture = try makeBoard(background: "{color: fern, image: sunset.jpg}")
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.file("sunset.jpg", Data("photo".utf8))
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
history.undo()
|
||||
|
||||
let undone = try document(fixture)
|
||||
#expect(undone.background == .valid("fern"))
|
||||
#expect(undone.backgroundImage == .valid("sunset.jpg"))
|
||||
}
|
||||
|
||||
/// **The undo restores fields, never bytes** — stated as a test so the limit is visible rather
|
||||
/// than folklore: regenerating over the app's own output leaves the second picture on disk, and
|
||||
/// ⌘Z points the (unchanged) name back at it.
|
||||
@Test("Undo does not bring the overwritten pixels back")
|
||||
func undoDoesNotRestoreBytes() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
await reload(store)
|
||||
store.applyGeneratedBackground(png: otherPNG, colorHex: "#513D1A")
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture).background == .valid("#E0E5EB"))
|
||||
#expect(try document(fixture).backgroundImage == .valid("facets.png"))
|
||||
#expect(try fixture.data("facets.png") == otherPNG, "the first generation's bytes are gone")
|
||||
}
|
||||
|
||||
/// A foreign edit to the field the step wrote stales it — the field-level predicate, applied to
|
||||
/// the subkey this gesture owns.
|
||||
@Test("A foreign edit to the image subkey skips the undo")
|
||||
func foreignEditStalesTheStep() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let (store, history) = try makeStore(fixture)
|
||||
|
||||
store.applyGeneratedBackground(png: png, colorHex: "#E0E5EB")
|
||||
|
||||
var foreign = try document(fixture)
|
||||
foreign.setBackgroundImage("elsewhere.png")
|
||||
try fixture.item("", foreign.serialized())
|
||||
|
||||
history.undo()
|
||||
#expect(try document(fixture).backgroundImage == .valid("elsewhere.png"))
|
||||
#expect(store.banners.signposts.isEmpty == false, "the skip says so on the strip")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **The clipboard** — ⌘X/⌘C/⌘V move cards *and* lanes, within a board and across boards, so structure transfers without a mouse. It's a hybrid: the pasteboard carries a small manifest plus the titles as plain text, while the real content — whole folders, attachments and strays and all — is snapshotted into Application Support the instant you press ⌘C, so a copy captures the item as it was at that moment and survives the original being deleted, its volume unmounting, or the app quitting and relaunching. The store keeps exactly one snapshot: every copy and every launch sweeps whatever the pasteboard no longer points at. If a snapshot has gone missing by the time you paste, the manifest still carries each item's full `index.md`, so the paste lands with its content intact — and says so out loud, naming exactly what was left behind ("Pasted 'Fix login' without its 2 attachments") rather than leaving you to find an empty `attachments/` later. Cut is Finder-style deferred: the items dim in place and stay put until a paste moves them, voiding if another app takes the pasteboard or the source board closes (the paste then quietly becomes a copy), and voiding *per item* if one is deleted in the meantime — so a paste moves whatever survived, and a cut emptied down to nothing simply does nothing. Paste lands after the anchor card, at a selected lane's bottom, or at the last member of a multi-selection in flatten order — the same anchor ⌘N uses — and a lane payload lands after the anchor lane or at the board's right end, which is one of the two ways out of a board with no lanes at all. Copies keep `created` and take fresh identities throughout; a lane carries exactly its cards, copied or moved, because the trash is board-level and there is nothing lane-nested to strip; pasting a lane back into its own board is the within-board duplicate the drag deliberately doesn't offer. The clipboard works on trash cards like on any card — ⌘C yields a live copy wherever you paste it, and ⌘X in the trash followed by ⌘V is the keyboard-native restore, a card into a lane and a trashed lane row after the anchor lane — while paste never targets the trash itself, and the read-only lock blocks cut without ever blocking copy, because copying out is a read.
|
||||
|
||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. **A board can also wear a picture.** `background` is a mapping at every level — `{color: fern}` on a card or a lane — and a board's may name an image beside its colour: `background: {color: "#112233", image: art/sunset.jpg}` paints that image over that colour, scaled to fill, across the **whole window**: the content runs under a transparent title bar with a frosted strip keeping the toolbar and the board-name widget legible on top of it. The path is relative to the board folder, so the picture travels with the document when it is copied, zipped or synced (an absolute path, or one climbing out of the folder, simply paints nothing). Either half may stand alone, the colour shows through while a large photograph decodes off the main thread, and replacing the file in Finder swaps the backdrop live. There is no picker for it — like a hand-written hex, the raw file is the escape hatch — and a board with no background of its own keeps the standard window chrome exactly as before.
|
||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. **A board can also wear a picture.** `background` is a mapping at every level — `{color: fern}` on a card or a lane — and a board's may name an image beside its colour: `background: {color: "#112233", image: art/sunset.jpg}` paints that image over that colour, scaled to fill, across the **whole window**: the content runs under a transparent title bar with a frosted strip keeping the toolbar and the board-name widget legible on top of it. The path is relative to the board folder, so the picture travels with the document when it is copied, zipped or synced (an absolute path, or one climbing out of the folder, simply paints nothing). Either half may stand alone, the colour shows through while a large photograph decodes off the main thread, and replacing the file in Finder swaps the backdrop live. The board popover's Background tab can also *generate* the picture: a faceted triangle-mesh recipe filtered by tone (defaulting to the current appearance), color count (mono, complementary duo, contrasting trio), mesh density and saturation, previewed across an eight-hue carousel whose Reroll button re-mints the geometry — clicking a swatch renders it at full resolution into the board folder as `facets.png` and points `background` at it, with the recipe's primary color written beside it as the fallback underlay. Generated or dragged in, a background is always static pixels — the raw file stays the escape hatch — and a board with no background of its own keeps the standard window chrome exactly as before.
|
||||
|
||||
- **The trash** — deleting a card **moves** it: its folder travels into the board's reserved `.trash/`, always landing at the top, and View ▸ Show Trash reveals a trailing column where those cards live. A trashed card is an ordinary card in a special place — the same card face, the same colour stripe, the same attachments chip, the same search, the same selection, the same clipboard — so `.trash/` is self-describing in Finder and to agents, and there is no tombstone flag anywhere. **Lanes delete into the trash too**: the folder travels subtree-intact and shows as one distinct dimmed row carrying its title and held-card count — an opaque unit that never expands, whose cards aren't individually addressable, and which restores whole or purges whole (its confirmation counting the cards it would take with it). The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it, and its newest-first order falls out of ordinary ranks with no timestamp sort. There is no Put Back: restore by dragging a card out into any lane at any position, or ⌘X in the trash and ⌘V into a lane — both are ordinary moves, so a restored card lands where you put it. Drop a live card on the column to delete it — the pointer's twin of ⌫, writing the identical move, and its shadow always takes the top row because that is genuinely where the card lands. Delete is one vocabulary staged by place: ⌫/⌘⌫ moves a board card to the trash and deletes a trash card permanently, and ⇧⌘⌫ Empty Trash… purges the whole container — each confirmed where the loss is real, named by count, and Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style…, Finder file drops — applies to a trash selection, and a selection never mixes trashed with live.
|
||||
|
||||
@@ -47,7 +47,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **Raw source** — View ▸ Raw Source (⌥⌘E) swaps the card window's whole content area — title, body and sidebar — for the literal `index.md` in a monospaced editor with Cancel and Apply. It's the escape hatch that keeps everything reachable in-app: unknown keys an agent added, hand-written comments, exotic YAML the app has no control for. Entering flushes whatever you were typing and then reads the file fresh off disk, never an in-memory copy. Apply validates through the very same fail-fast parse the loader uses — a broken proposal stops with a detailed alert naming the line, source mode stays open with your text, and the file on disk is untouched — and a valid one is written byte for byte, the only write in the app that neither stamps `modified` nor clears a `modified-by` you typed or kept, because you wrote those bytes and nothing may quietly edit them. The reload then refreshes every window. Escape is Cancel, ⌘↩ is Apply, ⌥⌘E toggled off applies too, Return just types; Cancel and closing the window discard without ceremony, and a card deleted out from under an open buffer discards it rather than letting a stale Apply undelete the card. ⌘E stands down while source mode is up, ⌘F still finds, and ⌘Z is the editor's own undo. A file that isn't valid UTF-8 declines to open as source rather than showing you a lossy guess of it.
|
||||
|
||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it.
|
||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it the popover is tabbed — Info with the board's vital statistics, Background carrying the board-aimed style editor and the generated-background picker, and Git; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it.
|
||||
|
||||
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Zoom In, Zoom Out, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user