// s21 Close. The end card, and the last frame people see. It bookends s01: the same full-stage
// studio, a title top left, the ceramic mark in the middle, a mono line under it.
// Hero move: on "All of this" the mark drops in and lands in the centre (hero3d `land`: touchdown
// f27 with squash, settled at LAND.settle), with a tap one frame after touchdown. Then the card
// builds one line at a time, each on the words that say it:
// "free video skill" → "video skill · free and open source" (title, top left)
// "made with it" → "Made with the skill it explains" (its deck)
// "Florence's article" → "Florence's article: x.com/flxrnc" (a white pill under the mark)
// "the skill" → the install line, mono, in a white pill
// The full card is complete 0.5 s after the last cue and holds about 5.2 s to the end of the video.
// All text on the studio is ink (≥ 9.7:1 on the backdrop); the accent appears only on white (5.9:1).
import * as THREE from "/_vendor/three/build/three.module.js";
import { createHero, LAND } from "../hero3d.js";
export default async function build(root, k) {
const { make, place, show, cueIn, check, sfx, box, overlap, C } = k;
const { cutIn, cutOut } = k.win;
const FPS = 60;
const tLand = cueIn(k.id, "All of this");
const tTitle = cueIn(k.id, "free video skill");
const tDeck = cueIn(k.id, "made with it");
const tArticle = cueIn(k.id, "Florence's article");
const tInstall = cueIn(k.id, "the skill");
const touchdown = tLand + LAND.contact / FPS;
const settled = tLand + LAND.settle / FPS;
const ENTER = 0.5;
const tFull = tInstall + ENTER; // every line fully in
// ---- the studio fills the stage; the mark lands in its centre ----
const canvas = make("canvas", "", "", root, { position: "absolute", left: "0px", top: "0px" });
const MARK_Y = 470; // the mark's resting centre in stage pixels
const hero = createHero(canvas, {
variant: "directed", width: 1080, height: 992, seed: 7,
framing: { fill: 0.2, aim: [0, 0.5], center: [0.5, MARK_Y / 992] },
});
const state = { land: tLand };
// An invisible box that follows the mark on screen: the speed graph samples it, checks use it.
const tracker = make("div", "", "", root, { position: "absolute", pointerEvents: "none" });
tracker.dataset.track = "s21-mark";
const v = new THREE.Vector3();
// ---- the card ----
const title = place(make("div", "", "", root, { fontWeight: "700", letterSpacing: "-0.025em", lineHeight: "1.05", whiteSpace: "nowrap", color: C.ink }), 80, 64);
title.innerHTML = `video skill · free and open source`;
const deck = place(make("div", "body", "Made with the skill it explains", root, { fontSize: "36px", fontWeight: "500", color: C.ink }), 80, 0);
const pill = (html, css = {}) => make("div", "", html, root, {
position: "absolute", left: "0px", padding: "14px 30px 16px", borderRadius: "999px", background: C.paper,
boxShadow: "var(--shadow)", whiteSpace: "nowrap", color: C.ink, ...css,
});
const article = pill(`Florence’s article: x.com/flxrnc`, { fontSize: "36px", fontWeight: "560" });
const install = pill("/plugin marketplace add tstout729/video-skill", { fontFamily: "'Geist Mono', ui-monospace, monospace", fontSize: "32px", fontWeight: "560", padding: "16px 26px 18px" });
const ARTICLE_Y = 680, INSTALL_Y = 784;
sfx(touchdown + 1 / FPS, "tap"); // the landing, one frame after its picture
// Fit the title to the 920 px measure (80 px margins), then lay out the rest from it.
await document.fonts.ready;
title.style.fontSize = "64px";
const fit = Math.min(64, (64 * 920) / title.scrollWidth);
title.style.fontSize = `${fit.toFixed(2)}px`;
deck.style.top = `${(64 + title.offsetHeight + 14).toFixed(2)}px`;
for (const [el, y] of [[article, ARTICLE_Y], [install, INSTALL_Y]]) {
el.style.left = `${((1080 - el.offsetWidth) / 2).toFixed(2)}px`;
el.style.top = `${y}px`;
}
// ---- checks ----
check(`s21 mark settled by land + f${LAND.settle}`, settled, () => {
const p = hero.pose(settled, state);
return (Math.abs(p.sy - 1) < 0.003 && Math.abs(p.yaw) < 0.3 && p.y === 0) || `sy ${p.sy.toFixed(4)} yaw ${p.yaw.toFixed(2)}`;
});
check("s21 mark rests on the centre line (±8 px) to the end", [settled, cutOut - 1 / FPS], () => {
const b = box(tracker);
return Math.abs(b.cx - 540) < 8 || `cx ${b.cx.toFixed(1)}`;
});
check("s21 lines arrive in order, each after the last one has entered", cutIn, () =>
(tTitle > settled && tDeck >= tTitle + ENTER && tArticle >= tDeck + ENTER && tInstall >= tArticle + ENTER) || "order");
check("s21 the full card is complete and holds at least 4 s to the last frame", [tFull, cutOut - 1 / FPS], () => {
if (cutOut - tFull < 4) return `hold only ${(cutOut - tFull).toFixed(2)} s`;
const off = [title, deck, article, install].find((el) => Number(el.style.opacity) < 0.999 || el.style.visibility === "hidden");
return !off || `${off.textContent.slice(0, 24)} at ${off.style.opacity}`;
});
check("s21 the full card holds still: no line moves after it has entered", [tFull, cutOut - 1 / FPS], () =>
[title, deck, article, install].every((el) => ["none", "matrix(1, 0, 0, 1, 0, 0)"].includes(getComputedStyle(el).transform)) || "moving");
check("s21 the mark clears every line, and the lines clear each other", [settled, cutOut - 1 / FPS], () => {
const els = [title, deck, article, install, tracker];
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("s21 text stays inside the stage, with 80 px margins", [tFull, cutOut - 1 / FPS], () =>
[title, deck, article, install].map((el) => { const b = box(el); return b.x >= 79.5 && b.x + b.w <= 1000.5 && b.y >= 128 && b.y + b.h <= 1120 ? "" : `${el.textContent.slice(0, 16)} x ${b.x.toFixed(1)}–${(b.x + b.w).toFixed(1)} y ${b.y.toFixed(1)}–${(b.y + b.h).toFixed(1)}`; }).filter(Boolean).join("; ") || true);
return (t) => {
hero.render(t, state);
const p = hero.pose(t, state);
v.set(p.x, p.y + 0.5 * p.sy, 0).project(hero.camera);
const x = (v.x * 0.5 + 0.5) * 1080, y = (0.5 - v.y * 0.5) * 992;
const hpx = 200 * p.sy, wpx = 190 * p.sxz;
Object.assign(tracker.style, { left: `${(x - wpx / 2).toFixed(2)}px`, top: `${(y - hpx / 2).toFixed(2)}px`, width: `${wpx.toFixed(2)}px`, height: `${hpx.toFixed(2)}px`, visibility: p.visible ? "visible" : "hidden" });
// Text: ease-out entrances only, no exits. Nothing moves once it is in.
show(title, t, tTitle, Infinity, { dur: ENTER, dy: 14 });
show(deck, t, tDeck, Infinity, { dur: ENTER, dy: 12 });
show(article, t, tArticle, Infinity, { dur: ENTER, dy: 12 });
show(install, t, tInstall, Infinity, { dur: ENTER, dy: 12 });
};
}