// s13 Camera, parallax, accessibility. Three cards; every number is measured from what is drawn.
// Parallax (the hero): a three-layer landscape in a window. On "parallax" the camera pans once, 1.8 s
// on an ease-in-out sine (the gentlest curve). Foreground, midground and background move at 1.0, 0.5
// and 0.2 of the camera: 400, 200 and 80 px. One accent shape per layer is tracked, with a dashed
// ghost where it started; the speeds are labeled on "foreground", "middle" and "background".
// Contrast: two samples on white, ratios computed with the WCAG 2 formula from the colors on screen:
// #9d9a96 is 2.80:1 (struck on "contrast"), #777772 is 4.50:1 (checked on "one").
// Flashes: a lamp pulses three times, 0.6 s apart, on smooth bumps; a gauge measures the most peaks in
// any one second (2) against the limit of 3.
const NS = "http://www.w3.org/2000/svg";
// WCAG 2 relative luminance and contrast ratio.
const channel = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
const contrast = (a, b) => { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
const hexRGB = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const cssRGB = (s) => s.match(/[\d.]+/g).slice(0, 3).map(Number);
export default function build(root, k) {
const { make, place, show, cueIn, easeOut, p01, clamp, check, box, overlap, sfx, C } = k;
const { cutIn, cutOut } = k.win;
const FPS = 60;
const F = (t) => Math.round(t * FPS) / FPS;
// ---- cues ----
const tGentle = cueIn(k.id, "gentlest");
const tPan = F(cueIn(k.id, "parallax"));
const tLayer = { fg: cueIn(k.id, "foreground"), mid: cueIn(k.id, "middle"), bg: cueIn(k.id, "background") };
const tKeep = cueIn(k.id, "keep");
const tStrike = cueIn(k.id, "contrast");
const tFour = cueIn(k.id, "four");
const tOne = cueIn(k.id, "one");
const tNever = F(cueIn(k.id, "never"));
// ---- small builders ----
const MONO = '"Geist Mono", ui-monospace, monospace';
const txt = (parent, html, x, y, css = {}) =>
place(make("div", "", html, parent, { fontSize: "30px", fontWeight: "620", whiteSpace: "nowrap", lineHeight: "1.2", ...css }), x, y);
const mono = (parent, html, x, y, css = {}) => txt(parent, html, x, y, { fontFamily: MONO, fontSize: "28px", fontWeight: "500", ...css });
const head = (parent, html) => txt(parent, html, 28, 18, { fontSize: "32px", fontWeight: "680" });
const card = (x, y, w, h) => place(make("div", "card", "", root), x, y, w, h);
const rect = (parent, x, y, w, h, css = {}) => place(make("div", "", "", parent, css), x, y, w, h);
const svgEl = (tag, attrs, parent) => {
const e = document.createElementNS(NS, tag);
for (const [a, v] of Object.entries(attrs)) e.setAttribute(a, v);
parent.appendChild(e);
return e;
};
const icon = (parent, kind, color, x, y) => { // drawn, 2.5 px strokes
const s = svgEl("svg", { width: 34, height: 34, viewBox: "0 0 34 34" }, parent);
Object.assign(s.style, { position: "absolute", left: `${x}px`, top: `${y}px`, overflow: "visible" });
svgEl("circle", { cx: 17, cy: 17, r: 15.5, fill: "none", stroke: color, "stroke-width": 2.5 }, s);
svgEl("path", { d: kind === "check" ? "M10 17.5 L15 22.5 L24.5 12" : "M11.5 11.5 L22.5 22.5 M22.5 11.5 L11.5 22.5",
fill: "none", stroke: color, "stroke-width": 2.5, "stroke-linecap": "round", "stroke-linejoin": "round" }, s);
return s;
};
const lines = [];
// =========================== Parallax ===========================
const P = card(24, 8, 1032, 462);
lines.push(head(P, "Parallax · one camera pan"));
const VW = 620, VH = 324, CAM = 400, PAN = 1.8;
const cam = (t) => { const p = p01(t, tPan, PAN); return 0.5 - 0.5 * Math.cos(Math.PI * p); }; // ease-in-out sine
const view = rect(P, 28, 66, VW, VH, { background: "#f6f5f2", borderRadius: "18px", overflow: "hidden" });
const layer = (w) => make("div", "", "", view, { position: "absolute", left: "0px", top: "0px", width: `${w}px`, height: `${VH}px` });
const blob = (parent, x, y, w, h, bg, r = "50%") => rect(parent, x, y, w, h, { background: bg, borderRadius: r });
// background, 0.2
const bgL = layer(VW + 160);
blob(bgL, -60, 168, 540, 320, "#e7e4dd"); blob(bgL, 360, 186, 520, 300, "#e7e4dd");
const sun = blob(bgL, 380, 34, 92, 92, C.accentSoft);
// midground, 0.5
const midL = layer(VW + 280);
blob(midL, -140, 236, 440, 230, "#d3d0c8"); blob(midL, 150, 214, 540, 260, "#d3d0c8"); blob(midL, 600, 228, 480, 250, "#d3d0c8");
const trees = [170, 430, 740, 920].map((x) => { // spaced so no tree ends under the tracked one's ghost
rect(midL, x - 4, 196, 8, 40, { background: "#8f8c86", borderRadius: "4px" });
return blob(midL, x - 26, 150, 52, 52, x === 430 ? C.accent : "#a8a6a0");
});
const tree = trees[1];
// foreground, 1.0
const fgL = layer(VW + 520);
rect(fgL, 0, 276, VW + 520, 48, { background: "#3b3a36" });
rect(fgL, 0, 240, VW + 520, 6, { background: C.ink, borderRadius: "3px" });
const posts = [];
for (let x = 40; x < VW + 500; x += 116) posts.push(rect(fgL, x, 216, 16, 72, { background: x === 504 ? C.accent : C.ink, borderRadius: "6px" }));
const post = posts.find((p) => p.style.left === "504px");
post.id = "s13-fg-post";
// Ghosts: where each tracked shape started (a static overlay).
const ghosts = make("div", "", "", view, { position: "absolute", inset: "0px" });
const ghost = (el) => rect(ghosts, parseFloat(el.style.left), parseFloat(el.style.top), parseFloat(el.style.width), parseFloat(el.style.height),
{ border: `2.5px dashed ${C.muted}`, borderRadius: el.style.borderRadius, boxSizing: "border-box" });
ghost(sun); ghost(tree); ghost(post);
const LAYERS = [
{ key: "fg", name: "foreground", speed: 1.0, el: fgL, mark: post, y: 292 },
{ key: "mid", name: "midground", speed: 0.5, el: midL, mark: tree, y: 206 },
{ key: "bg", name: "background", speed: 0.2, el: bgL, mark: sun, y: 110 },
].map((L) => {
const name = txt(P, L.name, 680, L.y);
const val = mono(P, `${L.speed.toFixed(1)} × · ${Math.round(L.speed * CAM)} px`, 680, L.y + 38);
lines.push(name, val);
return { ...L, nameEl: name, val, x0: parseFloat(L.mark.style.left) };
});
// The camera: its curve, as a dot on a track.
const camRow = make("div", "", "", P, { position: "absolute", inset: "0px" });
const camName = txt(camRow, "camera", 28, 404);
rect(camRow, 170, 421, 340, 2, { background: C.line });
const camDot = rect(camRow, 170 - 10, 412, 20, 20, { background: C.ink, borderRadius: "10px" });
const camCurve = mono(camRow, `ease-in-out · ${PAN.toFixed(1)} s`, 540, 405, { color: C.muted });
lines.push(camName, camCurve);
// =========================== Contrast ===========================
const CT = card(24, 482, 1032, 262);
lines.push(head(CT, "Contrast · WCAG, on white"));
const BG = "#ffffff";
const SAMPLES = [
{ fg: "#9d9a96", want: 2.8, pass: false, x: 28 },
{ fg: "#777772", want: 4.5, pass: true, x: 530 },
].map((s) => {
const ratio = contrast(hexRGB(s.fg), hexRGB(BG)); // computed, not typed
const panel = rect(CT, s.x, 66, 474, 180, { background: BG, border: `2px solid ${C.canvas}`, borderRadius: "18px" });
const sample = txt(panel, "", 24, 14, { fontSize: "44px", fontWeight: "600", color: s.fg });
const word = make("span", "", "Body text", sample, { position: "relative", display: "inline-block" });
const strike = s.pass ? null : make("div", "", "", word, { position: "absolute", left: "-6px", right: "-6px", top: "54%", height: "5px",
marginTop: "-2px", borderRadius: "3px", background: C.red, transformOrigin: "left center", transform: "scaleX(0)" });
const value = mono(panel, `${ratio.toFixed(2)} : 1`, 24, 80, { fontSize: "40px", fontWeight: "600", color: C.ink });
const hex = mono(panel, `${s.fg} on ${BG}`, 24, 132, { color: C.muted });
const mark = icon(panel, s.pass ? "check" : "x", s.pass ? C.green : C.red, 412, 22);
lines.push(sample, value, hex);
return { ...s, ratio, panel, sample, word, strike, value, hex, mark };
});
const [failS, passS] = SAMPLES;
// =========================== Flashes ===========================
const FL = card(24, 756, 1032, 228);
lines.push(head(FL, "Flashes · at most 3 a second"));
const PULSE = 0.6, LIMIT = 3, DIM = 0.2;
const pulses = [0, 1, 2].map((i) => tNever + i * PULSE);
const peaks = pulses.map((p) => p + PULSE / 2);
const lampLevel = (t) => { // smooth raised-cosine bumps; never a hard on/off
let v = 0;
for (const p of pulses) { const u = (t - p) / PULSE; if (u > 0 && u < 1) v = Math.max(v, 0.5 - 0.5 * Math.cos(2 * Math.PI * u)); }
return DIM + (1 - DIM) * v;
};
// The most peaks inside any one-second window, among those that have happened by t.
const mostPerSecond = (t) => {
const seen = peaks.filter((p) => p <= t + 1e-9);
let most = 0;
for (const a of seen) most = Math.max(most, seen.filter((b) => b >= a && b < a + 1).length);
return most;
};
const lamp = rect(FL, 28, 78, 112, 112, { background: C.accent, borderRadius: "56px" });
const GX = 200, GU = 96; // gauge: 0–5 flashes a second, 96 px each
const gauge = rect(FL, GX, 104, 5 * GU, 18, { background: "#f1f0ec", borderRadius: "9px", overflow: "hidden" });
rect(gauge, LIMIT * GU, 0, 2 * GU, 18, { background: "#fde8eb" });
const gFill = make("div", "", "", gauge, { position: "absolute", left: "0px", top: "0px", width: `${5 * GU}px`, height: "18px", background: C.accent, borderRadius: "9px", transformOrigin: "left center" });
rect(FL, GX + LIMIT * GU - 1, 94, 3, 38, { background: C.red, borderRadius: "2px" });
for (let i = 0; i <= 5; i++) lines.push(mono(FL, String(i), GX + i * GU - 20, 140, { width: "40px", textAlign: "center", color: i > LIMIT ? C.red : C.muted }));
const reading = mono(FL, "", 760, 96, { fontSize: "32px", fontWeight: "600" });
const flashOk = icon(FL, "check", C.green, 930, 96);
lines.push(reading);
const tFlashOk = pulses.at(-1) + PULSE;
sfx(tOne + 1 / FPS, "success"); // contrast check turns green
sfx(tFlashOk + 1 / FPS, "success"); // flash check turns green
// =========================== checks ===========================
const shift = (L) => box(L.mark).x - box(view).x - L.x0;
check("s13 parallax: layers move at 1.0 / 0.5 / 0.2 of the camera on every frame of the pan", [tPan, tPan + PAN + 2 / FPS], (t) => {
const fg = -shift(LAYERS[0]), want = CAM * cam(t);
if (Math.abs(fg - want) > 0.02) return `foreground ${fg.toFixed(2)} want ${want.toFixed(2)}`;
for (const L of LAYERS.slice(1)) {
const d = -shift(L);
if (Math.abs(d - L.speed * want) > 0.02) return `${L.name} ${d.toFixed(2)} want ${(L.speed * want).toFixed(2)}`;
}
return true;
});
check("s13 parallax: travels 400 / 200 / 80 px, as labeled", tPan + PAN + 0.1, () =>
LAYERS.every((L) => Math.abs(-shift(L) - L.speed * CAM) < 0.02 && L.val.textContent === `${L.speed.toFixed(1)} × · ${L.speed * CAM} px`) ||
LAYERS.map((L) => `${(-shift(L)).toFixed(2)} "${L.val.textContent}"`).join("; "));
check("s13 parallax: the camera curve starts and ends at rest (ease-in-out)", tPan, () => {
const v0 = (cam(tPan + 1 / FPS) - cam(tPan)) * CAM, v1 = (cam(tPan + PAN) - cam(tPan + PAN - 1 / FPS)) * CAM, vm = (cam(tPan + PAN / 2 + 0.5 / FPS) - cam(tPan + PAN / 2 - 0.5 / FPS)) * CAM;
return (v0 < 0.15 * vm && v1 < 0.15 * vm) || `first ${v0.toFixed(2)} last ${v1.toFixed(2)} mid ${vm.toFixed(2)} px/frame`;
});
for (const s of SAMPLES) {
check(`s13 contrast: the ${s.pass ? "passing" : "failing"} sample measures ${s.want.toFixed(2)}:1 on screen (WCAG)`, s.pass ? tOne + 0.5 : tStrike + 0.5, () => {
let op = 1;
for (let n = s.sample; n && n !== document.body; n = n.parentElement) op *= Number(getComputedStyle(n).opacity);
if (op < 0.9999) return `sample at opacity ${op.toFixed(4)}`;
const r = contrast(cssRGB(getComputedStyle(s.word).color), cssRGB(getComputedStyle(s.panel).backgroundColor));
if (Math.abs(r - s.want) >= 0.005) return `measured ${r.toFixed(4)}:1`;
if (s.pass !== (r >= 4.5)) return `${r.toFixed(4)}:1 is on the wrong side of 4.5`;
return s.value.textContent === `${r.toFixed(2)} : 1` || `shows "${s.value.textContent}" for ${r.toFixed(4)}`;
});
}
check("s13 contrast: the strike covers all of the failing sample", tStrike + 0.6, () => {
const a = box(failS.strike), w = box(failS.word);
return (a.x <= w.x && a.x + a.w >= w.x + w.w) || `strike ${a.x.toFixed(0)}–${(a.x + a.w).toFixed(0)}, word ${w.x.toFixed(0)}–${(w.x + w.w).toFixed(0)}`;
});
// Measured from the rendered lamp: seek every frame of the pulses, find the brightness peaks, and count
// the most in any 60-frame window. Restores the frame being audited before it returns.
check("s13 flashes: the lamp peaks at most 3 times in any second (measured: 2), and the gauge reads it", tFlashOk + 0.3, (t) => {
const f0 = Math.round((tNever - 0.1) * FPS), f1 = Math.round((tFlashOk + 0.1) * FPS), op = [];
for (let f = f0; f <= f1; f++) { window.seek(f / FPS); op.push(Number(getComputedStyle(lamp).opacity)); }
window.seek(t);
const pk = [];
for (let i = 1; i < op.length - 1; i++) if (op[i] > 0.9 && op[i] >= op[i - 1] && op[i] > op[i + 1]) pk.push(f0 + i);
let most = 0;
for (const a of pk) most = Math.max(most, pk.filter((b) => b >= a && b < a + FPS).length);
if (pk.length !== 3) return `${pk.length} peaks`;
if (most > LIMIT) return `${most} in one second`;
const fillW = box(gFill).w;
if (Math.abs(fillW - most * GU) > 0.5) return `gauge at ${(fillW / GU).toFixed(2)}`;
return reading.textContent === `${most} / s` || `reads "${reading.textContent}", measured ${most}`;
});
check("s13 text is 28 px or larger", cutOut - 2 / FPS, () => {
const small = [...root.querySelectorAll("div, span")].filter((el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()))
.filter((el) => parseFloat(getComputedStyle(el).fontSize) < 28);
return !small.length || small.map((el) => `${el.textContent.slice(0, 16)} ${getComputedStyle(el).fontSize}`).join("; ");
});
check("s13 text lines don't overlap each other or the window; all inside the stage", cutOut - 2 / FPS, () => {
const els = [...lines, view, lamp, gauge, failS.mark, passS.mark, flashOk];
for (let i = 0; i < els.length; i++) {
const b = box(els[i]);
if (b.x < 0 || b.x + b.w > 1080 || b.y < 128 || b.y + b.h > 1120) return `${els[i].textContent?.slice(0, 20)} outside`;
for (let j = i + 1; j < els.length; j++) {
const o = overlap(els[i], els[j]);
if (o > 0) return `${(els[i].textContent || els[i].tagName).slice(0, 18)} x ${(els[j].textContent || els[j].tagName).slice(0, 18)}: ${o | 0} px²`;
}
}
return true;
});
// =========================== seek ===========================
return (t) => {
// Parallax: one pan of the camera; each layer moves at its speed times the camera's travel.
const c = cam(t) * CAM;
for (const L of LAYERS) {
L.el.style.transform = `translateX(${(-L.speed * c).toFixed(3)}px)`;
show(L.nameEl, t, tLayer[L.key], Infinity, { dur: 0.5, dy: 12 });
show(L.val, t, tLayer[L.key] + 0.08, Infinity, { dur: 0.5, dy: 12 });
}
show(ghosts, t, tPan, Infinity, { dur: 0.4, dy: 0 });
show(camRow, t, tGentle, Infinity, { dur: 0.5, dy: 12 });
camDot.style.transform = `translateX(${(cam(t) * 340).toFixed(2)}px)`;
// Contrast: the failing sample, struck; then the passing one, checked.
show(failS.panel, t, tKeep, Infinity, { dur: 0.5, dy: 12 });
failS.strike.style.transform = `scaleX(${easeOut(p01(t, tStrike, 0.4)).toFixed(4)})`;
show(failS.mark, t, tStrike + 0.3, Infinity, { dur: 0.3, dy: 0, scale: 0.6 });
show(passS.panel, t, tFour, Infinity, { dur: 0.5, dy: 12 });
show(passS.mark, t, tOne, Infinity, { dur: 0.3, dy: 0, scale: 0.6 });
// Flashes: three smooth pulses; the gauge holds the most peaks seen in any one second.
lamp.style.opacity = lampLevel(t).toFixed(4);
const most = mostPerSecond(t);
const lastStep = peaks.filter((p) => p <= t).at(-1) ?? -Infinity;
const prev = mostPerSecond(lastStep - 1e-6);
const g = prev + (most - prev) * easeOut(p01(t, lastStep, 0.25));
gFill.style.transform = `scaleX(${(g / 5).toFixed(5)})`;
reading.textContent = `${most} / s`;
show(reading, t, tNever, Infinity, { dur: 0.3, dy: 0 });
show(flashOk, t, tFlashOk, Infinity, { dur: 0.3, dy: 0, scale: 0.6 });
};
}