The backgrounds get a swatch gallery — sweep one of the bundled artwork exploration

DESIGN/explorations/board-backgrounds: working material, not a numbered
design doc — the app side of background images is settled, so the
deliverable is purely artwork. The model is base layer × filler layer,
swept across hue strategy, HSB family, filler shape, density, placement
and opacity; the gallery is one self-contained HTML generator, every
swatch procedural SVG off a seeded PRNG so swatch IDs stay stable
references across reloads. Status: first sweep published, awaiting review.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-07 11:24:10 -04:00
parent 38ff520ae9
commit ccf55f1d49
2 changed files with 809 additions and 0 deletions
@@ -0,0 +1,755 @@
<title>Board Background Swatches</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; }
.page-head .subtitle{ font-size:13px; color:var(--ink-2); margin:0; }
.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; }
.section-label{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--ink-2); margin:0 0 10px; }
.axes{ margin-bottom:40px; }
.axes-list{ display:grid; grid-template-columns:1fr 1fr; gap:7px 32px; margin:0; }
.axis{ display:flex; gap:6px; font-size:13px; line-height:1.5; }
.axis dt{ margin:0; font-weight:600; color:var(--ink-2); flex:0 0 auto; }
.axis dd{ margin:0; color:var(--ink-2); }
@media (max-width:720px){ .axes-list{ grid-template-columns:1fr; } }
.family{ margin-bottom:40px; }
.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; }
.grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(260px,1fr)); gap:14px; }
.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 Background Swatches</h1>
<p class="subtitle">96 procedural candidates — base layer × filler layer. Toggle the board overlay to judge them under content.</p>
</header>
<div class="controlbar">
<div class="chipgroup" id="family-chips" role="group" aria-label="Family filter">
<button type="button" class="chip active" data-family="all" aria-pressed="true">All</button>
<button type="button" class="chip" data-family="whisper" aria-pressed="false">Whisper</button>
<button type="button" class="chip" data-family="confetti" aria-pressed="false">Confetti</button>
<button type="button" class="chip" data-family="aurora" aria-pressed="false">Aurora</button>
<button type="button" class="chip" data-family="contours" aria-pressed="false">Contours</button>
<button type="button" class="chip" data-family="drift" aria-pressed="false">Drift</button>
<button type="button" class="chip" data-family="bubbles" aria-pressed="false">Bubbles</button>
<button type="button" class="chip" data-family="terrazzo" aria-pressed="false">Terrazzo</button>
<button type="button" class="chip" data-family="graph" aria-pressed="false">Graph</button>
<button type="button" class="chip" data-family="waves" aria-pressed="false">Waves</button>
<button type="button" class="chip" data-family="dusk" aria-pressed="false">Dusk</button>
<button type="button" class="chip" data-family="starfield" aria-pressed="false">Starfield</button>
<button type="button" class="chip" data-family="slate" aria-pressed="false">Slate</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>
</div>
<section class="axes">
<p class="section-label">Axes</p>
<dl class="axes-list">
<div class="axis"><dt>Base</dt><dd>solid · linear 2/3-stop (angle) · radial/corner glow · soft-blob mesh</dd></div>
<div class="axis"><dt>Hue strategy</dt><dd>mono · analogous · complementary accent · multicolor</dd></div>
<div class="axis"><dt>HSB family</dt><dd>pastel · muted · deep/dark</dd></div>
<div class="axis"><dt>Filler shape</dt><dd>dots · rings · blobs/chips · capsules · triangles · plus-signs · contour lines · sine bands · grid</dd></div>
<div class="axis"><dt>Density</dt><dd>sparse → dense</dd></div>
<div class="axis"><dt>Size</dt><dd>uniform vs power-law</dd></div>
<div class="axis"><dt>Placement</dt><dd>uniform scatter · diagonal band · corner-weighted · grid-jitter</dd></div>
<div class="axis"><dt>Filler color</dt><dd>same-hue tint · accent hue · multicolor · alpha-only</dd></div>
<div class="axis"><dt>Opacity</dt><dd>whisper 410% → visible 1525%</dd></div>
<div class="axis"><dt>Depth</dt><dd>crisp vs gradient-soft, layered sizes</dd></div>
</dl>
</section>
<section class="family" data-family="whisper" data-tone="light">
<h2>Whisper</h2>
<p class="recipe">A vertical pastel gradient ground carries roughly sixty faint dots at 610% opacity — the quietest family in the set.</p>
<div class="grid" id="grid-whisper"></div>
</section>
<section class="family" data-family="confetti" data-tone="light">
<h2>Confetti</h2>
<p class="recipe">A near-neutral ground is scattered with about seventy tiny circles, rotated squares and triangles across three related hues.</p>
<div class="grid" id="grid-confetti"></div>
</section>
<section class="family" data-family="aurora" data-tone="light">
<h2>Aurora</h2>
<p class="recipe">A flat pastel ground holds four to six large soft-edged gradient blobs that overlap and drift toward one corner, mesh-gradient style.</p>
<div class="grid" id="grid-aurora"></div>
</section>
<section class="family" data-family="contours" data-tone="light">
<h2>Contours</h2>
<p class="recipe">A flat ground is crossed by fourteen thin wavy horizontal lines built from summed sine harmonics, evoking topographic contours.</p>
<div class="grid" id="grid-contours"></div>
</section>
<section class="family" data-family="drift" data-tone="light">
<h2>Drift</h2>
<p class="recipe">A diagonal two-tone gradient ground carries rotated capsule shapes rejection-sampled into a diagonal band across the canvas.</p>
<div class="grid" id="grid-drift"></div>
</section>
<section class="family" data-family="bubbles" data-tone="light">
<h2>Bubbles</h2>
<p class="recipe">A top-left radial gradient ground carries a power-law scatter of circles — a few large, more medium, many small — mixing filled discs with stroked rings.</p>
<div class="grid" id="grid-bubbles"></div>
</section>
<section class="family" data-family="terrazzo" data-tone="light">
<h2>Terrazzo</h2>
<p class="recipe">A near-neutral ground is covered in about fifty-five small irregular polygon chips across four related colors, like stone terrazzo.</p>
<div class="grid" id="grid-terrazzo"></div>
</section>
<section class="family" data-family="graph" data-tone="light">
<h2>Graph</h2>
<p class="recipe">A pale ground is ruled into a 24px grid, with a few plus-mark intersections and small accent dots — graph paper.</p>
<div class="grid" id="grid-graph"></div>
</section>
<section class="family" data-family="waves" data-tone="light">
<h2>Waves</h2>
<p class="recipe">A horizontal gradient ground is layered with six translucent sine-wave bands, phase-shifted and stacked toward the bottom third like dunes.</p>
<div class="grid" id="grid-waves"></div>
</section>
<section class="family" data-family="dusk" data-tone="dark">
<h2>Dusk</h2>
<p class="recipe">A deep vertical gradient ground holds large soft gradient blobs and a faint horizon glow low in the frame — moody, no small shapes.</p>
<div class="grid" id="grid-dusk"></div>
</section>
<section class="family" data-family="starfield" data-tone="dark">
<h2>Starfield</h2>
<p class="recipe">A dark radial ground is scattered with about ninety tiny stars, a handful haloed and brighter, plus two faint nebula blobs.</p>
<div class="grid" id="grid-starfield"></div>
</section>
<section class="family" data-family="slate" data-tone="dark">
<h2>Slate</h2>
<p class="recipe">A flat dark ground carries the same wavy contour lines as Contours, with small accent dots resting on the lines — dark topographic.</p>
<div class="grid" id="grid-slate"></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 ----------
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){
const seed = xmur3(id)();
return mulberry32(seed);
}
// ---------- small helpers ----------
function mod360(h){ return ((h % 360) + 360) % 360; }
function hsl(h,s,l){ return "hsl(" + mod360(h) + "," + s + "%," + l + "%)"; }
function rnd(rng,min,max){ return min + rng() * (max - min); }
function rndInt(rng,min,max){ return Math.floor(rnd(rng,min,max+1)); }
function pick(rng,arr){ return arr[Math.floor(rng()*arr.length)]; }
function pad2(n){ return String(n).padStart(2,"0"); }
function f1(n){ return n.toFixed(1); }
function linGrad(id,x1,y1,x2,y2,stops){
let s = "";
for(let i=0;i<stops.length;i++){
s += '<stop offset="' + stops[i][0] + '" stop-color="' + stops[i][1] + '"/>';
}
return '<linearGradient id="' + id + '" x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '">' + s + '</linearGradient>';
}
function radGrad(id,cx,cy,r,stops){
let s = "";
for(let i=0;i<stops.length;i++){
const st = stops[i];
const op = st.length > 2 ? st[2] : 1;
s += '<stop offset="' + st[0] + '" stop-color="' + st[1] + '" stop-opacity="' + op + '"/>';
}
return '<radialGradient id="' + id + '" cx="' + cx + '" cy="' + cy + '" r="' + r + '">' + s + '</radialGradient>';
}
function triPoints(cx,cy,size,rotDeg){
const r = size * 0.7;
const rot = rotDeg * Math.PI / 180;
const pts = [];
for(let k=0;k<3;k++){
const a = rot + k*(Math.PI*2/3) - Math.PI/2;
pts.push(f1(cx + r*Math.cos(a)) + "," + f1(cy + r*Math.sin(a)));
}
return pts.join(" ");
}
// shared wavy-contour-line builder, used by "contours" and "slate"
function contourLines(rng,count,spacing,startY,stroke,opacity,widthAttr){
let out = "";
const allPoints = [];
for(let i=0;i<count;i++){
const baseY = startY + i*spacing;
const harmonics = rndInt(rng,2,3);
const comps = [];
let wsum = 0;
for(let k=0;k<harmonics;k++){
const c = { freq: rnd(rng,1,3), phase: rnd(rng,0,Math.PI*2), weight: rnd(rng,0.3,1) };
comps.push(c);
wsum += c.weight;
}
const amp = rnd(rng,6,14);
const pts = [];
for(let x=0;x<=480;x+=8){
let s = 0;
for(let k=0;k<comps.length;k++){
const c = comps[k];
s += c.weight * Math.sin(c.freq*(x/480)*Math.PI*2 + c.phase);
}
const y = baseY + amp*(s/wsum);
pts.push([x,y]);
}
allPoints.push(pts);
let d = "";
for(let p=0;p<pts.length;p++){
d += (p===0 ? "M" : "L") + f1(pts[p][0]) + "," + f1(pts[p][1]) + " ";
}
out += '<path d="' + d + '" stroke="' + stroke + '" stroke-width="' + widthAttr + '" fill="none" opacity="' + opacity + '"/>';
}
return { svg: out, lines: allPoints };
}
// ---------- board overlay (shared across all families) ----------
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;
}
// ---------- 12 families ----------
function famWhisper(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + linGrad(baseId,"0","0","0","1",[["0%",hsl(H,45,94)],["100%",hsl(H,40,88)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
let filler = "";
for(let i=0;i<60;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,2,5), op = rnd(rng,0.06,0.10);
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,50,55) + '" opacity="' + op.toFixed(2) + '"/>';
}
return s + filler;
}
function famConfetti(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,12,95) + '"/>';
const hues = [H, H+120, H+240];
let filler = "";
for(let i=0;i<70;i++){
const type = pick(rng,["circle","square","triangle"]);
const hue = pick(rng,hues);
const color = hsl(hue,55,55);
const size = rnd(rng,3,8);
const x = rnd(rng,0,480), y = rnd(rng,0,300);
const op = rnd(rng,0.10,0.16).toFixed(2);
if(type === "circle"){
filler += '<circle cx="' + f1(x) + '" cy="' + f1(y) + '" r="' + f1(size/2) + '" fill="' + color + '" opacity="' + op + '"/>';
} else if(type === "square"){
const rot = rnd(rng,0,360).toFixed(1);
filler += '<rect x="' + f1(x-size/2) + '" y="' + f1(y-size/2) + '" width="' + f1(size) + '" height="' + f1(size) + '" fill="' + color + '" opacity="' + op + '" transform="rotate(' + rot + ' ' + f1(x) + ' ' + f1(y) + ')"/>';
} else {
const rot = rnd(rng,0,360);
filler += '<polygon points="' + triPoints(x,y,size,rot) + '" fill="' + color + '" opacity="' + op + '"/>';
}
}
return s + filler;
}
function famAurora(rng,H,gid){
const corners = [[0,0],[480,0],[0,300],[480,300]];
const corner = pick(rng,corners);
const n = rndInt(rng,4,6);
let defs = "", circles = "";
for(let i=0;i<n;i++){
const r = rnd(rng,90,180);
const rx = rnd(rng,0,480), ry = rnd(rng,0,300);
const t = rnd(rng,0.3,0.7);
const cx = rx + (corner[0]-rx)*t;
const cy = ry + (corner[1]-ry)*t;
const offset = pick(rng,[-25,25]);
const gidL = gid + "-aur-" + i;
defs += radGrad(gidL,"50%","50%","50%",[["0%",hsl(H+offset,55,80),0.55],["100%",hsl(H+offset,55,80),0]]);
circles += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + gidL + ')"/>';
}
let s = "<defs>" + defs + "</defs>";
s += '<rect width="480" height="300" fill="' + hsl(H,35,93) + '"/>';
return s + circles;
}
function famContours(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,30,92) + '"/>';
const res = contourLines(rng,14,22,6,hsl(H,45,60),0.14,1.2);
return s + res.svg;
}
function famDrift(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + linGrad(baseId,"0","0","1","1",[["0%",hsl(H,40,93)],["100%",hsl(H+30,40,89)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
const a = 300, b = -480;
const denom = Math.sqrt(a*a + b*b);
let count = 0, tries = 0, filler = "";
while(count < 40 && tries < 2000){
tries++;
const x = rnd(rng,0,480), y = rnd(rng,0,300);
const dist = Math.abs(a*x + b*y) / denom;
if(dist < 90){
const len = rnd(rng,20,70), thick = rnd(rng,4,7);
const op = rnd(rng,0.08,0.14).toFixed(2);
filler += '<rect x="' + f1(-len/2) + '" y="' + f1(-thick/2) + '" width="' + f1(len) + '" height="' + f1(thick) + '" rx="' + f1(thick/2) + '" fill="' + hsl(H,45,60) + '" opacity="' + op + '" transform="translate(' + f1(x) + ' ' + f1(y) + ') rotate(-32)"/>';
count++;
}
}
return s + filler;
}
function famBubbles(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + radGrad(baseId,"0%","0%","100%",[["0%",hsl(H,45,95)],["100%",hsl(H,40,88)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
const groups = [[5,40,80,0.05],[15,12,30,0.08],[40,2,8,0.12]];
let filler = "";
for(let gi=0; gi<groups.length; gi++){
const n = groups[gi][0], rmin = groups[gi][1], rmax = groups[gi][2], op = groups[gi][3];
for(let i=0;i<n;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,rmin,rmax);
const ring = rng() < 0.4;
if(ring){
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="none" stroke="' + hsl(H,50,55) + '" stroke-width="1.5" opacity="' + op + '"/>';
} else {
filler += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,50,55) + '" opacity="' + op + '"/>';
}
}
}
return s + filler;
}
function famTerrazzo(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,10,94) + '"/>';
const colors = [hsl(H,45,60), hsl(H+40,40,55), hsl(H-40,40,50), hsl(40,8,55)];
let filler = "";
for(let i=0;i<55;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300);
const sides = rndInt(rng,5,7);
const rot = rnd(rng,0,Math.PI*2);
const color = pick(rng,colors);
const pts = [];
for(let k=0;k<sides;k++){
const ang = rot + k*(Math.PI*2/sides);
const r = rnd(rng,4,14);
pts.push(f1(cx + r*Math.cos(ang)) + "," + f1(cy + r*Math.sin(ang)));
}
filler += '<polygon points="' + pts.join(" ") + '" fill="' + color + '" opacity="0.18"/>';
}
return s + filler;
}
function famGraph(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,25,96) + '"/>';
const pitch = 24;
const cols = Math.floor(480/pitch);
const rows = Math.floor(300/pitch);
let grid = "";
for(let c=0;c<=cols;c++){
const x = c*pitch;
grid += '<line x1="' + x + '" y1="0" x2="' + x + '" y2="300" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.10"/>';
}
for(let r=0;r<=rows;r++){
const y = r*pitch;
grid += '<line x1="0" y1="' + y + '" x2="480" y2="' + y + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.10"/>';
}
let plus = "";
for(let i=0;i<12;i++){
const gx = rndInt(rng,0,cols)*pitch, gy = rndInt(rng,0,rows)*pitch;
plus += '<line x1="' + (gx-4) + '" y1="' + gy + '" x2="' + (gx+4) + '" y2="' + gy + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.25"/>';
plus += '<line x1="' + gx + '" y1="' + (gy-4) + '" x2="' + gx + '" y2="' + (gy+4) + '" stroke="' + hsl(H,35,65) + '" stroke-width="1" opacity="0.25"/>';
}
let dots = "";
const nd = rndInt(rng,3,4);
for(let i=0;i<nd;i++){
const gx = rndInt(rng,0,cols)*pitch, gy = rndInt(rng,0,rows)*pitch;
dots += '<circle cx="' + gx + '" cy="' + gy + '" r="3" fill="' + hsl(H,55,55) + '" opacity="0.35"/>';
}
return s + grid + plus + dots;
}
function famWaves(rng,H,gid){
const baseId = gid + "-base";
let s = "<defs>" + linGrad(baseId,"0","0","1","0",[["0%",hsl(H,38,94)],["100%",hsl(H,34,89)]]) + "</defs>";
s += '<rect width="480" height="300" fill="url(#' + baseId + ')"/>';
let filler = "";
for(let i=0;i<6;i++){
const baseline = 190 + i*18;
const amp = rnd(rng,10,24);
const freq = rnd(rng,1,2);
const phase = rnd(rng,0,Math.PI*2);
let d = "M0,300 ";
for(let x=0;x<=480;x+=12){
const y = baseline + amp*Math.sin(freq*(x/480)*Math.PI*2 + phase);
d += "L" + x + "," + f1(y) + " ";
}
d += "L480,300 Z";
filler += '<path d="' + d + '" fill="' + hsl(H,40,80) + '" opacity="0.10"/>';
}
return s + filler;
}
function famDusk(rng,H,gid){
const baseId = gid + "-base";
let defs = linGrad(baseId,"0","0","0","1",[["0%",hsl(H,35,16)],["100%",hsl(H,45,9)]]);
const n = rndInt(rng,4,5);
let blobs = "";
for(let i=0;i<n;i++){
const r = rnd(rng,90,180);
const cx = rnd(rng,0,480), cy = rnd(rng,0,220);
const offset = pick(rng,[-30,30]);
const gidL = gid + "-dusk-" + i;
defs += radGrad(gidL,"50%","50%","50%",[["0%",hsl(H+offset,55,35),0.5],["100%",hsl(H+offset,55,35),0]]);
blobs += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + gidL + ')"/>';
}
const horizonId = gid + "-horizon";
defs += radGrad(horizonId,"50%","50%","50%",[["0%",hsl(H,50,30),0.45],["100%",hsl(H,50,30),0]]);
const horizon = '<ellipse cx="240" cy="270" rx="320" ry="70" fill="url(#' + horizonId + ')"/>';
return "<defs>" + defs + "</defs>" + '<rect width="480" height="300" fill="url(#' + baseId + ')"/>' + blobs + horizon;
}
function famStarfield(rng,H,gid){
const baseId = gid + "-base";
let defs = radGrad(baseId,"50%","50%","75%",[["0%",hsl(H,40,14)],["100%",hsl(H,45,7)]]);
let nebula = "";
for(let i=0;i<2;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300), r = rnd(rng,120,200);
const nId = gid + "-neb-" + i;
defs += radGrad(nId,"50%","50%","50%",[["0%",hsl(H+40,50,30),0.3],["100%",hsl(H+40,50,30),0]]);
nebula += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="url(#' + nId + ')"/>';
}
const brightIdx = new Set();
while(brightIdx.size < 8){ brightIdx.add(rndInt(rng,0,89)); }
let stars = "";
for(let i=0;i<90;i++){
const cx = rnd(rng,0,480), cy = rnd(rng,0,300);
if(brightIdx.has(i)){
const haloId = gid + "-star-" + i;
defs += radGrad(haloId,"50%","50%","50%",[["0%",hsl(H,30,88),0.9],["100%",hsl(H,30,88),0]]);
const r = rnd(rng,0.6,1.8);
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="6" fill="url(#' + haloId + ')"/>';
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,30,88) + '" opacity="0.9"/>';
} else {
const r = rnd(rng,0.6,1.8);
const op = rnd(rng,0.25,0.5).toFixed(2);
stars += '<circle cx="' + f1(cx) + '" cy="' + f1(cy) + '" r="' + f1(r) + '" fill="' + hsl(H,30,88) + '" opacity="' + op + '"/>';
}
}
return "<defs>" + defs + "</defs>" + '<rect width="480" height="300" fill="url(#' + baseId + ')"/>' + nebula + stars;
}
function famSlate(rng,H,gid){
let s = '<rect width="480" height="300" fill="' + hsl(H,18,13) + '"/>';
const res = contourLines(rng,14,22,6,hsl(H,35,55),0.12,1.2);
s += res.svg;
let dots = "";
for(let i=0;i<10;i++){
const li = rndInt(rng,0,res.lines.length-1);
const line = res.lines[li];
const pi = rndInt(rng,0,line.length-1);
const pt = line[pi];
const r = rnd(rng,2,3);
dots += '<circle cx="' + f1(pt[0]) + '" cy="' + f1(pt[1]) + '" r="' + f1(r) + '" fill="' + hsl(H,60,60) + '" opacity="0.5"/>';
}
return s + dots;
}
// ---------- registry ----------
const HUES = [
{ name:"clay", h:8 },
{ name:"amber", h:38 },
{ name:"olive", h:80 },
{ name:"forest", h:140 },
{ name:"teal", h:175 },
{ name:"sky", h:215 },
{ name:"iris", h:262 },
{ name:"rose", h:335 }
];
const FAMILIES = [
{ id:"whisper", tone:"light", gen:famWhisper },
{ id:"confetti", tone:"light", gen:famConfetti },
{ id:"aurora", tone:"light", gen:famAurora },
{ id:"contours", tone:"light", gen:famContours },
{ id:"drift", tone:"light", gen:famDrift },
{ id:"bubbles", tone:"light", gen:famBubbles },
{ id:"terrazzo", tone:"light", gen:famTerrazzo },
{ id:"graph", tone:"light", gen:famGraph },
{ id:"waves", tone:"light", gen:famWaves },
{ id:"dusk", tone:"dark", gen:famDusk },
{ id:"starfield", tone:"dark", gen:famStarfield },
{ id:"slate", tone:"dark", gen:famSlate }
];
// ---------- render ----------
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();
});
let renderedCount = 0;
function renderAll(){
for(let fi=0; fi<FAMILIES.length; fi++){
const fam = FAMILIES[fi];
const grid = document.getElementById("grid-" + fam.id);
for(let hi=0; hi<HUES.length; hi++){
const hue = HUES[hi];
const id = fam.id + "-" + pad2(hi+1);
const rng = seededRng(id);
const inner = fam.gen(rng, hue.h, id);
const overlay = buildOverlay(rng, fam.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";
card.dataset.family = fam.id;
card.dataset.tone = fam.tone;
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 = hue.name;
caption.appendChild(idSpan);
caption.appendChild(noteSpan);
card.appendChild(frame);
card.appendChild(caption);
card.addEventListener("click", function(){ openLightbox(svgEl, id); });
grid.appendChild(card);
renderedCount++;
}
}
}
// ---------- filters ----------
let activeFamily = "all", activeTone = "all";
const familyChips = document.querySelectorAll("#family-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 fam = sec.dataset.family, tone = sec.dataset.tone;
const visible = (activeFamily === "all" || activeFamily === fam) && (activeTone === "all" || activeTone === tone);
sec.style.display = visible ? "" : "none";
}
}
familyChips.forEach(function(chip){
chip.addEventListener("click", function(){
familyChips.forEach(function(c){ c.classList.remove("active"); c.setAttribute("aria-pressed","false"); });
chip.classList.add("active");
chip.setAttribute("aria-pressed","true");
activeFamily = chip.dataset.family;
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);
});
renderAll();
})();
</script>
+54
View File
@@ -0,0 +1,54 @@
# 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.
## What this is
A set of shippable background images for boards. The app side needs nothing new: `background: {image: <path>}` relative to the board root, drawn scaled-to-fill and cropped under the transparent title bar with the frosted strip (BoardBackdropImage.swift), decoded at a 3072px ceiling (`BoardBackdrop.maximumPixelSize`), no in-app editor — the raw file is the escape hatch. So the deliverable is purely the artwork: PNG files generated from procedural recipes.
## The model
Every background = **base layer × filler layer**.
Axes swept or reserved:
- **Base**: solid · linear gradient (2/3-stop, angle) · radial/corner glow · soft-blob mesh
- **Hue strategy**: monochrome · analogous · complementary accent · multicolor
- **HSB family**: pastel · muted/dusty · deep-dark
- **Filler shapes**: dots · rings · blob chips · capsules · triangles · plus-signs · contour lines · sine bands · grid
- **Density** sparse→dense; **size distribution** uniform vs power-law; **placement** uniform scatter · diagonal band · corner-weighted · grid-jitter
- **Filler color**: same-hue tint · accent hue · multicolor · alpha-only; **opacity** whisper 410% → visible 1525%; **depth** crisp vs gradient-soft, layered sizes
Deliberately left out of sweep 1 (add to surviving families in sweep 2): grain/noise texture, two-layer depth (large blurred behind small crisp), clustered placement, outlined-only variants.
## Sweep 1 — the gallery
- **Artifact (preview)**: https://claude.ai/code/artifact/afa8f1d8-ded0-4d7b-9785-f4b7eb4f8008
- **Generator source**: `board-backgrounds-gallery.html` (this folder) — single self-contained HTML, all swatches procedural SVG off a seeded PRNG (xmur3 + mulberry32, seeded from the swatch ID string), so IDs are stable references across reloads and rebuilds. To republish after edits: publish this file via the Artifact tool passing the URL above as `url` to keep the link.
- **96 swatches** = 12 families × 8 hues. Hue wheel (shared by all families): clay 8° · amber 38° · olive 80° · forest 140° · teal 175° · sky 215° · iris 262° · rose 335°. IDs are `family-NN` (`aurora-05` = aurora × teal).
- **Board overlay toggle** draws translucent lane plates + opaque card plates + title strip over every swatch — the judging condition, since real backgrounds sit under exactly that stack (lanes are a translucent quaternary wash, cards opaque plates).
Families (19 light, 1012 dark):
| Family | Recipe |
|---|---|
| whisper | pastel vertical gradient + faint scattered dots — the quietest |
| confetti | neutral ground + small multicolor shapes (H, H+120, H+240) |
| aurora | 46 large soft radial-gradient blobs, mesh look |
| contours | topographic wavy polylines, mono |
| drift | diagonal band of 32°-tilted capsules over diagonal gradient |
| bubbles | power-law circles/rings over corner glow |
| terrazzo | irregular 57-gon stone chips, four colors |
| graph | 24px graph-paper grid + plus-signs + accent dots |
| waves | 6 layered sine dunes toward the bottom |
| dusk | dark gradient, large glow blobs, horizon ellipse |
| starfield | tiny stars w/ gradient halos + faint nebulae on dark radial |
| slate | dark topographic lines + accent dots on flat dark ground |
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%.
## Next steps
1. Review with overlay on; pick surviving families / specific IDs and direction tweaks ("aurora but duskier", "contours denser").
2. Sweep 2: deepen winners, add the reserved axes (grain, two-layer depth, clusters, outlines).
3. Settle final recipes → render real PNGs at 3072px long edge (canvas render of the same recipes, or a small script) → decide packaging (bundled set offered how? templates carry them? drag-in only?) — packaging is an open question, nothing ruled yet.