aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes')
-rw-r--r--docs/prototypes/2026-07-31-timeline-face-prototype-1.html367
-rw-r--r--docs/prototypes/2026-07-31-timeline-face-prototype-2.html198
-rw-r--r--docs/prototypes/2026-07-31-timeline-face-prototype-3.html224
-rw-r--r--docs/prototypes/2026-07-31-timeline-face-prototype-4.html242
-rw-r--r--docs/prototypes/2026-07-31-timeline-face-prototype-5.html263
-rw-r--r--docs/prototypes/2026-07-31-timeline-face-prototype-6.html151
-rw-r--r--docs/prototypes/README.md41
7 files changed, 1486 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-1.html b/docs/prototypes/2026-07-31-timeline-face-prototype-1.html
new file mode 100644
index 0000000..d323854
--- /dev/null
+++ b/docs/prototypes/2026-07-31-timeline-face-prototype-1.html
@@ -0,0 +1,367 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Timeline face — prototype 1</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823; --now:#c8452f;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:#111;color:var(--silver);
+ font-family:var(--mono)}
+ .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em;
+ text-transform:uppercase;color:var(--steel)}
+ .chrome b{color:var(--cream);font-weight:700}
+ .bar{padding:10px 18px 14px;display:flex;gap:10px;flex-wrap:wrap;
+ align-items:center;font-size:13px}
+ button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;
+ text-transform:uppercase;background:#191919;color:var(--silver);
+ border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer}
+ button.on{background:var(--gold);color:#111;border-color:var(--gold)}
+ .note{color:var(--dim);font-size:12px;letter-spacing:.04em;
+ text-transform:none;padding:0 18px 12px;max-width:1100px;line-height:1.5}
+ .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground);
+ /* the real surface is 3440x1440; scaled to fit the page */
+ width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative}
+ svg{position:absolute;inset:0;width:100%;height:100%}
+</style>
+</head>
+<body>
+
+<div class="chrome">Timeline face &middot; prototype 1 &middot; <b>synthetic data</b></div>
+<div class="bar">
+ <span style="color:var(--steel)">direction</span>
+ <button data-dir="A" class="on">A &mdash; crossing 20%</button>
+ <button data-dir="B">B &mdash; crossing 80%</button>
+ <button data-dir="C">C &mdash; cosmos orbit</button>
+ <button data-dir="D">D &mdash; typographic</button>
+ <span style="width:18px"></span>
+ <button id="tnow" class="on">live clock</button>
+ <button id="tscrub">scrub day</button>
+</div>
+<div class="note">
+ Surface is 3440&times;1440, drawn to scale. Cities are Craig's live waybar
+ worldclock roster (15 zones, home New Orleans). Events are a synthetic
+ fixture, never his real agenda &mdash; this repo is still served anonymously
+ over cgit, so real calendar titles cannot live in a tracked file.
+ Brightness runs dark in the past to bright in the future. The next event is
+ gold. Type size follows tick spacing, capped at 72px, and grey level is
+ derived from that same size &mdash; Maeda's law from Line, one rule driving
+ scale, weight and colour.
+</div>
+
+<div class="stage"><svg id="face" viewBox="0 0 3440 1440" preserveAspectRatio="none"></svg></div>
+
+<script>
+// ---------------------------------------------------------------------------
+// Data. Cities are real (the roster is not sensitive); events are invented.
+// ---------------------------------------------------------------------------
+const CITIES = [
+ {tz:"Pacific/Honolulu", label:"Honolulu"},
+ {tz:"America/Anchorage", label:"Anchorage"},
+ {tz:"America/Los_Angeles",label:"Berkeley"},
+ {tz:"America/Chicago", label:"New Orleans", home:true},
+ {tz:"America/New_York", label:"New York"},
+ {tz:"Europe/London", label:"London"},
+ {tz:"Europe/Paris", label:"Paris"},
+ {tz:"Europe/Athens", label:"Athens"},
+ {tz:"Europe/Istanbul", label:"Istanbul"},
+ {tz:"Asia/Yerevan", label:"Yerevan"},
+ {tz:"Asia/Kolkata", label:"Delhi"},
+ {tz:"Asia/Shanghai", label:"Shanghai"},
+ {tz:"Asia/Tokyo", label:"Tokyo"},
+ {tz:"Australia/Sydney", label:"Sydney"},
+ {tz:"Pacific/Auckland", label:"Wellington"},
+];
+
+// Offsets in hours from "now", so the fixture is always relative and never
+// goes stale. Durations in minutes. Titles are invented.
+const FIXTURE = [
+ {dh:-3.4, dur: 25, title:"Morning prep"},
+ {dh:-1.9, dur: 15, title:"Daily standup"},
+ {dh:-0.6, dur: 30, title:"Review inbox"},
+ {dh: 0.8, dur: 25, title:"Weekly 1:1"},
+ {dh: 2.3, dur: 60, title:"Design review"},
+ {dh: 4.1, dur: 30, title:"Vendor call"},
+ {dh: 6.5, dur: 45, title:"Planning"},
+ {dh: 9.2, dur: 20, title:"Wind down"},
+ {dh:12.0, dur: 30, title:"Goodnight"},
+];
+
+const W = 3440, H = 1440, SPAN_H = 24, MAX_FONT = 72;
+const svg = document.getElementById("face");
+const NS = "http://www.w3.org/2000/svg";
+
+let direction = "A";
+let scrub = null; // null = live clock, else epoch ms
+
+// Maeda's law: size follows spacing, grey follows size.
+const fontFor = sp => Math.min(MAX_FONT, sp);
+const greyFor = sz => Math.floor(Math.min(MAX_FONT, sz) * 127 / MAX_FONT) + 128;
+
+function el(name, attrs, text){
+ const n = document.createElementNS(NS, name);
+ for (const k in attrs) n.setAttribute(k, attrs[k]);
+ if (text != null) n.textContent = text;
+ return n;
+}
+
+function tzParts(date, tz){
+ const f = new Intl.DateTimeFormat("en-GB", {timeZone:tz, hour12:false,
+ hour:"2-digit", minute:"2-digit", weekday:"short"});
+ const p = {};
+ for (const {type, value} of f.formatToParts(date)) p[type] = value;
+ return p;
+}
+
+// Sun-independent day/night proxy: local hour between 06 and 19.
+const isDay = h => h >= 6 && h < 19;
+
+function now(){ return scrub != null ? new Date(scrub) : new Date(); }
+
+// ---------------------------------------------------------------------------
+// Shared geometry
+// ---------------------------------------------------------------------------
+function layout(){
+ const crossFrac = direction === "B" ? 0.80 : 0.20;
+ const M = {l:150, r:150, t:110, b:130};
+ return {
+ crossFrac,
+ x0: M.l, x1: W - M.r, y0: M.t, y1: H - M.b,
+ crossX: M.l + (W - M.l - M.r) * crossFrac,
+ };
+}
+
+// Horizontal axis: 24 hours wide, `now` sits at crossX.
+function timeToX(g, t, ref){
+ const hours = (t - ref) / 3600000;
+ const pxPerHour = (g.x1 - g.x0) / SPAN_H;
+ return g.crossX + hours * pxPerHour;
+}
+
+// ---------------------------------------------------------------------------
+// Directions
+// ---------------------------------------------------------------------------
+function drawDiagonalCities(g, ref, homeY){
+ const n = CITIES.length;
+ // Cities ride a diagonal: west/behind at bottom-left, east/ahead at top-right.
+ CITIES.forEach((c, i) => {
+ const f = i / (n - 1);
+ const y = g.y1 - f * (g.y1 - g.y0);
+ const x = g.x0 + f * (g.crossX - g.x0) * 0.55;
+ const p = tzParts(ref, c.tz);
+ const hr = parseInt(p.hour, 10);
+ const day = isDay(hr);
+ const sp = (g.y1 - g.y0) / (n - 1);
+ const size = Math.max(11, Math.min(fontFor(sp) * 0.34, 30));
+
+ const fill = c.home ? "var(--gold)" : (day ? "var(--cream)" : "var(--slate)");
+ const t = el("text", {x, y, "text-anchor":"end", "font-size":size,
+ fill, "letter-spacing":"0.18em",
+ "font-weight": c.home ? 700 : 400});
+ t.textContent = c.label.toUpperCase() + (day ? "" : " ☾");
+ svg.appendChild(t);
+
+ svg.appendChild(el("text", {x: x + 14, y, "font-size": size * 0.92,
+ fill: c.home ? "var(--gold-hi)" : (day ? "var(--steel)" : "var(--night)"),
+ "letter-spacing":"0.06em"}, `${p.hour}:${p.minute}`));
+
+ // the wire out to the axis
+ svg.appendChild(el("line", {x1: x + 110, y1: y - 5, x2: g.x1, y2: y - 5,
+ stroke: c.home ? "var(--gold)" : "var(--line)",
+ "stroke-width": c.home ? 1.4 : 0.6,
+ "stroke-opacity": c.home ? 0.5 : 0.5}));
+ });
+}
+
+function drawHourAxis(g, ref, y){
+ const pxPerHour = (g.x1 - g.x0) / SPAN_H;
+ const size = Math.max(12, Math.min(fontFor(pxPerHour) * 0.30, 26));
+ const start = new Date(ref.getTime() - (g.crossX - g.x0) / pxPerHour * 3600000);
+ const h0 = new Date(start); h0.setMinutes(0, 0, 0);
+ for (let k = 0; k <= SPAN_H + 1; k++){
+ const t = new Date(h0.getTime() + k * 3600000);
+ const x = timeToX(g, t.getTime(), ref.getTime());
+ if (x < g.x0 - 1 || x > g.x1 + 1) continue;
+ const future = t.getTime() >= ref.getTime();
+ // brightness encodes tense: past dark, future bright
+ const lvl = future ? greyFor(size) : Math.round(greyFor(size) * 0.42);
+ const col = `rgb(${lvl},${lvl},${lvl})`;
+ svg.appendChild(el("line", {x1:x, y1:y - 9, x2:x, y2:y + 9,
+ stroke:col, "stroke-width":1, "stroke-opacity":0.55}));
+ const hh = String(t.getHours()).padStart(2, "0");
+ svg.appendChild(el("text", {x, y: y + 34, "text-anchor":"middle",
+ "font-size": size * 0.74, fill: col, "letter-spacing":"0.12em"}, hh));
+ }
+}
+
+function drawEvents(g, ref, y){
+ const refMs = ref.getTime();
+ const pxPerHour = (g.x1 - g.x0) / SPAN_H;
+ const evs = FIXTURE.map(e => ({
+ ...e, start: refMs + e.dh * 3600000,
+ end: refMs + e.dh * 3600000 + e.dur * 60000,
+ })).sort((a, b) => a.start - b.start);
+ const next = evs.find(e => e.start >= refMs);
+
+ // Perpendicular axis: vertical offset is collision avoidance only. It
+ // carries no data. Duration is a quantity of time, so it reads on the time
+ // axis as bar length, not as height.
+ const lanes = [];
+ evs.forEach(e => {
+ const x = timeToX(g, e.start, refMs);
+ if (x < g.x0 - 40 || x > g.x1 + 40) return;
+ const wpx = Math.max(3, (e.dur / 60) * pxPerHour);
+ const future = e.start >= refMs;
+ const isNext = next && e.start === next.start;
+ const estWidth = e.title.length * 9 + 60;
+
+ let lane = 0;
+ while (lanes[lane] != null && lanes[lane] > x - estWidth) lane++;
+ lanes[lane] = x + estWidth;
+ const dir = lane % 2 === 0 ? -1 : 1;
+ const ly = y + dir * (46 + Math.floor(lane / 2) * 40);
+
+ const lvl = future ? 232 : 96;
+ const col = isNext ? "var(--gold-hi)" : `rgb(${lvl},${lvl},${lvl})`;
+
+ svg.appendChild(el("rect", {x, y: y - 5, width: wpx, height: 10,
+ fill: col, "fill-opacity": isNext ? 0.95 : (future ? 0.5 : 0.28)}));
+ svg.appendChild(el("line", {x1:x, y1:y, x2:x, y2:ly + (dir < 0 ? 12 : -22),
+ stroke: col, "stroke-width": isNext ? 1.6 : 0.8,
+ "stroke-opacity": isNext ? 0.8 : 0.35}));
+
+ const hh = new Date(e.start);
+ const label = `${String(hh.getHours()).padStart(2,"0")}:${String(hh.getMinutes()).padStart(2,"0")}`;
+ svg.appendChild(el("text", {x, y: ly, "text-anchor":"middle",
+ "font-size": isNext ? 30 : 20, fill: col,
+ "font-weight": isNext ? 700 : 400, "letter-spacing":"0.06em"},
+ isNext ? `${label} ${e.title}` : e.title));
+ if (!isNext)
+ svg.appendChild(el("text", {x, y: ly + (dir < 0 ? -20 : 20),
+ "text-anchor":"middle", "font-size":15,
+ fill:`rgb(${Math.round(lvl*0.62)},${Math.round(lvl*0.62)},${Math.round(lvl*0.66)})`,
+ "letter-spacing":"0.1em"}, label));
+ });
+}
+
+function drawCrossing(g, ref, y){
+ svg.appendChild(el("line", {x1:g.crossX, y1:g.y0 - 30, x2:g.crossX, y2:g.y1 + 40,
+ stroke:"var(--now)", "stroke-width":1.2, "stroke-opacity":0.65}));
+ svg.appendChild(el("circle", {cx:g.crossX, cy:y, r:7, fill:"var(--now)"}));
+ const p = tzParts(ref, "America/Chicago");
+ svg.appendChild(el("text", {x:g.crossX + 18, y:g.y0 - 10, "font-size":26,
+ fill:"var(--now)", "letter-spacing":"0.14em"},
+ `NOW ${p.hour}:${p.minute}`));
+}
+
+function drawOdometer(g, ref){
+ // Cosmos's linear strip living inside the frame, at a different scale.
+ const s = ref.getSeconds();
+ for (let k = -6; k <= 6; k++){
+ const v = (s + k + 60) % 60;
+ const yy = H / 2 + k * 26;
+ const d = 1 - Math.abs(k) / 7;
+ const lvl = Math.round(80 + d * 150);
+ svg.appendChild(el("text", {x: W - 46, y: yy, "text-anchor":"end",
+ "font-size": 13 + d * 5, fill:`rgb(${lvl},${lvl},${lvl})`,
+ "letter-spacing":"0.1em"}, String(v).padStart(2,"0")));
+ }
+}
+
+function drawOrbit(g, ref){
+ // Direction C: the 24-hour cycle as a rotated ellipse, cities as bodies,
+ // now on the sweep hand. Cosmos's construction with timezones as the system.
+ const cx = W * 0.42, cy = H * 0.52, rx = W * 0.30, ry = H * 0.34, tilt = -14;
+ const rad = tilt * Math.PI / 180;
+ const place = (frac) => {
+ const a = frac * Math.PI * 2 - Math.PI / 2;
+ const px = Math.cos(a) * rx, py = Math.sin(a) * ry;
+ return [cx + px * Math.cos(rad) - py * Math.sin(rad),
+ cy + px * Math.sin(rad) + py * Math.cos(rad)];
+ };
+ svg.appendChild(el("ellipse", {cx, cy, rx, ry,
+ transform:`rotate(${tilt} ${cx} ${cy})`, fill:"none",
+ stroke:"var(--line)", "stroke-width":1, "stroke-opacity":0.7}));
+
+ CITIES.forEach(c => {
+ const p = tzParts(ref, c.tz);
+ const hr = parseInt(p.hour,10) + parseInt(p.minute,10)/60;
+ const [x, y] = place(hr / 24);
+ const day = isDay(Math.floor(hr));
+ svg.appendChild(el("circle", {cx:x, cy:y, r: c.home ? 6 : 3,
+ fill: c.home ? "var(--gold)" : (day ? "var(--cream)" : "var(--night)")}));
+ svg.appendChild(el("text", {x: x + 12, y: y + 5, "font-size": c.home ? 22 : 17,
+ fill: c.home ? "var(--gold-hi)" : (day ? "var(--steel)" : "var(--night)"),
+ "letter-spacing":"0.12em"}, `${c.label.toUpperCase()} ${p.hour}:${p.minute}`));
+ });
+
+ const hNow = ref.getHours() + ref.getMinutes()/60;
+ const [hx, hy] = place(hNow / 24);
+ svg.appendChild(el("line", {x1:cx, y1:cy, x2:hx, y2:hy,
+ stroke:"var(--now)", "stroke-width":1.4, "stroke-opacity":0.8}));
+}
+
+// ---------------------------------------------------------------------------
+function render(){
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+ const ref = now();
+ const g = layout();
+ const homeIdx = CITIES.findIndex(c => c.home);
+ const homeY = g.y1 - (homeIdx / (CITIES.length - 1)) * (g.y1 - g.y0);
+
+ if (direction === "C"){
+ drawOrbit(g, ref);
+ drawEvents(g, ref, H - 210);
+ drawHourAxis(g, ref, H - 150);
+ drawCrossing(g, ref, H - 150);
+ } else if (direction === "D"){
+ // Typographic: no diagonal. The day axis carries everything, cities stack
+ // as a quiet column so the type law is the only structure.
+ CITIES.forEach((c, i) => {
+ const p = tzParts(ref, c.tz);
+ const day = isDay(parseInt(p.hour,10));
+ const y = g.y0 + 26 + i * 26;
+ svg.appendChild(el("text", {x: g.x0 - 40, y, "text-anchor":"end",
+ "font-size":16, fill: c.home ? "var(--gold)" : (day ? "var(--steel)" : "var(--night)"),
+ "letter-spacing":"0.14em"}, `${c.label.toUpperCase()} ${p.hour}:${p.minute}`));
+ });
+ drawEvents(g, ref, H * 0.56);
+ drawHourAxis(g, ref, H * 0.56);
+ drawCrossing(g, ref, H * 0.56);
+ } else {
+ drawDiagonalCities(g, ref, homeY);
+ drawEvents(g, ref, homeY);
+ drawHourAxis(g, ref, homeY);
+ drawCrossing(g, ref, homeY);
+ }
+ drawOdometer(g, ref);
+}
+
+document.querySelectorAll("button[data-dir]").forEach(b => {
+ b.addEventListener("click", () => {
+ direction = b.dataset.dir;
+ document.querySelectorAll("button[data-dir]").forEach(o => o.classList.toggle("on", o === b));
+ render();
+ });
+});
+document.getElementById("tnow").addEventListener("click", e => {
+ scrub = null;
+ e.target.classList.add("on");
+ document.getElementById("tscrub").classList.remove("on");
+});
+document.getElementById("tscrub").addEventListener("click", e => {
+ scrub = Date.now();
+ e.target.classList.add("on");
+ document.getElementById("tnow").classList.remove("on");
+});
+// Scrubbing advances 4 minutes per tick so a full day passes in ~90s.
+setInterval(() => { if (scrub != null) scrub += 4 * 60000; render(); }, 250);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-2.html b/docs/prototypes/2026-07-31-timeline-face-prototype-2.html
new file mode 100644
index 0000000..2a7bdc4
--- /dev/null
+++ b/docs/prototypes/2026-07-31-timeline-face-prototype-2.html
@@ -0,0 +1,198 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Timeline face — prototype 2</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823; --now:#c8452f;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)}
+ .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em;
+ text-transform:uppercase;color:var(--steel)}
+ .chrome b{color:var(--cream);font-weight:700}
+ .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px}
+ button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;
+ background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer}
+ button.on{background:var(--gold);color:#111;border-color:var(--gold)}
+ .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55}
+ .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground);
+ width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative}
+ svg{position:absolute;inset:0;width:100%;height:100%}
+</style>
+</head>
+<body>
+
+<div class="chrome">Timeline face &middot; prototype 2 &middot; <b>synthetic events</b></div>
+<div class="bar">
+ <span style="color:var(--steel)">diagonal</span>
+ <button data-dir="W" class="on">west at left &mdash; crossing 21.4%</button>
+ <button data-dir="E">east at left &mdash; crossing 78.6%</button>
+ <span style="width:20px"></span>
+ <button id="tscrub">scrub the day</button>
+</div>
+<div class="note">
+ The crossing is not a free parameter. The horizontal meets the diagonal at the
+ home city, and New Orleans is 4th of 15 west-to-east, so it lands at 21.4%.
+ Reversing the diagonal puts the same intersection at 78.6%. Both numbers
+ describe one point. Events are invented; the city roster is real.
+ Vertical offset on an event is collision avoidance and carries no data &mdash;
+ duration is a quantity of time, so it reads as bar length on the time axis.
+</div>
+
+<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div>
+
+<script>
+const CITIES = [
+ {tz:"Pacific/Honolulu",label:"Honolulu"}, {tz:"America/Anchorage",label:"Anchorage"},
+ {tz:"America/Los_Angeles",label:"Berkeley"},{tz:"America/Chicago",label:"New Orleans",home:true},
+ {tz:"America/New_York",label:"New York"}, {tz:"Europe/London",label:"London"},
+ {tz:"Europe/Paris",label:"Paris"}, {tz:"Europe/Athens",label:"Athens"},
+ {tz:"Europe/Istanbul",label:"Istanbul"}, {tz:"Asia/Yerevan",label:"Yerevan"},
+ {tz:"Asia/Kolkata",label:"Delhi"}, {tz:"Asia/Shanghai",label:"Shanghai"},
+ {tz:"Asia/Tokyo",label:"Tokyo"}, {tz:"Australia/Sydney",label:"Sydney"},
+ {tz:"Pacific/Auckland",label:"Wellington"},
+];
+const FIXTURE = [
+ {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"},
+ {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"},
+ {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"},
+ {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"},
+ {dh:12.0,dur:30,title:"Goodnight"},
+];
+
+const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg";
+const svg=document.getElementById("face");
+let orient="W", scrub=null;
+
+const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128;
+const rgb = l => `rgb(${l},${l},${l})`;
+function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]);
+ if(t!=null)e.textContent=t;return e;}
+function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false,
+ hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;}
+const isDay = h => h>=6 && h<19;
+const now = () => scrub!=null ? new Date(scrub) : new Date();
+
+// The diagonal spans the full plot. Home's rank fixes the crossing.
+function geom(){
+ const M={l:210,r:210,t:150,b:190};
+ const n=CITIES.length, hi=CITIES.findIndex(c=>c.home);
+ const frac = orient==="W" ? hi/(n-1) : 1-hi/(n-1);
+ return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n, hi, frac,
+ crossX: M.l+(W-M.l-M.r)*frac,
+ homeY: (H-M.b)-(hi/(n-1))*((H-M.b)-M.t)};
+}
+const cityPos = (g,i) => {
+ const f=i/(g.n-1);
+ return [g.x0+(orient==="W"?f:1-f)*(g.x1-g.x0), g.y1-f*(g.y1-g.y0)];
+};
+const pxPerHour = g => (g.x1-g.x0)/SPAN_H;
+const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g);
+
+function drawDiagonal(g,ref){
+ const [ax,ay]=cityPos(g,0), [bx,by]=cityPos(g,g.n-1);
+ svg.appendChild(el("line",{x1:ax,y1:ay,x2:bx,y2:by,stroke:"var(--line)",
+ "stroke-width":1,"stroke-opacity":0.85}));
+ CITIES.forEach((c,i)=>{
+ const [x,y]=cityPos(g,i);
+ const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr);
+ const size = c.home?27:20;
+ const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)");
+ svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5,
+ fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")}));
+ // label sits above-left of its node so it never leaves the frame
+ svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col,
+ "letter-spacing":"0.18em","font-weight":c.home?700:400},
+ c.label.toUpperCase()+(day?"":" ☾")));
+ svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95,
+ fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"),
+ "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`));
+ });
+}
+
+function drawHourAxis(g,ref){
+ const pph=pxPerHour(g), y=g.homeY, size=22;
+ svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1}));
+ const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0);
+ for(let k=0;k<=SPAN_H+1;k++){
+ const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime());
+ if(x<g.x0-1||x>g.x1+1) continue;
+ const future=t.getTime()>=ref.getTime();
+ const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40);
+ svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl),
+ "stroke-width":1,"stroke-opacity":0.6}));
+ svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8,
+ fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0")));
+ }
+}
+
+function drawEvents(g,ref){
+ const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY;
+ const evs=FIXTURE.map(e=>({...e,start:refMs+e.dh*3600000})).sort((a,b)=>a.start-b.start);
+ const next=evs.find(e=>e.start>=refMs);
+ const lanes=[];
+ evs.forEach(e=>{
+ const x=timeToX(g,e.start,refMs);
+ if(x<g.x0-30||x>g.x1+30) return;
+ const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs;
+ const isNext = next && e.start===next.start;
+ const est=e.title.length*11+70;
+ let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++;
+ lanes[lane]=x+est;
+ const ly = y - 46 - lane*46; // events live above the axis only
+ const lvl = future?236:100;
+ const col = isNext?"var(--gold-hi)":rgb(lvl);
+ svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col,
+ "fill-opacity":isNext?0.95:(future?0.5:0.25)}));
+ svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col,
+ "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3}));
+ const d=new Date(e.start);
+ const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
+ svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21,
+ fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"},
+ isNext?`${hhmm} ${e.title}`:e.title));
+ if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15,
+ fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm));
+ });
+}
+
+function drawCrossing(g,ref){
+ svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60,
+ stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55}));
+ svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"}));
+ const p=tzParts(ref,"America/Chicago");
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30,
+ fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`));
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15,
+ fill:"var(--dim)","letter-spacing":"0.14em"},
+ `CROSSING ${(g.frac*100).toFixed(1)}%`));
+}
+
+function render(){
+ while(svg.firstChild) svg.removeChild(svg.firstChild);
+ const ref=now(), g=geom();
+ drawDiagonal(g,ref);
+ drawHourAxis(g,ref);
+ drawEvents(g,ref);
+ drawCrossing(g,ref);
+}
+
+document.querySelectorAll("button[data-dir]").forEach(b=>b.addEventListener("click",()=>{
+ orient=b.dataset.dir;
+ document.querySelectorAll("button[data-dir]").forEach(o=>o.classList.toggle("on",o===b));
+ render();
+}));
+document.getElementById("tscrub").addEventListener("click",e=>{
+ scrub = scrub==null ? Date.now() : null;
+ e.target.classList.toggle("on", scrub!=null);
+});
+setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-3.html b/docs/prototypes/2026-07-31-timeline-face-prototype-3.html
new file mode 100644
index 0000000..1fcc506
--- /dev/null
+++ b/docs/prototypes/2026-07-31-timeline-face-prototype-3.html
@@ -0,0 +1,224 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Timeline face — prototype 3</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823; --now:#c8452f;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)}
+ .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em;
+ text-transform:uppercase;color:var(--steel)}
+ .chrome b{color:var(--cream);font-weight:700}
+ .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px}
+ button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;
+ background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer}
+ button.on{background:var(--gold);color:#111;border-color:var(--gold)}
+ .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55}
+ .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground);
+ width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative}
+ svg{position:absolute;inset:0;width:100%;height:100%}
+</style>
+</head>
+<body>
+
+<div class="chrome">Timeline face &middot; prototype 3 &middot; <b>synthetic events</b></div>
+<div class="bar">
+ <span style="color:var(--steel)">latitude</span>
+ <button data-y="rank" class="on">ranked &mdash; evenly spaced</button>
+ <button data-y="true">true &mdash; honest degrees</button>
+ <span style="width:20px"></span>
+ <button id="tscrub">scrub the day</button>
+</div>
+<div class="note">
+ North is up. x is real longitude and y is latitude, so the crossing still falls
+ out of where home sits &mdash; longitude is timezone, and New Orleans lands at
+ 20.4%, within a point of the rank-derived 21.4%. Two latitude scales: ranked
+ spaces the cities evenly and keeps the north-to-south order, true uses honest
+ degrees and shows what that costs, since nine of fifteen cities sit between
+ 28 and 41 degrees north. Events are invented; the roster is real.
+</div>
+
+<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div>
+
+<script>
+// lat/lon are the roster's own — worldclock.conf already carries them.
+const CITIES = [
+ {tz:"Pacific/Honolulu", label:"Honolulu", lat: 21.31, lon:-157.86},
+ {tz:"America/Anchorage", label:"Anchorage", lat: 61.22, lon:-149.90},
+ {tz:"America/Los_Angeles",label:"Berkeley", lat: 37.87, lon:-122.27},
+ {tz:"America/Chicago", label:"New Orleans",lat: 29.95, lon: -90.07, home:true},
+ {tz:"America/New_York", label:"New York", lat: 40.71, lon: -74.01},
+ {tz:"Europe/London", label:"London", lat: 51.51, lon: -0.13},
+ {tz:"Europe/Paris", label:"Paris", lat: 48.86, lon: 2.35},
+ {tz:"Europe/Athens", label:"Athens", lat: 37.98, lon: 23.73},
+ {tz:"Europe/Istanbul", label:"Istanbul", lat: 41.01, lon: 28.98},
+ {tz:"Asia/Yerevan", label:"Yerevan", lat: 40.18, lon: 44.51},
+ {tz:"Asia/Kolkata", label:"Delhi", lat: 28.61, lon: 77.21},
+ {tz:"Asia/Shanghai", label:"Shanghai", lat: 31.23, lon: 121.47},
+ {tz:"Asia/Tokyo", label:"Tokyo", lat: 35.68, lon: 139.69},
+ {tz:"Australia/Sydney", label:"Sydney", lat:-33.87, lon: 151.21},
+ {tz:"Pacific/Auckland", label:"Wellington", lat:-41.29, lon: 174.78},
+];
+const LON = CITIES.map(c=>c.lon), LAT = CITIES.map(c=>c.lat);
+const LON0=Math.min(...LON), LON1=Math.max(...LON);
+const LAT0=Math.min(...LAT), LAT1=Math.max(...LAT);
+// north-to-south rank, for the evenly-spaced variant
+const RANK = CITIES.slice().sort((a,b)=>b.lat-a.lat).map(c=>c.label);
+const FIXTURE = [
+ {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"},
+ {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"},
+ {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"},
+ {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"},
+ {dh:12.0,dur:30,title:"Goodnight"},
+];
+
+const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg";
+const svg=document.getElementById("face");
+let yMode="rank", scrub=null;
+
+const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128;
+const rgb = l => `rgb(${l},${l},${l})`;
+function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]);
+ if(t!=null)e.textContent=t;return e;}
+function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false,
+ hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;}
+const isDay = h => h>=6 && h<19;
+const now = () => scrub!=null ? new Date(scrub) : new Date();
+
+// Geography, not rank. x is real longitude, so the crossing still falls out of
+// where home sits — longitude IS timezone. y is latitude, north up, which is
+// the whole point of this iteration.
+function geom(){
+ const M={l:230,r:230,t:150,b:200};
+ const hi=CITIES.findIndex(c=>c.home);
+ const [hx,hy]=cityXY(CITIES[hi],{x0:M.l,x1:W-M.r,y0:M.t,y1:H-M.b});
+ return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n:CITIES.length, hi,
+ frac:(hx-M.l)/(W-M.l-M.r), crossX:hx, homeY:hy};
+}
+function cityXY(c,g){
+ const fx=(c.lon-LON0)/(LON1-LON0);
+ const fy = yMode==="true"
+ ? (c.lat-LAT0)/(LAT1-LAT0) // honest latitude
+ : 1-RANK.indexOf(c.label)/(CITIES.length-1); // even spacing, order kept
+ return [g.x0+fx*(g.x1-g.x0), g.y1-fy*(g.y1-g.y0)];
+}
+const cityPos = (g,i) => cityXY(CITIES[i],g);
+const pxPerHour = g => (g.x1-g.x0)/SPAN_H;
+const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g);
+
+function drawDiagonal(g,ref){
+ // No connecting wire. Once y is latitude the cities are a scatter, not a
+ // sequence, and a polyline through them in longitude order draws a zigzag
+ // that asserts an adjacency the data does not have. The equator earns a rule
+ // instead — it is the one latitude that means something here.
+ if (yMode === "true") {
+ const eq = g.y1 - ((0-LAT0)/(LAT1-LAT0))*(g.y1-g.y0);
+ svg.appendChild(el("line",{x1:g.x0,y1:eq,x2:g.x1,y2:eq,stroke:"var(--line)",
+ "stroke-width":1,"stroke-dasharray":"6 10","stroke-opacity":0.5}));
+ svg.appendChild(el("text",{x:g.x0-14,y:eq+5,"text-anchor":"end","font-size":15,
+ fill:"var(--night)","letter-spacing":"0.14em"},"EQUATOR"));
+ }
+ CITIES.forEach((c,i)=>{
+ const [x,y]=cityPos(g,i);
+ const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr);
+ const size = c.home?27:20;
+ const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)");
+ svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5,
+ fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")}));
+ // label sits above-left of its node so it never leaves the frame
+ svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col,
+ "letter-spacing":"0.18em","font-weight":c.home?700:400},
+ c.label.toUpperCase()+(day?"":" ☾")));
+ svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95,
+ fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"),
+ "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`));
+ });
+}
+
+function drawHourAxis(g,ref){
+ const pph=pxPerHour(g), y=g.homeY, size=22;
+ svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1}));
+ const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0);
+ for(let k=0;k<=SPAN_H+1;k++){
+ const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime());
+ if(x<g.x0-1||x>g.x1+1) continue;
+ const future=t.getTime()>=ref.getTime();
+ const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40);
+ svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl),
+ "stroke-width":1,"stroke-opacity":0.6}));
+ svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8,
+ fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0")));
+ }
+}
+
+function drawEvents(g,ref){
+ const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY;
+ const evs=FIXTURE.map(e=>({...e,start:refMs+e.dh*3600000})).sort((a,b)=>a.start-b.start);
+ const next=evs.find(e=>e.start>=refMs);
+ const lanes=[];
+ evs.forEach(e=>{
+ const x=timeToX(g,e.start,refMs);
+ if(x<g.x0-30||x>g.x1+30) return;
+ const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs;
+ const isNext = next && e.start===next.start;
+ const est=e.title.length*11+70;
+ let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++;
+ lanes[lane]=x+est;
+ const ly = y - 46 - lane*46; // events live above the axis only
+ const lvl = future?236:100;
+ const col = isNext?"var(--gold-hi)":rgb(lvl);
+ svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col,
+ "fill-opacity":isNext?0.95:(future?0.5:0.25)}));
+ svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col,
+ "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3}));
+ const d=new Date(e.start);
+ const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
+ svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21,
+ fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"},
+ isNext?`${hhmm} ${e.title}`:e.title));
+ if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15,
+ fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm));
+ });
+}
+
+function drawCrossing(g,ref){
+ svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60,
+ stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55}));
+ svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"}));
+ const p=tzParts(ref,"America/Chicago");
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30,
+ fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`));
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15,
+ fill:"var(--dim)","letter-spacing":"0.14em"},
+ `CROSSING ${(g.frac*100).toFixed(1)}%`));
+}
+
+function render(){
+ while(svg.firstChild) svg.removeChild(svg.firstChild);
+ const ref=now(), g=geom();
+ drawDiagonal(g,ref);
+ drawHourAxis(g,ref);
+ drawEvents(g,ref);
+ drawCrossing(g,ref);
+}
+
+document.querySelectorAll("button[data-y]").forEach(b=>b.addEventListener("click",()=>{
+ yMode=b.dataset.y;
+ document.querySelectorAll("button[data-y]").forEach(o=>o.classList.toggle("on",o===b));
+ render();
+}));
+document.getElementById("tscrub").addEventListener("click",e=>{
+ scrub = scrub==null ? Date.now() : null;
+ e.target.classList.toggle("on", scrub!=null);
+});
+setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-4.html b/docs/prototypes/2026-07-31-timeline-face-prototype-4.html
new file mode 100644
index 0000000..88648bf
--- /dev/null
+++ b/docs/prototypes/2026-07-31-timeline-face-prototype-4.html
@@ -0,0 +1,242 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Timeline face — prototype 4</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823; --now:#c8452f;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)}
+ .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em;
+ text-transform:uppercase;color:var(--steel)}
+ .chrome b{color:var(--cream);font-weight:700}
+ .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px}
+ button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;
+ background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer}
+ button.on{background:var(--gold);color:#111;border-color:var(--gold)}
+ .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55}
+ .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground);
+ width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative}
+ svg{position:absolute;inset:0;width:100%;height:100%}
+</style>
+</head>
+<body>
+
+<div class="chrome">Timeline face &middot; prototype 4 &middot; <b>synthetic events</b></div>
+<div class="bar">
+ <span style="color:var(--steel)">latitude</span>
+ <button data-y="rank" class="on">ranked &mdash; evenly spaced</button>
+ <button data-y="true">true &mdash; honest degrees</button>
+ <span style="width:20px"></span>
+ <button id="tscrub">scrub the day</button>
+</div>
+<div class="note">
+ One axis, one scale. The width is a full 360&deg; of longitude and a full 24
+ hours, which are the same quantity &mdash; so an event N hours ahead lands on
+ the city that is living that hour right now. Wind down at +9.2h falls on
+ Yerevan, 14 minutes off. Prototype 3 had the roster's 332.64&deg; stretched to
+ the full width, putting the city scale 7.6% off the time scale. Home sits at
+ 20%, which is nearly forced: Honolulu is 67.8&deg; west, so anything under
+ 18.8% drops it off the frame. Events are invented; the roster is real.
+</div>
+
+<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div>
+
+<script>
+// lat/lon are the roster's own — worldclock.conf already carries them.
+const CITIES = [
+ {tz:"Pacific/Honolulu", label:"Honolulu", lat: 21.31, lon:-157.86},
+ {tz:"America/Anchorage", label:"Anchorage", lat: 61.22, lon:-149.90},
+ {tz:"America/Los_Angeles",label:"Berkeley", lat: 37.87, lon:-122.27},
+ {tz:"America/Chicago", label:"New Orleans",lat: 29.95, lon: -90.07, home:true},
+ {tz:"America/New_York", label:"New York", lat: 40.71, lon: -74.01},
+ {tz:"Europe/London", label:"London", lat: 51.51, lon: -0.13},
+ {tz:"Europe/Paris", label:"Paris", lat: 48.86, lon: 2.35},
+ {tz:"Europe/Athens", label:"Athens", lat: 37.98, lon: 23.73},
+ {tz:"Europe/Istanbul", label:"Istanbul", lat: 41.01, lon: 28.98},
+ {tz:"Asia/Yerevan", label:"Yerevan", lat: 40.18, lon: 44.51},
+ {tz:"Asia/Kolkata", label:"Delhi", lat: 28.61, lon: 77.21},
+ {tz:"Asia/Shanghai", label:"Shanghai", lat: 31.23, lon: 121.47},
+ {tz:"Asia/Tokyo", label:"Tokyo", lat: 35.68, lon: 139.69},
+ {tz:"Australia/Sydney", label:"Sydney", lat:-33.87, lon: 151.21},
+ {tz:"Pacific/Auckland", label:"Wellington", lat:-41.29, lon: 174.78},
+];
+const LON = CITIES.map(c=>c.lon), LAT = CITIES.map(c=>c.lat);
+const LON0=Math.min(...LON), LON1=Math.max(...LON);
+const LAT0=Math.min(...LAT), LAT1=Math.max(...LAT);
+// north-to-south rank, for the evenly-spaced variant
+const RANK = CITIES.slice().sort((a,b)=>b.lat-a.lat).map(c=>c.label);
+const FIXTURE = [
+ {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"},
+ {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"},
+ {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"},
+ {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"},
+ {dh:12.0,dur:30,title:"Goodnight"},
+];
+
+const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg";
+const svg=document.getElementById("face");
+let yMode="rank", scrub=null;
+
+const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128;
+const rgb = l => `rgb(${l},${l},${l})`;
+function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]);
+ if(t!=null)e.textContent=t;return e;}
+function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false,
+ hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;}
+const isDay = h => h>=6 && h<19;
+const now = () => scrub!=null ? new Date(scrub) : new Date();
+
+// Geography, not rank. x is real longitude, so the crossing still falls out of
+// where home sits — longitude IS timezone. y is latitude, north up, which is
+// the whole point of this iteration.
+// Home sits at 20% -- and that is very nearly forced. The westernmost city,
+// Honolulu, is 67.8 degrees west of home, so home must be at least 18.8% from
+// the left or Honolulu falls off the frame. 20% is the tightest round number
+// that keeps the whole roster on screen.
+const CROSS_FRAC = 0.20;
+function geom(){
+ const M={l:230,r:230,t:150,b:200};
+ const hi=CITIES.findIndex(c=>c.home);
+ const [hx,hy]=cityXY(CITIES[hi],{x0:M.l,x1:W-M.r,y0:M.t,y1:H-M.b});
+ return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n:CITIES.length, hi,
+ frac:(hx-M.l)/(W-M.l-M.r), crossX:hx, homeY:hy};
+}
+// A full turn of longitude is a full day, so the width must be 360 degrees --
+// not the roster's 332.64 stretched to fit. Getting that wrong put the city
+// scale 7.6% off the time scale, and the two are physically the same axis.
+// With them aligned, an event N hours ahead lands on the city that is living
+// that hour right now.
+const HOME_LON = CITIES.find(c=>c.home).lon;
+// Normalise into the window the time axis actually shows -- 72 deg west of home
+// through 288 deg east -- not the shortest way round. Shortest-path (-180..180)
+// sends Tokyo, Shanghai, Sydney and Wellington westward off the left edge,
+// because from New Orleans they are more than half a turn east.
+const WEST = CROSS_FRAC * 360;
+const relLon = lon => ((lon - HOME_LON + WEST) % 360 + 360) % 360 - WEST;
+function cityXY(c,g){
+ const fx = CROSS_FRAC + relLon(c.lon)/360;
+ const fy = yMode==="true"
+ ? (c.lat-LAT0)/(LAT1-LAT0) // honest latitude
+ : 1-RANK.indexOf(c.label)/(CITIES.length-1); // even spacing, order kept
+ return [g.x0+fx*(g.x1-g.x0), g.y1-fy*(g.y1-g.y0)];
+}
+const cityPos = (g,i) => cityXY(CITIES[i],g);
+const pxPerHour = g => (g.x1-g.x0)/SPAN_H;
+const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g);
+
+function drawDiagonal(g,ref){
+ // No connecting wire. Once y is latitude the cities are a scatter, not a
+ // sequence, and a polyline through them in longitude order draws a zigzag
+ // that asserts an adjacency the data does not have. The equator earns a rule
+ // instead — it is the one latitude that means something here.
+ if (yMode === "true") {
+ const eq = g.y1 - ((0-LAT0)/(LAT1-LAT0))*(g.y1-g.y0);
+ svg.appendChild(el("line",{x1:g.x0,y1:eq,x2:g.x1,y2:eq,stroke:"var(--line)",
+ "stroke-width":1,"stroke-dasharray":"6 10","stroke-opacity":0.5}));
+ svg.appendChild(el("text",{x:g.x0-14,y:eq+5,"text-anchor":"end","font-size":15,
+ fill:"var(--night)","letter-spacing":"0.14em"},"EQUATOR"));
+ }
+ CITIES.forEach((c,i)=>{
+ const [x,y]=cityPos(g,i);
+ const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr);
+ const size = c.home?27:20;
+ const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)");
+ svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5,
+ fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")}));
+ // label sits above-left of its node so it never leaves the frame
+ svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col,
+ "letter-spacing":"0.18em","font-weight":c.home?700:400},
+ c.label.toUpperCase()+(day?"":" ☾")));
+ svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95,
+ fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"),
+ "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`));
+ });
+}
+
+function drawHourAxis(g,ref){
+ const pph=pxPerHour(g), y=g.homeY, size=22;
+ svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1}));
+ const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0);
+ for(let k=0;k<=SPAN_H+1;k++){
+ const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime());
+ if(x<g.x0-1||x>g.x1+1) continue;
+ const future=t.getTime()>=ref.getTime();
+ const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40);
+ svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl),
+ "stroke-width":1,"stroke-opacity":0.6}));
+ svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8,
+ fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0")));
+ }
+}
+
+function drawEvents(g,ref){
+ const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY;
+ const evs=FIXTURE.map(e=>({...e,start:refMs+e.dh*3600000})).sort((a,b)=>a.start-b.start);
+ const next=evs.find(e=>e.start>=refMs);
+ const lanes=[];
+ evs.forEach(e=>{
+ const x=timeToX(g,e.start,refMs);
+ if(x<g.x0-30||x>g.x1+30) return;
+ const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs;
+ const isNext = next && e.start===next.start;
+ const est=e.title.length*11+70;
+ let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++;
+ lanes[lane]=x+est;
+ const ly = y - 46 - lane*46; // events live above the axis only
+ const lvl = future?236:100;
+ const col = isNext?"var(--gold-hi)":rgb(lvl);
+ svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col,
+ "fill-opacity":isNext?0.95:(future?0.5:0.25)}));
+ svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col,
+ "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3}));
+ const d=new Date(e.start);
+ const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
+ svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21,
+ fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"},
+ isNext?`${hhmm} ${e.title}`:e.title));
+ if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15,
+ fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm));
+ });
+}
+
+function drawCrossing(g,ref){
+ svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60,
+ stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55}));
+ svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"}));
+ const p=tzParts(ref,"America/Chicago");
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30,
+ fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`));
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15,
+ fill:"var(--dim)","letter-spacing":"0.14em"},
+ `CROSSING ${(g.frac*100).toFixed(1)}%`));
+}
+
+function render(){
+ while(svg.firstChild) svg.removeChild(svg.firstChild);
+ const ref=now(), g=geom();
+ drawDiagonal(g,ref);
+ drawHourAxis(g,ref);
+ drawEvents(g,ref);
+ drawCrossing(g,ref);
+}
+
+document.querySelectorAll("button[data-y]").forEach(b=>b.addEventListener("click",()=>{
+ yMode=b.dataset.y;
+ document.querySelectorAll("button[data-y]").forEach(o=>o.classList.toggle("on",o===b));
+ render();
+}));
+document.getElementById("tscrub").addEventListener("click",e=>{
+ scrub = scrub==null ? Date.now() : null;
+ e.target.classList.toggle("on", scrub!=null);
+});
+setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-5.html b/docs/prototypes/2026-07-31-timeline-face-prototype-5.html
new file mode 100644
index 0000000..b77fd35
--- /dev/null
+++ b/docs/prototypes/2026-07-31-timeline-face-prototype-5.html
@@ -0,0 +1,263 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Timeline face — prototype 5</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --line:#2c2823; --now:#c8452f;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:#111;color:var(--silver);font-family:var(--mono)}
+ .chrome{padding:14px 18px 0;font-size:13px;letter-spacing:.16em;
+ text-transform:uppercase;color:var(--steel)}
+ .chrome b{color:var(--cream);font-weight:700}
+ .bar{padding:10px 18px 12px;display:flex;gap:10px;align-items:center;font-size:13px}
+ button{font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;
+ background:#191919;color:var(--silver);border:1px solid #2e2e2e;padding:6px 11px;cursor:pointer}
+ button.on{background:var(--gold);color:#111;border-color:var(--gold)}
+ .note{color:var(--dim);font-size:12px;padding:0 18px 12px;max-width:1180px;line-height:1.55}
+ .stage{margin:0 18px 18px;border:1px solid #262626;background:var(--ground);
+ width:calc(100% - 36px);aspect-ratio:3440/1440;position:relative}
+ svg{position:absolute;inset:0;width:100%;height:100%}
+</style>
+</head>
+<body>
+
+<div class="chrome">Timeline face &middot; prototype 5 &middot; <b>synthetic events</b></div>
+<div class="bar">
+ <span style="color:var(--steel)">latitude</span>
+ <button data-y="rank" class="on">ranked &mdash; evenly spaced</button>
+ <button data-y="true">true &mdash; honest degrees</button>
+ <span style="width:20px"></span>
+ <button id="tscrub">scrub the day</button>
+</div>
+<div class="note">
+ x is now each city's <em>clock</em> offset from home, not its longitude.
+ Longitude only approximates time &mdash; zones are political, and against this
+ roster it is off by up to 1.32 h (Tokyo), partly because New Orleans is on
+ daylight time. So a city now sits exactly under its own local hour and an
+ event N hours ahead lands on the living-that-hour city with no error at all.
+ Latitude still sets y, and Tokyo really is south of Athens and Yerevan
+ &mdash; 35.7&deg;N against 38.0&deg; and 40.2&deg;.
+ Events are invented; the roster is real.
+</div>
+
+<div class="stage"><svg id="face" viewBox="0 0 3440 1440"></svg></div>
+
+<script>
+// lat/lon are the roster's own — worldclock.conf already carries them.
+const CITIES = [
+ {tz:"Pacific/Honolulu", label:"Honolulu", lat: 21.31, lon:-157.86},
+ {tz:"America/Anchorage", label:"Anchorage", lat: 61.22, lon:-149.90},
+ {tz:"America/Los_Angeles",label:"Berkeley", lat: 37.87, lon:-122.27},
+ {tz:"America/Chicago", label:"New Orleans",lat: 29.95, lon: -90.07, home:true},
+ {tz:"America/New_York", label:"New York", lat: 40.71, lon: -74.01},
+ {tz:"Europe/London", label:"London", lat: 51.51, lon: -0.13},
+ {tz:"Europe/Paris", label:"Paris", lat: 48.86, lon: 2.35},
+ {tz:"Europe/Athens", label:"Athens", lat: 37.98, lon: 23.73},
+ {tz:"Europe/Istanbul", label:"Istanbul", lat: 41.01, lon: 28.98},
+ {tz:"Asia/Yerevan", label:"Yerevan", lat: 40.18, lon: 44.51},
+ {tz:"Asia/Kolkata", label:"Delhi", lat: 28.61, lon: 77.21},
+ {tz:"Asia/Shanghai", label:"Shanghai", lat: 31.23, lon: 121.47},
+ {tz:"Asia/Tokyo", label:"Tokyo", lat: 35.68, lon: 139.69},
+ {tz:"Australia/Sydney", label:"Sydney", lat:-33.87, lon: 151.21},
+ {tz:"Pacific/Auckland", label:"Wellington", lat:-41.29, lon: 174.78},
+];
+const LON = CITIES.map(c=>c.lon), LAT = CITIES.map(c=>c.lat);
+const LON0=Math.min(...LON), LON1=Math.max(...LON);
+const LAT0=Math.min(...LAT), LAT1=Math.max(...LAT);
+// north-to-south rank, for the evenly-spaced variant
+const RANK = CITIES.slice().sort((a,b)=>b.lat-a.lat).map(c=>c.label);
+// One fixed instant the whole fixture hangs off. Captured once at load so the
+// events keep absolute times and drift leftward as the day advances.
+const BASE = Date.now();
+const FIXTURE = [
+ {dh:-3.4,dur:25,title:"Morning prep"}, {dh:-1.9,dur:15,title:"Daily standup"},
+ {dh:-0.6,dur:30,title:"Review inbox"}, {dh:0.8,dur:25,title:"Weekly 1:1"},
+ {dh:2.3,dur:60,title:"Design review"}, {dh:4.1,dur:30,title:"Vendor call"},
+ {dh:6.5,dur:45,title:"Planning"}, {dh:9.2,dur:20,title:"Wind down"},
+ {dh:12.0,dur:30,title:"Goodnight"},
+];
+
+const W=3440, H=1440, SPAN_H=24, MAX_FONT=72, NS="http://www.w3.org/2000/svg";
+const svg=document.getElementById("face");
+let yMode="rank", scrub=null;
+
+const greyFor = sz => Math.floor(Math.min(MAX_FONT,sz)*127/MAX_FONT)+128;
+const rgb = l => `rgb(${l},${l},${l})`;
+function el(n,a,t){const e=document.createElementNS(NS,n);for(const k in a)e.setAttribute(k,a[k]);
+ if(t!=null)e.textContent=t;return e;}
+function tzParts(d,tz){const f=new Intl.DateTimeFormat("en-GB",{timeZone:tz,hour12:false,
+ hour:"2-digit",minute:"2-digit"});const p={};for(const{type,value}of f.formatToParts(d))p[type]=value;return p;}
+const isDay = h => h>=6 && h<19;
+const now = () => scrub!=null ? new Date(scrub) : new Date();
+
+// Geography, not rank. x is real longitude, so the crossing still falls out of
+// where home sits — longitude IS timezone. y is latitude, north up, which is
+// the whole point of this iteration.
+// The roster spans Honolulu at -5 h to Wellington at +17 h, so 22 of the 24
+// hours are spoken for. That pins the crossing between 20.8% (or Honolulu drops
+// off the left) and 29.2% (or Wellington drops off the right). 21% is the
+// tightest value that holds, so Craig's 20% was short by a hair -- and the
+// window is genuinely almost forced.
+const CROSS_FRAC = 0.21;
+function geom(){
+ const M={l:230,r:230,t:150,b:200};
+ const hi=CITIES.findIndex(c=>c.home);
+ const [hx,hy]=cityXY(CITIES[hi],{x0:M.l,x1:W-M.r,y0:M.t,y1:H-M.b},now());
+ return {x0:M.l, x1:W-M.r, y0:M.t, y1:H-M.b, n:CITIES.length, hi,
+ frac:(hx-M.l)/(W-M.l-M.r), crossX:hx, homeY:hy};
+}
+// x is the city's CLOCK offset from home, not its longitude.
+//
+// Longitude is only an approximation of time, because time zones are political.
+// Measured against this roster it is off by up to 1.32 h -- Tokyo's zone sits
+// east of its meridian, Shanghai spans a huge span on one zone, and New Orleans
+// is on daylight time (solar UTC-6, clock UTC-5), which shifts every comparison.
+// Placing by longitude put Tokyo 1.3 h right of the hour it actually reads.
+// Placing by clock offset makes a city sit exactly under its own local hour, so
+// an event N hours ahead lands on the living-that-hour city with no error.
+function tzOffsetHours(date, tz){
+ const f = new Intl.DateTimeFormat("en-US",{timeZone:tz,hour12:false,
+ year:"numeric",month:"2-digit",day:"2-digit",
+ hour:"2-digit",minute:"2-digit",second:"2-digit"});
+ const p={}; for(const {type,value} of f.formatToParts(date)) p[type]=value;
+ const asUTC = Date.UTC(+p.year, p.month-1, +p.day,
+ p.hour==="24"?0:+p.hour, +p.minute, +p.second);
+ return (asUTC - Math.floor(date.getTime()/1000)*1000)/3600000;
+}
+const WEST_H = CROSS_FRAC * 24;
+function relHours(c, ref){
+ const h = tzOffsetHours(ref, c.tz) - tzOffsetHours(ref, HOME_TZ);
+ return ((h + WEST_H) % 24 + 24) % 24 - WEST_H;
+}
+const HOME_TZ = CITIES.find(c=>c.home).tz;
+function cityXY(c,g,ref){
+ const fx = CROSS_FRAC + relHours(c, ref||new Date())/24;
+ const fy = yMode==="true"
+ ? (c.lat-LAT0)/(LAT1-LAT0) // honest latitude
+ : 1-RANK.indexOf(c.label)/(CITIES.length-1); // even spacing, order kept
+ return [g.x0+fx*(g.x1-g.x0), g.y1-fy*(g.y1-g.y0)];
+}
+const cityPos = (g,i,ref) => cityXY(CITIES[i],g,ref);
+const pxPerHour = g => (g.x1-g.x0)/SPAN_H;
+const timeToX = (g,t,ref) => g.crossX + (t-ref)/3600000*pxPerHour(g);
+
+function drawDiagonal(g,ref){
+ // No connecting wire. Once y is latitude the cities are a scatter, not a
+ // sequence, and a polyline through them in longitude order draws a zigzag
+ // that asserts an adjacency the data does not have. The equator earns a rule
+ // instead — it is the one latitude that means something here.
+ if (yMode === "true") {
+ const eq = g.y1 - ((0-LAT0)/(LAT1-LAT0))*(g.y1-g.y0);
+ svg.appendChild(el("line",{x1:g.x0,y1:eq,x2:g.x1,y2:eq,stroke:"var(--line)",
+ "stroke-width":1,"stroke-dasharray":"6 10","stroke-opacity":0.5}));
+ svg.appendChild(el("text",{x:g.x0-14,y:eq+5,"text-anchor":"end","font-size":15,
+ fill:"var(--night)","letter-spacing":"0.14em"},"EQUATOR"));
+ }
+ CITIES.forEach((c,i)=>{
+ const [x,y]=cityPos(g,i,ref);
+ const p=tzParts(ref,c.tz), hr=parseInt(p.hour,10), day=isDay(hr);
+ const size = c.home?27:20;
+ const col = c.home?"var(--gold)":(day?"var(--cream)":"var(--slate)");
+ svg.appendChild(el("circle",{cx:x,cy:y,r:c.home?6:2.5,
+ fill:c.home?"var(--gold)":(day?"var(--cream)":"var(--night)")}));
+ // label sits above-left of its node so it never leaves the frame
+ svg.appendChild(el("text",{x:x-16,y:y-16,"text-anchor":"end","font-size":size,fill:col,
+ "letter-spacing":"0.18em","font-weight":c.home?700:400},
+ c.label.toUpperCase()+(day?"":" ☾")));
+ svg.appendChild(el("text",{x:x+16,y:y-16,"font-size":size*0.95,
+ fill:c.home?"var(--gold-hi)":(day?"var(--steel)":"var(--night)"),
+ "letter-spacing":"0.06em"},`${p.hour}:${p.minute}`));
+ });
+}
+
+function drawHourAxis(g,ref){
+ const pph=pxPerHour(g), y=g.homeY, size=22;
+ svg.appendChild(el("line",{x1:g.x0,y1:y,x2:g.x1,y2:y,stroke:"var(--line)","stroke-width":1}));
+ const h0=new Date(ref.getTime()-(g.crossX-g.x0)/pph*3600000); h0.setMinutes(0,0,0);
+ for(let k=0;k<=SPAN_H+1;k++){
+ const t=new Date(h0.getTime()+k*3600000), x=timeToX(g,t.getTime(),ref.getTime());
+ if(x<g.x0-1||x>g.x1+1) continue;
+ const future=t.getTime()>=ref.getTime();
+ const lvl=future?greyFor(size):Math.round(greyFor(size)*0.40);
+ svg.appendChild(el("line",{x1:x,y1:y,x2:x,y2:y+11,stroke:rgb(lvl),
+ "stroke-width":1,"stroke-opacity":0.6}));
+ svg.appendChild(el("text",{x,y:y+40,"text-anchor":"middle","font-size":size*0.8,
+ fill:rgb(lvl),"letter-spacing":"0.12em"},String(t.getHours()).padStart(2,"0")));
+ }
+}
+
+function drawEvents(g,ref){
+ const refMs=ref.getTime(), pph=pxPerHour(g), y=g.homeY;
+ // Anchored to a fixed instant, NOT to refMs. Recomputing start from the
+ // current moment pinned every event to the crossing, so the clock labels
+ // advanced while nothing moved. Real events sit at fixed instants and the
+ // day flows past them right to left.
+ const evs=FIXTURE.map(e=>({...e,start:BASE+e.dh*3600000})).sort((a,b)=>a.start-b.start);
+ const next=evs.find(e=>e.start>=refMs);
+ const lanes=[];
+ evs.forEach(e=>{
+ const x=timeToX(g,e.start,refMs);
+ if(x<g.x0-30||x>g.x1+30) return;
+ const wpx=Math.max(4,(e.dur/60)*pph), future=e.start>=refMs;
+ const isNext = next && e.start===next.start;
+ const est=e.title.length*11+70;
+ let lane=0; while(lanes[lane]!=null&&lanes[lane]>x-est) lane++;
+ lanes[lane]=x+est;
+ const ly = y - 46 - lane*46; // events live above the axis only
+ const lvl = future?236:100;
+ const col = isNext?"var(--gold-hi)":rgb(lvl);
+ svg.appendChild(el("rect",{x,y:y-5,width:wpx,height:10,fill:col,
+ "fill-opacity":isNext?0.95:(future?0.5:0.25)}));
+ svg.appendChild(el("line",{x1:x,y1:y-6,x2:x,y2:ly+10,stroke:col,
+ "stroke-width":isNext?1.6:0.8,"stroke-opacity":isNext?0.85:0.3}));
+ const d=new Date(e.start);
+ const hhmm=`${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
+ svg.appendChild(el("text",{x,y:ly,"text-anchor":"middle","font-size":isNext?32:21,
+ fill:col,"font-weight":isNext?700:400,"letter-spacing":"0.05em"},
+ isNext?`${hhmm} ${e.title}`:e.title));
+ if(!isNext) svg.appendChild(el("text",{x,y:ly-21,"text-anchor":"middle","font-size":15,
+ fill:rgb(Math.round(lvl*0.6)),"letter-spacing":"0.1em"},hhmm));
+ });
+}
+
+function drawCrossing(g,ref){
+ svg.appendChild(el("line",{x1:g.crossX,y1:g.y0-60,x2:g.crossX,y2:g.y1+60,
+ stroke:"var(--now)","stroke-width":1.2,"stroke-opacity":0.55}));
+ svg.appendChild(el("circle",{cx:g.crossX,cy:g.homeY,r:8,fill:"var(--now)"}));
+ const p=tzParts(ref,"America/Chicago");
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+108,"text-anchor":"middle","font-size":30,
+ fill:"var(--now)","letter-spacing":"0.16em"},`NOW ${p.hour}:${p.minute}`));
+ svg.appendChild(el("text",{x:g.crossX,y:g.y1+140,"text-anchor":"middle","font-size":15,
+ fill:"var(--dim)","letter-spacing":"0.14em"},
+ `CROSSING ${(g.frac*100).toFixed(1)}%`));
+}
+
+function render(){
+ while(svg.firstChild) svg.removeChild(svg.firstChild);
+ const ref=now(), g=geom();
+ drawDiagonal(g,ref);
+ drawHourAxis(g,ref);
+ drawEvents(g,ref);
+ drawCrossing(g,ref);
+}
+
+document.querySelectorAll("button[data-y]").forEach(b=>b.addEventListener("click",()=>{
+ yMode=b.dataset.y;
+ document.querySelectorAll("button[data-y]").forEach(o=>o.classList.toggle("on",o===b));
+ render();
+}));
+document.getElementById("tscrub").addEventListener("click",e=>{
+ scrub = scrub==null ? Date.now() : null;
+ e.target.classList.toggle("on", scrub!=null);
+});
+setInterval(()=>{ if(scrub!=null) scrub+=4*60000; render(); },250);
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-31-timeline-face-prototype-6.html b/docs/prototypes/2026-07-31-timeline-face-prototype-6.html
new file mode 100644
index 0000000..d3f1427
--- /dev/null
+++ b/docs/prototypes/2026-07-31-timeline-face-prototype-6.html
@@ -0,0 +1,151 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Timeline face — prototype 6</title>
+<style>
+ :root{
+ --ground:#050505; --cream:#f3e7c5; --silver:#bfc4d0; --steel:#969385;
+ --dim:#7c838a; --gold:#dab53d; --gold-hi:#ffd75f; --slate:#54677d;
+ --night:#39435a; --grid:#332f28; --grid-hi:#4a443a; --now:#c8452f;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",ui-monospace,monospace;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:var(--ground);overflow:hidden;
+ font-family:var(--mono)}
+ #face{position:fixed;inset:0;width:100vw;height:100vh;display:block}
+ #hud{position:fixed;left:10px;top:8px;z-index:5;display:flex;gap:8px;
+ align-items:center;font-size:11px}
+ button{font-family:var(--mono);font-size:11px;letter-spacing:.1em;
+ text-transform:uppercase;background:#16161699;color:var(--silver);
+ border:1px solid #2e2e2e;padding:5px 9px;cursor:pointer}
+ button.on{background:var(--gold);color:#111;border-color:var(--gold)}
+ select{font-family:var(--mono);font-size:11px;letter-spacing:.08em;
+ background:#161616;color:var(--silver);border:1px solid #2e2e2e;
+ padding:4px 6px}
+
+ /* One rule per primitive class the engine emits. All appearance lives here;
+ the engine decides what to draw and never how it looks. */
+ .meridian,.parallel{stroke:var(--grid);stroke-width:1;stroke-dasharray:7 7}
+ .meridian-anchor,.equator{stroke:var(--grid-hi);stroke-width:1}
+ .day-axis{stroke:var(--grid-hi);stroke-width:1.4}
+ line.now{stroke:var(--now);stroke-width:1.2;stroke-opacity:.6}
+ circle.now{fill:var(--now)}
+ text.now{fill:var(--now)}
+ .grid-label{fill:var(--steel)}
+ rect.event-next{fill:var(--gold-hi);fill-opacity:.95}
+ rect.event-future{fill:#ececec;fill-opacity:.5}
+ rect.event-past{fill:#686868;fill-opacity:.25}
+ text.event-next,text.event-next-above{fill:var(--gold-hi);font-weight:700}
+ text.event-future,text.event-future-above{fill:#ececec}
+ text.event-past,text.event-past-above{fill:#686868}
+ .leader{stroke:var(--grid-hi);stroke-width:1;stroke-opacity:.8}
+ .dot-home{fill:var(--gold)}
+ /* temporary: every city marked in red so their true positions are obvious */
+ .dot-city{fill:#e0392b}
+ .city-home{fill:var(--gold);font-weight:700}
+ .city-day{fill:var(--cream)}
+ .city-night{fill:var(--slate)}
+</style>
+</head>
+<body>
+<div id="hud">
+ <button id="bTrue" class="on">true latitude</button>
+ <button id="bRank">ranked</button>
+ <button id="bScrub">scrub</button>
+ <span style="width:14px"></span>
+ <span style="color:var(--steel)">home</span>
+ <select id="homes"></select>
+</div>
+<svg id="face"></svg>
+
+<script src="timeline-face-engine.js"></script>
+<script>
+// The renderer. It measures the font, asks the engine for a scene, and turns
+// primitives into SVG nodes. It holds no layout, no time and no geography --
+// all of that lives in the engine, where it is tested.
+"use strict";
+const NS = "http://www.w3.org/2000/svg";
+const svg = document.getElementById("face");
+let mode = "true", scrub = null, charRatio = 0.6, home = TF.HOME;
+
+const now = () => (scrub != null ? new Date(scrub) : new Date());
+
+/**
+ * Advance width of one glyph as a fraction of font size, measured against the
+ * font actually in use. Every width downstream is arithmetic from this, which
+ * is what lets the engine derive margins without touching the DOM.
+ */
+function measureChar() {
+ const probe = document.createElementNS(NS, "text");
+ probe.setAttribute("font-size", "100");
+ probe.setAttribute("x", "-9999");
+ probe.setAttribute("y", "-9999");
+ probe.textContent = "MMMMMMMMMM";
+ svg.appendChild(probe);
+ try {
+ const w = probe.getComputedTextLength();
+ if (w > 0) charRatio = w / 10 / 100;
+ } catch (e) { /* keep the default */ }
+ probe.remove();
+}
+
+const ATTRS = {
+ line: (p) => ({ x1: p.x1, y1: p.y1, x2: p.x2, y2: p.y2 }),
+ circle: (p) => ({ cx: p.cx, cy: p.cy, r: p.r }),
+ rect: (p) => ({ x: p.x, y: p.y, width: p.width, height: p.height }),
+ text: (p) => ({
+ x: p.x, y: p.y, "text-anchor": p.anchor,
+ "font-size": p.size, "letter-spacing": p.ls + "em",
+ }),
+};
+
+function toNode(p) {
+ const node = document.createElementNS(NS, p.kind);
+ const attrs = ATTRS[p.kind](p);
+ for (const k in attrs) if (attrs[k] != null) node.setAttribute(k, attrs[k]);
+ node.setAttribute("class", p.cls);
+ if (p.kind === "text") node.textContent = p.text;
+ return node;
+}
+
+function render() {
+ const width = svg.clientWidth || window.innerWidth;
+ const height = svg.clientHeight || window.innerHeight;
+ svg.setAttribute("viewBox", "0 0 " + width + " " + height);
+ const items = TF.scene({ width, height, ref: now(), mode, charRatio, home });
+ svg.replaceChildren(...items.map(toNode));
+}
+
+function pick(id, value) {
+ mode = value;
+ for (const b of ["bTrue", "bRank"]) {
+ document.getElementById(b).classList.toggle("on", b === id);
+ }
+ render();
+}
+document.getElementById("bTrue").onclick = () => pick("bTrue", "true");
+document.getElementById("bRank").onclick = () => pick("bRank", "rank");
+document.getElementById("bScrub").onclick = (e) => {
+ scrub = scrub == null ? Date.now() : null;
+ e.target.classList.toggle("on", scrub != null);
+};
+
+// One button per candidate home. Moving home moves the crossing with it, since
+// the roster reaches a fixed distance either side of wherever you are.
+const homeSel = document.getElementById("homes");
+TF.HOMES.forEach((h, i) => {
+ const o = document.createElement("option");
+ o.value = String(i);
+ o.textContent = h.label;
+ homeSel.appendChild(o);
+});
+homeSel.onchange = () => { home = TF.HOMES[+homeSel.value]; render(); };
+
+new ResizeObserver(render).observe(document.documentElement);
+setInterval(function () { if (scrub != null) scrub += 4 * 60000; render(); }, 250);
+measureChar();
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/README.md b/docs/prototypes/README.md
new file mode 100644
index 0000000..fd73b2e
--- /dev/null
+++ b/docs/prototypes/README.md
@@ -0,0 +1,41 @@
+# Prototypes
+
+Frozen design history. Every file here is a snapshot of what a design looked
+like on the date in its name, kept so the evolution is walkable. Nothing here
+is maintained, and nothing here is loaded by anything that runs.
+
+Most pages are self-contained: open one and it renders.
+
+## The world face
+
+The world wallpaper's engine used to live here and was hand-copied into
+`~/.dotfiles/settings/faces/` on every change. Both copies were byte-identical
+and both test suites ran the same tests, so the duplication bought nothing and
+risked drift — the two pages' palettes had already diverged enough to break a
+scripted edit.
+
+The live engine, its tests and the lab page now live only with the shipped
+face, in the dotfiles `settings/faces/` directory:
+
+- `timeline-face-engine.js`, `timeline-face-spine.js` — the engine
+- `world.html` — the projected wallpaper
+- `lab.html` — the same engine with a movable home and a fast clock, for
+ trying an idea without touching the wallpaper
+
+`make test-faces` in dotfiles runs their tests.
+
+`2026-07-31-timeline-face-prototype-6.html` and the option-A pages load the
+engine, so they are readable here but not runnable here. The engine is
+deliberately **not** kept in this repo: it carries a list of the places I
+travel to, with coordinates, and this repo is published. A design record is
+not worth publishing that.
+
+To run prototype 6, copy the two engine modules in beside it:
+
+```sh
+cp ~/.dotfiles/settings/faces/timeline-face-*.js docs/prototypes/
+```
+
+They are gitignored here, so a copy stays local. Note that the live engine
+keeps moving: prototype 6 is a photograph of one day's design and a current
+engine may not render it the way it looked.