// s17 · Feelings into numbers. Two rows: "make it pop" becomes "+8% · 6 frames · on the beat" and a
// card pops 8% over 6 frames, peaking exactly on four beats of the bed; "it drags" becomes "each hold
// −1 beat" and a hold bar shortens by one beat, ending on the grid. Then the receipts: a log card
// with a follow-up count, pinned with a check.
// The receipt is from the article (Experiment 1, directed side: fifteen follow-ups over four review
// rounds). The brief's "23 notes" is not in the article, so it is not shown.
const RECEIPT = { kicker: "Experiment 1 · directed side", line: "4 review rounds · 15 follow-ups" };
export default function build(root, k) {
const { make, place, show, cueIn, typeOn, check, box, overlap, sfx, swap, style, mix, clamp, easeOut, easeInOut, spring01, nearestBeat, C, win } = k;
const S = "s17";
// ---------- cues ----------
const tPop = cueIn(k.id, "make it pop");
const tBecomes1 = cueIn(k.id, "becomes", 1);
const tDrags = cueIn(k.id, "it drags");
const tBecomes2 = cueIn(k.id, "becomes", 2);
const tShorter = cueIn(k.id, "shorter");
const tPost = cueIn(k.id, "post");
const tReceipts = cueIn(k.id, "receipts");
const CPS = 30;
// Four pops, each peaking exactly on a beat of the 120 BPM bed, starting once the numbers are typed.
const L1 = "+8% · 6 frames · on the beat";
const typed1 = tBecomes1 + L1.length / CPS;
const firstBeat = nearestBeat(typed1 + 0.25);
const BEATS = [0, 1, 2, 3].map((i) => firstBeat + i * 0.5);
const HALF = 3 / 60; // 3 frames up, 3 frames down: 6 frames
const popAt = (t) => {
for (const b of BEATS) {
const u = (t - (b - HALF)) / (2 * HALF);
// Raised cosine: zero speed at both ends, so it eases in and out (1, 1.02, 1.06, 1.08, 1.06, 1.02, 1).
if (u > 0 && u < 1) return 1 + 0.08 * (1 - Math.cos(2 * Math.PI * u)) / 2;
}
return 1;
};
// ---------- layout ----------
place(make("div", "h2", "Feelings → numbers", root), 80, 44);
const feeling = (text, y) => place(make("div", "h2", `“${text}”`, root), 80, y);
const numbers = (y) => {
const row = place(make("div", "", "", root, { display: "flex", alignItems: "baseline", gap: "16px" }), 80, y);
const arrow = make("div", "h2", "→", row, { color: C.accent });
const text = make("div", "mono", "", row, { fontSize: "36px", fontWeight: "550", color: C.ink, whiteSpace: "pre" });
return { row, arrow, text };
};
// Row 1: make it pop.
const f1 = feeling("make it pop", 160);
const n1 = numbers(234);
const popWrap = place(make("div", "", "", root), 780, 132, 220, 140);
const popCard = make("div", "card", "", popWrap, { left: "0px", top: "0px", width: "220px", height: "140px", transformOrigin: "50% 50%" });
popCard.id = "s17-pop";
popCard.innerHTML = ``;
const dots = [0, 1, 2, 3].map((i) => place(make("div", "", "", root, { width: "16px", height: "16px", borderRadius: "8px", background: C.line }), 830 + i * 40, 300));
// Row 2: it drags.
const f2 = feeling("it drags", 420);
const n2 = numbers(494);
const BX = 760, BW = 56, BY = 440, BH = 44;
const grid = place(make("div", "", "", root), 0, 0);
for (let i = 0; i <= 4; i++) place(make("div", "", "", grid, { width: "2.5px", height: `${BH + 24}px`, background: C.faint, borderRadius: "2px" }), BX + i * BW - 1.25, BY - 12);
const ghost = place(make("div", "", "", root, { border: `2.5px dashed ${C.faint}`, borderRadius: "10px" }), BX + 3 * BW + 4, BY, BW - 8, BH);
const bar = place(make("div", "", "", root, { background: C.accent, borderRadius: "10px" }), BX + 4, BY, 4 * BW - 8, BH);
bar.id = "s17-bar";
const holdA = place(make("div", "mono", "hold · 4 beats", root, { fontSize: "28px", color: C.muted }), BX, BY + BH + 24);
const holdB = place(make("div", "mono", "hold · 3 beats", root, { fontSize: "28px", color: C.ink }), BX, BY + BH + 24);
// Row 3: receipts.
const card = place(make("div", "card", "", root), 60, 700, 960, 196);
card.id = "s17-log";
place(make("div", "small", `log.md · ${RECEIPT.kicker}`, card, { fontSize: "28px", color: C.muted }), 44, 40);
place(make("div", "mono", RECEIPT.line, card, { fontSize: "40px", fontWeight: "600", color: C.ink }), 44, 100);
const pin = place(make("div", "", ``,
root, { width: "64px", height: "64px", transformOrigin: "50% 50%" }), 940, 700 - 26);
pin.id = "s17-check";
const SHRINK = 0.6;
sfx(tReceipts + 1 / 60, "success"); // the check pins on the log
// ---------- seek ----------
const seek = (t) => {
// Row 1
show(f1, t, tPop, Infinity, { dur: 0.5, dy: 12 });
show(popWrap, t, tPop + 0.15, Infinity, { dur: 0.5, dy: 12 });
dots.forEach((d) => show(d, t, tPop + 0.15, Infinity, { dur: 0.5, dy: 12 }));
show(n1.row, t, tBecomes1, Infinity, { dur: 0.3, dy: 0 });
typeOn(n1.text, L1, t, tBecomes1, CPS);
popCard.style.transform = `scale(${popAt(t).toFixed(5)})`;
// Beat dots: each lights on its beat (2 a second, under the 3 flashes/s limit) and fades in 0.2 s.
dots.forEach((d, i) => {
const lit = t >= BEATS[i] - 1e-6 ? 1 - easeInOut(clamp((t - BEATS[i] - 0.18) / 0.2)) : 0;
d.style.background = mix(C.line, C.accent, lit);
});
// Row 2
show(f2, t, tDrags, Infinity, { dur: 0.5, dy: 12 });
show(grid, t, tDrags + 0.15, Infinity, { dur: 0.5, dy: 0 });
show(bar, t, tDrags + 0.15, Infinity, { dur: 0.5, dy: 0 });
show(n2.row, t, tBecomes2, Infinity, { dur: 0.3, dy: 0 });
typeOn(n2.text, "each hold −1 beat", t, tBecomes2, CPS);
const p = easeInOut(clamp((t - tShorter) / SHRINK));
bar.style.width = `${(4 * BW - 8 - p * BW).toFixed(2)}px`;
ghost.style.opacity = (easeOut(clamp((t - tShorter - 0.2) / 0.4))).toFixed(4);
show(holdA, t, tDrags + 0.15, Infinity, { dur: 0.5, dy: 0 });
if (t >= tShorter + SHRINK) {
const s = swap(t, tShorter + SHRINK);
style(holdA, s.out); style(holdB, s.in);
} else style(holdB, { opacity: 0, blur: 0, y: 0 });
// Row 3
show(card, t, tPost, Infinity, { dur: 0.5, dy: 16 });
const a = spring01(t, tReceipts, { duration: 0.45, damping: 0.82 });
style(pin, { opacity: easeOut(clamp((t - tReceipts) / 0.2)), scale: 0.4 + 0.6 * a });
};
// ---------- checks ----------
const px = (el) => box(el);
check(`${S}: each pop peaks at +8% exactly on a beat of the 120 BPM grid`, BEATS[0], () => {
for (const b of BEATS) {
if (Math.abs(b * 2 - Math.round(b * 2)) > 1e-9) return `pop at ${b} is off the beat grid`;
if (Math.abs(popAt(b) - 1.08) > 1e-9) return `peak ${popAt(b)} at ${b}`;
}
return true;
});
BEATS.forEach((b, i) => check(`${S}: pop ${i + 1} lasts 6 frames (f${Math.round(b * 60) - 3}–f${Math.round(b * 60) + 3}) and peaks on f${Math.round(b * 60)}`,
[b - 4 / 60, b + 4 / 60], (t) => {
const d = Math.round((t - b) * 60), s = popAt(t);
const card = px(popCard).w / 220;
if (Math.abs(card - s) > 0.002) return `card drawn at ${card.toFixed(4)}, pop ${s.toFixed(4)}`;
if (Math.abs(d) >= 3) return Math.abs(s - 1) < 1e-9 || `still scaled ${s.toFixed(4)} at ${d} frames from the beat`;
return (s > 1 && (d !== 0 || Math.abs(s - 1.08) < 1e-9)) || `scale ${s.toFixed(4)} at ${d} frames from the beat`;
}));
check(`${S}: the hold bar ends on the grid, one beat shorter, from ${(tShorter + SHRINK).toFixed(2)} s to the cut`, [tShorter + SHRINK, win.cutOut - 1 / 60], () => {
const b = px(bar);
const end = BX + 3 * BW - 4;
return Math.abs(b.x + b.w - end) < 0.5 || `bar ends at ${(b.x + b.w).toFixed(2)}, grid at ${end}`;
});
check(`${S}: the check is pinned on the log card by ${(tReceipts + 0.6).toFixed(2)} s and holds to the cut`, [tReceipts + 0.6, win.cutOut - 1 / 60], () => {
const b = px(pin), c = px(card);
const on = b.x < c.x + c.w && b.x + b.w > c.x + c.w - 80 && b.y < c.y + 30;
return (Number(pin.style.opacity) > 0.999 && Math.abs(b.w - 64) < 1 && on) || `check w ${b.w.toFixed(1)} op ${pin.style.opacity}`;
});
check(`${S}: rows, demos and the log card stay apart and inside the stage`, win.cutOut - 0.1, () => {
const items = [f1, n1.row, popWrap, f2, n2.row, bar, holdB, card];
for (let i = 0; i < items.length; i++) {
const b = px(items[i]);
if (b.x < 40 || b.x + b.w > 1040 || b.y < 128 || b.y + b.h > 128 + 980) return `${items[i].textContent || items[i].id} off the stage`;
for (let j = i + 1; j < items.length; j++) if (overlap(items[i], items[j]) > 0) return `${items[i].textContent || items[i].id} overlaps ${items[j].textContent || items[j].id}`;
}
return true;
});
return seek;
}