// s19 The last mile. Four modules, each on its cue; the one before steps back to 45%.
// 1. At the cut a phone (large, centred) plays the real walkthrough clip with its own captions
// burned in. On "muted" a muted badge pops; on "silence" a green "works in silence" chip.
// 2. Hero move: on "Mix" the phone travels to its slot (1.2 s ease-in-out, scale 1.55 → 1) and the
// loudness meter arrives; on "fourteen" its level rises on a spring (ζ 0.82) and settles at −14
// LUFS under a peak line at −1 dBTP.
// 3. On "each picture" a frame strip; on "one frame" the picture hits at frame F (a cell pops 8% over
// 6 frames, and a real tap sounds at F+1); on "its sound" the waveform peaks at F+1, "1 frame later".
// 4. On "export GIFs" two 1-second loops side by side, each with a clock row (true time) and a GIF
// row (what a GIF player shows). A GIF stores delays in hundredths of a second, so 24 fps is stored
// as 4 cs = 40 ms: its GIF row runs 24 × 40 ms = 0.96 s per loop and pulls ahead of the clock by
// one frame a second (4% fast). 25 fps is exactly 4 cs: its rows never part.
export default async function build(root, k) {
const { make, place, show, cueIn, spring01, easeOut, easeInOut, p01, frames, rand, check, sfx, box, overlap, C } = k;
const { cutIn, cutOut } = k.win;
const FPS = 60;
const MINUS = "−";
const tMuted = cueIn(k.id, "muted");
const onFrame = (t) => Math.ceil(t * FPS - 1e-6) / FPS; // the first frame at or after t
const tSilence = onFrame(cueIn(k.id, "silence"));
const tMix = cueIn(k.id, "Mix");
const tFourteen = cueIn(k.id, "fourteen");
const tPicture = cueIn(k.id, "each picture");
const tHit = Math.round((cueIn(k.id, "one frame") + 0.12) * FPS) / FPS; // on a frame: the word's start
const F = Math.round(tHit * FPS);
const tSound = cueIn(k.id, "its sound");
const tGif = cueIn(k.id, "export GIFs");
const tStore = cueIn(k.id, "they can store");
const tFour = cueIn(k.id, "four percent");
const tFast = onFrame(tFour + 0.2); // the "4% fast" note pins
const MOVE = 1.2; // the phone's travel, ease-in-out (a layout change: 0.9–1.5 s)
const tMeter = tMix + 0.75 * MOVE; // the meter enters once the shrinking phone has cleared its slot
const tLevel = Math.max(tFourteen, tMeter + 0.5);
const LEVEL = { duration: 1.0, damping: 0.82 };
const tLoops = tGif + 0.6; // both GIF loops start once their card is in
const DIM = 0.45;
const dimAt = (t, t0) => 1 - (1 - DIM) * easeOut(p01(t, t0 + 0.15, 0.4));
// ================= 1. the phone =================
const GX = 60, GY = 24, GW = 600, PH = 300;
const group = place(make("div", "", "", root, { transformOrigin: "50% 50%" }), GX, GY, GW, PH + 62);
group.dataset.track = "s19-phone";
const phone = place(make("div", "", "", group, { background: C.ink, borderRadius: "56px", boxShadow: "0 18px 50px rgba(28,28,26,0.18)" }), 0, 0, GW, PH);
const screen = place(make("div", "", "", phone, { background: "#000", borderRadius: "44px", overflow: "hidden" }), 14, 14, GW - 28, PH - 28);
const VW = 484, VH = 272;
const video = place(make("div", "", "", screen, { overflow: "hidden", background: "#ecebe7" }), (GW - 28 - VW) / 2, 0, VW, VH);
const vImg = make("img", "", "", video, { position: "absolute", left: "0px", top: "0px", width: `${VW}px`, height: `${VH}px` });
const wInfo = await (await fetch("clips/walkthrough/clip.json")).json();
const walk = frames(vImg, { dir: "clips/walkthrough", ...wInfo });
const clipT = (t) => Math.max(0, t - cutIn);
// The walkthrough's own captions (its .srt), at the clip's offset: the clip starts 17.5 s in.
const CAPS = [[0, 1.83, "Next, click Invite."], [1.86, 4.27, "Type your teammate’s email address."], [5.14, 99, "Now choose a role."]];
const capWrap = make("div", "", "", video, { position: "absolute", left: "0px", right: "0px", bottom: "14px", display: "flex", justifyContent: "center" });
const capLine = make("div", "", "", capWrap, {
maxWidth: "440px", textAlign: "center", padding: "5px 16px 7px", borderRadius: "14px",
background: "rgba(28,28,26,0.86)", color: "#ffffff", fontSize: "28px", fontWeight: "600", lineHeight: "1.2", textWrap: "balance",
});
// Muted badge: a speaker with a slash, 2.5 px strokes.
const badge = place(make("div", "", "", video, { borderRadius: "28px", background: "rgba(28,28,26,0.78)", display: "flex", alignItems: "center", justifyContent: "center" }), VW - 68, 12, 56, 56);
badge.innerHTML = ``;
const silent = place(make("div", "chip green", `works in silence`, group, { fontSize: "28px" }), 0, PH + 14);
// ================= 2. the loudness meter =================
const meter = place(make("div", "card", "", root), 690, 24, 330, 362);
place(make("div", "", "loudness", meter, { fontSize: "28px", color: C.muted, fontWeight: "560" }), 28, 20);
const BAR_Y = 72, BAR_H = 260, PXDB = BAR_H / 30; // 0 at the top, −30 at the bottom
const yOf = (db) => BAR_Y - db * PXDB;
place(make("div", "", "", meter, { background: "#f1f0ec", borderRadius: "22px", border: `2px solid ${C.line}` }), 28, BAR_Y, 44, BAR_H);
const fill = place(make("div", "", "", meter, { background: C.accent, borderRadius: "0 0 20px 20px" }), 30, BAR_Y + BAR_H - 2, 40, 0);
fill.dataset.track = "s19-level";
place(make("div", "", "", meter, { background: C.ink, borderRadius: "2px" }), 20, yOf(-1) - 1.5, 64, 3);
const peak = place(make("div", "mono", `peak ${MINUS}1 dBTP`, meter, { fontSize: "28px", fontWeight: "560", whiteSpace: "nowrap" }), 98, yOf(-1) - 20);
const read = place(make("div", "mono", `${MINUS}14`, meter, { fontSize: "64px", fontWeight: "620", letterSpacing: "-0.02em", lineHeight: "1" }), 98, yOf(-14) - 36);
const unit = place(make("div", "", "LUFS", meter, { fontSize: "30px", fontWeight: "600", color: C.muted }), 102, yOf(-14) + 34);
const levelAt = (t) => -30 + 16 * spring01(t, tLevel, LEVEL);
// ================= 3. picture and sound, one frame apart =================
const strip = place(make("div", "card", "", root), 60, 404, 960, 220);
place(make("div", "", "picture", strip, { fontSize: "30px", fontWeight: "560" }), 32, 64);
const soundLabel = place(make("div", "", "sound", strip, { fontSize: "30px", fontWeight: "560" }), 32, 122);
const CX0 = 200, CWID = 112, CSTEP = 122;
const cellX = (i) => CX0 + i * CSTEP, cellC = (i) => cellX(i) + CWID / 2;
const nums = [], cells = [];
for (let i = 0; i < 6; i++) {
nums.push(place(make("div", "mono", String(F - 2 + i), strip, { fontSize: "28px", width: `${CWID}px`, textAlign: "center", color: C.muted }), cellX(i), 14));
cells.push(place(make("div", "", "", strip, { background: "#f1f0ec", border: `2px solid ${C.line}`, borderRadius: "10px" }), cellX(i), 60, CWID, 48));
}
cells[2].dataset.track = "s19-hit";
// The waveform: a quiet floor, then a transient that peaks at the centre of cell F+1.
const SVGNS = "http://www.w3.org/2000/svg";
const wave = document.createElementNS(SVGNS, "svg");
wave.setAttribute("width", "732"); wave.setAttribute("height", "56");
Object.assign(wave.style, { position: "absolute", left: `${CX0}px`, top: "114px", overflow: "visible" });
strip.appendChild(wave);
const r = rand("s19-wave");
const peakX = cellC(3) - CX0;
let d = "";
for (let x = 0; x <= 732; x += 3) {
const u = x - peakX;
let y = 28 + (r() - 0.5) * 3; // the bed: under ±1.5 px
if (u >= -6 && u < 0) y = 28 - 24 * ((u + 6) / 6); // attack in 6 px
else if (u >= 0) y = 28 - 24 * Math.exp(-u / 26) * Math.cos(u / 6.5); // decay
d += `${x ? "L" : "M"}${x} ${y.toFixed(2)}`;
}
const wpath = document.createElementNS(SVGNS, "path");
Object.entries({ d, fill: "none", stroke: C.ink, "stroke-width": 2.5, "stroke-linejoin": "round", "stroke-linecap": "round" }).forEach(([a, v]) => wpath.setAttribute(a, v));
wave.appendChild(wpath);
const peakDot = document.createElementNS(SVGNS, "circle");
Object.entries({ cx: peakX, cy: 4, r: 7, fill: C.accent }).forEach(([a, v]) => peakDot.setAttribute(a, v));
wave.appendChild(peakDot);
const bracket = document.createElementNS(SVGNS, "svg");
bracket.setAttribute("width", "960"); bracket.setAttribute("height", "40");
Object.assign(bracket.style, { position: "absolute", left: "0px", top: "170px", overflow: "visible" });
bracket.innerHTML = ``;
strip.appendChild(bracket);
const later = place(make("div", "", "1 frame later", strip, { fontSize: "28px", fontWeight: "600", color: C.accent }), cellC(3) + 24, 170);
// ================= 4. GIF: two 1-second loops =================
const gif = place(make("div", "card", "", root), 60, 644, 960, 316);
place(make("div", "", "A GIF stores each frame’s delay in hundredths of a second", gif, { fontSize: "28px", color: C.muted, fontWeight: "500" }), 32, 22);
place(make("div", "", "", gif, { background: C.line }), 479, 74, 2, 224);
const TRACK_W = 330;
const half = (i, fps) => {
const ox = i * 480, N = fps; // a 1-second loop has fps frames
const title = place(make("div", "mono", `${fps} fps → 4 cs`, gif, { fontSize: "32px", fontWeight: "600" }), ox + 32, 70);
const row = (label, y) => {
place(make("div", "", label, gif, { fontSize: "28px", color: C.muted, fontWeight: "560" }), ox + 32, y);
const track = place(make("div", "", "", gif, { background: "#f1f0ec", borderRadius: "8px", overflow: "hidden" }), ox + 120, y + 2, TRACK_W, 34);
for (let c = 1; c < N; c++) place(make("div", "", "", track, { background: C.line }), (TRACK_W * c) / N - 0.75, 0, 1.5, 34);
const head = place(make("div", "", "", track, { borderRadius: "4px" }), 0, 0, TRACK_W / N, 34);
return { track, head };
};
const clock = row("clock", 124), shown = row("GIF", 168);
clock.head.style.background = C.faint;
const math = place(make("div", "mono", `${fps} × 40 ms = ${(fps * 0.04).toFixed(2)} s`, gif, { fontSize: "28px", fontWeight: "560" }), ox + 32, 218);
return { N, fps, title, clock, shown, math };
};
const L24 = half(0, 24), L25 = half(1, 25);
L24.shown.head.dataset.track = "s19-gif24";
const fast = place(make("div", "chip red", "4% fast", gif, { fontSize: "28px" }), 32, 260);
const exact = place(make("div", "chip green", "exact", gif, { fontSize: "28px" }), 512, 260);
// True time T since the loops started. The clock row shows the frame due now, ⌊T × fps⌋; the GIF
// row shows the frame a GIF player shows when every delay is 4 cs, ⌊T / 0.04⌋. Both wrap at N.
const EPS = 1e-9;
const due = (T, fps) => Math.floor(T * fps + EPS);
const gifFrame = (T) => Math.floor(T / 0.04 + EPS);
const loopT = (t) => Math.max(0, t - tLoops);
sfx(tSilence + 2 / FPS, "success"); // show() starts at 0: first visible frame +1, its sound +2
sfx(tHit + 1 / FPS, "tap"); // the picture hits at F, its sound at F+1: the strip's own claim
sfx(tFast + 2 / FPS, "pop"); // show() starts at 0: first visible frame +1, its sound +2
await document.fonts.ready;
// ---- checks ----
const phoneAt = (t) => easeInOut(p01(t, tMix, MOVE));
let levelSettle = 0;
for (let f = 0; f < 240; f++) if (Math.abs(16 * (1 - spring01(f / FPS, 0, LEVEL))) > 0.05) levelSettle = f + 1;
const tLevelSettled = tLevel + levelSettle / FPS;
check("s19 cut is a finished picture: the phone with a decoded clip frame and its caption", cutIn, () =>
(vImg.complete && vImg.naturalWidth > 0 && capLine.textContent.length > 0 && Number(group.style.opacity) > 0.99) || "missing");
check("s19 phone on its slot by the end of its move and held", [tMix + MOVE, cutOut - 1 / FPS], () => {
const b = box(phone);
return (Math.abs(b.x - GX) < 0.5 && Math.abs(b.y - 128 - GY) < 0.5 && Math.abs(b.w - GW) < 0.5) || `phone at ${b.x.toFixed(1)},${(b.y - 128).toFixed(1)} w ${b.w.toFixed(1)}`;
});
check(`s19 level settled at −14 LUFS (±0.05 dB) by f${Math.round(tLevelSettled * FPS)} and held`, [tLevelSettled, cutOut - 1 / FPS], () => {
const L = levelAt(tLevelSettled), h = Number.parseFloat(fill.style.height);
return (Math.abs(L + 14) < 0.05 && Math.abs(h - 16 * PXDB) < 0.5 && read.textContent === `${MINUS}14`) || `level ${L.toFixed(3)} fill ${h}`;
});
check(`s19 the picture hits on f${F}, not before`, [tHit - 1 / FPS, tHit], (t) => {
const lit = cells[2].style.background === "rgb(47, 85, 224)" || cells[2].style.background === C.accent;
return Math.round(t * FPS) >= F ? (lit || "not lit on F") : (!lit || "lit before F");
});
check(`s19 its sound is declared one frame later (f${F + 1})`, cutIn, () =>
window.SFX.some((s) => s.kind === "tap" && Math.abs(s.t * FPS - (F + 1)) < 1e-6) || "no tap at F+1");
check("s19 GIF rows show ⌊T/0.04⌋, clock rows ⌊T·fps⌋; 24 fps pulls ahead, 25 fps stays exact", [tLoops, cutOut - 1 / FPS], (t) => {
const T = loopT(t);
for (const L of [L24, L25]) {
const want = [due(T, L.fps) % L.N, gifFrame(T) % L.N];
const got = [Number(L.clock.head.dataset.idx), Number(L.shown.head.dataset.idx)];
if (got[0] !== want[0] || got[1] !== want[1]) return `${L.fps} fps: clock ${got[0]}/${want[0]} gif ${got[1]}/${want[1]}`;
}
if (due(T, 25) !== gifFrame(T)) return "25 fps drifted";
return true;
});
check("s19 24 fps GIF is ahead by ⌊T⌋ ± 1 frames (4% fast), at least 4 frames by the cut", cutOut - 1 / FPS, () => {
const T = loopT(cutOut - 1 / FPS), ahead = gifFrame(T) - due(T, 24);
return (Math.abs(ahead - T) <= 1 && ahead >= 4) || `ahead ${ahead} after ${T.toFixed(2)} s`;
});
check("s19 modules never overlap, during the phone's move or after", [tMix, cutOut - 1 / FPS], () => {
const els = [group, meter, strip, gif];
for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) if (overlap(els[i], els[j]) > 0) return `${i} x ${j}`;
return true;
});
check("s19 everything stays inside the stage", [cutIn, cutOut - 1 / FPS], () =>
[phone, silent, meter, strip, gif].every((el) => {
if (getComputedStyle(el).visibility === "hidden") return true;
const b = box(el); return b.x >= 0 && b.x + b.w <= 1080 && b.y >= 128 && b.y + b.h <= 1120;
}) || "outside");
return (t) => {
// 1. The phone: large and centred until "Mix", then it travels to its slot.
const m = phoneAt(t);
const S = 1.55, dx = 540 - (GX + GW / 2), dy = 470 - (GY + (PH + 62) / 2);
group.style.transform = `translate(${(dx * (1 - m)).toFixed(2)}px, ${(dy * (1 - m)).toFixed(2)}px) scale(${(S + (1 - S) * m).toFixed(4)})`;
group.style.opacity = dimAt(t, tMeter).toFixed(4);
const c = clipT(t);
const cap = CAPS.find(([a, b]) => c >= a && c < b);
capLine.textContent = cap ? cap[2] : "";
capLine.style.visibility = cap ? "visible" : "hidden";
const bs = spring01(t, tMuted, { duration: 0.45, damping: 0.82 });
badge.style.transform = `scale(${(0.6 + 0.4 * bs).toFixed(4)})`;
badge.style.opacity = easeOut(p01(t, tMuted, 0.2)).toFixed(4);
show(silent, t, tSilence, Infinity, { dur: 0.45, dy: 10 });
// 2. The meter: enters, then its level rises and settles at −14 LUFS.
show(meter, t, tMeter, Infinity, { dur: 0.5, dy: 16 });
meter.style.opacity = (Number(meter.style.opacity) * dimAt(t, tPicture)).toFixed(4);
const L = levelAt(t);
const h = (30 + L) * PXDB;
fill.style.height = `${h.toFixed(2)}px`;
fill.style.top = `${(BAR_Y + BAR_H - h).toFixed(2)}px`;
fill.style.borderRadius = h > BAR_H - 22 ? "20px" : "0 0 20px 20px";
const on = t >= tLevel;
read.textContent = on ? `${MINUS}${Math.abs(Math.round(L))}` : `${MINUS}30`;
read.style.opacity = easeOut(p01(t, tLevel, 0.3)).toFixed(4);
unit.style.opacity = read.style.opacity;
// 3. Picture and sound.
show(strip, t, tPicture, Infinity, { dur: 0.5, dy: 16 });
strip.style.opacity = (Number(strip.style.opacity) * dimAt(t, tGif)).toFixed(4);
const hit = t >= tHit;
const pop = hit ? 1 + 0.08 * Math.sin(Math.PI * Math.min(1, (t - tHit) * FPS / 6)) : 1; // +8% over 6 frames
cells[2].style.background = hit ? C.accent : "#f1f0ec";
cells[2].style.borderColor = hit ? C.accent : C.line;
cells[2].style.transform = `scale(${pop.toFixed(4)})`;
nums[2].style.color = hit ? C.accent : C.muted;
const wd = easeOut(p01(t, tSound, 0.5));
const len = 760;
wpath.style.strokeDasharray = `${len} ${len}`;
wpath.style.strokeDashoffset = (len * (1 - wd)).toFixed(2);
wave.style.visibility = t >= tSound ? "visible" : "hidden";
peakDot.setAttribute("r", (7 * spring01(t, tSound + 0.32, { duration: 0.35, damping: 0.82 })).toFixed(3));
nums[3].style.color = t >= tSound + 0.32 ? C.accent : C.muted;
show(bracket, t, tSound + 0.5, Infinity, { dur: 0.4, dy: 0 });
show(later, t, tSound + 0.6, Infinity, { dur: 0.45, dx: -12, dy: 0 });
// 4. The GIF loops.
show(gif, t, tGif, Infinity, { dur: 0.5, dy: 16 });
const T = loopT(t);
for (const Lp of [L24, L25]) {
const ci = due(T, Lp.fps) % Lp.N, gi = gifFrame(T) % Lp.N;
Lp.clock.head.style.left = `${((TRACK_W * ci) / Lp.N).toFixed(2)}px`;
Lp.shown.head.style.left = `${((TRACK_W * gi) / Lp.N).toFixed(2)}px`;
Lp.clock.head.dataset.idx = ci;
Lp.shown.head.dataset.idx = gi;
}
const red = t >= tFour, green = t >= tStore;
L24.shown.head.style.background = red ? C.red : C.ink;
L24.title.style.color = red ? C.red : C.ink;
L25.shown.head.style.background = green ? C.green : C.ink;
L25.title.style.color = green ? C.green : C.ink;
show(exact, t, tStore, Infinity, { dur: 0.45, dx: -12, dy: 0 });
show(fast, t, tFast, Infinity, { dur: 0.45, dx: -12, dy: 0 });
return walk(clipT(t));
};
}