// s12 Durations, stagger, beats, holds. Four cards, each demo running on its real number. // Durations: one ruler in ms. A block grows (ease-in-out) to its duration on the ruler in exactly that long, and its // stopwatch counts the real elapsed time: feedback 100 ms (on "tenth"), UI move 350 ms (on "and"), // hero reveal 750 ms (on "six"), each inside its band (100–200, 200–500, 600–900). // Stagger: on "list items" six rows enter 60 ms apart (400 ms ease-out each); beside them the entrance // bars and their start offsets, +0 … +300 ms. // Beat grid (the scene's hero): 113 BPM at 24 fps is 12.74 frames a beat. Row 1 rounds that to 13 and // adds it up; row 2 rounds each beat from time, round(n × 60/113 × 24). Beats 1–16 reveal on // "Compute"; row 1 drifts, its error in frames grows and turns red: +4 frames (167 ms) by beat 16. // Holds: "Two words" enters on "text" and a countdown bar drains for 0.3 × 2 + 0.5 = 1.1 s. const NS = "http://www.w3.org/2000/svg"; export default function build(root, k) { const { make, place, show, cueIn, easeInOut, p01, clamp, check, box, overlap, sfx, C } = k; const { cutIn, cutOut } = k.win; const FPS = 60; const F = (t) => Math.round(t * FPS) / FPS; // snap a launch to a frame // ---- cues ---- const tFeedback = F(cueIn(k.id, "tenth")); const tUI = F(cueIn(k.id, "and")); const tHeroGo = F(cueIn(k.id, "six")); const tList = cueIn(k.id, "list items"); const tRange = cueIn(k.id, "forty to eighty"); const tApart = cueIn(k.id, "apart"); const tBeats = cueIn(k.id, "compute"); const tNever = cueIn(k.id, "never"); const tDrift = cueIn(k.id, "drift"); const tText = cueIn(k.id, "text"); const tPlus = cueIn(k.id, "plus"); // ---- 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 checkIcon = (parent) => { // drawn, 2.5 px strokes const s = svgEl("svg", { width: 34, height: 34, viewBox: "0 0 34 34" }, parent); Object.assign(s.style, { overflow: "visible", flex: "none" }); svgEl("circle", { cx: 17, cy: 17, r: 15.5, fill: "none", stroke: C.green, "stroke-width": 2.5 }, s); svgEl("path", { d: "M10 17.5 L15 22.5 L24.5 12", fill: "none", stroke: C.green, "stroke-width": 2.5, "stroke-linecap": "round", "stroke-linejoin": "round" }, s); return s; }; const lines = []; // every text line, for the overlap and size checks const BAND = "#f1f0ec"; // =========================== Durations =========================== const D = card(24, 8, 1032, 256); lines.push(head(D, "Durations · ms")); const RX = 260, K = 0.72; // the ruler: 0–1000 ms over 720 px const rx = (ms) => RX + ms * K; const DUR = [ { name: "feedback", d: 100, band: [100, 200], t0: tFeedback }, { name: "UI move", d: 350, band: [200, 500], t0: tUI }, { name: "hero reveal", d: 750, band: [600, 900], t0: tHeroGo }, ].map((r, i) => { const y = 66 + i * 44; const nameEl = txt(D, r.name, 28, y); const band = rect(D, rx(r.band[0]), y + 1, (r.band[1] - r.band[0]) * K, 34, { background: BAND, borderRadius: "8px" }); const bar = rect(D, RX, y + 4, r.d * K, 28, { background: C.accent, borderRadius: "8px", transformOrigin: "left center" }); const out = mono(D, "", rx(r.d) + 14, y + 1, { fontWeight: "600" }); lines.push(nameEl, out); return { ...r, y, nameEl, band, bar, out }; }); DUR[2].bar.id = "s12-hero-bar"; rect(D, RX, 202, 720, 2, { background: C.faint }); const TICKS = [0, 100, 200, 500, 600, 900, 1000]; for (const ms of TICKS) { rect(D, rx(ms) - 1, 196, 2, 12, { background: C.faint }); lines.push(mono(D, String(ms), rx(ms) - 32, 212, { width: "64px", textAlign: "center", color: C.muted })); } // =========================== Stagger =========================== const SG = card(24, 274, 1032, 248); const STAGGER = 0.06, ENTER = 0.4, GX = 330, GK = 0.4; // gantt: 0.4 px per ms rect(SG, GX - 1, 14, 2, 222, { background: C.line }); const LINE_W = [178, 132, 204, 118, 166, 146]; const items = LINE_W.map((w, i) => { const y = 20 + i * 36, start = tList + i * STAGGER; const row = rect(SG, 28, y, 260, 28, { background: BAND, borderRadius: "8px" }); make("div", "", "", row, { position: "absolute", left: "10px", top: "7px", width: "14px", height: "14px", borderRadius: "7px", background: C.accent }); make("div", "", "", row, { position: "absolute", left: "36px", top: "9px", width: `${w}px`, height: "10px", borderRadius: "5px", background: C.line }); const bar = rect(SG, GX + i * STAGGER * 1000 * GK, y + 8, ENTER * 1000 * GK, 12, { background: C.accent, borderRadius: "6px", transformOrigin: "left center" }); const lab = mono(SG, `+${Math.round(i * STAGGER * 1000)} ms`, GX + i * STAGGER * 1000 * GK + ENTER * 1000 * GK + 12, y - 3); lines.push(lab); return { row, bar, lab, start }; }); items[0].row.id = "s12-item-0"; lines.push(txt(SG, "Stagger", 800, 18, { fontSize: "32px", fontWeight: "680" })); lines.push(txt(SG, "list items", 800, 60, { color: C.muted, fontWeight: "500" })); const range = mono(SG, "40–80 ms", 800, 126, { fontSize: "30px", color: C.muted }); const apart = mono(SG, "60 ms apart", 800, 170, { fontSize: "30px", color: C.accent, fontWeight: "600" }); lines.push(range, apart); // =========================== Beat grid =========================== // The numbers, computed: frames per beat, rounded and added up, against each beat rounded from time. const BPM = 113, FPS24 = 24, N = 16; const FPB = (FPS24 * 60) / BPM; // 12.743… const STEP = Math.round(FPB); // 13 const beats = []; for (let n = 1, acc = 0; n <= N; n++) { acc += STEP; // rounded once, then accumulated const fromTime = Math.round(((n * 60) / BPM) * FPS24); beats.push({ n, acc, fromTime, exact: n * FPB, err: acc - fromTime }); } const last = beats.at(-1); const B = card(24, 532, 1032, 268); lines.push(head(B, "Beat grid · 113 BPM at 24 fps")); lines.push(mono(B, `24 × 60 ÷ 113 = ${FPB.toFixed(2)} f`, 604, 22, { color: C.muted, width: "400px", textAlign: "right" })); const X0 = 28, PX = 976 / 212; // frames 0–212 across the card const xf = (f) => X0 + f * PX; const ROW1 = 104, ROW2 = 218, MH = 32; const lab1 = txt(B, `rounded: ${FPB.toFixed(2)} → ${STEP}, added up`, 28, 62); const lab2 = txt(B, `from time: round(n × ${FPB.toFixed(2)})`, 28, 176); const late = mono(B, `+${last.err} frames · ${Math.round((last.err / FPS24) * 1000)} ms`, 604, 63, { color: C.red, fontWeight: "600", width: "400px", textAlign: "right" }); const onLine = mono(B, "on the line", 704, 177, { color: C.green, fontWeight: "600", width: "300px", textAlign: "right" }); lines.push(lab1, lab2, late, onLine); for (const y of [ROW1, ROW2]) { rect(B, X0, y + MH / 2 - 1, 976, 2, { background: C.line }); rect(B, xf(0) - 3, y, 6, MH, { background: C.ink, borderRadius: "3px" }); // beat 0 } // The true beat, n × 12.74 frames: a short dashed line through both rows. for (const b of beats) for (const y of [ROW1, ROW2]) rect(B, xf(b.exact) - 1, y - 6, 0, MH + 12, { borderLeft: `2px dashed ${C.faint}` }); const marks = beats.map((b) => { const errBar = rect(B, xf(b.fromTime), ROW1 + MH / 2 - 3, (b.acc - b.fromTime) * PX, 6, { background: C.red, borderRadius: "3px" }); const m1 = rect(B, xf(b.acc) - 3, ROW1, 6, MH, { background: b.err > 0 ? C.red : C.ink, borderRadius: "3px" }); const m2 = rect(B, xf(b.fromTime) - 3, ROW2, 6, MH, { background: C.ink, borderRadius: "3px" }); const num = mono(B, String(b.err), xf(b.acc) - 20, ROW1 + MH + 4, { width: "40px", textAlign: "center", color: b.err > 0 ? C.red : C.muted, fontWeight: "600" }); lines.push(num); return { ...b, errBar, m1, m2, num, at: tBeats + (b.n - 1) * 0.14 }; }); // =========================== Holds =========================== const TITLE = "Two words"; const WORDS = TITLE.split(/\s+/).length; const HOLD = 0.3 * WORDS + 0.5; // 1.1 s for two words const H = card(24, 810, 1032, 172); lines.push(head(H, "Holds")); const title = place(make("div", "h1", TITLE, H, { whiteSpace: "nowrap" }), 28, 60); const track = rect(H, 28, 142, 360, 12, { background: C.line, borderRadius: "6px", overflow: "hidden" }); const fill = make("div", "", "", track, { position: "absolute", inset: "0px", background: C.accent, borderRadius: "6px", transformOrigin: "left center" }); const readWrap = txt(H, "", 404, 126, { display: "flex", alignItems: "center", gap: "12px" }); const readout = make("span", "", "", readWrap, { fontFamily: MONO, fontSize: "30px", fontWeight: "600" }); const ok = checkIcon(readWrap); const f1 = mono(H, `0.3 s × ${WORDS} words`, 680, 58, { fontSize: "30px" }); const f2 = mono(H, `+ 0.5 s = ${HOLD.toFixed(1)} s`, 680, 102, { fontSize: "30px", fontWeight: "600" }); lines.push(title, readWrap, f1, f2); const tTitle = tText, tCount = F(tText + 0.5); // the hold counts from when the title has arrived sfx(tDrift + 1 / FPS, "pop"); // the red drift readout pins sfx(tCount + HOLD + 1 / FPS, "success"); // the hold's check turns green // =========================== checks =========================== check("s12 durations: each block reaches its length on its exact frame (100 / 350 / 750 ms)", [tFeedback, tHeroGo + 0.8], (t) => { for (const r of DUR) { const w = box(r.bar).w, full = r.d * K, end = r.t0 + r.d / 1000; if (t >= end - 1e-6 && Math.abs(w - full) > 0.05) return `${r.name} ${w.toFixed(2)} at end`; if (t > r.t0 && t < end - 1e-6 && r.out.textContent === `${r.d} ms`) return `${r.name} readout done early`; if (t >= r.t0 && Math.abs(w - full * easeInOut(p01(t, r.t0, r.d / 1000))) > 0.05) return `${r.name} off its curve`; if (t >= end - 1e-6 && r.out.textContent !== `${r.d} ms`) return `${r.name} readout "${r.out.textContent}"`; } return true; }); check("s12 durations: each lands inside its band", tHeroGo + 1, () => DUR.every((r) => { const b = box(r.bar), a = box(r.band); return b.x + b.w >= a.x - 0.5 && b.x + b.w <= a.x + a.w + 0.5; }) || "outside"); // Opacity is written to 4 decimals, so a row's start can be read back from it to well under 1 ms // while the row is mid-entrance (opacity 0.001–0.99). Every frame compares the neighbours it can read. check("s12 stagger: rows start 60 ms apart, measured from their rendered opacity", [tList + 0.07, tList + 0.5], (t) => { const starts = items.map((it) => { const v = Number(getComputedStyle(it.row).opacity); return v > 0.001 && v < 0.99 ? t - ENTER * (1 - Math.cbrt(1 - v)) : null; // invert the ease-out entrance }); const gaps = []; starts.forEach((s, i) => { if (i && s !== null && starts[i - 1] !== null) gaps.push((s - starts[i - 1]) * 1000); }); if (!gaps.length) return "no two neighbouring rows mid-entrance"; return gaps.every((g) => Math.abs(g - 60) < 1) || `gaps ${gaps.map((g) => g.toFixed(2)).join(", ")} ms`; }); check("s12 stagger: offsets read +0 … +300 ms", tApart, () => items.map((it) => it.lab.textContent).join(" ") === "+0 ms +60 ms +120 ms +180 ms +240 ms +300 ms" || items.map((it) => it.lab.textContent).join(" ")); check(`s12 beat grid: ${FPB.toFixed(4)} f/beat, rounded ${STEP}, drift 0→${last.err} frames over ${N} beats`, cutOut - 2 / FPS, () => { if (Math.abs(FPB - 12.7434) > 1e-4 || STEP !== 13) return `fpb ${FPB} step ${STEP}`; const errs = beats.map((b) => b.err).join(""); if (errs !== "0111122223333444") return `errors ${errs}`; const shown = marks.map((m) => m.num.textContent).join(""); return shown === errs || `shown ${shown}`; }); check("s12 beat grid: markers sit on their frames (row 1 at 13n, row 2 at round(n × 12.74))", cutOut - 2 / FPS, () => { const o = box(B).x; for (const m of marks) { const a = box(m.m1).cx - o - X0, b = box(m.m2).cx - o - X0; if (Math.abs(a - m.acc * PX) > 0.05 || Math.abs(b - m.fromTime * PX) > 0.05) return `beat ${m.n}: ${(a / PX).toFixed(2)} / ${(b / PX).toFixed(2)} frames`; if (Math.abs(m.fromTime - m.exact) > 0.5) return `beat ${m.n} from time is ${(m.fromTime - m.exact).toFixed(2)} f off`; } return true; }); check("s12 beat grid: drift readout is +4 frames · 167 ms", tDrift + 1, () => late.textContent === "+4 frames · 167 ms" || late.textContent); check(`s12 hold: the bar drains over exactly ${HOLD.toFixed(1)} s for ${WORDS} words`, [tCount - 2 / FPS, tCount + HOLD + 2 / FPS], (t) => { if (Math.abs(HOLD - 1.1) > 1e-9 || WORDS !== 2) return `${WORDS} words, ${HOLD} s`; const r = box(fill).w / box(track).w, want = 1 - p01(t, tCount, HOLD); if (Math.abs(r - want) > 0.002) return `bar ${r.toFixed(4)} want ${want.toFixed(4)}`; if (t < tCount + HOLD - 1e-6 && r <= 0) return "empty early"; return true; }); check("s12 the title holds still while it is read", [tTitle + 0.5, cutOut - 2 / FPS], () => { const b = box(title), o = box(H); return (Math.abs(b.x - o.x - 28) < 0.01 && Math.abs(b.y - o.y - 60) < 0.01) || `title at ${(b.x - o.x).toFixed(2)}, ${(b.y - o.y).toFixed(2)}`; }); check("s12 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("s12 text lines don't overlap each other or the marks; all inside the stage", cutOut - 2 / FPS, () => { const els = [...lines, ...DUR.map((r) => r.bar), ...items.map((it) => it.bar), ...items.map((it) => it.row), track]; 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) => { // Durations: each block grows to its length in exactly its duration; the stopwatch is real time. for (const r of DUR) { r.bar.style.transform = `scaleX(${easeInOut(p01(t, r.t0, r.d / 1000)).toFixed(5)})`; // a move across the ruler: ease-in-out r.bar.style.visibility = t > r.t0 ? "visible" : "hidden"; r.out.textContent = `${Math.round(clamp(t - r.t0, 0, r.d / 1000) * 1000)} ms`; show(r.out, t, r.t0, Infinity, { dur: 0.15, dy: 0 }); } // Stagger: the rows, their entrance bars (linear progress) and offsets, 60 ms apart. for (const it of items) { show(it.row, t, it.start, Infinity, { dur: ENTER, dx: -24, dy: 0 }); it.bar.style.transform = `scaleX(${p01(t, it.start, ENTER).toFixed(4)})`; it.bar.style.visibility = t > it.start ? "visible" : "hidden"; show(it.lab, t, it.start, Infinity, { dur: ENTER, dy: 0 }); } show(range, t, tRange, Infinity, { dur: 0.5, dy: 12 }); show(apart, t, tApart, Infinity, { dur: 0.5, dy: 12 }); // Beat grid: beats 1–16 appear 140 ms apart; row 1's error grows and turns red. for (const m of marks) { for (const el of [m.m1, m.m2]) show(el, t, m.at, Infinity, { dur: 0.3, dy: 0, scale: 0.6 }); show(m.errBar, t, m.at + 0.1, Infinity, { dur: 0.3, dy: 0 }); show(m.num, t, m.at + 0.1, Infinity, { dur: 0.3, dy: 0 }); } show(onLine, t, tNever, Infinity, { dur: 0.5, dy: 0, dx: 16 }); show(late, t, tDrift, Infinity, { dur: 0.5, dy: 0, dx: 16 }); // Holds: the title arrives, then the bar drains for exactly HOLD seconds. show(title, t, tTitle, Infinity, { dur: 0.5, dy: 12 }); show(track, t, tTitle, Infinity, { dur: 0.5, dy: 0 }); show(readWrap, t, tTitle, Infinity, { dur: 0.5, dy: 0 }); const left = HOLD - clamp(t - tCount, 0, HOLD); fill.style.transform = `scaleX(${(left / HOLD).toFixed(5)})`; const done = t >= tCount + HOLD - 1e-6; readout.textContent = done ? `held ${HOLD.toFixed(2)} s` : `${left.toFixed(2)} s`; readout.style.color = done ? C.green : C.ink; show(ok, t, tCount + HOLD, Infinity, { dur: 0.3, dy: 0, scale: 0.6 }); show(f1, t, tPlus, Infinity, { dur: 0.5, dy: 12 }); show(f2, t, tPlus + 0.08, Infinity, { dur: 0.5, dy: 12 }); }; }