// s15 · The audit. A counter of checks runs to 71; a scan reads a strip of 480 frame ticks and six
// ticks turn red as it finds the failures; on "fixed" each turns green and the readout becomes
// "71 / 71 pass". The hero move is the scan (ease-in-out across the strip).
export default function build(root, k) {
const { make, place, show, cueIn, countTo, check, box, overlap, sfx, swap, style, rand, clamp, easeOut, easeInOut, mix, spring01, C, win } = k;
const S = "s15";
const NS = "http://www.w3.org/2000/svg";
// ---------- cues ----------
const tCount = cueIn(k.id, "ran"); // counts 0 → 71, landing as the narrator says "seventy-one"
const tScan = cueIn(k.id, "four"); // the scan reads the 480 frames
const tSix = cueIn(k.id, "six"); // "Six failed": the readout
const tFix = cueIn(k.id, "fixed"); // each tick turns green
const COUNT = 1.0, SCAN = 2.0, FIX_STAGGER = 0.06, FIX_DUR = 0.3;
const tSwap = tFix + 0.15; // the readout follows the ticks by 150 ms (kit swap: out 0.14, gap 0.04, in 0.22)
// ---------- layout ----------
const title = place(make("div", "h2", "Automated audit", root), 80, 108);
const counter = place(make("div", "", "", root, { display: "flex", alignItems: "baseline", gap: "22px" }), 80, 176);
const num = make("div", "mono", "0", counter, { width: "206px", textAlign: "left", fontSize: "168px", fontWeight: "600",
letterSpacing: "-0.04em", lineHeight: "1" });
num.id = "s15-count";
make("div", "h2", "checks", counter);
const card = place(make("div", "card", "", root), 60, 420, 960, 362);
const L = 100, Rx = 980, N = 480, T0 = 562, T1 = 690; // tick strip, stage coords
const xOf = (f) => L + ((f + 0.5) / N) * (Rx - L);
place(make("div", "body", `480 frames`, root, { color: C.muted }), 100, 446);
place(make("div", "mono", "f0", root, { fontSize: "28px", color: C.muted }), 100, 716);
place(make("div", "mono", "f479", root, { fontSize: "28px", color: C.muted, textAlign: "right" }), 780, 716, 200);
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("width", 1080); svg.setAttribute("height", 992);
Object.assign(svg.style, { position: "absolute", left: "0px", top: "0px", overflow: "visible" });
root.appendChild(svg);
const el = (tag, attrs, parent = svg) => {
const n = document.createElementNS(NS, tag);
for (const [a, b] of Object.entries(attrs)) n.setAttribute(a, b);
parent.appendChild(n);
return n;
};
// 480 ticks, a taller one each second (60 frames). Two copies: faint, and ink revealed by the scan.
const ticks = (stroke) => {
let d = "";
for (let f = 0; f < N; f++) {
const x = xOf(f).toFixed(2), top = f % 60 === 0 ? T0 - 14 : T0;
d += `M${x} ${top}V${T1}`;
}
return el("path", { d, stroke, "stroke-width": 1.1, fill: "none" });
};
ticks(C.line);
const clip = el("clipPath", { id: "s15-clip" }, el("defs", {}));
const clipRect = el("rect", { x: L - 2, y: T0 - 20, width: 0, height: T1 - T0 + 40 }, clip);
const ink = ticks(C.muted);
ink.setAttribute("clip-path", "url(#s15-clip)");
// The scan: an accent bar crossing the strip on ease-in-out.
const scanX = (t) => L + (Rx - L) * easeInOut(clamp((t - tScan) / SCAN));
const scan = make("div", "", "", root, { position: "absolute", top: `${T0 - 30}px`, width: "4px", height: `${T1 - T0 + 46}px`,
background: C.accent, borderRadius: "2px" });
scan.id = "s15-scan";
// Six failing frames, seeded, spread across the strip.
const r = rand("s15-fails");
const fails = [];
while (fails.length < 6) {
const f = 30 + Math.floor(r() * (N - 70));
if (fails.every((g) => Math.abs(g - f) > 50)) fails.push(f);
}
fails.sort((a, b) => a - b);
// When the scan crosses each one (bisection on the ease-in-out), at build time.
const crossAt = (x) => {
let a = tScan, b = tScan + SCAN;
for (let i = 0; i < 50; i++) { const m = (a + b) / 2; if (scanX(m) < x) a = m; else b = m; }
return b;
};
const marks = fails.map((f, i) => {
const g = el("g", { opacity: 0 });
const bar = el("rect", { x: xOf(f) - 2.5, y: T0 - 16, width: 5, height: T1 - T0 + 32, rx: 2.5, fill: C.red }, g);
const dot = el("circle", { cx: xOf(f), cy: T0 - 34, r: 9, fill: C.red }, g);
return { f, i, g, bar, dot, found: crossAt(xOf(f)), fixed: tFix + i * FIX_STAGGER };
});
// Readout: "6 failed" (red), then "71 / 71 pass" (green) with a drawn check.
const chipCss = { fontSize: "40px", padding: "14px 30px", gap: "14px" };
const failed = place(make("div", "chip red", `6failed`, root, chipCss), 80, 832);
const check2 = ``;
const pass = place(make("div", "chip green", `${check2}71 / 71pass`, root, chipCss), 80, 832);
failed.id = "s15-failed"; pass.id = "s15-pass";
sfx(tSix + 1 / 60, "pop"); // the failures pinned as a note
sfx(tSwap + 0.18 + 1 / 60, "success"); // the readout turns green
// ---------- seek ----------
const seek = (t) => {
countTo(num, t, tCount, tCount + COUNT, 0, 71);
const x = scanX(t);
clipRect.setAttribute("width", Math.max(0, x - L + 2).toFixed(2));
scan.style.left = `${(x - 2).toFixed(2)}px`;
show(scan, t, tScan - 0.3, tScan + SCAN, { dur: 0.3, dy: 0 });
for (const m of marks) {
const on = t >= m.found;
m.g.setAttribute("opacity", on ? easeOut(clamp((t - m.found) / 0.15)).toFixed(4) : "0");
m.dot.setAttribute("r", (9 * (0.45 + 0.55 * spring01(t, m.found, { duration: 0.4, damping: 0.82 }))).toFixed(2));
const p = easeInOut(clamp((t - m.fixed) / FIX_DUR));
const c = mix(C.red, C.green, p);
m.bar.setAttribute("fill", c); m.dot.setAttribute("fill", c);
m.g.dataset.state = !on ? "hidden" : p >= 1 ? "green" : p > 0 ? "turning" : "red";
}
// "6 failed" enters on "six"; at the swap it leaves and "71 / 71 pass" enters (kit swap).
if (t < tSwap) {
show(failed, t, tSix, Infinity, { dur: 0.5, dy: 12 });
failed.style.filter = "none";
style(pass, { opacity: 0, blur: 0, y: 0 });
pass.style.visibility = "hidden";
} else {
const s = swap(t, tSwap);
style(failed, s.out);
style(pass, s.in);
failed.style.visibility = s.out.opacity < 0.001 ? "hidden" : "visible";
pass.style.visibility = s.in.opacity < 0.001 ? "hidden" : "visible";
}
};
// ---------- checks ----------
const reds = () => marks.filter((m) => m.g.dataset.state === "red").length;
check(`${S}: the counter lands on 71 with "seventy-one" and holds to the cut`, [tCount + COUNT, win.cutOut - 1 / 60],
() => num.textContent === "71" || `counter ${num.textContent}`);
check(`${S}: the scan reaches f479 at the end of the strip, then leaves`, tScan + SCAN, () => {
const b = box(scan);
return Math.abs(b.cx - Rx) < 0.5 || `scan at ${b.cx.toFixed(2)}, want ${Rx}`;
});
check(`${S}: each tick turns red once the scan reaches it, never ahead of the scan`, [tScan - 0.1, tScan + SCAN + 0.1], () => {
const x = box(scan).cx;
for (const m of marks) {
const shown = m.g.dataset.state !== "hidden";
if (shown && xOf(m.f) > x + 0.5) return `f${m.f} red ahead of the scan (x ${x.toFixed(1)})`;
if (!shown && xOf(m.f) < x - 15) return `f${m.f} passed but not red (x ${x.toFixed(1)})`;
}
return true;
});
check(`${S}: on "six" the strip shows exactly six red ticks and the "6 failed" note`, tSix + 0.5, () =>
(reds() === 6 && Number(failed.style.opacity) > 0.999 && failed.textContent.includes("failed")) || `${reds()} red, readout ${failed.style.opacity}`);
const tDone = Math.max(tSwap + 0.4, tFix + 5 * FIX_STAGGER + FIX_DUR); // the last tick and the readout settle
check(`${S}: all six green and "71 / 71 pass" settled by ${tDone.toFixed(2)} s, held to the cut`, [tDone, win.cutOut - 1 / 60], () => {
const green = marks.filter((m) => m.g.dataset.state === "green").length;
return (green === 6 && Number(pass.style.opacity) > 0.999 && Number(failed.style.opacity) < 0.001) || `${green} green, pass ${pass.style.opacity}`;
});
check(`${S}: title, counter, strip and readout stay apart and inside the stage`, tSwap + 0.5, () => {
const items = [title, counter, card, pass];
for (let i = 0; i < items.length; i++) {
const b = box(items[i]);
if (b.x < 40 || b.x + b.w > 1040 || b.y < 128 || b.y + b.h > 128 + 980) return `${items[i].textContent || "card"} off the stage`;
for (let j = i + 1; j < items.length; j++) if (overlap(items[i], items[j]) > 0) return `${items[i].textContent} overlaps ${items[j].textContent}`;
}
return true;
});
return seek;
}