项目文件夹

文件
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

8782 行
458 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/usr/bin/env node
/*
* make-theme.cjs — THEME mode compiler (third compiler, beside standard/cinematic).
*
* A theme DNA (themes/<name>.json) is a complete visual constitution composed
* from two registries implemented HERE, once, for all DNAs:
*
* BODY PARADIGMS how the transcript surface lives
* rail lower-third lines that replace each other (fg alpha)
* panel console log accumulating in a docked glass panel (fg alpha)
* poem lines accumulate in open space, stanza by stanza (fg alpha)
* takeover every beat owns the full frame as a hard-cut card (bg)
*
* HERO SETPIECES the climax choreography
* detonation sliced stencil stamp: shear-in, snap, squash, cool, furniture
* decode slot-machine glyph reels lock left→right, CRT-off exit
* drawon single-line-font stroke-order writing (sequential dash reveal)
* assembly inline word assembles from inbound particles (poem apex)
* colorflip inline accent-color crush card (takeover apex)
*
* plus LINKAGES (theme interactions: redact-until-hero, disperse-on-last-word,
* corrupt-on-last-word) and a PLATE budget compiled to _postfx.sh.
*
* Inputs : <project>/theme.json { dna, lines:[[w,...],...], hero:{match,text?},
* minors?:[...], width?, height?, fps? }
* <project>/transcript.json (verbatim, word timings)
* themes/<dna>.json
* Outputs: index.html (bg: plate reaction + embedded setpiece)
* rail.html (fg alpha: body paradigm + front fx) [unless body.layer=bg]
* _postfx.sh (plate reaction: punch/shake/grain after composite)
*
* Render: scripts/render-theme.sh <project> (render-and-composite + postfx)
*
* Determinism contract: paused GSAP on window.__timelines["main"], seeded PRNG
* only (mulberry32), set-chains / pure-f(t) keyframes, no Math.random/Date.now.
*/
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const PROJECT = path.resolve(process.argv[2] || ".");
const SKILL = path.resolve(__dirname, "..");
const F = 1 / 24;
// ---------- load inputs ----------
const theme = JSON.parse(fs.readFileSync(path.join(PROJECT, "theme.json"), "utf8"));
const dna = JSON.parse(fs.readFileSync(path.join(SKILL, "themes", theme.dna + ".json"), "utf8"));
const transcript = JSON.parse(fs.readFileSync(path.join(PROJECT, "transcript.json"), "utf8"));
const W = theme.width || 1280,
H = theme.height || 720;
// FPS: matte.fps is AUTHORITATIVE (written by matte.cjs at the source's native
// rate; the matte overlay + postfx zoompan must run at this rate or the output
// retimes). theme.fps only overrides when matte.fps is absent.
function readMatteFps() {
try {
const v = parseInt(
fs.readFileSync(path.join(PROJECT, "matte.fps"), "utf8").replace(/\D/g, ""),
10,
);
return v > 0 ? v : null;
} catch {
return null;
}
}
const FPS = readMatteFps() || theme.fps || 24;
function probeDuration() {
try {
const out = execFileSync(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
path.join(PROJECT, "source.mp4"),
],
{ encoding: "utf8" },
);
return parseFloat(out.trim());
} catch {
const n = fs.readdirSync(path.join(PROJECT, "frames_fg")).length;
return n / FPS;
}
}
const DUR = +(theme.duration || probeDuration()).toFixed(6);
// ---------- scene awareness (safe-zones.json): inherit the typography system ----------
// The existing modes' scene intelligence applies here too: hero placement comes
// from the measured occlusion bands + subject anchor, panel/poem dock on the
// clearer side, and hero font size is width-fit to the actual word. Explicit
// theme.json values > scene auto > DNA fallback.
function readJson2(p) {
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
}
const sz = readJson2(path.join(PROJECT, "safe-zones.json"));
// per-setpiece preferred band (topPct of the hero's TOP edge)
const SETPIECE_PREF_TOP = {
detonation: 28,
decode: 26,
drawon: 18,
cpslam: 27,
coverword: 27,
settle: 27,
flapboard: 25,
ledwipe: 52,
vhsosd: 26,
bossintro: 30,
rubberstamp: 29,
lasercage: 33,
boltstrike: 24,
holoboot: 28,
biobloom: 33,
silkribbon: 18,
scopetrace: 25,
papermat: 26,
centerfold: 21,
chalkwrite: 44,
spraytag: 26,
brushwrite: 19,
inkbloom: 25,
ransomnote: 31,
};
function sceneHeroXY(setpiece, fontPx) {
const exX = theme.hero && theme.hero.x,
exY = theme.hero && theme.hero.y;
let x = exX ?? null,
y = exY ?? null;
if (x == null && sz && sz.heroAnchor && sz.heroAnchor.centerXPct != null)
x = (W * sz.heroAnchor.centerXPct) / 100;
if (x == null) x = dna.hero.x ?? W / 2;
if (y == null && sz && sz.heroBands && Array.isArray(sz.heroBands.profile)) {
const pref = SETPIECE_PREF_TOP[setpiece] ?? 28;
const ok = sz.heroBands.profile.filter((b) => b.occPct >= 18 && b.occPct <= 55);
const pool = ok.length ? ok : sz.heroBands.profile;
const best = pool.reduce((a, b) =>
Math.abs(b.topPct - pref) < Math.abs(a.topPct - pref) ? b : a,
);
y = (H * best.topPct) / 100 + fontPx * 0.55; // band top → glyph center
}
if (y == null) y = dna.hero.y ?? H * 0.37;
return { x: Math.round(x), y: Math.round(y) };
}
// width-fit: hero font size from the actual word (never overflow, never timid)
function fitHeroPx(text, basePx, emPerChar, maxFrac) {
const est = (px) => text.length * emPerChar * px;
let px = basePx;
const maxW = W * (maxFrac || 0.92);
if (est(px) > maxW) px = Math.floor(maxW / (text.length * emPerChar));
// short words: allow growth toward the poster fill, capped — except in the
// calm register, where the DNA base size IS the ceiling (quiet briefs got a
// 188px hero from this growth in the cold-start E2E)
else if (est(px) < W * 0.55 && dna.register !== "calm")
px = Math.min(Math.floor(maxW / (text.length * emPerChar)), Math.round(basePx * 1.25));
return Math.max(64, px);
}
// clearer side for docked furniture (panel / poem): away from the subject
const CLEARER = (sz && sz.subject && sz.subject.clearerSide) || "left";
// ---------- measured glyph metrics (assets/fonts/char-widths.json) ----------
// Per-char advance widths (em) + fontBoundingBox ascent/descent for every
// bundled family, measured once in headless Chrome over the SAME woff2 faces
// the renderer embeds. Lets a paradigm BAKE pixel-exact word geometry at
// compile time (beam aim, flare centers) so font-load timing in the render
// browser can never skew it (kerning ignored: ≤3px/word vs ground truth).
const CHARW = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(SKILL, "assets/fonts/char-widths.json"), "utf8"));
} catch {
return null;
}
})();
function famMetrics(fam) {
return (CHARW && CHARW[fam]) || null;
}
function wordPx(text, fam, px, lsEm) {
const m = famMetrics(fam);
if (!m) console.error(`[make-theme] WARN no measured widths for "${fam}" -> 0.6em/char estimate`);
const adv = m
? [...text].reduce((a, c) => a + (m.widths[c] ?? m.widths["0"] ?? 0.6), 0)
: text.length * 0.6;
return adv * px + (lsEm || 0) * px * text.length;
}
// ---------- word timing: sequential matcher (verbatim completeness) ----------
const norm = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
const tWords = transcript.words.map((w) => ({
text: w.text ?? w.word,
start: w.start,
end: w.end,
used: false,
}));
let cursor = 0;
function takeWord(authored) {
const target = norm(authored);
for (let i = cursor; i < Math.min(cursor + 3, tWords.length); i++) {
if (norm(tWords[i].text) === target) {
if (tWords[i].used)
throw new Error(
`[make-theme] "${authored}" is already claimed by hero.match — embed setpieces own the hero word: leave it OUT of lines (rail↔climax hand-off)`,
);
tWords[i].used = true;
cursor = i + 1;
return tWords[i];
}
}
throw new Error(
`[make-theme] cannot match authored word "${authored}" near transcript position ${cursor} ("${(tWords[cursor] || {}).text}") — lines must follow transcript order verbatim`,
);
}
// hero phrase: located independently in the transcript (it may sit between lines)
function findPhrase(phrase) {
const parts = phrase.trim().split(/\s+/).map(norm);
for (let i = 0; i <= tWords.length - parts.length; i++) {
if (parts.every((p, k) => norm(tWords[i + k].text) === p)) {
return {
start: tWords[i].start,
end: tWords[i + parts.length - 1].end,
idx: i,
len: parts.length,
};
}
}
throw new Error(`[make-theme] hero phrase "${phrase}" not found in transcript`);
}
const heroInline = !!dna.hero.inline;
// theme.hero is OPTIONAL: a heroless theme runs pure-body (the right authoring
// for quiet briefs — "安静/每个词都要读到" wants no setpiece at all).
const HEROLESS = !theme.hero;
if (HEROLESS) theme.hero = {};
const hero = theme.hero.match ? findPhrase(theme.hero.match) : null;
if (!hero && !HEROLESS)
throw new Error(
"[make-theme] theme.json requires hero:{match} (omit hero entirely for a quiet pure-body run)",
);
const heroText = (theme.hero.text || theme.hero.match || "").toUpperCase();
const heroDisplay = theme.hero.text || theme.hero.match; // case preserved for drawon/assembly
// mark hero transcript words as consumed when the setpiece is EMBED (not inline):
// the rail/panel/poem must NOT contain them (rail↔climax hand-off), except panel
// with redact linkage (panel shows them redacted — author includes them).
const redactLinkage = (dna.linkages || []).includes("redact-until-hero");
if (!heroInline && !HEROLESS && !redactLinkage) {
for (let k = 0; k < hero.len; k++) tWords[hero.idx + k].used = true;
}
// resolve lines: every entry gets word objects with timings + role flags
const minors = new Set((theme.minors || []).map(norm));
const LINES = (theme.lines || []).map((arr, li) => {
const words = arr.map((a) => {
const tw = takeWord(a);
return {
display: a,
start: tw.start,
end: tw.end,
minor: minors.has(norm(a)),
isHero:
!!hero &&
norm(a) === norm((theme.hero.match || "").split(/\s+/)[0]) &&
tw.start >= hero.start - 0.01 &&
tw.start <= hero.end + 0.01,
};
});
return { id: li, words };
});
// completeness gate
const unused = tWords.filter((w) => !w.used);
if (unused.length)
throw new Error(
`[make-theme] transcript words not covered by lines/hero: ${unused.map((w) => w.text).join(" ")}`,
);
// per-line windows
LINES.forEach((L, i) => {
L.in = L.words[0].start - 0.02;
L.out = i + 1 < LINES.length ? LINES[i + 1].words[0].start - 0.02 : DUR - 0.1;
});
const LASTWORD = LINES[LINES.length - 1].words[LINES[LINES.length - 1].words.length - 1];
// hero window: onset → bounded hold (maxHold; 0 = to clip end — neonsign's
// lit-sign design). Unbounded default holds turned short clips into wallpaper.
const heroIn = hero ? hero.start : -999;
const MAXHOLD = dna.hero.maxHold ?? 3.2;
const heroOut = hero
? Math.min(DUR - 0.06, theme.hero.out ?? (MAXHOLD > 0 ? heroIn + MAXHOLD : DUR - 0.1))
: -999;
// ---------- hero geometry: scene-aware position + width-fit size (computed ONCE,
// shared by the setpiece and the front fx so flash/rings/sparks stay centered) ----
const HG = { x: dna.hero.x ?? W / 2, y: dna.hero.y ?? H * 0.37, fontPx: dna.hero.fontPx || 178 };
if (!heroInline && !HEROLESS) {
if (dna.hero.setpiece === "detonation") {
HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 178, 0.56, 0.92);
Object.assign(HG, sceneHeroXY("detonation", HG.fontPx));
HG.halfW = (heroText.length * 0.56 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "decode") {
const units = [...heroText].reduce((a, c) => a + (c === " " ? 0.38 : 0.82), 0);
HG.fontPx = Math.min(dna.hero.fontPx || 112, Math.floor((W * 0.94) / units));
Object.assign(HG, sceneHeroXY("decode", HG.fontPx));
HG.halfW = (units * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "drawon") {
Object.assign(HG, sceneHeroXY("drawon", 130));
HG.halfW = Math.min(dna.hero.params.targetWidth || 640, W - 200) / 2;
} else if (dna.hero.setpiece === "cpslam") {
HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 168, 0.56, 0.9);
Object.assign(HG, sceneHeroXY("cpslam", HG.fontPx));
HG.halfW = (heroText.length * 0.56 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "settle") {
HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 150, 0.58, 0.9);
Object.assign(HG, sceneHeroXY("settle", HG.fontPx));
HG.halfW = (heroText.length * 0.58 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "flapboard") {
// one flap tile per char; tile geometry derives from fontPx (demo ratio
// 100px tile @ 138px Anton), width-fit shrinks both together
const gap = dna.hero.params.gap || 7;
const n = heroText.length;
let fpx = dna.hero.fontPx || 138;
let tw = Math.round(fpx * 0.725);
const fitW = W * 0.94 - 40 - (n - 1) * gap;
if (n * tw > fitW) {
tw = Math.floor(fitW / n);
fpx = Math.round(tw / 0.725);
}
HG.fontPx = fpx;
Object.assign(HG, sceneHeroXY("flapboard", fpx));
HG.tileW = tw;
HG.tileH = Math.round(tw * 1.48);
HG.halfW = (n * tw + (n - 1) * gap + 40) / 2;
} else if (dna.hero.setpiece === "ledwipe") {
// LED transit panel: the word lights inside a fixed departure-board panel
// (demo 1080×232 @ 120px VT323 ≈ 0.50em/char incl tracking) — width-fit
// shrinks the type, never the panel; panel height derives from the type
const panelW = Math.min(dna.hero.params.panelW || 1080, W - 120);
const units = heroText.length * 0.5;
HG.fontPx = Math.min(dna.hero.fontPx || 120, Math.floor((panelW - 80) / units));
Object.assign(HG, sceneHeroXY("ledwipe", HG.fontPx));
HG.panelW = panelW;
HG.panelH = 44 + Math.round(HG.fontPx * 1.42) + 18;
HG.halfW = panelW / 2;
} else if (dna.hero.setpiece === "vhsosd") {
// camcorder OSD readout (demo: VT323 150px ≈ 0.47em/char incl 0.02em tracking)
HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 150, 0.47, 0.92);
Object.assign(HG, sceneHeroXY("vhsosd", HG.fontPx));
HG.halfW = (heroText.length * 0.47 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "bossintro") {
// 8-bit boss word (demo: Press Start 2P 84px ≈ 1.0em/char); the pixel-block
// matrix derives from the word rect (cell 0.7×/0.5× fontPx, ~50px side pad)
HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 84, 1.0, 0.92);
Object.assign(HG, sceneHeroXY("bossintro", HG.fontPx));
HG.halfW = (heroText.length * 1.0 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "rubberstamp") {
// bordered rubber-stamp face (demo: Special Elite 120px REMARKABLE ≈
// 0.60em/char + 0.015em tracking); the face adds padding + border that
// scale with fontPx (80px + 12px at the demo size)
HG.fontPx = fitHeroPx(heroText, dna.hero.fontPx || 120, 0.615, 0.86);
Object.assign(HG, sceneHeroXY("rubberstamp", HG.fontPx));
HG.halfW = (heroText.length * 0.615 * HG.fontPx + (92 * HG.fontPx) / 120) / 2;
} else if (dna.hero.setpiece === "lasercage") {
// beam-cage word (demo: Audiowide 100px): exact em width from the measured
// char table — the converging beams, cage lines and glow stack all derive
// from the word rect, so the fit must not guess
const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.02) || heroText.length * 0.8;
HG.fontPx = Math.min(dna.hero.fontPx || 100, Math.floor((W * 0.9) / em1));
Object.assign(HG, sceneHeroXY("lasercage", HG.fontPx));
HG.halfW = (em1 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "boltstrike") {
// storm slam (demo: Anton 145px REMARKABLE): exact em width from the
// measured char table — the bolt offsets, word scrim and ozone glow all
// derive from the word rect (demo rect: 720×145 → halfW 360)
const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.012) || heroText.length * 0.56;
HG.fontPx = Math.min(dna.hero.fontPx || 145, Math.floor((W * 0.92) / em1));
Object.assign(HG, sceneHeroXY("boltstrike", HG.fontPx));
HG.halfW = (em1 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "holoboot") {
// volumetric projection word (demo: Orbitron 700 118px, letter-spacing 0):
// exact em width from the measured char table — the projection cone, seed
// beam, emitter dot and scrim all derive from the word rect
const em1 = wordPx(heroText, dna.fonts.hero, 1, 0) || heroText.length * 0.8;
HG.fontPx = Math.min(dna.hero.fontPx || 118, Math.floor((W * 0.9) / em1));
Object.assign(HG, sceneHeroXY("holoboot", HG.fontPx));
HG.halfW = (em1 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "biobloom") {
// abyss bloom word (demo: Fredoka 700 120px RECOVER, 0.01em tracking):
// exact em width from the measured char table — the tendril anchors,
// pocket scrim, pre-glow and spill all derive from the word rect
const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.01) || heroText.length * 0.66;
HG.fontPx = Math.min(dna.hero.fontPx || 120, Math.floor((W * 0.9) / em1));
Object.assign(HG, sceneHeroXY("biobloom", HG.fontPx));
HG.halfW = (em1 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "silkribbon") {
// silk ribbon draw-on (demo: HersheyScript "recover" w=880 on a 1010×280
// canvas, ink center ≈ canvas center): the canvas, aurora-gradient span
// and spill all derive from targetWidth; 115 ≈ the ribbon's ink height
// (sky band top → ribbon center, same registration as the demo)
Object.assign(HG, sceneHeroXY("silkribbon", 115));
HG.ribbonW = Math.min(dna.hero.params.targetWidth || 880, W - 240);
HG.halfW = (HG.ribbonW + 130) / 2;
} else if (dna.hero.setpiece === "scopetrace") {
// phosphor trace word (demo: HersheyScript "pixel size" ≈705px ink, ink
// center ≈ baseline 28): the surge column, flatline flash, spill and
// write window all derive from targetWidth; 115 ≈ the trace's ink height
Object.assign(HG, sceneHeroXY("scopetrace", 115));
HG.traceW = Math.min(dna.hero.params.targetWidth || 720, W - 200);
HG.halfW = (HG.traceW + 50) / 2;
} else if (dna.hero.setpiece === "papermat") {
// torn-paper letter chips glued on a kraft mat (demo: Anton 130px ink on
// 118×170 chips, pitch tileW+14, mat N*pitch+80 wide × 240 tall): chip
// geometry derives from fontPx (demo ratios), width-fit shrinks both
const n = heroText.length;
let fpx = dna.hero.fontPx || 130;
let tw = Math.round(fpx * 0.91);
const fitW = W * 0.94 - 80 - n * 14;
if (n * tw > fitW) {
tw = Math.floor(fitW / n);
fpx = Math.round(tw / 0.91);
}
HG.fontPx = fpx;
Object.assign(HG, sceneHeroXY("papermat", fpx));
HG.tileW = tw;
HG.tileH = Math.round(fpx * 1.31);
HG.pitch = tw + 14;
HG.matW = n * HG.pitch + 80;
HG.matH = Math.round(fpx * 1.846);
HG.halfW = HG.matW / 2;
} else if (dna.hero.setpiece === "centerfold") {
// pop-up book centerfold (demo: Baloo 2 800 120px STARS over a 12-point
// paper starburst, outer R 220 ≈ 0.59×wordW, box 620×560 = 2R+180 × 2R+fontPx):
// the bursts, blob shadow, struts and the confetti hinge all derive from
// the word rect; width-fit shrinks the type, the burst follows the word
// but is capped so the unfold stays inside the frame
const em1 = wordPx(heroText, dna.fonts.hero, 1, 0.02) || heroText.length * 0.62;
HG.fontPx = Math.min(dna.hero.fontPx || 120, Math.floor((W * 0.5) / em1));
Object.assign(HG, sceneHeroXY("centerfold", HG.fontPx));
HG.wordW = Math.round(em1 * HG.fontPx);
HG.burstR = Math.round(Math.min(Math.max(1.83 * HG.fontPx, 0.59 * HG.wordW), H * 0.42));
HG.boxW = Math.max(2 * HG.burstR + 180, HG.wordW + 24);
HG.boxH = 2 * HG.burstR + HG.fontPx;
HG.halfW = HG.boxW / 2;
} else if (dna.hero.setpiece === "chalkwrite") {
// hand-written chalk word (demo: HersheyScript "remarkable" ≈860px ink,
// ink 130 tall, ink CENTER on HG.y): the writing starts at the board's
// LEFT margin — a hand writes from the left, never centered on the
// subject — so x derives from the body strip, not the hero anchor; the
// stroke path is parsed at compile time inside the setpiece and the
// board rect / underlines / fg dust clap all register on the measured
// ink box. 130 ≈ the chalk ink height (band top → ink center).
const stripW9 = (dna.body.strip && dna.body.strip.w) || 1200;
const SX9 = (dna.body.strip && dna.body.strip.x) ?? Math.round((W - stripW9) / 2);
HG.chalkW = Math.min(dna.hero.params.targetWidth || 860, stripW9 - 28);
HG.fontPx = 130;
Object.assign(HG, sceneHeroXY("chalkwrite", 130));
if ((theme.hero && theme.hero.x) == null) HG.x = SX9 + 12 + Math.round(HG.chalkW / 2);
HG.halfW = Math.round(HG.chalkW / 2);
} else if (dna.hero.setpiece === "spraytag") {
// spray-painted tag (demo: HersheyScript "recover" w=1000 → ink ≈1010×120,
// ink center (646,252)): the tag CENTERS on the hero anchor in the sky
// band — crossing the subject silhouette is the point; the stroke path is
// parsed at compile time inside the setpiece, which registers the
// pen-finish point on HG for the fg paint splat (fx.paintsplat). 120 ≈ the
// tag's ink height (band top → ink center); halfW adds the halo overspray.
HG.sprayW = Math.min(dna.hero.params.targetWidth || 1000, W - 140);
HG.fontPx = 120;
Object.assign(HG, sceneHeroXY("spraytag", 120));
HG.halfW = Math.round(HG.sprayW / 2) + 30;
} else if (dna.hero.setpiece === "brushwrite") {
// sumi-e gesture (demo: HersheyScript "remarkable" w=760 → ink ≈760×115,
// ink center (863,202)): the gesture CENTERS on the hero anchor in the sky
// band — crossing the subject silhouette is the point; the stroke path is
// parsed at compile time inside the setpiece, which registers the
// pen-START point on HG for the fg ink flecks (fx.brushflecks). 120 ≈ the
// gesture's ink height (band top → ink center); halfW adds spatter margin.
HG.brushW = Math.min(dna.hero.params.targetWidth || 760, W - 240);
HG.fontPx = 120;
Object.assign(HG, sceneHeroXY("brushwrite", 120));
HG.halfW = Math.round(HG.brushW / 2) + 40;
} else if (dna.hero.setpiece === "inkbloom") {
// sumi drop in still water (demo: Shippori Mincho 700 132px "pixel size.",
// 0.02em tracking, case preserved): exact em width from the measured char
// table — the blob stack, ripple rings and wisp anchors all derive from
// the word rect (kx/ky scale units inside the setpiece)
const em1 = wordPx(heroDisplay, dna.fonts.hero, 1, 0.02) || heroDisplay.length * 0.5;
HG.fontPx = Math.min(dna.hero.fontPx || 132, Math.floor((W * 0.9) / em1));
Object.assign(HG, sceneHeroXY("inkbloom", HG.fontPx));
HG.halfW = (em1 * HG.fontPx) / 2;
} else if (dna.hero.setpiece === "ransomnote") {
// ransom-note letter chips (demo: 10 chips, 4 recipes/fonts cycled, base
// 130px with seeded ±13% sizes): width-fit uses the measured advance of
// each glyph IN ITS OWN recipe font + 0.20em chip padding + 6px margins,
// so the crooked collage never overflows the frame
const fams4 = [dna.fonts.body, dna.fonts.dark, dna.fonts.news, dna.fonts.accent];
let k4 = 0;
const em1 = [...heroText].reduce((a, c) => {
if (c === " ") return a + 0.3;
const fam = fams4[k4++ % 4];
return a + (famMetrics(fam) ? wordPx(c, fam, 1, 0) : 0.62) + 0.2;
}, 0);
const n = heroText.length;
HG.fontPx = Math.min(dna.hero.fontPx || 130, Math.floor((W * 0.92 - n * 6) / em1));
Object.assign(HG, sceneHeroXY("ransomnote", HG.fontPx));
HG.halfW = (em1 * HG.fontPx + n * 6) / 2;
} else if (dna.hero.setpiece === "coverword") {
// metric-exact fit from the replica font's advance widths (logo case:
// first letter upper, rest lower — the official mark's own arrangement)
const CPM = JSON.parse(
fs.readFileSync(path.join(SKILL, "assets/brand/cyberpunk-widths.json"), "utf8"),
);
const disp = heroText[0].toUpperCase() + heroText.slice(1).toLowerCase();
const bad = [...disp].filter((c) => !(c in CPM.widths));
if (bad.length)
throw new Error(
`[make-theme] coverword: no replica glyph for ${JSON.stringify(bad)} in "${heroText}" — pick a hero without digits/special chars or use hero.text`,
);
const em = [...disp].reduce((a, c) => a + CPM.widths[c], 0) + 0.01 * (disp.length - 1);
// glyph ink is small inside the em box (x-height ~0.3em) -> size by INK:
// dna fontPx = target ink height in px, not nominal font-size
const inkTop = Math.max(...[...disp].map((c) => (CPM.bounds[c] || [0, 0, 0, 0.5])[3]));
const inkBot = Math.min(...[...disp].map((c) => (CPM.bounds[c] || [0, -0.1, 0, 0])[1]));
const inkH = inkTop - inkBot;
HG.fontPx = Math.round(Math.min((dna.hero.fontPx || 150) / inkH, (W * 0.84) / em));
Object.assign(HG, sceneHeroXY("coverword", Math.round(HG.fontPx * inkH)));
HG.halfW = (em * HG.fontPx) / 2 + 0.9 * HG.fontPx;
HG.coverEm = em;
HG.coverDisp = disp;
HG.coverInk = { inkTop, inkBot, inkH };
}
// keep the word on frame (when wider than the frame, CENTER it — an inverted
// Math.max/Math.min clamp would silently pin to the lower bound off-center)
if (HG.halfW)
HG.x =
2 * HG.halfW + 24 > W ? W / 2 : Math.max(HG.halfW + 12, Math.min(W - HG.halfW - 12, HG.x));
HG.y = Math.max(HG.fontPx * 0.55 + 8, Math.min(H * 0.62, HG.y));
}
// ---------- shared emit helpers ----------
const MULBERRY = `function mulberry32(a){return function(){a|=0;a=(a+0x6D2B79F5)|0;let t=Math.imul(a^(a>>>15),1|a);t=(t+Math.imul(t^(t>>>7),61|t))^t;return((t^(t>>>14))>>>0)/4294967296;}}`;
// bundled @font-face subset for this DNA's families — the renderer only
// auto-supplies its canonical fonts; anything else silently falls back unless
// the page ships a local @font-face (same determinism contract as Standard).
const FONT_FACES = (() => {
let css = "";
try {
css = fs.readFileSync(path.join(SKILL, "modes/standard/fonts/fonts.css"), "utf8");
} catch {
return null;
}
return css
.split("@font-face")
.slice(1)
.map((b) => "@font-face" + b.slice(0, b.indexOf("}") + 1));
})();
// per-page: embed only families this page's markup actually references
function fontCssFor(pageParts) {
if (!FONT_FACES) return "";
const fams = [...new Set(Object.values(dna.fonts || {}))].filter((f) => !/^Hiragino/.test(f));
const out = [];
for (const fam of fams) {
if (!pageParts.includes(fam)) continue;
// quote-agnostic: fonts.css has shipped with both 'single' and "double"
// quoted family names (formatters flip them); a literal match silently
// skipped EVERY embed and warned on bundled fonts.
const famRe = new RegExp(
`font-family\\s*:\\s*['"]` + fam.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + `['"]`,
);
const hits = FONT_FACES.filter((b) => famRe.test(b));
if (!hits.length)
console.error(
`[make-theme] WARN font "${fam}" not in bundled fonts.css -> renderer fallback risk`,
);
out.push(...hits);
}
return out.join("\n");
}
const GSAP = `<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>`;
function bgSkeleton(stageHtml, css, js) {
const FONT_CSS = fontCssFor(stageHtml + css);
return `<!doctype html>
<!-- generated by make-theme.cjs — theme DNA "${dna.name}" (bg layer) -->
<html lang="en">
<head>
<meta charset="utf-8">
${GSAP}
<style>
${FONT_CSS}
html, body { margin:0; padding:0; width:${W}px; height:${H}px; overflow:hidden; background:#000; }
#root { position:relative; width:${W}px; height:${H}px; }
#a-roll { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
#stage { position:absolute; inset:0; z-index:2; }
${css}
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="${DUR}"
data-width="${W}" data-height="${H}">
<video id="a-roll" src="source.mp4" muted playsinline
data-duration="${DUR}" data-track-index="0"></video>
<div id="stage">
${stageHtml}
</div>
<audio id="a-roll-audio" src="source.mp4" data-start="0" data-duration="${DUR}"
data-track-index="3" data-volume="1"></audio>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const F = ${F};
${MULBERRY}
${js}
tl.seek(0);
window.__timelines["main"] = tl;
</script>
</body>
</html>`;
}
function fgSkeleton(stageHtml, css, js) {
const FONT_CSS = fontCssFor(stageHtml + css);
return `<!doctype html>
<!-- generated by make-theme.cjs — theme DNA "${dna.name}" (fg alpha layer) -->
<html lang="en">
<head>
<meta charset="utf-8">
${GSAP}
<style>
${FONT_CSS}
html, body { margin:0; padding:0; width:${W}px; height:${H}px; overflow:hidden; background:transparent; }
#root { position:relative; width:${W}px; height:${H}px; }
#stage { position:absolute; inset:0; }
${css}
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="${DUR}"
data-width="${W}" data-height="${H}">
<div id="stage">
${stageHtml}
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const F = ${F};
${MULBERRY}
${js}
tl.seek(0);
window.__timelines["main"] = tl;
</script>
</body>
</html>`;
}
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;");
const J = JSON.stringify;
/* =====================================================================
* BODY PARADIGMS — each returns { css, html, js } for the fg (or bg) file
* ===================================================================== */
function paradigmRail() {
const b = dna.body;
const lineData = LINES.map((L) => ({
id: "r" + L.id,
in: +L.in.toFixed(3),
out: +L.out.toFixed(3),
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
}));
const css = `
.rail { position:absolute; left:${W / 2}px; top:${H - b.bottomPx}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.body}', sans-serif; font-size:${b.fontPx}px; line-height:1;
letter-spacing:${b.letterSpacing || "0.02em"}; color:${dna.palette.body};
${b.textTransform ? "text-transform:" + b.textTransform + ";" : ""}
text-shadow: ${b.glow || "0 3px 14px rgba(0,0,0,0.65), 0 1px 3px rgba(0,0,0,0.5)"}; }
.rail .w { display:inline-block; opacity:0; margin:0 0.14em; }
.rail .w.minor { font-size:${Math.round(b.fontPx * (b.minorScale || 1.4))}px; }
.rail .w.em { color:${dna.palette.em || dna.palette.accent}; font-weight:700; }
${
b.rule
? `#rrule { position:absolute; left:${W / 2}px; top:${H - b.bottomPx + 38}px; width:0; height:2px;
background:${b.rule.color}; opacity:0.5; transform:translateX(-50%);
box-shadow: 0 0 12px ${dna.palette.accent}; }`
: ""
}`;
const html =
lineData.map((L) => ` <div class="rail" id="${L.id}"></div>`).join("\n") +
(b.rule ? `\n <div id="rrule"></div>` : "");
const stamp = b.entrance === "stamp";
const boot = b.entrance === "bootflick";
const fade = b.entrance === "fade";
const js = `
// ---- body paradigm: RAIL (${b.entrance} in / ${b.exit} out) ----
const HOT = ${J(dna.palette.hot || dna.palette.accent)}, BONE = ${J(dna.palette.body)};
const RAIL = ${J(lineData)};
const rrnd = mulberry32(2026);
RAIL.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt,,minor]) => {
const s = document.createElement("span");
s.className = "w" + (minor ? (${J(stamp)} || ${J(boot)} ? " minor" : " em") : "");
s.textContent = txt; line.appendChild(s);
});
gsap.set(line, { xPercent: -50, yPercent: -100 });
tl.set(line, { opacity: 1 }, L.in);
const XO = L.out - ${b.exit === "drop" ? "0.18" : "0.17"}; // exit start (single source)
let lastB = -1; // line-y bounce ownership guard
L.words.forEach(([txt, st, minor], wi) => {
const el = line.children[wi];
${
boot
? ` // BOOTFLICK: corrupted-cyan boot 1-2f -> settles to UI color
tl.set(el, { opacity: 0.6, color: "#00F0FF", x: 2 }, st);
if (rrnd() < 0.45) { tl.set(el, { opacity: 0.15, x: -2 }, st + F); tl.set(el, { opacity: 1, color: BONE, x: 0 }, st + 2 * F); }
else { tl.set(el, { opacity: 1, color: BONE, x: 0 }, st + F); }
if (minor) {
tl.fromTo(el, { scale: 1.18 }, { scale: 1, duration: 0.25, ease: "power2.out" }, st + F);
tl.set(el, { textShadow: "2px 0 0 #00F0FF, -2px 0 0 #FF003C, 0 2px 10px rgba(0,0,0,0.75)" }, st + 2 * F);
}`
: fade
? ` // FADE: 2-frame fade-up — the word simply reads (anchor register)
tl.fromTo(el, { opacity: 0, y: 4 }, { opacity: 1, y: 0, duration: 0.12, ease: "power2.out" }, st);
if (minor) tl.fromTo(el, { scale: 1.07 }, { scale: 1, duration: 0.3, ease: "power2.out" }, st);`
: stamp
? ` // STAMP: 1f appear oversized+hot, crush, recoil, cool to bone
tl.set(el, { opacity: 1, scale: minor ? 2.1 : 1.28, color: HOT, transformOrigin: "50% 80%" }, st);
tl.to(el, { scale: 1, duration: 0.11, ease: "power3.in" }, st + 0.01);
tl.set(el, { scaleX: 1.05, scaleY: 0.95 }, st + 0.12);
tl.to(el, { scaleX: 1, scaleY: 1, duration: 0.22, ease: "elastic.out(1, 0.45)" }, st + 0.16);
tl.to(el, { color: minor ? ${J(dna.palette.minorCool || "#F2CFA0")} : BONE, duration: minor ? 0.6 : 0.32, ease: "power1.in" }, st + 0.15);
if (st + 0.02 >= lastB && st + 0.24 <= XO) { // one bounce owner at a time; none during exit
tl.set(line, { y: minor ? 4 : 2 }, st + 0.02);
tl.to(line, { y: 0, duration: 0.18, ease: "power2.out" }, st + 0.06);
lastB = st + 0.24;
}`
: ` // FLICK: tube lights with seeded double-flicks
tl.set(el, { opacity: 0.35 }, st);
if (rrnd() < 0.35) { tl.set(el, { opacity: 0.08 }, st + F); tl.set(el, { opacity: 1 }, st + 2 * F); }
else { tl.set(el, { opacity: 1 }, st + F); }
if (minor) tl.fromTo(el, { scale: ${dna.body.emScale || 1.18} }, { scale: 1, duration: 0.3, ease: "power2.out" }, st + F);`
}
});
${
b.exit === "drop"
? ` // DROP exit (completes before the next line stamps)
tl.to(line, { y: 34, opacity: 0, duration: 0.16, ease: "power2.in" }, XO);
tl.set(line, { display: "none" }, XO + 0.18);`
: b.exit === "fade"
? ` // FADE exit (quiet settle-down)
tl.to(line, { opacity: 0, y: 6, duration: 0.15, ease: "power1.in" }, XO);
tl.set(line, { display: "none" }, XO + 0.17);`
: ` // POWERCUT exit (completes before the next line)
tl.set(line, { opacity: 0.5 }, XO); tl.set(line, { opacity: 0.12 }, XO + F);
tl.set(line, { opacity: 0 }, XO + 3 * F); tl.set(line, { display: "none" }, XO + 4 * F);`
}
});
${
b.rule
? ` tl.fromTo("#rrule", { width: 0, opacity: 0 }, { width: ${b.rule.width}, opacity: 0.5, duration: 0.4, ease: "power2.out" }, 0.25);
tl.to("#rrule", { opacity: 0, duration: 0.3 }, ${(DUR - 0.2).toFixed(2)});`
: ""
}
${
b.yield
? ` // rail yields while the apex lands (furniture never contests the hero).
// Overlap guards: dim starts after the line is IN, never runs into the exit;
// restore is emitted only with clear runway before the exit (else the line
// simply exits dimmed — never two owners of one opacity).
RAIL.forEach((L) => {
if (L.in < ${heroIn.toFixed(3)} + 0.9 && L.out > ${heroIn.toFixed(3)} - 0.3) {
const XO2 = L.out - ${b.exit === "drop" ? "0.18" : "0.17"};
const dimT = Math.max(${(heroIn - (b.yield.pre || 0.2)).toFixed(3)}, L.in + 0.05);
if (XO2 > dimT + 0.25) {
tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.2 }, dimT);
const resT = ${(heroIn + (b.yield.post || 0.9)).toFixed(3)};
if (resT + 0.3 < XO2) tl.to("#" + L.id, { opacity: 1, duration: 0.25 }, resT);
}
}
});`
: ""
}`;
return { css, html, js };
}
function paradigmPanel() {
const b = dna.body;
const lockT =
heroIn + 0.3 + (dna.hero.params.lockStagger || 0.045) * (heroText.length - 1) + 0.083;
const lineData = LINES.map((L) => ({
id: "ln" + L.id,
words: L.words.map((w) => [
w.display,
+w.start.toFixed(3),
// redaction: words inside the hero phrase window get blocks until lock
redactLinkage && w.start >= hero.start - 0.01 && w.start <= hero.end + 0.01
? +lockT.toFixed(3)
: 0,
]),
}));
const lineH = Math.round(b.fontPx * 1.35);
const maxVis = Math.max(3, Math.floor((H * 0.48 - 60) / lineH));
const dockCss = CLEARER === "right" ? `right:${b.panel.left}px;` : `left:${b.panel.left}px;`;
const css = `
#panel { position:absolute; ${dockCss} bottom:${b.panel.bottom}px; width:${b.panel.width}px;
padding:18px 22px 22px; background:${dna.palette.panelBg};
border:1px solid ${dna.palette.accent}59; border-radius:4px; opacity:0;
box-shadow: 0 0 24px rgba(0,0,0,0.35), inset 0 0 60px ${dna.palette.accent}0a; }
#panel .hd { font-family:'${dna.fonts.tag}', monospace; font-size:22px; color:${dna.palette.prompt};
letter-spacing:2px; border-bottom:1px solid ${dna.palette.accent}40;
padding-bottom:8px; margin-bottom:10px; }
.ln { font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:1.35;
color:${dna.palette.body}; text-shadow: 0 0 8px ${dna.palette.accent}59;
white-space:nowrap; opacity:0; }
.ln .pr { color:${dna.palette.prompt}; margin-right:8px; }
.ln .w { display:inline-block; overflow:hidden; vertical-align:bottom; white-space:nowrap;
margin-right:0.3em; }
.ln .red { color:#7adcff; }
.caret { display:inline-block; width:14px; height:26px; background:${dna.palette.body};
vertical-align:baseline; opacity:0; margin-left:4px; }
#logclip { max-height:${maxVis * lineH}px; overflow:hidden; }
#logwrap { position:relative; }`;
const html = ` <div id="panel">
<div class="hd">${esc(theme.panelHeader || "OBS-01 // " + (b.panel.header || "LOG_"))}</div>
<div id="logclip"><div id="logwrap">
${lineData.map((L) => ` <div class="ln" id="${L.id}"></div>`).join("\n")}
</div></div>
</div>`;
const js = `
// ---- body paradigm: PANEL (typed console log, accumulate) ----
tl.fromTo("#panel", { opacity: 0, x: -16 }, { opacity: 1, x: 0, duration: 0.3, ease: "power2.out" }, 0.05);
${
b.yield
? ` tl.to("#panel", { opacity: ${b.yield.dim}, duration: 0.25, ease: "power1.in" }, ${(heroIn - 0.08).toFixed(3)});
tl.to("#panel", { opacity: 1, duration: 0.3, ease: "power1.out" }, ${(lockT + 0.15).toFixed(3)});`
: ""
}
const LOG = ${J(lineData)};
const MAXVIS = ${maxVis}, LINEH = ${lineH};
LOG.forEach((L, li) => {
if (li >= MAXVIS) { // terminal scroll: older rows slide up out of the clip
tl.to("#logwrap", { y: -(li - MAXVIS + 1) * LINEH, duration: 0.28, ease: "power2.out" },
L.words[0][1] - 0.10);
}
const line = document.getElementById(L.id);
const p = document.createElement("span"); p.className = "pr"; p.textContent = "$";
line.appendChild(p);
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);
L.words.forEach(([txt, st, redUntil]) => {
const wrap = document.createElement("span"); wrap.className = "w";
const inner = document.createElement("span");
inner.textContent = redUntil ? "█".repeat(Math.max(3, txt.length - 1)) : txt;
wrap.appendChild(inner); line.appendChild(wrap);
tl.fromTo(wrap, { width: 0 }, { width: "auto", duration: 0.12,
ease: "steps(" + Math.max(2, txt.length) + ")" }, st);
if (redUntil) {
const real = document.createElement("span");
real.textContent = txt; real.style.display = "none"; real.className = "red";
wrap.appendChild(real);
tl.set(inner, { display: "none" }, redUntil);
tl.set(real, { display: "inline" }, redUntil);
}
});
${
b.caret
? ` const c = document.createElement("span"); c.className = "caret"; line.appendChild(c);
const lastEnd = L.words[L.words.length - 1][1] + 0.25;
for (let bk = 0; bk < 4; bk++) tl.set(c, { opacity: bk % 2 === 0 ? 1 : 0 }, lastEnd + bk * 0.22);
tl.set(c, { opacity: 0 }, lastEnd + 0.9);`
: ""
}
});
${
(dna.linkages || []).includes("corrupt-on-last-word")
? ` // the feed corrupts on the final word
const NZ = ${(LASTWORD.start + 0.04).toFixed(3)};
[[3,0],[-4,1],[3,2],[-2,3],[0,4]].forEach(([dx, k]) => {
tl.set("#panel", { x: dx, filter: dx ? "hue-rotate(" + dx * 8 + "deg)" : "none" }, NZ + k * F);
});
tl.set("#panel", { x: 0, filter: "none" }, NZ + 5 * F);`
: ""
}`;
return { css, html, js };
}
function paradigmPoem() {
const b = dna.body;
const colLeft = CLEARER === "right" ? Math.round(W * 0.52) : b.left;
const scrimSide = CLEARER === "right" ? "right" : "left";
// stanza split: break after a line whose last word ends a sentence
const stanzas = [[]];
LINES.forEach((L) => {
stanzas[stanzas.length - 1].push(L);
const last = L.words[L.words.length - 1].display;
if (/[.!?]$/.test(last) && L !== LINES[LINES.length - 1]) stanzas.push([]);
});
if (stanzas.some((s) => s.length > b.stanzaTops.length))
console.warn(
"[make-theme] poem stanza exceeds available slots — extra lines share the last slot",
);
const stanzaData = stanzas.map((s, si) =>
s.map((L, li) => ({
id: "s" + si + "l" + L.id,
top: b.stanzaTops[Math.min(li, b.stanzaTops.length - 1)] + si * 16,
words: L.words.map((w) => [
w.display,
+w.start.toFixed(3),
w.isHero && heroInline ? "big" : w.minor ? "em" : "",
]),
})),
);
const lastStanzaIdx = stanzaData.length - 1;
const css = `
${
b.scrim
? `#pscrim { position:absolute; inset:0; opacity:0;
background: linear-gradient(${scrimSide === "left" ? "100deg" : "260deg"},
rgba(4,8,16,${b.scrim.opacity}) 0%, rgba(4,8,16,${b.scrim.opacity * 0.6}) 38%, rgba(4,8,16,0) 62%); }`
: ""
}
.pline { position:absolute; left:${colLeft}px; white-space:nowrap;
font-family:'${dna.fonts.body}', serif; font-weight:600; font-size:${b.fontPx}px;
line-height:1; color:${dna.palette.body};
text-shadow: 0 0 14px rgba(255,244,214,0.5), 0 2px 12px rgba(0,0,0,0.6); }
.pline .l { display:inline-block; opacity:0; }
.pline .sp { display:inline-block; width:0.28em; }
.pline .big { font-size:${Math.round(b.fontPx * (b.bigScale || 2))}px; font-style:italic; font-weight:700;
text-shadow: 0 0 24px rgba(255,244,214,0.7), 0 2px 14px rgba(0,0,0,0.6); }
.pline .em { font-size:${Math.round(b.fontPx * (b.emScale || 1.38))}px; font-style:italic; font-weight:700; }
.star { position:absolute; border-radius:50%; background:#fff7e0; opacity:0;
box-shadow: 0 0 8px rgba(255,247,224,0.9); }`;
const html =
(b.scrim ? ` <div id="pscrim"></div>\n` : "") +
stanzaData
.flat()
.map((L) => ` <div class="pline" id="${L.id}" style="top:${L.top}px"></div>`)
.join("\n");
const disperse = (dna.linkages || []).includes("disperse-on-last-word");
const js = `
// ---- body paradigm: POEM (condense, accumulate, ${disperse ? "disperse" : "hold"}) ----
const prnd = mulberry32(${b.seed || 777});
const stage = document.getElementById("stage");
${
b.scrim
? ` tl.fromTo("#pscrim", { opacity: 0 }, { opacity: 1, duration: 0.6, ease: "power1.out" }, 0.15);
tl.to("#pscrim", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(LASTWORD.start + 0.15).toFixed(3)});`
: ""
}
const STANZAS = ${J(stanzaData)};
const lastLetters = [];
STANZAS.forEach((stanza, si) => {
stanza.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt, st, cls], wi) => {
if (wi > 0) { const g = document.createElement("span"); g.className = "sp"; line.appendChild(g); }
const letters = [];
for (const ch of txt) {
const l = document.createElement("span");
l.className = "l" + (cls ? " " + cls : ""); l.textContent = ch;
line.appendChild(l); letters.push(l);
}
const spread = cls === "big" ? ${dna.hero.params.spread || 150} : 46;
letters.forEach((l, li) => {
const sx = (prnd() - 0.5) * 2 * spread, sy = (prnd() - 0.5) * 2 * spread * 0.7;
tl.fromTo(l, { x: sx, y: sy, opacity: 0, filter: "blur(6px)" },
{ x: 0, y: 0, opacity: 1, filter: "blur(0px)",
duration: cls === "big" ? 0.7 : 0.5, ease: "power2.out" },
st - 0.04 + li * (cls === "big" ? 0.03 : 0.016));
if (si === ${lastStanzaIdx}) lastLetters.push(l);
});
if (cls === "big") {
// SETPIECE assembly: star particles fly INTO the apex as it forms
const lt = line.style.top ? parseFloat(line.style.top) : 110;
for (let i = 0; i < ${dna.hero.params.particles || 22}; i++) {
const p = document.createElement("div");
p.className = "star"; stage.appendChild(p);
const s = 2 + Math.round(prnd() * 3);
const tx = ${colLeft} + 90 + prnd() * 240, ty = lt - 20 + prnd() * 70;
const fx = tx + (prnd() - 0.5) * 560, fy = ty + (prnd() - 0.5) * 380;
gsap.set(p, { width: s, height: s, left: fx, top: fy });
tl.to(p, { keyframes: { x: [0, (tx - fx) * 0.6, tx - fx], y: [0, (ty - fy) * 0.6, ty - fy],
opacity: [0, 0.95, 0] },
duration: 0.5 + prnd() * 0.2, ease: "power2.in" }, st - 0.05 + prnd() * 0.3);
}
tl.fromTo(letters, { textShadow: "0 0 30px rgba(255,244,214,0.9), 0 2px 12px rgba(0,0,0,0.5)" },
{ textShadow: "0 0 18px rgba(255,244,214,0.55), 0 2px 12px rgba(0,0,0,0.5)",
duration: 0.8, ease: "power1.out" }, st + 0.55);
}
});
});
// earlier stanzas drift out before the next stanza begins
if (si < STANZAS.length - 1) {
const nextIn = STANZAS[si + 1][0].words[0][1];
stanza.forEach((L, k) => {
tl.to("#" + L.id + " .l", { y: -10, opacity: 0, filter: "blur(4px)",
duration: 0.5, ease: "power1.in", stagger: 0.008 }, nextIn - 0.62 + k * 0.12);
tl.set("#" + L.id, { display: "none" }, nextIn + 0.1);
});
}
});
${
disperse
? ` // SEMANTIC EXIT: the whole visible poem disperses on the final word.
// Clamped so the scatter COMPLETES before the clip ends (stagger included).
const NZ = ${Math.min(LASTWORD.start + 0.06, DUR - 0.58).toFixed(3)};
const DDUR = ${Math.min(0.42, Math.max(0.24, DUR - Math.min(LASTWORD.start + 0.06, DUR - 0.58) - 0.14)).toFixed(2)};
lastLetters.forEach((l) => {
const dx = (prnd() - 0.5) * 260, dy = (prnd() - 0.5) * 200 - 40;
tl.to(l, { x: dx, y: dy, opacity: 0, filter: "blur(7px)",
duration: DDUR, ease: "power2.in" }, NZ + prnd() * 0.05);
});`
: ""
}`;
return { css, html, js };
}
function paradigmTakeover() {
const b = dna.body;
// each LINE is a card; minors hot; hero = colorflip apex inline
const cards = LINES.map((L, i) => {
const txt = L.words
.map((w) => w.display)
.join(" ")
.toUpperCase();
const isHero = L.words.some((w) => w.isHero);
const hot = isHero || L.words.some((w) => w.minor);
const _contentish = txt.replace(/[^A-Z]/g, "").length;
const px = isHero
? dna.hero.fontPx || 196
: Math.min(
b.sizes.minor,
Math.round(Math.min(b.sizes.content, (W * 0.86) / (0.55 * Math.max(3, txt.length)))),
);
return {
id: "c" + i,
txt,
in: +L.in.toFixed(3),
out: +L.out.toFixed(3),
px,
hot: hot ? 1 : 0,
hero: isHero ? 1 : 0,
last: i === LINES.length - 1 ? 1 : 0,
};
});
// a held card before a silence ≥0.5s creeps, then VOID until the next card
for (let i = 0; i + 1 < cards.length; i++) {
const gap = cards[i + 1].in - LINES[i].words[LINES[i].words.length - 1].end;
if (gap > 0.5) {
cards[i].creep = 1;
cards[i].out = +(cards[i + 1].in - Math.min(0.6, gap * 0.45)).toFixed(3);
}
}
const css = `
#dim { position:absolute; inset:0; opacity:0; background:#05070c; }
.card { position:absolute; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.body}', sans-serif; line-height:1; letter-spacing:0.015em;
color:${dna.palette.body}; text-shadow: 0 4px 26px rgba(0,0,0,0.65); }
.card.hot { color:${dna.palette.accent};
text-shadow: 0 0 34px ${dna.palette.accent}73, 0 4px 26px rgba(0,0,0,0.65); }`;
const html = ` <div id="dim"></div>`;
const js = `
// ---- body paradigm: TAKEOVER (hard-cut full-frame cards) + colorflip apex ----
const stage = document.getElementById("stage");
const ANCH = ${J(b.anchors)};
tl.fromTo("#dim", { opacity: 0 }, { opacity: ${b.baseDim}, duration: 0.25, ease: "power2.in" }, 0.05);
const CARDS = ${J(cards)};
CARDS.forEach((c, ci) => {
const el = document.createElement("div");
el.className = "card" + (c.hot ? " hot" : "");
el.textContent = c.txt; el.style.fontSize = c.px + "px";
stage.appendChild(el);
const [ax, ay] = ANCH[ci % ANCH.length];
gsap.set(el, { left: ax, top: ay, xPercent: -50, yPercent: -50,
rotation: (ci % 2 === 0 ? 1 : -1) * (0.8 + (ci % 3) * 0.7) });
if (c.hero) {
// SETPIECE colorflip: crush-in, squash, settle; plate dim deepens
tl.set("#dim", { opacity: ${dna.hero.params.dimKick} }, c.in);
tl.to("#dim", { opacity: ${b.baseDim}, duration: 0.5, ease: "power2.out" }, c.in + 0.12);
tl.set(el, { opacity: 1, scale: ${dna.hero.params.crushFrom}, filter: "blur(10px) brightness(2.6)" }, c.in - 0.04);
tl.to(el, { scale: 1, filter: "blur(0px) brightness(1)", duration: 0.11, ease: "power4.in" }, c.in - 0.04);
tl.set(el, { scaleX: 1.10, scaleY: 0.92 }, c.in + 0.07);
tl.to(el, { scaleX: 1, scaleY: 1, duration: 0.45, ease: "elastic.out(1.05, 0.36)" }, c.in + 0.15);
tl.to(el, { scale: 1.05, duration: Math.max(0.2, c.out - c.in - 0.3), ease: "power1.inOut" }, c.in + 0.25);
} else if (c.hot) {
tl.set(el, { opacity: 1, scale: ${b.hotSnap} }, c.in);
tl.to(el, { scale: 1, duration: 0.22, ease: "back.out(1.8)" }, c.in + 0.02);
} else {
tl.set(el, { opacity: 1, scale: 0.94 }, c.in);
tl.to(el, { scale: c.creep ? ${b.creepScale} : 1.0,
duration: Math.max(0.08, c.out - c.in - 0.02),
ease: c.creep ? "power1.in" : "power2.out" }, c.in + 0.01);
}
if (c.last && ${J(!!dna.hero.params.dissolveLast)}) {
tl.to(el, { filter: "blur(9px)", opacity: 0, duration: 0.32, ease: "power2.in" }, c.out - 0.23);
tl.set(el, { display: "none" }, c.out + 0.1);
} else {
tl.set(el, { opacity: 0, display: "none" }, c.out);
}
});
tl.to("#dim", { opacity: 0, duration: 0.3, ease: "power1.in" }, ${(DUR - 0.27).toFixed(2)});`;
return { css, html, js };
}
/* =====================================================================
* HERO SETPIECES (embedded, bg layer) — { css, html, js } for index.html
* ===================================================================== */
function setpieceDetonation() {
const h = dna.hero,
p = h.params,
I = heroIn;
const bw = Math.min(p.barWidth, W - 100, HG.halfW * 2 + 140);
const css = `
#scrim { position:absolute; inset:0; opacity:0;
background: radial-gradient(120% 105% at 50% 38%, rgba(0,0,0,0) 26%, rgba(0,0,0,0.8) 100%); }
#dimP { position:absolute; inset:0; opacity:0; background:#000; }
#det { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
.band, #heat { position:absolute; left:0; top:0; transform:translate(-50%,-50%);
font-family:'${dna.fonts.hero}', sans-serif; font-size:${HG.fontPx}px; line-height:1;
letter-spacing:0.012em; white-space:nowrap; color:${dna.palette.body};
text-shadow: 0 3px 18px rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.4); }
${p.slices.map((s, i) => `#b${i + 1} { clip-path: inset(${s[0]}% 0 ${s[1]}% 0); }`).join("\n ")}
#heat { top:6px; color:#ff7a28; filter:blur(24px); opacity:0; text-shadow:none; }
.bar { position:absolute; left:50%; width:${bw}px; height:3px; margin-left:-${bw / 2}px;
background:${dna.palette.body}; opacity:0.85; transform-origin:50% 50%; }
#barT { top:-${Math.round(HG.fontPx * 0.7)}px; } #barB { top:${Math.round(HG.fontPx * 0.66)}px; }
.tick { position:absolute; width:3px; height:14px; background:${dna.palette.hot}; top:-${Math.round(HG.fontPx * 0.7)}px; opacity:0; }
#tickL { left:calc(50% - ${bw / 2}px); } #tickR { left:calc(50% + ${bw / 2 - 3}px); }
#tagwrap { position:absolute; left:50%; transform:translateX(-50%); top:${Math.round(HG.fontPx * 0.7)}px;
overflow:hidden; white-space:nowrap; }
#tag { font-family:'${dna.fonts.tag}', monospace; font-size:21px; letter-spacing:0.34em;
color:${dna.palette.hot}; white-space:nowrap; text-shadow: 0 2px 8px rgba(0,0,0,0.6); }`;
const html = ` <div id="scrim"></div><div id="dimP"></div>
<div id="det">
<div id="heat">${esc(heroText)}</div>
${p.slices.map((_, i) => `<div class="band" id="b${i + 1}">${esc(heroText)}</div>`).join("\n ")}
${p.bars ? `<div class="bar" id="barT"></div><div class="bar" id="barB"></div>` : ""}
${p.ticks ? `<div class="tick" id="tickL"></div><div class="tick" id="tickR"></div>` : ""}
${p.tag ? `<div id="tagwrap"><div id="tag">${esc(p.tag)}&nbsp;//&nbsp;T+${heroIn.toFixed(2)}S</div></div>` : ""}
</div>`;
const bandIds = p.slices.map((_, i) => "#b" + (i + 1));
const js = `
// ---- setpiece: DETONATION (charge → sheared slices snap → cool → furniture) ----
const I = ${I.toFixed(3)}, HOTC = ${J(dna.palette.hot)}, BONEC = ${J(dna.palette.body)};
tl.fromTo("#scrim", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.40, ease: "power2.in" }, I - 0.42);
tl.set("#dimP", { opacity: ${dna.plate.dim} }, I);
tl.to("#dimP", { opacity: ${(dna.plate.dim * 0.47).toFixed(2)}, duration: 0.9, ease: "power2.out" }, I + 0.10);
tl.to("#scrim", { opacity: 0.30, duration: 0.9, ease: "power2.out" }, I + 0.10);
tl.to(["#scrim","#dimP"], { opacity: 0, duration: 0.45, ease: "power1.in" }, ${(heroOut - 0.25).toFixed(3)});
tl.set("#det", { opacity: 1 }, I - 0.05);
tl.fromTo("#det", { scale: 3.4 }, { scale: 1.0, duration: 0.10, ease: "power4.in" }, I - 0.05);
const BANDS = ${J(bandIds)};
const SHEAR = ${J(p.sliceShear)};
BANDS.forEach((sel, i) => {
tl.fromTo(sel, { x: SHEAR[i], filter: "blur(14px) brightness(3.4)" },
{ x: SHEAR[i] * 0.26, filter: "blur(0px) brightness(1.5)", duration: 0.10, ease: "power4.in" }, I - 0.05);
});
tl.set(BANDS, { x: 0, filter: "brightness(1.9)" }, I + 0.05);
tl.set("#det", { scaleX: 1.12, scaleY: 0.90 }, I + 0.05);
tl.to("#det", { scaleX: 1, scaleY: 1, duration: 0.55, ease: "elastic.out(1.05, 0.34)" }, I + 0.13);
tl.to(BANDS, { filter: "brightness(1)", duration: 0.5, ease: "power2.out" }, I + 0.15);
tl.set(BANDS, { color: HOTC }, I + 0.05);
tl.to(BANDS, { color: BONEC, duration: 0.9, ease: "power1.in" }, I + 0.22);
${p.bars ? ` tl.fromTo(["#barT","#barB"], { scaleX: 0, opacity: 0.85 }, { scaleX: 1, duration: 0.34, ease: "expo.out" }, I + 0.16);` : ""}
${
p.ticks
? ` tl.set(["#tickL","#tickR"], { opacity: 1 }, I + 0.42);
tl.fromTo(["#tickL","#tickR"], { scaleY: 0 }, { scaleY: 1, duration: 0.14, ease: "back.out(2)" }, I + 0.42);`
: ""
}
${p.tag ? ` tl.fromTo("#tagwrap", { width: 0 }, { width: 340, duration: 0.45, ease: "steps(16)" }, I + 0.5);` : ""}
tl.fromTo("#heat", { opacity: 0 }, { opacity: 0.75, duration: 0.3 }, I + 0.06);
tl.to("#heat", { keyframes: { opacity: [0.75, 0.5, 0.6, 0.4, 0.46, 0.30] }, duration: 1.4, ease: "none" }, I + 0.5);
tl.to("#det", { scale: 1.035, duration: 1.2, ease: "power1.inOut" }, I + 0.75);
// EXIT: the stamp shears apart
BANDS.forEach((sel, i) => {
tl.to(sel, { x: -SHEAR[i] * 1.5, opacity: 0, duration: 0.16, ease: "power2.in" }, ${(heroOut - 0.18).toFixed(3)} + i * 0.01);
});
tl.to(["#barT","#barB","#tickL","#tickR","#tagwrap","#heat"], { opacity: 0, duration: 0.13 }, ${(heroOut - 0.18).toFixed(3)});
tl.set("#det", { display: "none" }, ${(heroOut + 0.02).toFixed(3)});`;
return { css, html, js };
}
function setpieceDecode() {
const h = dna.hero,
p = h.params,
I = heroIn;
const lockStagger = p.lockStagger || 0.045;
const E = theme.hero.exitAt ?? Math.min(heroOut, I + 1.75);
const broom = Math.min(HG.x, W - HG.x) - HG.halfW; // room for brackets
const useBrackets = !!p.brackets && broom > 22;
const boff = Math.max(14, Math.min(40, Math.floor(broom - 8)));
const css = `
#dimP { position:absolute; inset:0; opacity:0; background:#021018; }
#blk { position:absolute; left:${HG.x}px; top:${HG.y}px; transform:translate(-50%,-50%); opacity:0; }
#word { display:flex; align-items:flex-start; justify-content:center;
font-family:'${dna.fonts.hero}', sans-serif; font-weight:800; font-size:${HG.fontPx}px;
line-height:${HG.fontPx}px; color:#eafaff;
text-shadow: 0 0 14px ${dna.palette.accent}8c, 0 3px 16px rgba(0,0,0,0.6); }
.reel { display:block; height:${HG.fontPx}px; width:0.82em; overflow:hidden; text-align:center; }
.reel.sp { width:0.38em; }
.col { display:block; } .col span { display:block; height:${HG.fontPx}px; }
.br { position:absolute; width:26px; height:26px; opacity:0; }
.br.tl { left:-${boff}px; top:-30px; border-left:3px solid ${dna.palette.accent}; border-top:3px solid ${dna.palette.accent}; }
.br.tr { right:-${boff}px; top:-30px; border-right:3px solid ${dna.palette.accent}; border-top:3px solid ${dna.palette.accent}; }
.br.bl { left:-${boff}px; bottom:-34px; border-left:3px solid ${dna.palette.accent}; border-bottom:3px solid ${dna.palette.accent}; }
.br.brr{ right:-${boff}px; bottom:-34px; border-right:3px solid ${dna.palette.accent}; border-bottom:3px solid ${dna.palette.accent}; }`;
const html = ` <div id="dimP"></div>
<div id="blk">
<div id="word"></div>
${useBrackets ? `<div class="br tl"></div><div class="br tr"></div><div class="br bl"></div><div class="br brr"></div>` : ""}
</div>`;
const js = `
// ---- setpiece: DECODE (glyph reels lock left→right, CRT-off exit) ----
const I = ${I.toFixed(3)}, TEXT = ${J(heroText)}, GLYPHS = ${J(p.glyphs)};
const drnd = mulberry32(${p.seed || 99});
tl.fromTo("#dimP", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.3, ease: "power2.in" }, I - 0.25);
tl.to("#dimP", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(E + 0.05).toFixed(3)});
const word = document.getElementById("word");
const reels = [];
for (let i = 0; i < TEXT.length; i++) {
const ch = TEXT[i];
const reel = document.createElement("span");
reel.className = "reel" + (ch === " " ? " sp" : "");
const col = document.createElement("span"); col.className = "col";
if (ch !== " ") {
const n = 5 + Math.floor(drnd() * 4);
for (let k = 0; k < n; k++) {
const s = document.createElement("span");
s.textContent = GLYPHS[Math.floor(drnd() * GLYPHS.length)]; col.appendChild(s);
}
const fin = document.createElement("span"); fin.textContent = ch; col.appendChild(fin);
reel.dataset.steps = n;
}
reel.appendChild(col); word.appendChild(reel); reels.push({ el: reel, col, ch, i });
}
tl.set("#blk", { opacity: 1 }, I - 0.02);
reels.forEach((r) => {
if (r.ch === " ") return;
const lock = I + 0.30 + ${lockStagger} * r.i;
const steps = parseInt(r.el.dataset.steps, 10);
tl.fromTo(r.col, { y: 0 }, { y: -${HG.fontPx} * steps, duration: lock - I, ease: "steps(" + steps + ")" }, I);
tl.set(r.el, { color: "#bfe9ff",
textShadow: "3px 0 10px ${dna.palette.accent}cc, -3px 0 10px ${dna.palette.magenta}cc" }, I);
tl.set(r.el, { textShadow: "-3px 0 10px ${dna.palette.accent}cc, 3px 0 10px ${dna.palette.magenta}cc" }, I + 0.12 + 0.02 * r.i);
tl.set(r.el, { color: "#ffffff", textShadow: "0 0 22px rgba(255,255,255,0.95)" }, lock);
tl.set(r.el, { color: "#eafaff",
textShadow: "0 0 14px ${dna.palette.accent}8c, 0 3px 16px rgba(0,0,0,0.6)" }, lock + 0.083);
});
const LOCKED = I + 0.30 + ${lockStagger} * (TEXT.length - 1) + 0.083;
tl.set("#blk", { scale: 1.04 }, LOCKED - 0.083);
tl.to("#blk", { scale: 1, duration: 0.3, ease: "elastic.out(1, 0.4)" }, LOCKED);
${
useBrackets
? ` [".br.tl",".br.tr",".br.bl",".br.brr"].forEach((sel, k) => {
tl.fromTo(sel, { opacity: 0, scale: 0.3 }, { opacity: 0.95, scale: 1, duration: 0.22, ease: "expo.out" }, I - 0.12 + k * 0.05);
});`
: ""
}
// EXIT: CRT power-off
tl.to("#blk", { filter: "brightness(2.6)", duration: 0.07, ease: "power2.in" }, ${E.toFixed(3)});
tl.to("#blk", { scaleY: 0.012, duration: 0.13, ease: "power4.in" }, ${(E + 0.06).toFixed(3)});
tl.set("#blk", { opacity: 0, display: "none" }, ${(E + 0.2).toFixed(3)});`;
return { css, html, js };
}
function setpieceDrawon() {
const h = dna.hero,
p = h.params,
I = heroIn;
// generate the stroke path at COMPILE time — any word, zero tuning
const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
const tw = Math.min(p.targetWidth || 640, W - 200);
const D = execFileSync(
"python3",
[gen, fontPath, heroDisplay.toLowerCase(), String(tw), "185", String(Math.round(380 - tw / 2))],
{ encoding: "utf8" },
).trim();
const WIN = p.window || 0.78;
const css = `
#spill { position:absolute; left:${HG.x}px; top:${HG.y}px; width:820px; height:440px;
margin-left:-410px; margin-top:-220px; border-radius:50%; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, ${dna.palette.accent}57 0%, ${dna.palette.accent}00 70%); }
#sign { position:absolute; left:${HG.x}px; top:${HG.y}px; }
#neonsvg { position:absolute; left:0; top:0; margin-left:-380px; margin-top:-150px;
transform:rotate(${p.rotate || -3}deg); overflow:visible; }
#coreG { filter: drop-shadow(0 0 5px #fff) drop-shadow(0 0 16px ${dna.palette.accent}); }
#pen { filter: drop-shadow(0 0 8px #fff) drop-shadow(0 0 22px ${dna.palette.accent}); }`;
const html = ` <div id="spill"></div>
<div id="sign">
<svg id="neonsvg" width="760" height="300" viewBox="0 0 760 300">
<defs><filter id="haloblur" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="10"/></filter></defs>
<path id="tubeP" fill="none" stroke="rgba(255,235,245,0.16)" stroke-width="${p.tubeWidth + 1}"
stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
<g id="haloG" filter="url(#haloblur)" opacity="0.9"></g>
<g id="coreG"></g>
<circle id="pen" r="6" fill="#fff" opacity="0"/>
</svg>
</div>`;
const js = `
// ---- setpiece: DRAWON (single-line font, SEQUENTIAL stroke reveal) ----
// SVG dash restarts per subpath → one <path> per pen stroke, revealed in
// writing order at constant pen speed; nib rides each stroke, hops at lifts.
const I = ${I.toFixed(3)}, WIN = ${WIN}, DRAWN = I + WIN, ENDT = ${heroOut.toFixed(3)};
const D = ${J(D)};
document.getElementById("tubeP").setAttribute("d", D);
const subs = D.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
const SVGNS = "http://www.w3.org/2000/svg";
function mk(parent, d, stroke, w) {
const el = document.createElementNS(SVGNS, "path");
el.setAttribute("d", d); el.setAttribute("fill", "none");
el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
el.setAttribute("opacity", "0"); // hidden until its turn (kills round-cap dot)
document.getElementById(parent).appendChild(el);
return el;
}
const strokes = subs.map((d) => ({ halo: mk("haloG", d, ${J(dna.palette.accent)}, ${p.haloWidth}),
core: mk("coreG", d, "#fff", ${p.tubeWidth}) }));
let total = 0;
strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
tl.fromTo("#tubeP", { opacity: 0 }, { opacity: 1, duration: 0.35 }, I - 0.6);
tl.fromTo("#spill", { opacity: 0 }, { opacity: ${p.spill || 0.38}, duration: WIN + 0.2, ease: "power1.in" }, I);
const pen = document.getElementById("pen");
let t = I;
strokes.forEach((s) => {
const d = WIN * s.len / total;
gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
tl.set([s.core, s.halo], { opacity: 1 }, t);
tl.fromTo([s.core, s.halo], { strokeDashoffset: s.len }, { strokeDashoffset: 0, duration: d, ease: "none" }, t);
const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
for (let k = 0; k <= n; k++) {
const pt = s.core.getPointAtLength(s.len * k / n); xs.push(pt.x); ys.push(pt.y);
}
tl.set(pen, { x: xs[0], y: ys[0] }, t);
tl.to(pen, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t);
t += d;
});
gsap.set(pen, { attr: { cx: 0, cy: 0 } });
tl.set(pen, { opacity: 1 }, I - 0.01);
tl.to(pen, { scale: 2.2, opacity: 0, duration: 0.12, ease: "power2.in", transformOrigin: "50% 50%" }, DRAWN);
${
p.hum
? ` const humStart = DRAWN + 0.08, humDur = Math.max(0.2, ENDT - humStart);
const NN = Math.max(6, Math.round(humDur / (2 * F)));
const humVals = [];
for (let k = 0; k <= NN; k++) {
const tt = k / NN * humDur;
humVals.push(0.78 + 0.06 * Math.sin(2 * Math.PI * 9 * tt) + 0.05 * Math.sin(2 * Math.PI * 13 * tt + 1.1));
}
tl.to("#haloG", { keyframes: { opacity: humVals }, duration: humDur, ease: "none" }, humStart);
tl.set("#coreG", { opacity: 0.45 }, ENDT + (${p.buzzDipAt || -0.18}));
tl.set("#coreG", { opacity: 1 }, ENDT + (${p.buzzDipAt || -0.18}) + F);`
: ""
}`;
return { css, html, js };
}
/* =====================================================================
* FRONT FX (fg layer additions) — flash / rings / sparks / scanband
* ===================================================================== */
function frontFx() {
const fx = dna.fx || {};
let css = "",
html = "",
js = "";
const I = heroIn;
if (fx.flash) {
css += `
#fxflash { position:absolute; inset:0; opacity:0;
background: radial-gradient(95% 80% at 50% 37%, rgba(255,250,240,0.95) 0%, rgba(255,235,210,0.7) 38%, rgba(120,90,60,0) 100%); }`;
html += ` <div id="fxflash"></div>\n`;
js += `
tl.set("#fxflash", { opacity: ${fx.flash} }, ${(I + 0.045).toFixed(3)});
tl.to("#fxflash", { opacity: 0, duration: 0.17, ease: "expo.out" }, ${(I + 0.085).toFixed(3)});`;
}
if (fx.rings) {
css += `
.fxring { position:absolute; left:${HG.x}px; top:${HG.y}px; width:90px; height:32px;
margin-left:-45px; margin-top:-16px; border-radius:50%; opacity:0; }
#fxr1 { border:4px solid rgba(255,228,196,0.95); filter:blur(1.5px); }
#fxr2 { border:3px solid rgba(255,150,70,0.8); filter:blur(3px); }`;
html += ` <div class="fxring" id="fxr1"></div>\n <div class="fxring" id="fxr2"></div>\n`;
js += `
tl.set("#fxr1", { opacity: 1, scale: 1 }, ${(I + 0.05).toFixed(3)});
tl.to("#fxr1", { scale: 16, opacity: 0, duration: 0.58, ease: "expo.out" }, ${(I + 0.05).toFixed(3)});
tl.to("#fxr1", { filter: "blur(7px)", duration: 0.58, ease: "power1.in" }, ${(I + 0.05).toFixed(3)});
tl.set("#fxr2", { opacity: 0.55, scale: 1 }, ${(I + 0.13).toFixed(3)});
tl.to("#fxr2", { scale: 11, opacity: 0, duration: 0.72, ease: "expo.out" }, ${(I + 0.13).toFixed(3)});`;
}
if (fx.sparks) {
css += `
.fxspark { position:absolute; left:${HG.x}px; top:${HG.y}px; border-radius:2px;
background:#ffd9a8; opacity:0; }
.fxember { position:absolute; border-radius:50%; background:#ff9544; filter:blur(1px); opacity:0; }`;
js += `
{ const frnd = mulberry32(${fx.seed || 7});
const stg = document.getElementById("stage");
for (let i = 0; i < ${fx.sparks}; i++) {
const el = document.createElement("div"); el.className = "fxspark"; stg.appendChild(el);
const ang = frnd() * Math.PI * 2, dist = 130 + frnd() * 330;
const dx = Math.cos(ang) * dist, dy = Math.sin(ang) * dist * 0.55;
const grav = 40 + frnd() * 90, s = 3 + Math.round(frnd() * 5), d = 0.6 + frnd() * 0.45;
gsap.set(el, { width: s, height: s, x: 0, y: 0 });
tl.set(el, { opacity: 1 }, ${(I + 0.05).toFixed(3)});
tl.to(el, { keyframes: { x: [0, dx*0.55, dx*0.85, dx],
y: [0, dy*0.55 + grav*0.15, dy*0.85 + grav*0.55, dy + grav],
opacity: [1, 1, 0.65, 0] }, duration: d, ease: "power2.out" }, ${(I + 0.05).toFixed(3)});
}
for (let i = 0; i < ${fx.embers || 0}; i++) {
const el = document.createElement("div"); el.className = "fxember"; stg.appendChild(el);
const s = 3 + Math.round(frnd() * 3);
gsap.set(el, { width: s, height: s, left: ${HG.x - 220} + frnd() * 440, top: ${HG.y + 32} + frnd() * 60 });
const t0 = ${(I + 0.6).toFixed(3)} + frnd() * 0.9, dr = 1.0 + frnd() * 0.7;
const yA = -40 - frnd() * 50, yB = -80 - frnd() * 60;
const xA = (frnd() - 0.5) * 30, xB = (frnd() - 0.5) * 50;
tl.to(el, { keyframes: { opacity: [0, 0.75, 0.4, 0.8, 0],
y: [0, yA*0.5, yA, (yA+yB)/2, yB],
x: [0, xA*0.5, xA, (xA+xB)/2, xB] }, duration: dr, ease: "power1.out" }, t0);
}
}`;
}
if (fx.crowdflash) {
// two stadium camera-flash pops: lock start / lock end (flapboard rhythm)
const [fa, fb] = fx.crowdflash;
const lockEnd = I + ((dna.hero.params || {}).lockWindow || 0.34);
css += `
#fxcrowd { position:absolute; inset:0; opacity:0;
background: radial-gradient(85% 70% at 50% 34%, rgba(255,250,238,0.85) 0%,
rgba(255,238,210,0.45) 45%, rgba(60,50,35,0) 100%); }`;
html += ` <div id="fxcrowd"></div>\n`;
js += `
tl.set("#fxcrowd", { opacity: ${fa} }, ${I.toFixed(3)});
tl.to("#fxcrowd", { opacity: 0, duration: 0.10, ease: "expo.out" }, ${(I + 0.042).toFixed(3)});
tl.set("#fxcrowd", { opacity: ${fb} }, ${lockEnd.toFixed(3)});
tl.to("#fxcrowd", { opacity: 0, duration: 0.12, ease: "expo.out" }, ${(lockEnd + 0.042).toFixed(3)});`;
}
if (fx.paflash) {
// PA-system flash over the board glow at apex contact (ledwipe completes)
const C2 = I + ((dna.hero.params || {}).wipeDur || 0.3);
const pw = (HG.panelW || 900) - 180,
ph = Math.round((HG.panelH || 232) * 1.9);
css += `
#fxpa { position:absolute; left:${Math.round(HG.x - pw / 2)}px; top:${Math.round(HG.y - ph / 2)}px;
width:${pw}px; height:${ph}px; opacity:0;
background: radial-gradient(closest-side, rgba(255,200,100,0.55), ${dna.palette.accent}00 70%); }`;
html += ` <div id="fxpa"></div>\n`;
js += `
tl.set("#fxpa", { opacity: ${fx.paflash} }, ${C2.toFixed(3)});
tl.to("#fxpa", { opacity: 0, duration: 0.22, ease: "expo.out" }, ${(C2 + 0.042).toFixed(3)});`;
}
if (fx.coinflash) {
// 1-frame arcade impact flash at the boss-word strike (the coin blip at
// max amplitude) — vertical center rides the hero band
const FT = I + ((dna.hero.params || {}).flashDelay ?? 0.14);
const cyP = Math.round((HG.y / H) * 100);
css += `
#fxcoin { position:absolute; inset:0; opacity:0; z-index:16;
background: radial-gradient(58% 38% at 50% ${cyP}%, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0) 70%); }`;
html += ` <div id="fxcoin"></div>\n`;
js += `
tl.set("#fxcoin", { opacity: ${fx.coinflash} }, ${FT.toFixed(3)});
tl.to("#fxcoin", { opacity: 0, duration: 0.12, ease: "expo.out" }, ${(FT + 0.042).toFixed(3)});`;
}
if (fx.inkflecks) {
// red ink flecks spatter off the rubber-stamp contact (fg layer, ballistic
// with gravity) — fires at C = heroIn + params.crush, the punch anchor
const C4 = I + ((dna.hero.params || {}).crush ?? 0.074);
css += `
.fxfleck { position:absolute; left:${HG.x}px; top:${HG.y}px; border-radius:50%;
background:${dna.palette.fleck || dna.palette.accent}; opacity:0; }`;
js += `
{ const frnd = mulberry32(${fx.seed || 18});
const stg = document.getElementById("stage");
for (let i = 0; i < ${fx.inkflecks}; i++) {
const el = document.createElement("div"); el.className = "fxfleck"; stg.appendChild(el);
const ang = frnd() * Math.PI * 2, dist = 70 + frnd() * 200;
const dx = Math.cos(ang) * dist, dy = Math.sin(ang) * dist * 0.6;
const grav = 30 + frnd() * 60, sz2 = 2 + Math.round(frnd() * 3), d = 0.32 + frnd() * 0.2;
gsap.set(el, { width: sz2, height: sz2, x: 0, y: 0 });
tl.set(el, { opacity: 1 }, ${C4.toFixed(3)});
tl.to(el, { keyframes: { x: [0, dx * 0.6, dx],
y: [0, dy * 0.6 + grav * 0.2, dy + grav],
opacity: [1, 0.85, 0] },
duration: d, ease: "power2.out" }, ${(C4 + 0.01).toFixed(3)});
}
}`;
}
if (fx.laserflash) {
// apex contact flash in FRONT of everything (the laser ignite pop, 4
// frames) — sized from the hero rect, tinted with the accent
const fw = Math.round(2 * (HG.halfW || 330) + 80),
fh = Math.round((HG.fontPx || 100) * 4.8);
css += `
#fxlaser { position:absolute; left:${HG.x - Math.round(fw / 2)}px; top:${HG.y - Math.round(fh / 2)}px;
width:${fw}px; height:${fh}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, rgba(235,255,244,0.9) 0%, ${dna.palette.accent}73 38%, rgba(0,0,0,0) 72%); }`;
html += ` <div id="fxlaser"></div>\n`;
js += `
tl.set("#fxlaser", { opacity: ${fx.laserflash} }, ${(I + 0.03).toFixed(3)});
tl.to("#fxlaser", { opacity: 0, duration: 0.16, ease: "expo.out" }, ${(I + 0.075).toFixed(3)});`;
}
if (fx.strikeflash) {
// full-frame lightning flash in FRONT of everything: leader blink 2 frames
// before contact (bright / dim alternation), return stroke at contact + 1f,
// expo-out decay — same flicker grammar as the bg bolt
css += `
#fxstrike { position:absolute; inset:0; opacity:0;
background: radial-gradient(80% 65% at 50% 38%, rgba(245,250,255,0.9) 0%, rgba(205,228,252,0.5) 40%, rgba(110,140,180,0) 100%); }`;
html += ` <div id="fxstrike"></div>\n`;
js += `
tl.set("#fxstrike", { opacity: ${(fx.strikeflash * 0.45).toFixed(3)} }, ${(I - 2 * F).toFixed(3)});
tl.set("#fxstrike", { opacity: ${(fx.strikeflash * 0.18).toFixed(3)} }, ${(I - F).toFixed(3)});
tl.set("#fxstrike", { opacity: ${fx.strikeflash} }, ${(I + 0.02).toFixed(3)});
tl.to("#fxstrike", { opacity: 0, duration: 0.16, ease: "expo.out" }, ${(I + 0.06).toFixed(3)});`;
}
if (fx.holoflash) {
// cyan LOCK flash in front of everything when the projection boots to
// full brightness — a radial centered on the hero (frame-fraction anchor)
const hxP = ((HG.x / W) * 100).toFixed(1),
hyP = ((HG.y / H) * 100).toFixed(1);
css += `
#fxholo { position:absolute; inset:0; opacity:0;
background: radial-gradient(58% 44% at ${hxP}% ${hyP}%, ${dna.palette.accent}8c 0%, rgba(80,160,200,0.18) 45%, rgba(40,80,110,0) 72%); }`;
html += ` <div id="fxholo"></div>\n`;
js += `
tl.set("#fxholo", { opacity: ${fx.holoflash} }, ${(I + 0.075).toFixed(3)});
tl.to("#fxholo", { opacity: 0, duration: 0.22, ease: "expo.out" }, ${(I + 0.105).toFixed(3)});`;
}
if (fx.sporeburst) {
// bioluminescent spores burst PAST the bloom at contact (fg, in front of
// the subject) — seeded radial puffs from the hero center, slight sink
const C6 = I + ((dna.hero.params || {}).crush ?? 0.11);
css += `
.fxspore { position:absolute; left:${HG.x}px; top:${HG.y}px; border-radius:50%;
background:${dna.palette.spore || dna.palette.accent};
box-shadow: 0 0 6px 1px ${dna.palette.accent}cc; opacity:0; }`;
js += `
{ const frnd = mulberry32(${fx.seed || 12});
const stg = document.getElementById("stage");
for (let i = 0; i < ${fx.sporeburst}; i++) {
const el = document.createElement("div"); el.className = "fxspore"; stg.appendChild(el);
const s = 2 + Math.round(frnd());
gsap.set(el, { width: s, height: s });
const ang = frnd() * Math.PI * 2, dist = 70 + frnd() * 110;
const dx = Math.cos(ang) * dist * 1.25, dy = Math.sin(ang) * dist * 0.7;
tl.to(el, { keyframes: { x: [0, dx * 0.6, dx], y: [0, dy * 0.6, dy + 10],
opacity: [0.7, 0.5, 0] },
duration: 0.45 + frnd() * 0.15, ease: "power2.out" }, ${(C6 + 0.01).toFixed(3)} + i * 0.012);
}
}`;
}
if (fx.silkmotes) {
// gentle aurora light motes drifting up near the ribbon during its hold
// (fg layer) — seeded along the ribbon span at the demo's spread fractions
// (the gap around 0.4 keeps them off the subject's face)
const halfW = HG.halfW || 440;
css += `
.fxmote { position:absolute; border-radius:50%; opacity:0; filter:blur(1.5px); }`;
js += `
{ const frnd = mulberry32(${fx.seed || 9});
const stg9 = document.getElementById("stage");
const PALM = [${J(dna.palette.rose)}, ${J(dna.palette.accent)}, ${J(dna.palette.violet)}];
const FRX = [-0.88, -0.56, -0.27, 0.05, 0.72, 0.84];
for (let i = 0; i < ${fx.silkmotes}; i++) {
const m = document.createElement("div"); m.className = "fxmote"; stg9.appendChild(m);
const s = 3 + Math.round(frnd() * 3);
const c = PALM[i % 3];
const mx = ${HG.x} + FRX[i % 6] * ${Math.round(halfW)} + (frnd() - 0.5) * 40;
const my = ${HG.y} - 5 + frnd() * 110;
gsap.set(m, { width: s, height: s, left: mx, top: my,
background: c, boxShadow: "0 0 6px " + c });
const t0 = ${(I + 0.13).toFixed(3)} + i * 0.09 + frnd() * 0.08;
tl.fromTo(m, { y: 10 },
{ keyframes: { opacity: [0, 0.45, 0], y: [10, -16, -44] },
duration: 0.75, ease: "sine.out" }, Math.min(t0, ${(DUR - 0.79).toFixed(3)}));
}
}`;
}
if (fx.paperscraps) {
// torn paper scraps puff PAST the subject at each apex chip contact (fg,
// in front) — ballistic steps(4) arcs, one accent scrap per puff
// (the papermat landing rhythm); centers baked from the chip geometry
const N9 = heroText.length;
const pitch9 = HG.pitch || 132,
step9 = (dna.hero.params || {}).step ?? 0.09;
const y9 = Math.round(HG.y + (HG.tileH || 170) / 2 - 9);
const puffs = [];
for (let i = 0; i < N9; i++)
if (heroText[i] !== " ")
puffs.push([
Math.round(HG.x + (i - (N9 - 1) / 2) * pitch9),
+(I + i * step9).toFixed(3),
+((i - (N9 - 1) / 2) * 22).toFixed(1),
]);
css += `
.fxscrap { position:absolute; opacity:0; border-radius:1px; }`;
js += `
{ const frnd = mulberry32(${fx.seed || 150372});
const stg = document.getElementById("stage");
const PUFFS = ${J(puffs)};
PUFFS.forEach(([px, pc, drift]) => {
for (let k = 0; k < ${fx.paperscraps}; k++) {
const el = document.createElement("div"); el.className = "fxscrap"; stg.appendChild(el);
const s = 7 + Math.round(frnd()*6);
el.style.width = s+"px"; el.style.height = Math.max(4, s-3)+"px";
el.style.background = (k === 1) ? ${J(dna.palette.accent)} : ${J(dna.palette.chip || "#f6edd8")};
el.style.left = (px + (frnd()-0.5)*40)+"px";
el.style.top = (${y9} + (frnd()-0.5)*14)+"px";
const dx = (frnd()-0.5)*180 + drift;
const dyU = -(35 + frnd()*55);
const dyF = dyU + 80 + frnd()*55;
const rot = (frnd()-0.5)*240;
tl.set(el, { opacity: 1 }, pc);
tl.to(el, { keyframes: { x: [0, dx*0.6, dx], y: [0, dyU, dyF],
rotation: [0, rot*0.6, rot], opacity: [1, 1, 0] },
duration: 0.42, ease: "steps(4)" }, pc);
}
});
}`;
}
if (fx.confetti) {
// paper confetti flicks up PAST the subject when the apex word pops (fg,
// in front) — seeded ballistic arcs with card tumble; launch line baked
// from the centerfold hinge (90% of the burst box, where the paper folds)
const u0 = (HG.burstR || 220) / 220;
const boxH0 = HG.boxH || 560;
const hingeY = Math.round(HG.y + 24 * u0 - boxH0 / 2 + 0.9 * boxH0);
const CC = dna.palette.conf || [
dna.palette.cream || "#f6ecd4",
dna.palette.accent,
dna.palette.body,
];
css += `
.fxconf { position:absolute; opacity:0; border-radius:1px; }`;
js += `
{ const frnd = mulberry32(${fx.seed || 360372});
const stg = document.getElementById("stage");
const CCOL = ${J(CC)};
for (let i = 0; i < ${fx.confetti}; i++) {
const el = document.createElement("div"); el.className = "fxconf";
el.style.background = CCOL[i % CCOL.length]; stg.appendChild(el);
gsap.set(el, { width: 5 + Math.round(frnd() * 5), height: 4 + Math.round(frnd() * 4),
left: ${HG.x - 30} + frnd() * 70, top: ${hingeY - 2} + frnd() * 16 });
const dx = (frnd() - 0.5) * 320, up = -90 - frnd() * 140, grav = 150 + frnd() * 120;
const rot = (frnd() - 0.5) * 520, d = 0.55 + frnd() * 0.3, t0 = ${(I + 0.128).toFixed(3)} + frnd() * 0.08;
tl.set(el, { opacity: 1 }, t0);
tl.to(el, { keyframes: { x: [0, dx * 0.5, dx * 0.85, dx],
y: [0, up, up * 0.55 + grav * 0.35, up * 0.1 + grav],
rotation: [0, rot * 0.4, rot * 0.75, rot],
opacity: [1, 1, 0.8, 0] },
duration: d, ease: "power1.out" }, t0);
}
}`;
}
if (fx.chalkclap) {
// hand-slap dust CLAP in FRONT of the subject the instant the chalk word
// completes (the bg board shakes the same frame) — seeded radial specks +
// a soft blurred puff, centered on the pen's final contact point (baked
// by setpieceChalkwrite into HG.clapX/Y/T)
const cx9 = Math.round(HG.clapX ?? HG.x + (HG.halfW || 80)),
cy9 = Math.round(HG.clapY ?? HG.y),
ct9 = +(HG.clapT ?? I + 0.5).toFixed(3);
const DUST9 = dna.palette.dust || "#f7f5ea";
css += `
#fxclap { position:absolute; left:${cx9}px; top:${cy9}px; width:0; height:0; }
#fxclap .cd { position:absolute; left:0; top:0; border-radius:50%; background:${DUST9}; opacity:0; }
#fxpuff { position:absolute; left:-48px; top:-48px; width:96px; height:96px; border-radius:50%; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, ${DUST9}80 0%, ${DUST9}00 70%);
filter: blur(4px); }`;
html += ` <div id="fxclap"><div id="fxpuff"></div></div>\n`;
js += `
{ const frnd = mulberry32(${fx.seed || 13});
const clap = document.getElementById("fxclap");
for (let i = 0; i < ${fx.chalkclap}; i++) {
const s = document.createElement("div"); s.className = "cd";
const sz = 2.5 + frnd() * 3.2;
s.style.width = sz + "px"; s.style.height = sz + "px";
s.style.marginLeft = (-sz / 2) + "px"; s.style.marginTop = (-sz / 2) + "px";
clap.appendChild(s);
const a = (i / ${fx.chalkclap}) * 6.2832 + (frnd() - 0.5) * 0.5;
const dist = 42 + frnd() * 55;
tl.set(s, { opacity: 1 }, ${ct9});
tl.to(s, { x: Math.cos(a) * dist, y: Math.sin(a) * dist * 0.8 + 26,
duration: 0.5 + frnd() * 0.15, ease: "power2.out" }, ${ct9});
tl.to(s, { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(ct9 + 0.1).toFixed(3)});
}
tl.fromTo("#fxpuff", { opacity: 0.55, scale: 0.35 },
{ opacity: 0, scale: 1.8, duration: 0.5, ease: "power2.out" }, ${ct9});
}`;
}
if (fx.paintsplat) {
// paint SPLAT in FRONT of the subject the instant the spray tag completes
// — a hot magenta ring + a soft paint-color ring + seeded droplets with
// gravity, centered on the pen-finish point (baked by setpieceSpraytag
// into HG.splatX/Y/T)
const sx = Math.max(60, Math.min(W - 60, Math.round(HG.splatX ?? HG.x + (HG.halfW || 80))));
const sy = Math.round(HG.splatY ?? HG.y);
const st9 = +Math.min(HG.splatT ?? I + 0.5, DUR - 0.5).toFixed(3);
const HOT9 = dna.palette.em2 || dna.palette.hot || "#ff2e88";
const PAINT9 = dna.palette.accent || "#ffd23f";
css += `
.fxsplat { position:absolute; left:${sx}px; top:${sy}px; border-radius:50%; opacity:0; }
#fxsp1 { width:40px; height:30px; border:5px solid ${HOT9}; margin-left:-20px; margin-top:-15px; }
#fxsp2 { width:26px; height:20px; border:4px solid ${PAINT9}; margin-left:-13px; margin-top:-10px;
filter: blur(1.5px); }
.fxsdrop { position:absolute; left:${sx}px; top:${sy}px; background:${HOT9};
border-radius:50%; opacity:0; }`;
html += ` <div class="fxsplat" id="fxsp1"></div>\n <div class="fxsplat" id="fxsp2"></div>\n`;
js += `
tl.set("#fxsp1", { opacity: 1, scale: 0.3 }, ${st9});
tl.to("#fxsp1", { scale: 4.6, opacity: 0, duration: 0.42, ease: "expo.out" }, ${st9});
tl.to("#fxsp1", { filter: "blur(4px)", duration: 0.42, ease: "power1.in" }, ${st9});
tl.set("#fxsp2", { opacity: 0.8, scale: 0.4 }, ${(st9 + 0.04).toFixed(3)});
tl.to("#fxsp2", { scale: 3.4, opacity: 0, duration: 0.5, ease: "expo.out" }, ${(st9 + 0.04).toFixed(3)});
{ const frnd = mulberry32(${fx.seed || 20260611});
const stg = document.getElementById("stage");
for (let i = 0; i < ${fx.paintsplat}; i++) {
const el = document.createElement("div");
el.className = "fxsdrop"; stg.appendChild(el);
const s = 3 + Math.round(frnd() * 4);
gsap.set(el, { width: s, height: s });
const ang = frnd() * Math.PI * 2, dist = 36 + frnd() * 64; // capped: stays inside the frame edge
const dx = Math.cos(ang) * dist, dyv = Math.sin(ang) * dist * 0.7;
const grav = 26 + frnd() * 40, d = 0.38 + frnd() * 0.25;
tl.set(el, { opacity: 1, x: 0, y: 0 }, ${st9});
tl.to(el, { keyframes: { x: [0, dx * 0.6, dx],
y: [0, dyv * 0.6 + grav * 0.2, dyv + grav],
opacity: [1, 0.85, 0] },
duration: d, ease: "power2.out" }, ${st9});
}
}`;
}
if (fx.brushflecks) {
// ink flecks kicked PAST the subject at the brush's first contact (the
// start-splat depth pass) — seeded upward ballistic arcs from the pen-START
// point (baked by setpieceBrushwrite into HG.fleckX/Y/T)
const bx = Math.max(40, Math.min(W - 40, Math.round(HG.fleckX ?? HG.x - (HG.halfW || 80))));
const by = Math.round(HG.fleckY ?? HG.y);
const bt = +Math.min(HG.fleckT ?? I + 0.01, DUR - 0.4).toFixed(3);
css += `
.fxbfleck { position:absolute; left:${bx}px; top:${by}px; border-radius:50%;
background:${dna.palette.ink || "#1a130c"}; opacity:0; }`;
js += `
{ const frnd = mulberry32(${fx.seed || 20260611});
const stg = document.getElementById("stage");
for (let i = 0; i < ${fx.brushflecks}; i++) {
const el = document.createElement("div"); el.className = "fxbfleck"; stg.appendChild(el);
const s = 3 + Math.round(frnd() * 3);
gsap.set(el, { width: s, height: s });
const a = -0.4 - frnd() * 2.4, dist = 46 + frnd() * 90;
const dx = Math.cos(a) * dist, dyv = Math.sin(a) * dist;
tl.set(el, { opacity: 1 }, ${bt});
tl.to(el, { keyframes: { x: [0, dx * 0.6, dx], y: [0, dyv * 0.6, dyv + 26],
opacity: [1, 0.85, 0] },
duration: 0.36, ease: "power2.out" }, ${bt});
}
}`;
}
if (fx.scanband) {
const lockT =
heroIn + 0.3 + (dna.hero.params.lockStagger || 0.045) * (heroText.length - 1) + 0.083;
css += `
#fxband { position:absolute; left:0; top:-80px; width:100%; height:70px; opacity:0;
background: linear-gradient(180deg, ${dna.palette.accent}00 0%, ${dna.palette.accent}66 50%, ${dna.palette.accent}00 100%); }
#fxlock { position:absolute; inset:0; opacity:0;
background: radial-gradient(80% 60% at 52% 35%, ${dna.palette.accent}99 0%, ${dna.palette.accent}00 70%); }`;
html += ` <div id="fxband"></div>\n <div id="fxlock"></div>\n`;
js += `
tl.set("#fxband", { opacity: 0.5 }, ${(I - 0.05).toFixed(3)});
tl.fromTo("#fxband", { y: 0 }, { y: ${H + 160}, duration: 0.5, ease: "power1.in" }, ${(I - 0.05).toFixed(3)});
tl.set("#fxband", { opacity: 0 }, ${(I + 0.46).toFixed(3)});
tl.set("#fxlock", { opacity: ${fx.lockflash || 0.55} }, ${(lockT - 0.083).toFixed(3)});
tl.to("#fxlock", { opacity: 0, duration: 0.2, ease: "expo.out" }, ${lockT.toFixed(3)});`;
}
return { css, html, js };
}
/* =====================================================================
* ASSEMBLE + POSTFX
* ===================================================================== */
function paradigmLastpage() {
// THE LAST PAGE: a blurred "future manuscript" haunts the room all video;
// at the apex one rack-focus reveals every faint line was the apex word.
const b = dna.body,
I = heroIn;
const prnd = (() => {
let a = b.seed || 4242;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
})();
// field instances: seeded positions in the upper room, away from the subject column
const subjX = sz && sz.heroAnchor ? (W * sz.heroAnchor.centerXPct) / 100 : W * 0.5;
const inst = [];
for (let i = 0; i < (b.fieldCount || 9); i++) {
let x = 80 + prnd() * (W - 160),
y = 60 + prnd() * (H * 0.55);
if (Math.abs(x - subjX) < 170)
x = x < subjX ? subjX - 180 - prnd() * 120 : subjX + 180 + prnd() * 120;
inst.push({
x: Math.round(Math.max(70, Math.min(W - 70, x))),
y: Math.round(y),
px: Math.round(34 + prnd() * 96),
rot: +((prnd() - 0.5) * 8).toFixed(1),
op: +(0.26 + prnd() * 0.2).toFixed(2),
});
}
const main = inst.reduce((a, c) => (c.px > a.px ? c : a), inst[0]);
const lineData = LINES.map((L) => ({
id: "m" + L.id,
in: +L.in.toFixed(3),
out: +L.out.toFixed(3),
words: L.words.map((w) => [w.display, +w.start.toFixed(3)]),
}));
const css = `
.fld { position:absolute; white-space:nowrap; font-family:'${dna.fonts.body}', serif;
font-weight:600; color:${dna.palette.body}; filter: blur(${b.fieldBlur || 9}px); }
.ms { position:absolute; left:${W / 2}px; top:${H - (b.bottomPx || 96)}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.body}', serif; font-size:${b.fontPx}px; line-height:1;
color:${dna.palette.body}; text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
.ms .w { display:inline-block; opacity:0; margin:0 0.14em; overflow:hidden; vertical-align:bottom; white-space:nowrap; }`;
const html =
inst
.map(
(f, i) =>
` <div class="fld" id="f${i}" style="left:${f.x}px; top:${f.y}px; font-size:${f.px}px; opacity:${f.op}; transform:translate(-50%,-50%) rotate(${f.rot}deg)">${esc(heroDisplay)}</div>`,
)
.join("\n") +
"\n" +
lineData.map((L) => ` <div class="ms" id="${L.id}"></div>`).join("\n");
const js = `
// ---- THE LAST PAGE ----
const I = ${I.toFixed(3)};
const MS = ${J(lineData)};
MS.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt]) => { const sp = document.createElement("span"); sp.className = "w"; sp.textContent = txt; line.appendChild(sp); });
gsap.set(line, { xPercent: -50, yPercent: -100 });
tl.set(line, { opacity: 1 }, L.in);
const XO = L.out - ${b.exit === "drop" ? "0.18" : "0.17"}; // exit start (single source)
let lastB = -1; // line-y bounce ownership guard
L.words.forEach(([txt, st], wi) => {
const el = line.children[wi];
tl.set(el, { opacity: 1 }, st);
tl.fromTo(el, { width: 0 }, { width: "auto", duration: Math.min(0.18, 0.03 * txt.length + 0.04),
ease: "steps(" + Math.max(2, txt.length) + ")" }, st);
});
const xo = L.out - 0.18;
tl.to(line, { opacity: 0, duration: 0.16, ease: "power2.in" }, xo);
tl.set(line, { display: "none" }, xo + 0.18);
});
// the field breathes imperceptibly (alive, unreadable)
${inst.map((f, i) => `tl.to("#f${i}", { y: ${prnd() - 0.5 > 0 ? "+" : "-"}${(3 + prnd() * 5).toFixed(1)}, duration: ${(2.4 + prnd() * 2).toFixed(1)}, ease: "sine.inOut" }, 0);`).join("\n ")}
// APEX: rack focus — the future was only ever one sentence
tl.to(".fld", { filter: "blur(0px)", duration: 0.38, ease: "power3.inOut" }, I - 0.1);
${inst
.map((f, i) =>
i === inst.indexOf(main)
? `tl.to("#f${i}", { opacity: 1, scale: 1.22, duration: 0.5, ease: "power2.out" }, I + 0.15);
tl.to("#f${i}", { textShadow: "0 0 28px rgba(255,244,220,0.5)", duration: 0.5 }, I + 0.15);`
: `tl.to("#f${i}", { opacity: ${(f.op * 0.85).toFixed(2)}, duration: 0.5 }, I + 0.3);`,
)
.join("\n ")}
// hold revealed ~1.2s, then the future blurs back except the main instance
tl.to(".fld", { filter: "blur(${b.fieldBlur || 9}px)", duration: 0.6, ease: "power2.inOut" }, I + 1.5);
tl.to("#f${inst.indexOf(main)}", { filter: "blur(0px)", duration: 0.01 }, I + 1.5);
${inst.map((f, i) => (i === inst.indexOf(main) ? "" : `tl.to("#f${i}", { opacity: 0, duration: 0.6 }, I + 1.6);`)).join("\n ")}
tl.to("#f${inst.indexOf(main)}", { opacity: 0, duration: 0.3, ease: "power2.in" }, ${(heroOut - 0.3).toFixed(3)});`;
return { css, html, js };
}
function paradigmFlaprail() {
// FLAPRAIL: stadium-departures chyron bar; body words flip in on split-flap
// tiles (2 seeded wrong-word flickers then the real word clacks on), exits
// flip to blank cascading left→right. Furniture: bar tag + ROW flip counter
// (+ optional odometer chip pinned to the first emphasis line).
const b = dna.body;
const barH = b.barH || 96;
const stagger = 0.025,
exDu = 0.075;
const lineData = LINES.map((L) => ({
id: "fl" + L.id,
in: +L.in.toFixed(3),
out: +L.out.toFixed(3),
words: L.words.map((w) => [w.display.toUpperCase(), +w.start.toFixed(3), w.minor ? 1 : 0]),
}));
const chy = b.chyron || {};
const ctrVals = lineData.map((_, i) => String(i + 1).padStart(2, "0"));
// odometer chip docks above the bar while the FIRST emphasis line reads
const chipL = b.chip ? LINES.find((L) => L.words.some((w) => w.minor)) : null;
const chipParts = chipL ? String(b.chip.value).split(".") : [];
const css = `
#flwrap { position:absolute; inset:0; }
#flbar { position:absolute; left:0; right:0; top:${H - barH}px; height:${barH}px;
background: linear-gradient(180deg, rgba(26,29,35,0.93) 0%, rgba(15,17,21,0.96) 100%);
border-top:3px solid ${dna.palette.accent};
box-shadow: 0 -6px 26px rgba(0,0,0,0.45), inset 0 1px 0 ${dna.palette.accent}40; }
#fltag { position:absolute; left:30px; top:30px; font-family:'${dna.fonts.tag}', sans-serif;
font-weight:600; font-size:25px; letter-spacing:4px; color:${dna.palette.accent}; opacity:0.92; }
#flctr { position:absolute; right:30px; top:22px; white-space:nowrap; }
#flctr .clab { font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:22px;
letter-spacing:3px; color:#8A8F98; vertical-align:18px; margin-right:8px; }
#ctrbox { display:inline-block; width:54px; height:46px; perspective:260px; vertical-align:top; }
#ctrflap { position:relative; display:block; width:100%; height:100%; transform-origin:50% 0%;
backface-visibility:hidden; border-radius:3px;
background: linear-gradient(180deg,#2E333B 0%,#262B32 48%,#1B1F25 52%,#242930 100%);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.6), 0 2px 5px rgba(0,0,0,0.5); }
#ctrflap .nv { position:absolute; inset:0; display:none; text-align:center;
font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:36px;
line-height:50px; color:${dna.palette.accent}; }
.flline { position:absolute; left:${W / 2}px; top:${H - Math.round(barH / 2) - 1}px; white-space:nowrap; }
.ftile { display:inline-block; perspective:240px; opacity:0; margin-right:8px; vertical-align:middle; }
.fflap { position:relative; display:inline-block; transform-origin:50% 0%; backface-visibility:hidden;
font-family:'${dna.fonts.body}', sans-serif; font-weight:600; font-size:${b.fontPx}px; line-height:1;
letter-spacing:${b.letterSpacing || "1px"}; color:${dna.palette.body}; padding:8px 13px 4px; border-radius:4px;
background: linear-gradient(180deg,#31363F 0%,#272C33 47%,#1C2026 53%,#262B32 100%);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.55), 0 3px 8px rgba(0,0,0,0.5),
inset 0 1px 0 rgba(255,255,255,0.07);
text-shadow: 0 2px 4px rgba(0,0,0,0.45); }
.ftile.em .fflap { box-shadow: inset 0 0 0 1px rgba(0,0,0,0.55), inset 0 -3px 0 ${dna.palette.accent},
0 3px 8px rgba(0,0,0,0.5); }
.fflap .rl { visibility:hidden; }
.fflap .wv { position:absolute; left:0; right:0; top:8px; text-align:center; display:none;
color:${dna.palette.grey || "#B9BEC7"}; }
${
chipL
? ` #flchip { position:absolute; left:${b.chip.x}px; top:${b.chip.y}px; perspective:300px; opacity:0; }
#chipflap { display:block; transform-origin:50% 0%; backface-visibility:hidden; white-space:nowrap;
background: linear-gradient(180deg,#2B3038 0%,#22262D 50%,#1A1E24 100%);
border-radius:4px; border:1px solid rgba(0,0,0,0.6); padding:5px 12px 3px;
box-shadow: inset 0 0 0 1px ${dna.palette.accent}59, 0 3px 10px rgba(0,0,0,0.5); }
#chipflap .clab { font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:24px;
letter-spacing:2px; color:${dna.palette.accent}; margin-right:8px; vertical-align:4px; }
.dcol { display:inline-block; height:36px; overflow:hidden; vertical-align:top; }
.dcol span { display:block; height:36px; line-height:38px; font-family:'${dna.fonts.tag}', sans-serif;
font-weight:600; font-size:34px; color:${dna.palette.body}; text-align:center; }
.cfix { display:inline-block; height:36px; line-height:38px; font-family:'${dna.fonts.tag}', sans-serif;
font-weight:600; font-size:34px; color:${dna.palette.body}; vertical-align:top; }
#cunit { font-size:22px; color:#8A8F98; margin-left:3px; }
#bdot { display:inline-block; width:9px; height:9px; border-radius:50%;
background:${dna.palette.accent}; margin-left:9px; vertical-align:14px; opacity:0.15;
box-shadow: 0 0 7px ${dna.palette.accent}cc; }`
: ""
}`;
const html = ` <div id="flwrap">
<div id="flbar">
<div id="fltag">${esc(chy.tag || "DEPARTURES")}</div>
<div id="flctr"><span class="clab">${esc(chy.counter || "ROW")}</span><span id="ctrbox"><span id="ctrflap">${ctrVals
.map((v, i) => `<span class="nv" id="n${i}">${v}</span>`)
.join("")}</span></span></div>
</div>
${lineData.map((L) => ` <div class="flline" id="${L.id}"></div>`).join("\n")}
${
chipL
? ` <div id="flchip"><div id="chipflap"><span class="clab">${esc(b.chip.label)}</span>${chipParts
.map((_, i) => `<span class="dcol" id="dc${i}"></span>`)
.join(
`<span class="cfix">.</span>`,
)}<span class="cfix" id="cunit">${esc(b.chip.unit || "")}</span><span id="bdot"></span></div></div>`
: ""
}
</div>`;
const js = `
// ---- body paradigm: FLAPRAIL (split-flap word tiles on a chyron bar) ----
const HOT = ${J(dna.palette.hot || dna.palette.accent)}, BONE = ${J(dna.palette.body)};
const frnd2 = mulberry32(${b.seed || 808});
const GLYPHS = ${J(dna.body.glyphs || "ABCDEFGHIKLMNOPRSTUVXYZ0123456789")};
function wrongOf(txt) {
let s = "";
for (const c of txt) s += /[A-Z']/.test(c) ? GLYPHS[Math.floor(frnd2() * GLYPHS.length)] : c;
return s;
}
// chyron bar rides up; nothing visible at t=0
tl.fromTo("#flbar", { y: ${barH + 14} }, { y: 0, duration: 0.20, ease: "power3.out" }, 0.0);
tl.set("#n0", { display: "block" }, 0.18);
const FRAIL = ${J(lineData)};
FRAIL.forEach((L, li) => {
const line = document.getElementById(L.id);
const tiles = [];
L.words.forEach(([txt, , em]) => {
const tile = document.createElement("span"); tile.className = "ftile" + (em ? " em" : "");
const flap = document.createElement("span"); flap.className = "fflap";
const rl = document.createElement("span"); rl.className = "rl"; rl.textContent = txt;
const w1 = document.createElement("span"); w1.className = "wv"; w1.textContent = wrongOf(txt);
const w2 = document.createElement("span"); w2.className = "wv"; w2.textContent = wrongOf(txt);
flap.appendChild(rl); flap.appendChild(w1); flap.appendChild(w2);
tile.appendChild(flap); line.appendChild(tile);
tiles.push({ tile, flap, rl, w1, w2 });
});
gsap.set(line, { xPercent: -50, yPercent: -50 });
L.words.forEach(([txt, st, em], wi) => {
const { tile, flap, rl, w1, w2 } = tiles[wi];
// flick 1 (wrong), flick 2 (wrong), flick 3 (real) — the flap motif
tl.set(tile, { opacity: 1 }, st);
tl.set(w1, { display: "block" }, st);
tl.fromTo(flap, { rotationX: -88 }, { rotationX: 0, duration: 0.05, ease: "power1.in" }, st);
tl.set(w1, { display: "none" }, st + 0.055);
tl.set(w2, { display: "block" }, st + 0.055);
tl.set(flap, { rotationX: -88 }, st + 0.055);
tl.to(flap, { rotationX: 0, duration: 0.05, ease: "power1.in" }, st + 0.06);
tl.set(w2, { display: "none" }, st + 0.115);
tl.set(rl, { visibility: "visible" }, st + 0.115);
tl.set(flap, { rotationX: -88, color: HOT }, st + 0.115);
tl.to(flap, { rotationX: 0, duration: 0.065, ease: "power2.in" }, st + 0.12);
// contact squash → elastic settle, paint cools (amber stays on emphasized tiles)
tl.set(tile, { scaleX: 1.05, scaleY: 0.92 }, st + 0.185);
tl.to(tile, { scaleX: 1, scaleY: 1, duration: 0.28, ease: "elastic.out(1, 0.4)" }, st + 0.21);
tl.to(flap, { color: em ? HOT : BONE, duration: 0.35, ease: "power1.in" }, st + 0.25);
});
// hold life: low breathe on the line
const lastIn = L.words[L.words.length - 1][1] + 0.4;
const span = (L.words.length - 1) * ${stagger} + ${exDu} + 0.02;
const XO = L.out - span;
if (XO - lastIn > 0.3)
tl.to(line, { scale: 1.014, duration: XO - lastIn, ease: "sine.inOut" }, lastIn);
// exit: flip to blank, cascading left→right (completes before the next line)
L.words.forEach((w, wi) => {
const t = XO + wi * ${stagger};
tl.to(tiles[wi].flap, { rotationX: 90, duration: ${exDu}, ease: "power2.in" }, t);
tl.set(tiles[wi].tile, { opacity: 0 }, t + ${exDu});
});
tl.set(line, { display: "none" }, L.out);
});
// row counter flips at each line change
FRAIL.slice(1).forEach((L, k) => {
const t = L.words[0][1];
tl.set("#n" + k, { display: "none" }, t);
tl.set("#n" + (k + 1), { display: "block" }, t);
// immediateRender:false — the flap is visible from t=0; the from-state must
// not leak into the opening hold (paused timeline + seek renders it)
tl.fromTo("#ctrflap", { rotationX: -80 }, { rotationX: 0, duration: 0.07, ease: "power2.in", immediateRender: false }, t);
tl.set("#ctrflap", { scaleY: 0.92 }, t + 0.07);
tl.to("#ctrflap", { scaleY: 1, duration: 0.18, ease: "elastic.out(1, 0.45)" }, t + 0.09);
});
${
chipL
? ` // odometer chip (emphasis furniture): clacks on beside the emphasis line,
// digits roll on true reels, status dot blinks through the hold (dead-air life)
const CIN = ${(chipL.words[chipL.words.length - 1].start + 0.06).toFixed(3)};
const COUT = ${(+chipL.out).toFixed(3)};
${J(chipParts)}.forEach((fin, ci) => {
const col = document.getElementById("dc" + ci);
const stack = document.createElement("div");
const steps = 4 + ci;
for (let k = 0; k < steps; k++) { const s = document.createElement("span");
s.textContent = Math.floor(frnd2() * 10); stack.appendChild(s); }
const f = document.createElement("span"); f.textContent = fin; stack.appendChild(f);
col.appendChild(stack);
tl.fromTo(stack, { y: 0 }, { y: -36 * steps, duration: 0.42 + ci * 0.13,
ease: "steps(" + steps + ")" }, CIN + 0.08 + ci * 0.04);
});
tl.set("#flchip", { opacity: 1 }, CIN);
tl.fromTo("#chipflap", { rotationX: -85 }, { rotationX: 0, duration: 0.09, ease: "power2.in" }, CIN);
tl.set("#chipflap", { scaleX: 1.04, scaleY: 0.93 }, CIN + 0.09);
tl.to("#chipflap", { scaleX: 1, scaleY: 1, duration: 0.24, ease: "elastic.out(1, 0.42)" }, CIN + 0.11);
tl.set(".dcol span", { color: HOT }, CIN + 0.67);
tl.to(".dcol span", { color: BONE, duration: 0.4 }, CIN + 0.87);
for (let k = 0; k < 4; k++) {
const t = CIN + 0.64 + k * 0.22;
if (t + 0.11 < COUT - 0.05) { tl.set("#bdot", { opacity: 1 }, t); tl.set("#bdot", { opacity: 0.15 }, t + 0.11); }
}
tl.to("#chipflap", { rotationX: 88, duration: 0.09, ease: "power2.in" }, COUT);
tl.set("#flchip", { display: "none" }, COUT + 0.10);`
: ""
}
${
b.yield
? ` // rail yields while the apex board locks
tl.to("#flwrap", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${(heroIn - (b.yield.pre || 0.07)).toFixed(3)});
tl.to("#flwrap", { opacity: 1, duration: 0.22, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.47)).toFixed(3)});`
: ""
}`;
return { css, html, js };
}
function paradigmLedboard() {
// LEDBOARD: docked transit departure-strip; body lines arrive by vertical
// SCROLL-SNAP paging (steps(6)) inside the board window; each word lights via
// a small L→R LED column wipe (low-amplitude apex motif) + 1-frame arrival
// flicker; emphasis words get the LED-refresh double-blink. Furniture: green
// platform tag, ticking clock seconds, status swap gag (⚠ DELAYED → ● ON TIME
// shortly after the apex resolves), idle LED shimmer in dead air.
const b = dna.body;
const bd = b.board || {};
const bw = bd.width || 1000,
bh = bd.h || 116;
const bleft = bd.left ?? Math.round((W - bw) / 2);
const btop = H - (bd.bottom ?? 30) - bh;
const hdrH = 30,
winH = bh - hdrH - 2,
lineH = winH - 2;
const hd = b.header || {};
const A = dna.palette.accent;
const GRN = dna.palette.green || "#36e57a";
const WRN = dna.palette.warn || "#ff9e3d";
const lineData = LINES.map((L) => ({
id: "t" + L.id,
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
}));
// page-in ~0.2s before the first word lights (the paging IS the anticipation);
// each line pages out exactly as the next pages in (snap swap), last one early
// enough that the 0.20s page-out completes before the clip ends
const pins = LINES.map((L, i) =>
i === 0
? Math.max(0.02, +(L.words[0].start - 0.22).toFixed(3))
: +(L.words[0].start - 0.22).toFixed(3),
);
lineData.forEach((L, i) => {
L.pin = pins[i];
L.pout = i + 1 < pins.length ? pins[i + 1] : +Math.min(LINES[i].out, DUR - 0.22).toFixed(3);
});
// idle LED shimmer: any dead air ≥1.2s between word lights gets refresh blips
const starts = LINES.flatMap((L) => L.words.map((w) => w.start));
const shimmers = [];
for (let i = 0; i + 1 < starts.length; i++)
if (starts[i + 1] - starts[i] > 1.2)
for (let t = starts[i] + 0.43; t < starts[i + 1] - 0.55; t += 0.6)
shimmers.push(+t.toFixed(3));
// status gag: ⚠ DELAYED blinks while we wait, pages to ● ON TIME after the apex
const swapT = +Math.min(heroIn + 0.69, DUR - 1.2).toFixed(3);
const statBlinks = [swapT - 2.55, swapT - 1.75, swapT - 1.15]
.filter((t) => t > 0.25)
.map((t) => +t.toFixed(3));
// clock seconds reel: one stacked digit per elapsed second
const nDig = Math.floor(DUR - 0.02) + 1;
const lateT = +(LASTWORD.start + 0.19).toFixed(3); // one late LED refresh on the final word
const css = `
#tboard { position:absolute; left:${bleft}px; top:${btop}px; width:${bw}px; height:${bh}px; border-radius:6px;
background-color: rgba(8,8,11,0.90);
background-image: radial-gradient(${A}0d 1px, rgba(0,0,0,0) 1.4px);
background-size: 7px 7px;
border:1px solid ${A}47; border-top:2px solid ${A}73;
box-shadow: 0 10px 30px rgba(0,0,0,0.5), inset 0 0 40px ${A}0a; }
#thdr { position:absolute; left:0; right:0; top:0; height:${hdrH}px;
border-bottom:1px solid ${A}38;
font-family:'${dna.fonts.tag}', monospace; font-size:20px; letter-spacing:0.12em; }
#tplt { position:absolute; left:16px; top:4px; color:${GRN};
text-shadow:0 0 8px ${GRN}80; }
#tstatwin { position:absolute; right:128px; top:3px; width:170px; height:24px; overflow:hidden; }
#tstatstack { position:absolute; left:0; top:0; width:100%; }
.tstrow { display:block; height:24px; line-height:24px; text-align:right; }
#tstrow1 { color:${WRN}; text-shadow:0 0 8px ${WRN}80; }
#tstrow2 { color:${GRN}; text-shadow:0 0 8px ${GRN}80; }
#tclock { position:absolute; right:14px; top:4px; color:${A}bf;
text-shadow:0 0 6px ${A}59; }
#tclock .cw { display:inline-block; width:11px; height:22px; overflow:hidden;
vertical-align:bottom; }
#tdigcol span { display:block; height:22px; line-height:22px; }
#twin { position:absolute; left:0; right:0; top:${hdrH + 2}px; bottom:0; overflow:hidden; }
.tline { position:absolute; left:22px; top:0; height:${lineH}px; line-height:${lineH}px;
font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; letter-spacing:${b.letterSpacing || "0.05em"};
color:${dna.palette.body}; white-space:nowrap;
text-shadow: 0 0 9px ${A}73, 0 0 1px ${dna.palette.hot}e6; }
.tline .w { display:inline-block; margin-right:0.35em; }`;
const html = ` <div id="tboard">
<div id="thdr">
<div id="tplt">${esc(hd.plt || "PLT 4")}</div>
<div id="tstatwin"><div id="tstatstack">
<span class="tstrow" id="tstrow1">${esc(hd.statusWait || "⚠ DELAYED")}</span>
<span class="tstrow" id="tstrow2">${esc(hd.statusOk || "● ON TIME")}</span>
</div></div>
<div id="tclock">${esc(hd.clockPrefix || "19:42:0")}<span class="cw"><span id="tdigcol">${Array.from(
{ length: nDig },
(_, k) => `<span>${k}</span>`,
).join("")}</span></span></div>
</div>
<div id="twin"></div>
</div>`;
const js = `
// ---- body paradigm: LEDBOARD (scroll-snap paging + per-word LED column wipes) ----
const twin = document.getElementById("twin");
const TRAIL = ${J(lineData)};
let lastW = null;
TRAIL.forEach((L) => {
const line = document.createElement("div"); line.className = "tline";
twin.appendChild(line);
gsap.set(line, { y: ${winH + 2} }); // parked below the window
tl.to(line, { y: 0, duration: 0.22, ease: "steps(6)" }, L.pin); // page in
tl.to(line, { y: ${-(winH + 2)}, duration: 0.20, ease: "steps(6)" }, L.pout); // page out
L.words.forEach(([txt, st, em]) => {
const w = document.createElement("span"); w.className = "w";
w.textContent = txt; line.appendChild(w); lastW = w;
const steps = Math.max(3, txt.length);
tl.fromTo(w, { clipPath: "inset(0% 100% 0% 0%)" },
{ clipPath: "inset(0% 0% 0% 0%)", duration: 0.12, ease: "steps(" + steps + ")" }, st);
tl.set(w, { clipPath: "none" }, st + 0.17); // free the glow
tl.set(w, { opacity: 0.5 }, st + 0.13); // 1-frame arrival flicker
tl.set(w, { opacity: 1 }, st + 0.172);
if (em && st + 0.56 < L.pout) { // LED refresh: blinks twice
tl.set(w, { opacity: 0.22 }, st + 0.30);
tl.set(w, { opacity: 1 }, st + 0.383);
tl.set(w, { opacity: 0.22 }, st + 0.466);
tl.set(w, { opacity: 1 }, st + 0.55);
}
});
});
${
lateT + 0.05 < DUR - 0.25
? ` // the final word gets one late LED refresh blink (kept inside the clip)
tl.set(lastW, { opacity: 0.22 }, ${lateT.toFixed(3)});
tl.set(lastW, { opacity: 1 }, ${(lateT + 0.042).toFixed(3)});`
: ""
}
// ===== furniture =====
// status blinks while we wait, then the gag: pages to the OK row
${J(statBlinks)}.forEach((t) => {
tl.set("#tstrow1", { opacity: 0.35 }, t);
tl.set("#tstrow1", { opacity: 1 }, t + 0.042);
});
tl.to("#tstatstack", { y: -24, duration: 0.16, ease: "steps(4)" }, ${swapT.toFixed(3)});
tl.set("#tstrow2", { opacity: 0.5 }, ${(swapT + 0.18).toFixed(3)});
tl.set("#tstrow2", { opacity: 1 }, ${(swapT + 0.222).toFixed(3)});
// clock seconds tick (stacked digit column, stepped shifts)
for (let k = 1; k <= ${nDig - 1}; k++) tl.set("#tdigcol", { y: -22 * k }, k);
// idle LED refresh shimmer in the dead air (board never freezes)
${J(shimmers)}.forEach((t) => {
tl.set("#twin", { opacity: 0.88 }, t);
tl.set("#twin", { opacity: 1 }, t + 0.042);
});
${
b.yield
? `
// ===== hierarchy: board yields while the apex lands =====
tl.to("#tboard", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${(heroIn - (b.yield.pre || 0.2)).toFixed(3)});
tl.to("#tboard", { opacity: 1, duration: 0.25, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.9)).toFixed(3)});`
: ""
}`;
return { css, html, js };
}
function paradigmVhsrail() {
// VHSRAIL: camcorder-playback lower third. Every body word carries a PERMANENT
// 1px red+cyan misregistration fringe and arrives by tracking glitch (instant
// on + 2-frame y-shake + 1-frame band-slice shift of the word's bottom half);
// emphasis words get the PAUSE jitter (±1px y oscillation) + a white noise-band
// flicker. Exits are REWIND streaks left with a flat-white echo copy + scrub
// lines. Furniture: ▶ PLAY / ◀◀ REW top-left (REW gag in dead air and on the
// final rewind), ● REC top-right during the apex window, green timestamp
// bottom-right with a 1 Hz blinking colon, an idle tape-drift line in long
// silences, and the apex-coupled artifacts: 1-frame full white tear band +
// head-switching noise bar at REC FREEZE, scrub lines at the apex band on the
// hero's rewind exit.
const b = dna.body;
const p = dna.hero.params || {};
const X = theme.hero.exitAt ?? Math.min(heroOut, heroIn + (p.hold ?? 1.69)); // hero REWIND (shared with vhsosd)
const railTop = H - (b.bottomPx || 108);
const osd = b.osd || {};
const ts = osd.ts || ["JUN.11 2026 PM 7", "42"];
const A = dna.palette.accent,
GRN = dna.palette.green || dna.palette.accent,
RED = dna.palette.red || "#FF2E2E",
CYN = dna.palette.cyan || "#3FE8E8";
// line windows: `out` is the REWIND start — early into dead air (with the
// PLAY→REW gag), before the clip end for the last line
const lineData = LINES.map((L, i) => {
const lastEnd = L.words[L.words.length - 1].end;
const next = LINES[i + 1];
let out = +(L.out - 0.05).toFixed(3),
gag = 0;
if (next && next.in - lastEnd > 0.7) {
out = +(lastEnd + 0.53).toFixed(3);
gag = 1;
}
if (!next) out = +(DUR - 0.14).toFixed(3);
return {
id: "v" + L.id,
eid: "ve" + L.id,
in: +L.in.toFixed(3),
out,
gag,
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
};
});
const lastOut = lineData[lineData.length - 1].out;
const css = `
/* ---- OSD furniture ---- */
.osd { position:absolute; font-family:'${dna.fonts.tag}', monospace; font-size:28px; line-height:1;
letter-spacing:2px; color:${A}; opacity:0;
text-shadow: -1px 0 0 ${RED}59, 1px 0 0 ${CYN}59,
0 0 7px rgba(0,0,0,0.95), 0 2px 5px rgba(0,0,0,0.85); }
#osdplay { left:36px; top:34px; }
#pmode, #rmode { display:flex; align-items:center; gap:10px; }
#rmode { display:none; }
.tri { width:0; height:0; border-left:16px solid ${A};
border-top:9px solid transparent; border-bottom:9px solid transparent;
filter: drop-shadow(0 0 4px rgba(0,0,0,0.9)); }
.rtri { width:0; height:0; border-right:14px solid ${A};
border-top:8px solid transparent; border-bottom:8px solid transparent;
filter: drop-shadow(0 0 4px rgba(0,0,0,0.9)); }
#rmode .rtri + .rtri { margin-left:-6px; }
#rec { right:36px; top:34px; display:flex; align-items:center; gap:10px; color:${dna.palette.body}; }
#rdot { width:15px; height:15px; border-radius:50%; background:#ff3b30;
box-shadow: 0 0 6px rgba(0,0,0,0.85), 0 0 10px rgba(255,59,48,0.55); }
#osdts { right:36px; bottom:22px; color:${GRN};
text-shadow: -1px 0 0 ${RED}4d, 1px 0 0 ${CYN}4d,
0 0 7px rgba(0,0,0,0.95), 0 2px 5px rgba(0,0,0,0.85); }
/* ---- body rail ---- */
.vln, .vlnE { position:absolute; left:${W / 2}px; top:${railTop}px; white-space:nowrap; opacity:0;
font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:1;
letter-spacing:${b.letterSpacing || "0.04em"}; text-transform:uppercase;
color:${dna.palette.body};
text-shadow: -1px 0 0 ${RED}66, 1px 0 0 ${CYN}66,
0 2px 6px rgba(0,0,0,0.85), 0 0 14px rgba(0,0,0,0.55); }
.vlnE { color:#ffffff; text-shadow:none; }
.vw { position:relative; display:inline-block; margin-right:0.3em; opacity:0; }
.vwa { display:inline-block; clip-path: inset(0 0 56% 0); }
.vwb { position:absolute; left:0; top:0; clip-path: inset(44% 0 0 0); }
.vnz { position:absolute; left:-4px; right:-4px; top:30%; height:38%; opacity:0;
background: repeating-linear-gradient(0deg, rgba(255,255,255,0.85) 0 2px, rgba(255,255,255,0) 2px 5px); }
/* ---- tape artifacts ---- */
#tear { position:absolute; left:0; top:${HG.y - 55}px; width:100%; height:110px; opacity:0;
background: linear-gradient(180deg, rgba(255,255,255,0.55) 0%, #ffffff 30%, #ffffff 70%, rgba(255,255,255,0.55) 100%); }
#hsbar { position:absolute; left:0; bottom:0; width:100%; height:16px; opacity:0;
background: repeating-linear-gradient(90deg,
rgba(205,205,205,0.92) 0 7px, rgba(60,60,60,0.85) 7px 11px,
rgba(165,165,165,0.9) 11px 19px, rgba(28,28,28,0.85) 19px 26px); }
.scrub { position:absolute; left:0; width:100%; height:3px; opacity:0;
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.75) 8%,
rgba(225,225,225,0.5) 38%, rgba(255,255,255,0.8) 62%, rgba(255,255,255,0) 100%); }
#scrub1 { top:${railTop - 18}px; } #scrub2 { top:${railTop + 20}px; }
#scrub3 { top:${HG.y - 28}px; } #scrub4 { top:${HG.y + 34}px; }
#drift { position:absolute; left:0; top:${railTop + 48}px; width:100%; height:2px; opacity:0;
background: rgba(255,255,255,0.45); }`;
const html =
` <div class="osd" id="osdplay">
<div id="pmode"><div class="tri"></div><span>PLAY</span></div>
<div id="rmode"><div class="rtri"></div><div class="rtri"></div><span>REW</span></div>
</div>
<div class="osd" id="rec"><div id="rdot"></div><span>REC</span></div>
<div class="osd" id="osdts">${esc(ts[0])}<span id="col">:</span>${esc(ts[1])}</div>
` +
lineData
.map(
(L) => ` <div class="vln" id="${L.id}"></div><div class="vlnE" id="${L.eid}"></div>`,
)
.join("\n") +
`
<div id="drift"></div>
<div class="scrub" id="scrub1"></div><div class="scrub" id="scrub2"></div>
<div class="scrub" id="scrub3"></div><div class="scrub" id="scrub4"></div>
<div id="tear"></div>
<div id="hsbar"></div>`;
// PLAY → REW gags: each dead-air rewind + the final rewind (no restore)
const gags = lineData
.filter((L) => L.gag)
.map((L) => [+(L.out - 0.01).toFixed(3), +(L.out + 0.35).toFixed(3)]);
gags.push([+(lastOut - 0.01).toFixed(3), null]);
const js = `
// ---- body paradigm: VHSRAIL (tracking-glitch words, REWIND exits, OSD furniture) ----
const I = ${heroIn.toFixed(3)}, X = ${X.toFixed(3)};
// ===== OSD furniture: present the whole clip, 1-frame power-on glitch =====
tl.set(["#osdplay","#osdts"], { opacity: 1, x: 5 }, 0.02);
tl.set(["#osdplay","#osdts"], { x: 0 }, 0.02 + F);
// timestamp colon blinks at 1 Hz
for (let k = 0; 0.50 + k < ${(DUR - 0.04).toFixed(2)}; k++) {
tl.set("#col", { opacity: 0 }, 0.50 + k);
if (1.00 + k < ${(DUR - 0.04).toFixed(2)}) tl.set("#col", { opacity: 1 }, 1.00 + k);
}
// PLAY -> REW gag on each dead-air rewind, and again at the final rewind
${J(gags)}.forEach(([a, b]) => {
tl.set("#pmode", { display: "none" }, a);
tl.set("#rmode", { display: "flex" }, a);
if (b) { tl.set("#rmode", { display: "none" }, b); tl.set("#pmode", { display: "flex" }, b); }
});
// ===== body rail =====
const VRAIL = ${J(lineData)};
VRAIL.forEach((L) => {
const line = document.getElementById(L.id);
const echo = document.getElementById(L.eid);
echo.textContent = L.words.map((w) => w[0]).join(" ");
L.words.forEach(([txt]) => {
const w = document.createElement("span"); w.className = "vw";
const a = document.createElement("span"); a.className = "vwa"; a.textContent = txt;
const b2 = document.createElement("span"); b2.className = "vwb"; b2.textContent = txt;
const n = document.createElement("span"); n.className = "vnz";
w.appendChild(a); w.appendChild(b2); w.appendChild(n); line.appendChild(w);
});
gsap.set([line, echo], { xPercent: -50, yPercent: -50 });
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.02);
L.words.forEach(([txt, st, em], wi) => {
const w = line.children[wi];
const b2 = w.children[1], n = w.children[2];
// tracking glitch entrance: instant on, 2-frame y-shake, 1-frame band slice shift
tl.set(w, { opacity: 1, y: 3 }, st);
tl.set(b2, { x: 7 }, st);
tl.set(w, { y: -3 }, st + F);
tl.set(b2, { x: 0 }, st + F);
tl.set(w, { y: 0 }, st + 2 * F);
if (em) {
// PAUSE jitter: y oscillation +-1px for 0.3s + white noise band flicker
const e = st + 0.125;
[-1, 1, -1, 1, 0].forEach((v, k) => tl.set(w, { y: v }, e + k * 0.05));
[0.5, 0, 0.35, 0].forEach((o, k) => tl.set(n, { opacity: o }, e + k * 0.065));
}
});
// REWIND exit: streak left + echo copy + 2 scrub lines
const ed = L.out >= ${(DUR - 0.25).toFixed(2)} ? 0.12 : 0.16;
tl.to(line, { x: -60, opacity: 0, duration: 0.12, ease: "power2.in" }, L.out);
tl.set(echo, { opacity: 0.35 }, L.out + 0.01);
tl.to(echo, { x: -110, opacity: 0, duration: ed, ease: "power2.in" }, L.out + 0.02);
tl.set([line, echo], { display: "none" }, Math.min(L.out + 0.22, ${(DUR - 0.02).toFixed(2)}));
[0.6, 0.25, 0.5, 0].forEach((o, k) => {
tl.set(["#scrub1","#scrub2"], { opacity: o }, Math.min(L.out + k * F, ${(DUR - 0.02).toFixed(2)}));
});
});
${
b.yield
? `
// rail yields while the apex lands (dim clamped ≥ line-in; restore only with runway)
VRAIL.forEach((L) => {
if (L.in < I + 0.9 && L.out > I - 0.3) {
const dimT = Math.max(I - ${(b.yield.pre || 0.2).toFixed(2)}, L.in + 0.05);
if (L.out > dimT + 0.25) {
tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.12, ease: "power1.in" }, dimT);
const resT = I + ${(b.yield.post || 0.9).toFixed(2)};
if (resT + 0.3 < L.out) tl.to("#" + L.id, { opacity: 1, duration: 0.25 }, resT);
}
}
});`
: ""
}
// idle tape noise in dead air
VRAIL.filter((L) => L.gag).forEach((L) => {
tl.set("#drift", { opacity: 0.12 }, L.out + 0.2);
tl.fromTo("#drift", { y: 0 }, { y: -55, duration: 0.5, ease: "none" }, L.out + 0.2);
tl.set("#drift", { opacity: 0 }, L.out + 0.7);
});
// ===== REC FREEZE artifacts (apex-coupled, in FRONT of the subject) =====
// 1-frame full white tear band
tl.set("#tear", { opacity: 0.95 }, I);
tl.set("#tear", { opacity: 0 }, I + F);
// ● REC blinks top-right (1-frame glitch entrance)
tl.set("#rec", { opacity: 1, x: 6 }, I + 0.04);
tl.set("#rec", { x: 0 }, I + 0.04 + F);
[[0.49, 0], [0.79, 1], [1.24, 0], [1.54, 1]].forEach(([dt, o]) => {
if (I + dt < X - 0.05) tl.set("#rdot", { opacity: o }, I + dt);
});
tl.set("#rec", { opacity: 0 }, X);
// head-switching noise bar: sets every frame for 10 frames
const HSX = [0, 140, 60, 220, 30, 180, 90, 250, 10, 120];
HSX.forEach((px, k) => {
tl.set("#hsbar", { opacity: k % 2 ? 0.62 : 0.9, backgroundPositionX: px + "px" }, I + k * F);
});
tl.set("#hsbar", { opacity: 0 }, I + 10 * F);
// apex REWIND exit: 2 scrub lines at the apex band
[0.6, 0.25, 0.5, 0].forEach((o, k) => tl.set(["#scrub3","#scrub4"], { opacity: o }, X + k * F));
// head-switch reprise on the final rewind
[[0, 0.85, 40], [0.04, 0.6, 190], [0.08, 0.8, 90]].forEach(([dt, o, px]) => {
tl.set("#hsbar", { opacity: o, backgroundPositionX: px + "px" }, ${lastOut.toFixed(3)} + dt);
});
tl.set("#hsbar", { opacity: 0 }, ${Math.min(lastOut + 0.12, DUR - 0.02).toFixed(3)});`;
return { css, html, js };
}
function paradigmHudrail() {
// HUDRAIL: arcade-cabinet HUD. The whole frame plays through a CRT (scanline
// + vignette overlays); body words coin-pop onto a docked HUD bar (2px accent
// border, 1UP / CREDIT tags) with steps(2) pixel pops + a 1-frame white
// coin-blip tick over the word; emphasis words run a 3-frame palette swap
// (body -> accent -> magenta -> body). Lines exit by checkerboard dissolve
// (words vanish in 2 alternating 2-frame passes). The 1UP tag blinks on the
// classic attract cadence all clip; the HUD yields while the boss lands.
const b = dna.body;
const bar = b.bar || {};
const barH = bar.h || 70,
inset = bar.inset || 24,
barBot = bar.bottom || 50;
const barTop = H - barBot - barH;
const tags = b.tags || {};
const A = dna.palette.accent,
MG = dna.palette.magenta || "#FF2E88";
const lineData = LINES.map((L, i) => ({
id: "h" + L.id,
in: +L.in.toFixed(3),
// checkerboard exit start: just before the next line's first pop
xo: i + 1 < LINES.length ? +(L.out - 0.04).toFixed(3) : +Math.min(L.out, DUR - 0.11).toFixed(3),
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
}));
const css = `
#hud { position:absolute; left:${inset}px; top:${barTop}px; width:${W - 2 * inset}px; height:${barH}px;
opacity:0; background:rgba(3,8,18,0.84); border:2px solid ${A};
box-shadow:0 0 16px ${A}59, inset 0 0 24px ${A}14; z-index:3; }
.hln { position:absolute; left:50%; top:50%; white-space:nowrap; opacity:0;
font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:1;
${b.textTransform ? "text-transform:" + b.textTransform + ";" : ""}
color:${dna.palette.body}; text-shadow:0 3px 0 rgba(0,0,0,0.6); }
.hw { position:relative; display:inline-block; margin-right:0.45em; opacity:0; }
.hw:last-child { margin-right:0; }
.htick { position:absolute; inset:-3px; background:#ffffff; opacity:0; }
#tagL { position:absolute; left:18px; top:50%; transform:translateY(-50%);
font-family:'${dna.fonts.tag}', monospace; font-size:15px; color:${dna.palette.yellow || A};
text-shadow:0 2px 0 rgba(0,0,0,0.6); }
#tagR { position:absolute; right:18px; top:50%; transform:translateY(-50%);
font-family:'${dna.fonts.tag}', monospace; font-size:12px; color:${dna.palette.credit || A};
text-shadow:0 2px 0 rgba(0,0,0,0.6); }
#crtvig { position:absolute; inset:0; opacity:0; z-index:20; pointer-events:none;
background:radial-gradient(120% 95% at 50% 46%, rgba(0,0,0,0) 58%, rgba(0,0,0,0.34) 100%); }
#crtscan { position:absolute; inset:0; opacity:0; z-index:21; pointer-events:none;
background:repeating-linear-gradient(180deg, rgba(0,0,0,0.9) 0px, rgba(0,0,0,0.9) 2px, rgba(0,0,0,0) 2px, rgba(0,0,0,0) 5px); }`;
const html = ` <div id="hud">
<div id="tagL">${esc(tags.left || "1UP")}</div>
<div id="tagR">${esc(tags.right || "CREDIT 01")}</div>
${lineData.map((L) => ` <div class="hln" id="${L.id}"></div>`).join("\n")}
</div>
<div id="crtvig"></div>
<div id="crtscan"></div>`;
const js = `
// ---- body paradigm: HUDRAIL (coin-pop words on an arcade HUD bar) ----
// world overlays: scanlines + slight vignette
tl.set("#crtscan", { opacity: ${b.scan ?? 0.06} }, 0.03);
tl.set("#crtvig", { opacity: 1 }, 0.03);
// HUD bar boots up
tl.set("#hud", { opacity: 1 }, 0.05);
tl.fromTo("#hud", { y: 14 }, { y: 0, duration: 0.12, ease: "steps(2)" }, 0.05);
// 1UP blink, classic cadence (baked sets)
for (let t = 0.28, k = 0; t < ${(DUR - 0.06).toFixed(2)}; t += 0.45, k++) {
tl.set("#tagL", { opacity: k % 2 === 0 ? 0.3 : 1 }, t);
}
const HUD = ${J(lineData)};
HUD.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt]) => {
const w = document.createElement("span"); w.className = "hw"; w.textContent = txt;
const tick = document.createElement("div"); tick.className = "htick";
w.appendChild(tick); line.appendChild(w);
});
gsap.set(line, { xPercent: -50, yPercent: -50 });
tl.set(line, { opacity: 1 }, L.in);
L.words.forEach(([txt, st, em], wi) => {
const el = line.children[wi], tick = el.lastChild;
// coin pop: instant-on + steps(2) scale from the baseline + 1-frame blip
tl.set(el, { opacity: 1 }, st);
tl.fromTo(el, { scale: 0 }, { scale: 1, duration: 0.12, ease: "steps(2)",
transformOrigin: "50% 100%" }, st);
tl.set(tick, { opacity: 0.95 }, st); // coin blip, exactly 1 frame
tl.set(tick, { opacity: 0 }, st + 0.042);
if (em) { // 3-frame palette swap (body -> accent -> magenta -> body)
tl.set(el, { color: ${J(A)} }, st + 0.13);
tl.set(el, { color: ${J(MG)} }, st + 0.172);
tl.set(el, { color: ${J(dna.palette.body)} }, st + 0.214);
}
});
// checkerboard dissolve: words vanish in 2 alternating set passes
L.words.forEach((w, wi) => {
tl.set(line.children[wi], { opacity: 0 },
wi % 2 === 0 ? L.xo : Math.min(L.xo + 0.083, ${(DUR - 0.03).toFixed(2)}));
});
tl.set(line, { opacity: 0, display: "none" }, Math.min(L.xo + 0.15, ${(DUR - 0.02).toFixed(2)}));
});
${
b.yield
? ` // HUD yields while the boss lands (restore only with runway)
tl.to("#hud", { opacity: ${b.yield.dim}, duration: 0.15, ease: "power1.in" }, ${(heroIn - (b.yield.pre || 0.2)).toFixed(3)});
${
heroIn + (b.yield.post || 0.9) + 0.3 < DUR - 0.1
? ` tl.to("#hud", { opacity: 1, duration: 0.25, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.9)).toFixed(3)});`
: ""
}`
: ""
}`;
return { css, html, js };
}
function paradigmCarbonstrip() {
// CARBONSTRIP: cold-war case-file transcript strip docked on the clearer
// side (manila gradient, punch holes, faint red watermark, typed case
// header). Carbon type HAMMERS in per word — per-char sets 30ms apart,
// scale 1.35→1 in one frame (the stamp motif at low amplitude) — every
// strike kicks the strip 1px (y-channel locked while the strip itself is
// tweened); seeded ±1.2px baselines + seeded ribbon-ink weight. Emphasis
// words get a red ink ellipse drawn around them. Lines live on up to
// body.rows fixed slots; PAGES (broken at full rows, dead-air gaps > 0.7s,
// and after the hero hand-off line — it is filed away while the stamp owns
// the frame) exit by X-row over-strike (3 chunks per line, lines 0.16s
// apart) then a carriage yank-down feed; the final page is pulled with the
// whole strip at clip end. The strip top-shadow breathes during the
// stamp's dead-still hold; the strip yields (body.yield) while it lands.
const b = dna.body;
const p = dna.hero.params || {};
const C = heroIn + (p.crush ?? 0.074); // stamp contact (shared with rubberstamp)
const SW = (b.strip && b.strip.w) || 764,
SH = (b.strip && b.strip.h) || 132;
const SX = (b.strip && b.strip.x) ?? (CLEARER === "left" ? 48 : W - 48 - SW);
const ST = H - ((b.strip && b.strip.bottomPx) ?? 4) - SH;
const ROWS = b.rows || [36, 82];
const FY = +(DUR - 0.1).toFixed(3); // final carriage yank (the file is pulled)
const HDR = b.header || "TRANSCRIPT OF RECORDING";
const hdrW = Math.round(HDR.length * 11.4);
const hdrSteps = Math.max(8, Math.round(HDR.length * 0.58));
const rgb = (h) =>
[h.slice(1, 3), h.slice(3, 5), h.slice(5, 7)].map((x) => parseInt(x, 16)).join(",");
const ACC = dna.palette.accent || "#b3271e";
// ---- pages: up to ROWS.length lines per carriage feed ----
const pages = [];
let cur = [];
LINES.forEach((L, i) => {
cur.push(L);
const next = LINES[i + 1];
const heroLine = heroIn >= L.in - 0.01 && heroIn < L.out;
const gap = next ? next.words[0].start - L.words[L.words.length - 1].end : 0;
if (!next || cur.length === ROWS.length || heroLine || gap > 0.7) {
pages.push(cur);
cur = [];
}
});
// ---- exit schedule: X-strike chunks (0.05s apart, lines 0.16s apart) →
// yank → hide → carriage return. Roomy cadence in dead air, compressed
// when the next page is close (both cadences are the demo's). The whole
// exit always completes before the next page's first hammer strike.
const YLOCK = [[0, 0.215]]; // strip-y tween windows: no kicks inside
const pageData = pages.map((pg, pi) => {
const lines = pg.map((L, k) => ({
id: "d" + L.id,
top: ROWS[k],
n: L.words.reduce((a, w) => a + w.display.length, 0) + L.words.length - 1,
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
}));
if (pi === pages.length - 1) {
YLOCK.push([+(FY - 0.01).toFixed(3), +(DUR + 0.02).toFixed(3)]);
return { lines, xs: null, yank: null, limit: FY };
}
const lastW = pg[pg.length - 1].words;
const lastEnd = lastW[lastW.length - 1].end;
const nextIn = +(pages[pi + 1][0].words[0].start - 0.02).toFixed(3);
const n = pg.length;
const roomySpan = 0.16 * (n - 1) + 0.55,
tightSpan = 0.16 * (n - 1) + 0.3;
let xt0 = lastEnd + 0.33,
roomy = true;
if (xt0 + roomySpan > nextIn) {
xt0 = Math.max(lastEnd + 0.18, nextIn - roomySpan);
if (xt0 + roomySpan > nextIn + 0.001) {
roomy = false;
xt0 = Math.max(lastEnd + 0.15, nextIn - tightSpan);
}
}
xt0 = +xt0.toFixed(3);
const lastChunkEnd = xt0 + 0.16 * (n - 1) + 0.1;
const yankT = +(lastChunkEnd + (roomy ? 0.14 : 0.07)).toFixed(3);
const yankDur = roomy ? 0.1 : 0.07;
const hideT = +(yankT + (roomy ? 0.11 : 0.07)).toFixed(3);
const returnT = +(yankT + (roomy ? 0.13 : 0.075)).toFixed(3);
const returnDur = roomy ? 0.18 : 0.055;
const fits = xt0 + (roomy ? roomySpan : tightSpan) <= nextIn + 0.02;
YLOCK.push([+(yankT - 0.01).toFixed(3), +(returnT + returnDur).toFixed(3)]);
return {
lines,
xs: fits ? xt0 : null,
yank: [yankT, yankDur, hideT, returnT, returnDur],
limit: hideT,
};
});
// red-circle draw durations clamp inside the word's page life
pageData.forEach((pg) =>
pg.lines.forEach((ln) =>
ln.words.forEach((wd) => {
if (wd[2]) {
const circT = wd[1] + wd[0].length * 0.03 + 0.02;
wd.push(+Math.min(0.25, Math.max(0.14, pg.limit - circT - 0.02)).toFixed(3));
}
}),
),
);
const css = `
#strip { position:absolute; left:${SX}px; top:${ST}px; width:${SW}px; height:${SH}px; opacity:0;
background: linear-gradient(180deg, #e0cda6 0%, ${dna.palette.paper || "#d8c49a"} 42%, #ccb486 100%);
border:1px solid rgba(106,86,48,0.9); border-radius:2px;
box-shadow: inset 0 1px 0 rgba(255,250,235,0.5), inset 0 -14px 22px rgba(95,74,38,0.18); }
#shade { position:absolute; left:2px; right:2px; top:-16px; height:16px; opacity:0.5;
background: linear-gradient(180deg, rgba(15,10,4,0) 0%, rgba(15,10,4,0.38) 100%); }
.hole { position:absolute; left:14px; width:13px; height:13px; border-radius:50%;
background:rgba(58,45,24,0.34); box-shadow: inset 0 2px 2px rgba(30,22,10,0.5), 0 1px 0 rgba(255,248,230,0.45); }
#wm { position:absolute; right:18px; top:40px; font-family:'${dna.fonts.tag}', serif;
font-size:32px; letter-spacing:7px; color:rgba(${rgb(ACC)},0.13);
transform:rotate(-6deg); white-space:nowrap; }
#hdrwrap { position:absolute; left:52px; top:9px; overflow:hidden; white-space:nowrap; width:0; }
#hdr { font-family:'${dna.fonts.tag}', serif; font-size:14px; letter-spacing:3px;
color:${dna.palette.ink || "#8a6f3f"}; white-space:nowrap; }
.ln { position:absolute; left:52px; font-family:'${dna.fonts.body}', serif; font-size:${b.fontPx}px;
line-height:1; color:${dna.palette.body}; white-space:nowrap; }
.ln .w { display:inline-block; position:relative; margin-right:0.5em; }
.ln .c { display:inline-block; opacity:0; }
.xrow { position:absolute; left:52px; font-family:'${dna.fonts.body}', serif; font-size:${b.fontPx}px;
line-height:1; color:rgba(${rgb(ACC)},0.85); white-space:nowrap; }
.xrow span { opacity:0; }
.circ { position:absolute; left:-13px; top:-9px; width:calc(100% + 26px);
height:calc(100% + 18px); overflow:visible; pointer-events:none; }
.circ ellipse { fill:none; stroke:${ACC}; stroke-width:3.6; stroke-linecap:round; }`;
const html = ` <div id="strip">
<div id="shade"></div>
<div class="hole" style="top:24px"></div>
<div class="hole" style="top:${SH - 40}px"></div>
<div id="wm">${esc(b.watermark || "CONFIDENTIAL")}</div>
<div id="hdrwrap"><div id="hdr">${esc(HDR)}</div></div>
${pageData
.flatMap((pg) => pg.lines)
.map((ln) => ` <div class="ln" id="${ln.id}" style="top:${ln.top}px"></div>`)
.join("\n")}
</div>`;
const shadeStart = +(C + 0.12).toFixed(3);
const shadeDur = Math.min(1.5, FY - shadeStart - 0.05);
const dimT = +(heroIn - ((b.yield && b.yield.pre) || 0.02)).toFixed(3);
const resT = +(heroIn + ((b.yield && b.yield.post) || 0.41)).toFixed(3);
const js = `
// ---- body paradigm: CARBONSTRIP (typewriter hammer in / X-strike + carriage-feed out) ----
const strip = document.getElementById("strip");
const krnd = mulberry32(${b.seed || 180618});
// y-channel windows where the strip is tweened — no kicks allowed inside
const YLOCK = ${J(YLOCK)};
function kick(t) {
for (const [a, b] of YLOCK) if (t >= a && t <= b + 0.045) return;
tl.set(strip, { y: 1 }, t);
tl.set(strip, { y: 0 }, t + 0.042);
}
// ===== strip entrance: the carriage feeds the paper up =====
tl.set(strip, { opacity: 1 }, 0.02);
tl.fromTo(strip, { y: 130 }, { y: 0, duration: 0.19, ease: "power3.out" }, 0.02);
tl.fromTo("#hdrwrap", { width: 0 }, { width: ${hdrW}, duration: 0.42, ease: "steps(${hdrSteps})" }, 0.10);
// ===== body: typewriter hammer (per-char sets, 30ms apart) =====
const PAGES = ${J(pageData)};
const NS = "http://www.w3.org/2000/svg";
const strikes = [], circles = [];
PAGES.forEach((pg) => {
pg.lines.forEach((ln) => {
const line = document.getElementById(ln.id);
ln.words.forEach(([txt, st, em, cd]) => {
const w = document.createElement("span"); w.className = "w";
[...txt].forEach((ch, i) => {
const c = document.createElement("span"); c.className = "c"; c.textContent = ch;
w.appendChild(c);
strikes.push([c, st + i * 0.03]);
});
if (em) {
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("class", "circ"); svg.setAttribute("viewBox", "0 0 120 64");
svg.setAttribute("preserveAspectRatio", "none");
const el = document.createElementNS(NS, "ellipse");
el.setAttribute("cx", 60); el.setAttribute("cy", 32);
el.setAttribute("rx", 55); el.setAttribute("ry", 25);
el.setAttribute("transform", "rotate(-4 60 32)");
svg.appendChild(el); w.appendChild(svg);
circles.push([el, st + txt.length * 0.03 + 0.02, cd]);
}
line.appendChild(w);
});
});
});
strikes.forEach(([c, t]) => {
const off = (krnd() - 0.5) * 2.4; // ±1.2px baseline wobble
const ink = 0.84 + krnd() * 0.16; // uneven ribbon ink
gsap.set(c, { y: off, transformOrigin: "50% 85%" });
tl.set(c, { opacity: ink, scale: 1.35 }, t); // hammer = the stamp motif, low amplitude
tl.set(c, { scale: 1 }, t + 0.042);
kick(t); // every strike kicks the strip
});
// red ink circles draw around the emphasis words
circles.forEach(([el, t, d]) => {
const len = el.getTotalLength();
gsap.set(el, { attr: { "stroke-dasharray": len, "stroke-dashoffset": len } });
tl.to(el, { attr: { "stroke-dashoffset": 0 }, duration: d, ease: "power2.inOut" }, t);
});
// ===== exits: X-row over-strike (3 chunks per line) then carriage yank =====
PAGES.forEach((pg) => {
const hides = pg.lines.map((ln) => "#" + ln.id);
if (pg.xs != null) pg.lines.forEach((ln, k) => {
const row = document.createElement("div");
row.className = "xrow"; row.style.top = (ln.top + 2) + "px";
strip.appendChild(row); hides.push(row);
const third = Math.max(1, Math.ceil(ln.n / 3));
let left = ln.n;
for (let j = 0; j < 3 && left > 0; j++) {
const nC = Math.min(third, left); left -= nC;
const s = document.createElement("span"); s.textContent = "X".repeat(nC);
row.appendChild(s);
const t = pg.xs + k * 0.16 + j * 0.05;
tl.set(s, { opacity: 1 }, t);
kick(t);
}
});
if (pg.yank) {
const [yt, yd, ht, rt, rd] = pg.yank;
tl.to(strip, { y: 30, duration: yd, ease: "power3.in" }, yt);
tl.set(hides, { opacity: 0 }, ht);
tl.to(strip, { y: 0, duration: rd, ease: "power2.out" }, rt);
}
});
${
b.yield
? `
// ===== apex etiquette: the strip yields while the stamp lands =====
tl.to(strip, { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${dimT});
${resT + 0.3 < FY ? ` tl.to(strip, { opacity: 1, duration: 0.25, ease: "power1.out" }, ${resT});` : ""}`
: ""
}
${
shadeDur > 0.4
? `
// strip top-shadow breathes during the stamp's dead-still hold
tl.to("#shade", { keyframes: { opacity: [0.5, 0.72, 0.54, 0.7, 0.55, 0.66, 0.5] },
duration: ${shadeDur.toFixed(2)}, ease: "none" }, ${shadeStart});`
: ""
}
// ===== ending: the file is pulled — strip yanks down and is gone =====
tl.to(strip, { y: 34, duration: 0.09, ease: "power3.in" }, ${FY});
tl.to(strip, { opacity: 0, duration: 0.06 }, ${(FY + 0.02).toFixed(3)});`;
return { css, html, js };
}
function paradigmLaserrail() {
// LASERRAIL: concert lower third — every word is IGNITED where two thin
// laser beams (accent from the top-left, magenta from the top-right) sweep
// to CONVERGE on its position 2 frames early; at convergence a flare pops
// and the word snaps on in laser color (the apex motif at low amplitude:
// crush → contact squash → elastic settle, brightness cool). Emphasis words
// strobe once (1 frame off / back on) and their beams linger. Exits: a
// sweeper beam crosses L→R and carries the words (streak x+44 + blur).
// Word centers are BAKED at compile time from the measured char-width table
// (assets/fonts/char-widths.json) so font-load timing can't skew beam aim
// (calibrated vs the t37 demo's browser-measured table: ≤3px/word).
const b = dna.body;
const fam = dna.fonts.body;
const m = famMetrics(fam) || { ascent: 0.8, descent: 0.2, widths: {} };
const lsEm = parseFloat(b.letterSpacing) || 0;
const gapEm = b.wordGapEm ?? 0.24;
const railBot = H - b.bottomPx;
const emPx = Math.round(b.fontPx * (b.minorScale || 1.325));
const A = dna.palette.accent;
const lineData = LINES.map((L, i) => {
const words = L.words.map((w) => ({
txt: b.textTransform === "uppercase" ? w.display.toUpperCase() : w.display,
st: +w.start.toFixed(3),
em: w.minor ? 1 : 0,
}));
// centered-line layout, baked in px (CSS model: inline-blocks on one
// baseline, margin 0 <gapEm>em, tracking after every char incl the last)
const pxs = words.map((w) => (w.em ? emPx : b.fontPx));
const ws = words.map((w, k) => wordPx(w.txt, fam, pxs[k], lsEm));
const total = ws.reduce((a, x, k) => a + x + 2 * gapEm * pxs[k], 0);
const maxPx = Math.max(...pxs);
const baseline = railBot - (maxPx * (1 - m.ascent + m.descent)) / 2;
let cur = W / 2 - total / 2;
const pos = words.map((w, k) => {
const cx = cur + gapEm * pxs[k] + ws[k] / 2;
cur += ws[k] + 2 * gapEm * pxs[k];
return [
Math.round(cx),
Math.round(baseline - (pxs[k] * (m.ascent - m.descent)) / 2),
Math.round(ws[k]),
];
});
// sweep starts so the carried words clear before the next line's beams own
// the rail; last line keeps the demo's end-of-clip runway
const sweepT =
i + 1 < LINES.length
? +(L.out - 0.14).toFixed(3)
: +Math.min(L.out - 0.14, DUR - 0.26).toFixed(3);
return {
id: "lr" + L.id,
in: +L.in.toFixed(3),
sweepT,
words: words.map((w) => [w.txt, w.st, w.em]),
pos,
};
});
const css = `
#lrfx { position:absolute; inset:0; }
.lrline { position:absolute; left:${W / 2}px; top:${railBot}px; opacity:0; white-space:nowrap;
font-family:'${fam}', sans-serif; font-size:${b.fontPx}px; line-height:1;
letter-spacing:${b.letterSpacing || "0.02em"}; color:${dna.palette.body};
${b.textTransform ? "text-transform:" + b.textTransform + ";" : ""}
text-shadow: 0 0 10px ${A}bf, 0 0 26px ${A}61, 0 2px 6px rgba(0,0,0,0.7); }
.lrline .w { display:inline-block; opacity:0; margin:0 ${gapEm}em; }
.lrline .w.em { font-size:${emPx}px;
text-shadow: 0 0 14px ${A}e6, 0 0 34px ${A}80, 0 2px 6px rgba(0,0,0,0.7); }
.lflare { position:absolute; width:54px; height:54px; border-radius:50%; opacity:0;
background: radial-gradient(circle, rgba(220,255,235,0.95) 0%, ${A}80 40%, ${A}00 70%); }
.lsweep { position:absolute; width:3px; height:88px; opacity:0;
background: linear-gradient(180deg, ${A}00 0%, rgba(230,255,242,0.95) 45%, ${A}00 100%);
filter: drop-shadow(0 0 6px ${A}); }`;
const html =
` <svg id="lrfx" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
<g id="gLR"></g>
</svg>\n` + lineData.map((L) => ` <div class="lrline" id="${L.id}"></div>`).join("\n");
const js = `
// ---- body paradigm: LASERRAIL (per-word beam-converge ignition) ----
const LACC = ${J(A)}, LMAG = ${J(dna.palette.magenta || "#ff4df0")};
const NSL = "http://www.w3.org/2000/svg";
const lrStage = document.getElementById("stage");
const gLR = document.getElementById("gLR");
function lrLine(x1, y1, x2, y2, col, w) {
const l = document.createElementNS(NSL, "line");
l.setAttribute("x1", x1); l.setAttribute("y1", y1);
l.setAttribute("x2", x2); l.setAttribute("y2", y2);
l.setAttribute("stroke", col); l.setAttribute("stroke-width", w);
l.setAttribute("stroke-linecap", "round");
l.style.opacity = 0; gLR.appendChild(l); return l;
}
const LRAIL = ${J(lineData)};
LRAIL.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt, , em]) => {
const s = document.createElement("span");
s.className = "w" + (em ? " em" : ""); s.textContent = txt;
line.appendChild(s);
});
gsap.set(line, { xPercent: -50, yPercent: -100, transformOrigin: "50% 100%" });
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);
L.words.forEach(([txt, st, em], wi) => {
const el = line.children[wi];
const [wx, wy] = L.pos[wi];
// beams yield inside the apex window (the hero owns the light)
const yld = (st > ${(heroIn - 0.41).toFixed(3)} && st < ${(heroIn + 0.99).toFixed(3)})
? ${b.yield ? (b.yield.beamDim ?? 0.55) : 1} : 1;
const t0 = st - 0.21, cv = st - 0.083;
// two beams converge on the word 2 frames before its time
[[-25, -15, LACC, wx - 170], [${W + 25}, -15, LMAG, wx + 170]].forEach(([ex, ey, col, sx]) => {
const halo = lrLine(ex, ey, sx, wy - 30, col, 5);
const core = lrLine(ex, ey, sx, wy - 30, col, 1.5);
tl.set(halo, { opacity: 0.24 * yld }, t0);
tl.set(core, { opacity: 0.92 * yld }, t0);
tl.to([halo, core], { attr: { x2: wx, y2: wy }, duration: cv - t0, ease: "power2.out" }, t0);
tl.to([halo, core], { opacity: 0, duration: em ? 0.45 : 0.2, ease: "power1.in" }, st + 0.03);
});
// convergence flare
const f = document.createElement("div");
f.className = "lflare"; lrStage.appendChild(f);
f.style.left = (wx - 27) + "px"; f.style.top = (wy - 27) + "px";
tl.set(f, { opacity: 0.85, scale: 0.5 }, cv);
tl.to(f, { scale: 1.35, opacity: 0, duration: 0.13, ease: "power2.out" }, st - 0.01);
// IGNITE (apex motif at low amplitude): on in 1 frame, crush, squash, settle
tl.set(el, { opacity: 1, scale: 1.12, filter: "brightness(1.85)", transformOrigin: "50% 80%" }, st);
tl.to(el, { scale: 1, duration: 0.09, ease: "power3.in" }, st + 0.005);
tl.set(el, { scaleX: 1.05, scaleY: 0.94 }, st + 0.1);
tl.to(el, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.42)" }, st + 0.14);
tl.to(el, { filter: "brightness(1)", duration: 0.22, ease: "power2.out" }, st + 0.07);
if (em) { // single strobe: 1 frame off, back on
tl.set(el, { opacity: 0 }, st + 0.18);
tl.set(el, { opacity: 1 }, st + 0.222);
}
});
// hold life: gentle breathe + laser shimmer while the line waits
const lastSt = L.words[L.words.length - 1][1];
const free = L.sweepT - lastSt - 0.35;
if (free > 0.45) {
const half = Math.min(0.55, free / 2);
tl.to(line, { scale: 1.013, duration: half, ease: "sine.inOut" }, lastSt + 0.35);
tl.to(line, { scale: 1.0, duration: half, ease: "sine.inOut" }, lastSt + 0.35 + half);
}
if (free > 0.9) {
tl.to(line, { keyframes: { filter: ["brightness(1)","brightness(1.07)","brightness(1)","brightness(1.05)","brightness(1)"] },
duration: 0.9, ease: "none" }, lastSt + 0.5);
}
// EXIT: sweeper beam crosses L→R, words streak away as it passes them
const xs = L.pos.map((p) => p[0]);
const x0 = Math.min(...xs) - 46, x1 = Math.max(...xs) + 46;
const sw = document.createElement("div");
sw.className = "lsweep"; lrStage.appendChild(sw);
sw.style.left = x0 + "px"; sw.style.top = (L.pos[0][1] - 58) + "px";
tl.set(sw, { opacity: 0.9 }, L.sweepT);
tl.to(sw, { x: x1 - x0, duration: 0.13, ease: "power1.in" }, L.sweepT);
tl.to(sw, { opacity: 0, duration: 0.05 }, L.sweepT + 0.13);
L.words.forEach((w, wi) => {
const el = line.children[wi];
const frac = (L.pos[wi][0] - x0) / Math.max(1, x1 - x0);
tl.to(el, { x: 44, opacity: 0, filter: "blur(5px)", duration: 0.11, ease: "power2.in" },
L.sweepT + 0.11 * frac);
});
tl.set(line, { display: "none" }, Math.min(L.sweepT + 0.32, ${(DUR - 0.02).toFixed(3)}));
});
${
b.yield
? ` // rail yields while the apex lands (restore only with clear runway)
LRAIL.forEach((L) => {
if (L.in < ${heroIn.toFixed(3)} + 0.9 && L.sweepT > ${heroIn.toFixed(3)} - 0.3) {
const dimT = Math.max(${(heroIn - (b.yield.pre || 0.04)).toFixed(3)}, L.in + 0.05);
if (L.sweepT > dimT + 0.25) {
tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.15 }, dimT);
const resT = ${(heroIn + (b.yield.post || 0.9)).toFixed(3)};
if (resT + 0.3 < L.sweepT) tl.to("#" + L.id, { opacity: 1, duration: 0.25 }, resT);
}
}
});`
: ""
}`;
return { css, html, js };
}
function paradigmStormrail() {
// STORMRAIL: rain-washed lower third. Ambient rain = seeded thin streaks
// falling on repeating linear cycle tweens (pure f(t), one wind vector,
// mid-fall at t0); a second streak set DOUBLES the rain for ~1s after the
// strike. Body words arrive with the lightning-kiss motif at low amplitude
// (2-frame lit-bright pop + micro settle); emphasis = sheet-lightning double
// flash (the bg sky double-kisses at the same beats). Exits wash downward
// with the rain (y+22, blur). The visible line dims while the bolt lands and
// its text-shadow flips toward the strike for the flash frames.
const b = dna.body;
const r = b.rain || {};
const A = dna.palette.accent;
const railTop = H - (b.bottomPx || 98);
const sgn = (dna.hero.params || {}).side === "right" ? -1 : 1;
const lineData = LINES.map((L) => ({
id: "st" + L.id,
in: +L.in.toFixed(3),
// wash start: just before the next line — but a line facing dead air
// washes away early (the demo's r1 exits at lastWord + 1.08s)
out: +Math.min(L.out - 0.05, L.words[L.words.length - 1].end + 1.08, DUR - 0.2).toFixed(3),
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
}));
const css = `
#stscrim { position:absolute; left:0; right:0; top:${H - 180}px; height:180px; opacity:0;
background:linear-gradient(to bottom, rgba(6,10,18,0) 0%, rgba(6,10,18,0.45) 45%, rgba(6,10,18,0.62) 100%); }
#rain { position:absolute; inset:0; opacity:${r.opacity ?? 0.08}; }
#rain2 { position:absolute; inset:0; opacity:0; }
.drop { position:absolute; left:0; top:0; width:2px; border-radius:1px;
background:linear-gradient(to bottom, rgba(200,222,244,0) 0%, rgba(214,232,250,0.95) 100%); }
.stline { position:absolute; left:${W / 2}px; top:${railTop}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.body}', sans-serif; font-weight:700; font-size:${b.fontPx}px; line-height:1.15;
letter-spacing:${b.letterSpacing || "0.015em"}; color:${dna.palette.body};
text-shadow: 0 6px 16px rgba(2,8,18,0.85), 0 2px 4px rgba(2,8,18,0.7), 0 0 14px ${A}47; }
.stline .w { display:inline-block; opacity:0; margin:0 0.16em; }`;
const html =
` <div id="rain"></div>
<div id="stscrim"></div>
` +
lineData.map((L) => ` <div class="stline" id="${L.id}"></div>`).join("\n") +
`
<div id="rain2"></div>`;
const js = `
// ---- body paradigm: STORMRAIL (lightning-kiss words, rain-wash exits, ambient rain) ----
const I = ${heroIn.toFixed(3)};
// ambient rain: seeded streaks on repeating linear cycles (f(t), no random at render)
function makeRain(container, n, seed, t0, t1) {
const rnd = mulberry32(seed);
for (let i = 0; i < n; i++) {
const el = document.createElement("div");
el.className = "drop"; container.appendChild(el);
const x0 = 20 + rnd() * ${W - 40}, d = 0.55 + rnd() * 0.55;
const len = 46 + rnd() * 70, frac = rnd();
const travel = ${H + 300}, dx = -160; // slanted fall, one wind vector
gsap.set(el, { height: len, rotation: 9, x: x0, y: -160 });
let t = t0;
// partial first cycle so rain is mid-fall at t0
tl.fromTo(el, { y: -160 + travel * frac, x: x0 + dx * frac },
{ y: ${H + 140}, x: x0 + dx, duration: d * (1 - frac), ease: "none" }, t);
t += d * (1 - frac);
while (t < t1) {
tl.fromTo(el, { y: -160, x: x0 },
{ y: ${H + 140}, x: x0 + dx, duration: d, ease: "none" }, t);
t += d;
}
}
}
makeRain(document.getElementById("rain"), ${r.count ?? 18}, ${r.seed ?? 9021}, 0, ${(DUR - 0.02).toFixed(2)});
makeRain(document.getElementById("rain2"), ${r.count ?? 18}, ${r.seed2 ?? 4477}, ${(heroIn - 0.11).toFixed(3)}, ${Math.min(heroIn + 1.24, DUR - 0.02).toFixed(3)});
// rain doubles for ~1s after the strike
tl.set("#rain2", { opacity: 0.11 }, I + 0.02);
tl.to("#rain2", { opacity: 0, duration: 0.30 }, ${Math.min(heroIn + 1.02, DUR - 0.34).toFixed(3)});
// rail scrim (stable reading surface against the busy lower frame)
tl.to("#stscrim", { opacity: 1, duration: 0.20 }, 0.05);
tl.to("#stscrim", { opacity: 0, duration: 0.16 }, ${(DUR - 0.18).toFixed(3)});
const SRAIL = ${J(lineData)};
SRAIL.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt]) => {
const s = document.createElement("span");
s.className = "w"; s.textContent = txt; line.appendChild(s);
});
gsap.set(line, { xPercent: -50, yPercent: -100 });
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.025);
L.words.forEach(([txt, st, em], wi) => {
const el = line.children[wi];
// entrance = the apex motif at low amplitude: lit-bright pop + micro settle
tl.set(el, { opacity: 1, y: 4, scale: 1.06, filter: "brightness(2.2)",
transformOrigin: "50% 85%" }, st);
tl.to(el, { y: 0, scale: 1, duration: 0.12, ease: "power2.out" }, st + F);
tl.to(el, { filter: "brightness(1)", duration: 0.14, ease: "power1.out" }, st + F);
if (em && st + 0.20 + 3 * F < ${(DUR - 0.04).toFixed(2)}) { // sheet lightning: 2-frame double flash
const t0 = st + 0.20;
tl.set(el, { filter: "brightness(1.9)" }, t0);
tl.set(el, { filter: "brightness(1.0)" }, t0 + F);
tl.set(el, { filter: "brightness(1.8)" }, t0 + 2 * F);
tl.to(el, { filter: "brightness(1)", duration: 0.12 }, t0 + 3 * F);
}
});
// EXIT: wash downward with the rain
tl.to(line, { y: 22, opacity: 0, filter: "blur(3px)", duration: 0.18, ease: "power1.in" }, L.out);
tl.set(line, { display: "none" }, Math.min(L.out + 0.20, ${(DUR - 0.02).toFixed(2)}));
});
${
b.yield
? ` // rail yields while the bolt lands; the visible line's shadow flips toward
// the strike for the flash frames (light from the bolt side)
SRAIL.forEach((L) => {
if (L.in < I + 0.9 && L.out > I - 0.3) {
const dimT = Math.max(I - ${(b.yield.pre ?? 0.15).toFixed(2)}, L.in + 0.05);
if (L.out > dimT + 0.25) {
tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.12 }, dimT);
const resT = I + ${(b.yield.post ?? 0.29).toFixed(2)};
if (resT + 0.3 < L.out) tl.to("#" + L.id, { opacity: 1, duration: 0.10 }, resT);
}
}
if (L.in <= I - 2 * F && L.out > I) {
tl.set("#" + L.id, { textShadow: "${12 * sgn}px 4px 12px rgba(2,8,18,0.85), 0 0 14px ${A}73" }, I - 2 * F);
tl.set("#" + L.id, { textShadow: "0 6px 16px rgba(2,8,18,0.85), 0 2px 4px rgba(2,8,18,0.7), 0 0 14px ${A}47" }, I + 0.09);
}
});`
: ""
}`;
return { css, html, js };
}
function paradigmHolorail() {
// HOLORAIL: a volumetric projection PLATE (scanlined glass slab with corner
// brackets + feed tag) is the stable reading surface; it boots like a
// projector turning ON (scaleY snap + 1-frame flicker) and breathes a slow
// luminance cycle. Body words boot with the apex motif at low amplitude:
// instant opacity + RISING sweep reveal (clip wipe), a 1-frame interference
// x-glitch, and a constant chromatic-fringe text-shadow. Emphasis words get
// a 2-frame projection DROPOUT that restores brighter, then settles. Each
// line drifts slowly in rotationY (it IS a projection) and exits with the
// projector-cut: brightness pop → collapse to a horizontal slit → to a dot.
const b = dna.body;
const A = dna.palette.accent,
MG = dna.palette.magenta || "#ff5fd6";
const plateW = b.plateW || 980,
plateH = b.plateH || 96;
const plateY = H - (b.bottomPx || 108); // plate center
const lineY = plateY + (b.lineDy ?? 9); // line center sits low in the plate
const lineData = LINES.map((L, i) => ({
id: "hl" + L.id,
// projector-cut start: completes (0.15s) before the next line boots;
// the last line keeps the demo's end-of-clip runway
exit: +(i + 1 < LINES.length ? L.out - 0.15 : Math.min(L.out + 0.07, DUR - 0.18)).toFixed(3),
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
}));
const shadowBase = `-1.5px 0 0 ${A}8c, 1.5px 0 0 ${MG}6b, 0 0 16px ${A}80, 0 2px 8px rgba(1,10,16,0.85)`;
const shadowEm = `-1.5px 0 0 ${A}cc, 1.5px 0 0 ${MG}99, 0 0 26px ${dna.palette.glowEm || "#A0F2FF"}d9, 0 2px 8px rgba(1,10,16,0.85)`;
const css = `
#railwrap { position:absolute; inset:0; }
#plate { position:absolute; left:${W / 2}px; top:${plateY}px; width:${plateW}px; height:${plateH}px; opacity:0;
border-radius:3px;
background: linear-gradient(180deg, rgba(4,22,33,0.46) 0%, rgba(6,30,44,0.55) 100%);
border: 1px solid ${A}52;
box-shadow: 0 0 26px rgba(60,170,210,0.20), inset 0 0 38px ${A}0f; }
#pscan { position:absolute; inset:0; border-radius:3px;
background: repeating-linear-gradient(180deg, ${A}00 0px, ${A}00 3px, ${A}12 3px, ${A}12 4px); }
#ptag { position:absolute; left:14px; top:7px; font-family:'${dna.fonts.tag}', sans-serif;
font-weight:400; font-size:11px; letter-spacing:0.32em; color:${A}99; }
.crn { position:absolute; width:11px; height:11px; }
.crn.a { left:-1px; top:-1px; border-left:2px solid ${A}cc; border-top:2px solid ${A}cc; }
.crn.b { right:-1px; top:-1px; border-right:2px solid ${A}cc; border-top:2px solid ${A}cc; }
.crn.c { left:-1px; bottom:-1px; border-left:2px solid ${A}cc; border-bottom:2px solid ${A}cc; }
.crn.d { right:-1px; bottom:-1px; border-right:2px solid ${A}cc; border-bottom:2px solid ${A}cc; }
.hln { position:absolute; left:${W / 2}px; top:${lineY}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.body}', sans-serif; font-weight:500; font-size:${b.fontPx}px;
line-height:1.15; letter-spacing:${b.letterSpacing || "0.02em"}; }
.hln .w { display:inline-block; opacity:0; margin-right:0.3em;
color:${dna.palette.body}ed;
text-shadow: ${shadowBase}; }`;
const html =
` <div id="railwrap">
<div id="plate">
<div id="pscan"></div>
<div id="ptag">${esc(theme.plateTag || b.tag || "VOLUMETRIC FEED // CH-07")}</div>
<div class="crn a"></div><div class="crn b"></div>
<div class="crn c"></div><div class="crn d"></div>
</div>
` +
lineData.map((L) => ` <div class="hln" id="${L.id}"></div>`).join("\n") +
`
</div>`;
// plate breathe: finite yoyo cycles sized to the runway before the end cut
const pCut = +(DUR - 0.14).toFixed(3);
const breaths = Math.max(1, Math.floor((pCut - 0.5) / 0.8) - 1);
const js = `
// ---- body paradigm: HOLORAIL (projection plate, sweep-boot words, projector-cut exits) ----
gsap.set("#plate", { xPercent: -50, yPercent: -50, transformOrigin: "50% 50%" });
// plate boots like a projector turning ON (inverse of the projector-cut exit)
tl.set("#plate", { opacity: 1 }, 0.10);
tl.fromTo("#plate", { scaleY: 0.03 }, { scaleY: 1, duration: 0.13, ease: "expo.out" }, 0.10);
tl.set("#plate", { opacity: 0.35 }, 0.155);
tl.set("#plate", { opacity: 1 }, 0.195);
tl.to("#plate", { opacity: 0.93, duration: 0.8, ease: "sine.inOut", yoyo: true, repeat: ${breaths} }, 0.5);
const HRAIL = ${J(lineData)};
const HSH_BASE = ${J(shadowBase)}, HSH_EM = ${J(shadowEm)};
HRAIL.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt], wi) => {
const s = document.createElement("span");
s.className = "w"; s.textContent = txt;
if (wi === L.words.length - 1) s.style.marginRight = "0";
line.appendChild(s);
});
gsap.set(line, { xPercent: -50, yPercent: -50, transformPerspective: 700, transformOrigin: "50% 50%" });
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);
L.words.forEach(([txt, st, em], wi) => {
const el = line.children[wi];
// BOOT (low-amplitude apex motif): instant opacity + vertical sweep reveal
tl.set(el, { opacity: 1, clipPath: "inset(-20% -10% 100% -10%)" }, st);
tl.to(el, { clipPath: "inset(-20% -10% -30% -10%)", duration: 0.15, ease: "power2.out" }, st);
tl.set(el, { clipPath: "none" }, st + 0.16);
// 1-frame interference glitch
tl.set(el, { x: 2 }, st + 0.05);
tl.set(el, { x: -2 }, st + 0.09);
tl.set(el, { x: 0 }, st + 0.13);
// EMPHASIS: 2-frame projection dropout, restores brighter (delay shrinks
// near the line's projector-cut so dropout+restore always complete)
if (em) {
const d = Math.min(${b.emphDelay ?? 0.34}, L.exit - st - 0.183);
if (d >= 0.083) {
tl.set(el, { opacity: 0.2 }, st + d);
tl.set(el, { opacity: 1, color: ${J(dna.palette.em || "#f4feff")}, textShadow: HSH_EM }, st + d + 0.083);
const sb = st + d + 0.51;
if (sb < L.exit - 0.05)
tl.to(el, { color: ${J((dna.palette.body || "#c7f1ff") + "ed")}, textShadow: HSH_BASE,
duration: Math.min(0.4, ${(DUR - 0.04).toFixed(2)} - sb), ease: "power1.in" }, sb);
}
}
});
// slow rotateY drift — it IS a projection
const dS = L.words[0][1] + 0.08;
const dD = Math.max(0.3, L.exit - dS - 0.04);
tl.to(line, { keyframes: { rotationY: [-3.5, 3.5, -2.5] }, duration: dD, ease: "none" }, dS);
// EXIT: projector cut — line collapses to a bright slit, then to a dot
const ex = L.exit;
tl.set(line, { filter: "brightness(2.2)" }, ex);
tl.to(line, { scaleY: 0.045, duration: 0.07, ease: "power3.in" }, ex + 0.005);
tl.to(line, { scaleX: 0.015, duration: 0.06, ease: "power3.in" }, ex + 0.08);
tl.set(line, { opacity: 0, display: "none" }, ex + 0.15);
});
${
b.yield
? ` // the whole rail (plate included) yields while the apex lands
tl.to("#railwrap", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${(heroIn - (b.yield.pre ?? 0.18)).toFixed(3)});
tl.to("#railwrap", { opacity: 1, duration: 0.22, ease: "power1.out" }, ${(heroIn + (b.yield.post ?? 0.37)).toFixed(3)});`
: ""
}
// end: the plate itself gets the projector cut
tl.set("#plate", { filter: "brightness(2.2)" }, ${pCut});
tl.to("#plate", { scaleY: 0.03, duration: 0.07, ease: "power3.in" }, ${(pCut + 0.005).toFixed(3)});
tl.to("#plate", { scaleX: 0.012, duration: 0.05, ease: "power3.in" }, ${(pCut + 0.08).toFixed(3)});
tl.set("#plate", { opacity: 0 }, ${Math.min(pCut + 0.12, DUR - 0.02).toFixed(3)});`;
return { css, html, js };
}
function paradigmPlanktonrail() {
// PLANKTONRAIL: deep-sea lower third on TWO coexisting row slots — even
// lines ride the upper row, odd lines the lower, so couplets share the
// water like drifting strata. Words GLOW ON like jellyfish (the apex bloom
// at low amplitude): 2-frame opacity + radial bloom from center (scale
// 0.92→1, blur 5→0, brightness 0.45→1) while a cyan glow text-shadow grows
// in; emphasis words pulse a slow DOUBLE-BEAT (jellyfish propulsion). Every
// line floats on a gentle y drift and exits by SINKING (+15px, dimming).
// Furniture: seeded plankton motes drift f(t) in the open water; the ones
// seeded in a ring around the apex get ATTRACTED to the bloom and brighten
// as it lands. The line feeding the apex dims while the bloom owns the
// frame (no restore — it exits dimmed, by design); a line entering during
// the hold arrives dimmed and restores once the bloom settles.
const b = dna.body;
const A = dna.palette.accent,
VIO = dna.palette.violet || "#9d7bff";
const rowB = H - (b.bottomPx || 70); // lower row center
const rowA = rowB - (b.rowDy ?? 58); // upper row center
const m = b.motes || {};
const yld = b.yield || {};
// active line = the one feeding the apex (last line that starts before it)
const activeIdx = LINES.reduce((a, L, i) => (L.words[0].start < heroIn ? i : a), 0);
const lineData = LINES.map((L, i) => {
// two-row coexistence: a line clears when its ROW is next needed (line
// i+2) or ~0.3s after its couplet finishes speaking (staggered pair
// exits); a pre-apex leftover clears just before the bloom lands (the
// feeding line stays and yields instead); the last line floats to the end
const pairLast = LINES[Math.min(i - (i % 2) + 1, LINES.length - 1)];
const pairEnd = pairLast.words[pairLast.words.length - 1].end;
let out =
i + 2 < LINES.length
? Math.min(LINES[i + 2].in - 0.15, pairEnd + 0.28 + (i % 2) * 0.12)
: i + 1 < LINES.length
? LINES[i + 1].words[0].start - 0.05
: null;
if (i !== activeIdx && out != null && out > heroIn - 0.25 && L.in < heroIn) out = heroIn - 0.21;
return {
id: "pl" + L.id,
top: i % 2 ? rowB : rowA,
in: +L.in.toFixed(3),
out: out == null ? null : +Math.min(out, DUR - 0.36).toFixed(3),
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
};
});
const shOn = `0 2px 10px rgba(1,10,20,0.85), 0 0 16px ${A}cc`;
const shEm = `0 2px 10px rgba(1,10,20,0.85), 0 0 18px ${A}f2, 0 0 34px ${VIO}8c`;
const css = `
.mote { position:absolute; border-radius:50%; background:${dna.palette.mote || "#96f5e8"}f2;
box-shadow: 0 0 7px 2px ${A}bf; opacity:0; }
.pln { position:absolute; left:${W / 2}px; white-space:nowrap;
font-family:'${dna.fonts.body}', sans-serif; font-weight:400; font-size:${b.fontPx}px;
line-height:1.2; color:${dna.palette.body}; }
.pln .w { display:inline-block; position:relative; margin-right:0.30em; opacity:0; }
.pln .w:last-child { margin-right:0; }
.pln .ink { display:inline-block;
text-shadow: 0 2px 10px rgba(1,10,20,0.85), 0 0 0px ${A}00; }
.pln .w.em .ink { color:${dna.palette.em || "#f4fffd"};
text-shadow: 0 2px 10px rgba(1,10,20,0.85), 0 0 0px ${A}00, 0 0 0px ${VIO}00; }`;
const html =
` <div id="mfield"></div>
` +
lineData
.map((L) => ` <div class="pln" id="${L.id}" style="top:${L.top}px"></div>`)
.join("\n");
const js = `
// ---- body paradigm: PLANKTONRAIL (jellyfish glow-on, two-row float, sinking exits) ----
const I = ${heroIn.toFixed(3)};
const mrnd = mulberry32(${m.seed ?? 2929});
const mf = document.getElementById("mfield");
// plankton motes: seeded drift walks f(t); the first ${m.near ?? 5} seed a
// ring around the bloom heart and get ATTRACTED to it as the apex lands
const APX = ${HG.x}, APY = ${HG.y};
for (let i = 0; i < ${m.count ?? 12}; i++) {
const near = i < ${m.near ?? 5};
const el = document.createElement("div");
el.className = "mote"; mf.appendChild(el);
let L, T;
if (near) {
const ang = (i / ${m.near ?? 5}) * Math.PI * 2 + 0.55 + mrnd() * 0.5;
const r = (150 + mrnd() * 75) * ${(HG.fontPx / 120).toFixed(3)};
L = APX + Math.cos(ang) * r * 1.35;
T = APY + Math.sin(ang) * r * 0.85;
} else {
L = 60 + mrnd() * ${W - 120};
T = 60 + mrnd() * ${H - 260};
}
L = Math.max(20, Math.min(${W - 25}, L)); T = Math.max(25, Math.min(${H - 175}, T));
const s = 2 + Math.round(mrnd() * 2);
gsap.set(el, { width: s, height: s, left: L, top: T });
const tin = 0.04 + mrnd() * 0.35;
tl.to(el, { opacity: 0.1, duration: 0.3, ease: "power1.out" }, tin);
// slow seeded drift walk
const a1 = (mrnd() - 0.5) * 36, a2 = a1 + (mrnd() - 0.5) * 36, a3 = a2 + (mrnd() - 0.5) * 30;
const b1 = (mrnd() - 0.5) * 28, b2 = b1 + (mrnd() - 0.5) * 28, b3 = b2 + (mrnd() - 0.5) * 24;
const dEnd = near ? I - 0.25 : ${(DUR - 0.34).toFixed(3)};
tl.to(el, { keyframes: { x: [a1, a2, a3], y: [b1, b2, b3],
opacity: [0.13, 0.07, 0.11] },
duration: dEnd - (tin + 0.32), ease: "sine.inOut" }, tin + 0.32);
if (near) {
// ATTRACTION: pulled toward the bloom, brightening
const tx = (APX - L) * 0.72 + (mrnd() - 0.5) * 24;
const ty = (APY - T) * 0.72 + (mrnd() - 0.5) * 18;
tl.to(el, { x: tx, y: ty, opacity: 0.42, scale: 1.7,
duration: 0.75, ease: "power2.in" }, I - 0.19 + i * 0.045);
tl.to(el, { keyframes: { x: [tx + 4, tx - 3, tx + 2], y: [ty - 4, ty + 3, ty - 2] },
duration: 0.32, ease: "sine.inOut" }, ${Math.min(heroIn + 0.59, DUR - 0.42).toFixed(3)});
tl.to(el, { y: ty + 18, opacity: 0, duration: 0.18, ease: "power1.in" }, ${(DUR - 0.22).toFixed(3)});
} else {
tl.to(el, { y: "+=14", opacity: 0, duration: 0.16, ease: "power1.in" }, ${(DUR - 0.2).toFixed(3)});
}
}
// ---- body lines: jellyfish glow-on, floating couplet rows, sinking exits ----
const SH_ON = ${J(shOn)}, SH_EM = ${J(shEm)};
const PRAIL = ${J(lineData)};
PRAIL.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt, , em]) => {
const w = document.createElement("span");
w.className = "w" + (em ? " em" : "");
const ink = document.createElement("span"); ink.className = "ink";
ink.textContent = txt; w.appendChild(ink); line.appendChild(w);
});
gsap.set(line, { xPercent: -50, yPercent: -50 });
// gentle float while held (everything in the abyss floats)
const t0 = Math.max(0.02, L.words[0][1] - 0.02);
const D = (L.out ?? ${(DUR - 0.06).toFixed(3)}) - t0 - 0.04;
const yArr = D > 1.5 ? [-2, 2, -3, 1, 3, 0] : [-2, 1, -2, 0];
tl.to(line, { keyframes: { y: yArr }, duration: D, ease: "sine.inOut" }, t0);
L.words.forEach(([txt, st, em], wi) => {
const w = line.children[wi], ink = w.children[0];
// GLOW ON: radial bloom from word center (the apex bloom at low amplitude)
tl.set(w, { opacity: 1 }, st - 0.01);
tl.fromTo(ink, { opacity: 0 }, { opacity: 1, duration: 0.083, ease: "none" }, st);
tl.fromTo(ink, { scale: 0.92, filter: "blur(5px) brightness(0.45)" },
{ scale: 1, filter: "blur(0px) brightness(1)", duration: 0.4, ease: "sine.out" }, st);
tl.to(ink, { textShadow: em ? SH_EM : SH_ON, duration: 0.4, ease: "sine.out" }, st);
// EMPHASIS: slow double-beat pulse (jellyfish propulsion)
if (em && st + 1.07 <= ${(DUR - 0.09).toFixed(3)}) {
tl.to(ink, { keyframes: {
filter: ["blur(0px) brightness(1.4)", "blur(0px) brightness(1.06)",
"blur(0px) brightness(1.5)", "blur(0px) brightness(1.12)",
"blur(0px) brightness(1)"],
scale: [1.035, 1.01, 1.05, 1.015, 1] },
duration: 0.55, ease: "sine.inOut" }, st + 0.52);
}
});
// EXIT: light sinks — drift down +15 while dimming
if (L.out != null) {
tl.to(line, { y: 15, opacity: 0, duration: 0.3, ease: "power1.in" }, L.out);
tl.set(line, { display: "none" }, Math.min(L.out + 0.32, ${(DUR - 0.02).toFixed(2)}));
}
});
// rail yields to the apex landing: the feeding line dims (and exits dimmed
// unless it has ≥0.9s of hold left); a line entering during the hold
// arrives at ${yld.enter ?? 0.8} and restores once the bloom settles
PRAIL.forEach((L, i) => {
if (i === ${activeIdx} && (L.out ?? 99) > I - ${(yld.pre ?? 0.17).toFixed(2)} + 0.25) {
tl.to("#" + L.id, { opacity: ${yld.dim ?? 0.55}, duration: 0.18, ease: "power1.out" }, I - ${(yld.pre ?? 0.17).toFixed(2)});
if ((L.out ?? 99) > I + 1.2)
tl.to("#" + L.id, { opacity: 1, duration: 0.25, ease: "power1.out" }, I + 0.9);
}
if (L.in > I && L.in < I + 0.9) {
tl.set("#" + L.id, { opacity: ${yld.enter ?? 0.8} }, L.in);
tl.to("#" + L.id, { opacity: 1, duration: 0.25, ease: "power1.out" },
Math.min(Math.max(I + ${(yld.post ?? 0.63).toFixed(2)}, L.in + 0.1), ${(DUR - 0.28).toFixed(2)}));
}
});`;
return { css, html, js };
}
function paradigmSheenrail() {
// SHEENRAIL: iridescent silk lower third on TWO coexisting row slots — even
// lines ride the upper row (H bottomPx rowDy), odd lines the lower
// (H bottomPx), so couplets read together like a woven pair. Words are
// aurora-gradient ink (background-clip:text over a rose→teal→violet wash
// whose backgroundPosition DRIFTS f(t) across the whole clip — the body's
// hold life) above a blurred dark understroke; entrance = 2-frame opacity
// + y8 settle (the ribbon's flow at low amplitude); emphasis = ONE bright
// sheen sweep marching through the word + a brightness pulse. Exits
// dissolve UPWARD (y12, blur 3, staggered) and each line releases 3-4
// drifting light streaks that float up from its row. A soft radial band
// scrim sits under the rows (stable reading surface); the whole rail —
// band included — yields while the ribbon writes. Line scheduling mirrors
// planktonrail's two-row coexistence: a row clears when next needed (line
// i+2) or shortly after its couplet finishes speaking (staggered pair
// exits); the pre-apex leftover clears at heroIn0.32 while the FEEDING
// line stays (it dims with the railwrap yield); the last line holds.
const b = dna.body;
const PAL = [dna.palette.rose, dna.palette.accent, dna.palette.violet];
const rowB = H - (b.bottomPx || 76); // lower row TOP edge
const rowA = rowB - (b.rowDy ?? 58); // upper row TOP edge
const band = b.band || {};
const bandW = band.w ?? 860,
bandH = band.h ?? 170;
const bandCy = Math.round((rowA + rowB + b.fontPx * 1.1) / 2);
const yld = b.yield || {};
// active line = the one feeding the apex (last line that starts before it)
const activeIdx = LINES.reduce((a, L, i) => (L.words[0].start < heroIn ? i : a), 0);
const lineData = LINES.map((L, i) => {
const pairLast = LINES[Math.min(i - (i % 2) + 1, LINES.length - 1)];
const pairEnd = pairLast.words[pairLast.words.length - 1].end;
let out =
i + 2 < LINES.length
? Math.min(LINES[i + 2].in - 0.15, pairEnd + 0.23 + (i % 2) * 0.17)
: i + 1 < LINES.length
? LINES[i + 1].words[0].start - 0.11
: null;
if (i !== activeIdx && out != null && out > heroIn - 0.25 && L.in < heroIn) out = heroIn - 0.32;
return {
id: "sk" + L.id,
top: i % 2 ? rowB : rowA,
out: out == null ? null : +Math.min(out, DUR - 0.4).toFixed(3),
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
};
});
const css = `
#skwrap { position:absolute; inset:0; }
#band { position:absolute; left:${W / 2}px; top:${bandCy}px; width:${bandW}px; height:${bandH}px;
margin-left:-${bandW / 2}px; margin-top:-${bandH / 2}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, rgba(6,10,28,0.55) 0%, rgba(6,10,28,0.3) 55%, rgba(6,10,28,0) 78%); }
.skl { position:absolute; left:${W / 2}px; white-space:nowrap;
font-family:'${dna.fonts.body}', serif; font-weight:500; font-size:${b.fontPx}px;
line-height:1.1; }
.skl .w { position:relative; display:inline-block; margin-right:0.3em; opacity:0; }
.skl .w:last-child { margin-right:0; }
.skl .sh { position:absolute; left:0; top:3px; color:rgba(4,8,22,0.7);
filter:blur(3px); z-index:0; }
.skl .grad { position:relative; z-index:1;
background-image: linear-gradient(100deg, ${PAL[0]} 0%, ${PAL[1]} 32%, ${PAL[2]} 64%, ${PAL[0]} 96%);
background-size: 300% 100%;
-webkit-background-clip: text; background-clip: text; color: transparent; }
.skl .shine { position:absolute; left:0; top:0; z-index:2; opacity:0;
background-image: linear-gradient(100deg, rgba(255,255,255,0) 40%, rgba(255,255,255,0.95) 50%, rgba(255,255,255,0) 60%);
background-size: 260% 100%;
-webkit-background-clip: text; background-clip: text; color: transparent; }
.streak { position:absolute; width:3px; border-radius:2px; opacity:0; filter:blur(2px); }`;
const html =
` <div id="skwrap">
<div id="band"></div>
` +
lineData
.map((L) => ` <div class="skl" id="${L.id}" style="top:${L.top}px"></div>`)
.join("\n") +
`
</div>`;
const js = `
// ---- body paradigm: SHEENRAIL (flow-on entrance, sheen-sweep emphasis, dissolve-to-streaks exits) ----
const I = ${heroIn.toFixed(3)};
const srnd = mulberry32(${b.seed ?? 20260611});
const stg = document.getElementById("stage");
const PAL = ${J(PAL)};
const SRAIL = ${J(lineData)};
tl.to("#band", { opacity: 1, duration: 0.35, ease: "sine.out" }, 0.12);
const allGrads = [];
SRAIL.forEach((L) => {
const line = document.getElementById(L.id);
const wraps = [];
L.words.forEach(([txt, st, em]) => {
const w = document.createElement("span"); w.className = "w";
const sh = document.createElement("span"); sh.className = "sh"; sh.textContent = txt;
const g = document.createElement("span"); g.className = "grad"; g.textContent = txt;
const sn = document.createElement("span"); sn.className = "shine"; sn.textContent = txt;
w.appendChild(sh); w.appendChild(g); w.appendChild(sn);
line.appendChild(w); wraps.push(w); allGrads.push(g);
// FLOW ON: 2-frame opacity + y8 settle (the ribbon's flow at low amplitude)
tl.fromTo(w, { opacity: 0 }, { opacity: 1, duration: 2 * F, ease: "none" }, st);
tl.fromTo(w, { y: 8 }, { y: 0, duration: 0.38, ease: "sine.out" }, st);
// EMPHASIS: the sheen pulses bright once through the word
if (em) {
const pt = st + 0.16;
tl.set(sn, { opacity: 1 }, pt);
tl.fromTo(sn, { backgroundPosition: "-80% 50%" },
{ backgroundPosition: "180% 50%", duration: 0.42, ease: "power2.inOut" }, pt);
tl.to(sn, { opacity: 0, duration: 0.12, ease: "none" }, pt + 0.34);
tl.to(g, { keyframes: { filter: ["brightness(1)", "brightness(1.55)", "brightness(1)"] },
duration: 0.42, ease: "none" }, pt);
}
});
gsap.set(line, { xPercent: -50 });
// EXIT: the line dissolves upward and releases drifting light streaks
if (L.out != null) {
tl.to(wraps, { opacity: 0, y: -12, filter: "blur(3px)",
duration: 0.32, ease: "power1.in", stagger: 0.035 }, L.out);
tl.set(line, { display: "none" }, L.out + 0.6);
const nS = ${b.streaks ?? 4};
for (let i = 0; i < nS; i++) {
const s = document.createElement("div"); s.className = "streak"; stg.appendChild(s);
const c = PAL[i % 3];
const x = ${W / 2} + (i - (nS - 1) / 2) * 95 + (srnd() - 0.5) * 50;
const hh = 40 + Math.round(srnd() * 28);
gsap.set(s, { left: x, top: L.top + 14, height: hh,
background: "linear-gradient(to top, rgba(255,255,255,0) 0%, " + c + " 50%, rgba(255,255,255,0) 100%)" });
tl.fromTo(s, { y: 14 },
{ keyframes: { opacity: [0, 0.7, 0], y: [14, -28, -78] },
duration: 0.62, ease: "power1.out" }, Math.min(L.out + 0.04 + i * 0.05, ${(DUR - 0.66).toFixed(3)}));
}
}
});
// continuous iridescent sheen drift f(t) — the body's hold life
tl.fromTo(allGrads, { backgroundPosition: "0% 50%" },
{ backgroundPosition: "300% 50%", duration: ${(DUR - 0.04).toFixed(3)}, ease: "none" }, 0);
// rail yields — band included — while the ribbon writes (apex owns the frame)
tl.to("#skwrap", { opacity: ${yld.dim ?? 0.5}, duration: 0.2, ease: "sine.in" }, I - ${(yld.pre ?? 0.16).toFixed(2)});
tl.to("#skwrap", { opacity: 1, duration: 0.3, ease: "sine.out" }, I + ${(yld.post ?? 0.62).toFixed(2)});`;
return { css, html, js };
}
function paradigmScoperail() {
// SCOPERAIL: oscilloscope lower third. A dark phosphor scope band (HUD
// readouts, graticule) hosts an ALWAYS-ALIVE waveform — an SVG polyline
// rebuilt from baked point-set states (seeded pseudo-RMS envelope: speech
// grows it, silence flattens it, every word arrival kicks a local gaussian
// burst for ~3 frames; the hero words kick WIDE bursts where the trace
// surges off the scope to write). Body lines (phosphor mono) sit on the
// band above the trace and wipe in L→R over reserved-width slots (the apex
// pen-write at low amplitude) with a 2-frame just-traced flash; emphasis
// words get a ticking frequency-readout chip; the trig LED blinks on every
// word arrival. Lines collapse INTO the waveform on exit. At the FINAL word
// the trace explodes to max-amp chaos, flatlines, collapses to a single dot
// and `body.siglost` stamps — the scope dies with the clip.
const b = dna.body;
const A = dna.palette.accent,
BRT = dna.palette.bright || "#d6ffe4",
FLSH = dna.palette.flash || "#eafff2",
EM = dna.palette.em || "#ffb347",
HUD = dna.palette.hud || "#2f8f55";
const bandH = b.bandH || 164;
const bandTop = H - bandH;
const waveH = b.waveH || 84;
const NZ = +LASTWORD.start.toFixed(3); // the scope-death beat
// line windows: exits collapse into the trace — early into dead air
// (lastWordStart+0.67), else just before the next line needs the slot
const lineData = LINES.map((L, i) => {
const lastSt = L.words[L.words.length - 1].start;
const next = LINES[i + 1];
const out = next
? +Math.min(next.in - 0.05, lastSt + 0.67).toFixed(3)
: +Math.min(NZ + 0.33, DUR - 0.2).toFixed(3);
return {
id: "sc" + L.id,
in: +L.in.toFixed(3),
out,
words: L.words.map((w) => [
w.display,
+w.start.toFixed(3),
w.minor ? 1 : 0,
Math.abs(w.start - NZ) < 0.001 ? 1 : 0, // the death word settles hot
]),
};
});
// waveform burst map BAKED from measured glyph metrics (slot centers) +
// the hero kick positions (the trace surges wide where the apex writes)
const GAPem = 0.32;
const burst = []; // [t, x, big]
LINES.forEach((L) => {
const ws = L.words.map((w) => wordPx(w.display, dna.fonts.body, b.fontPx, 0));
const totalW = ws.reduce((a, c) => a + c, 0) + GAPem * b.fontPx * (L.words.length - 1);
let x = W / 2 - totalW / 2;
L.words.forEach((w, i) => {
burst.push([+w.start.toFixed(3), +(x + ws[i] / 2).toFixed(1), 0]);
x += ws[i] + GAPem * b.fontPx;
});
});
if (!heroInline)
for (let k = 0; k < hero.len; k++) {
const hx = HG.x + (hero.len === 1 ? 0 : (k / (hero.len - 1)) * 0.6 * (HG.halfW || 300));
burst.push([+tWords[hero.idx + k].start.toFixed(3), +hx.toFixed(1), 1]);
}
const css = `
#scband { position:absolute; left:0; top:${bandTop}px; width:${W}px; height:${bandH}px; opacity:0;
background: rgba(0,12,4,0.58); border-top:1px solid ${A}4d;
box-shadow: 0 -8px 30px rgba(0,0,0,0.35); }
.schud { position:absolute; top:6px; font-family:'${dna.fonts.tag}', monospace; font-size:20px;
color:${HUD}; letter-spacing:2px; }
#schudL { left:24px; }
#schudR { right:24px; }
#sctrig { display:inline-block; width:10px; height:10px; border-radius:50%;
background:${EM}; box-shadow:0 0 6px ${EM}cc; margin-left:8px; opacity:0.45; }
#scgrat { position:absolute; left:0; top:${b.waveTop || 80}px; width:${W}px; height:${waveH}px; opacity:0.6;
background-image:
repeating-linear-gradient(90deg, ${A}12 0 1px, transparent 1px 80px),
linear-gradient(180deg, transparent ${Math.round(waveH / 2 - 1)}px, ${A}1a ${Math.round(waveH / 2 - 1)}px ${Math.round(waveH / 2 + 1)}px, transparent ${Math.round(waveH / 2 + 1)}px); }
#scwavewrap { position:absolute; left:0; top:${b.waveTop || 80}px; width:${W}px; height:${waveH}px; }
#scwave { filter: drop-shadow(0 0 5px ${A}bf); }
.scln { position:absolute; left:0; top:${b.lineDy || 34}px; width:${W}px; text-align:center;
font-family:'${dna.fonts.body}', monospace; font-size:${b.fontPx}px; line-height:${Math.round(b.fontPx * 1.1)}px;
color:${A}; text-shadow:0 0 8px ${A}73;
white-space:nowrap; opacity:0; }
.scln .w { display:inline-block; vertical-align:bottom; white-space:nowrap;
margin-right:${GAPem}em; position:relative; }
.scln .sz { visibility:hidden; }
.scln .clip { position:absolute; left:0; top:0; overflow:hidden; white-space:nowrap; width:0; }
.scchip { position:absolute; left:50%; top:-26px; width:130px; margin-left:-65px;
height:24px; font-family:'${dna.fonts.tag}', monospace; font-size:21px;
color:${EM}; text-shadow:0 0 6px ${EM}99; opacity:0; }
.scchip .cv { position:absolute; left:0; top:0; width:100%; text-align:center; opacity:0; }
#scdot { position:absolute; left:${W / 2 - 6}px; top:${bandTop + (b.waveTop || 80) + Math.round(waveH / 2) - 4}px;
width:9px; height:9px; border-radius:50%; background:${BRT}; opacity:0;
box-shadow:0 0 8px ${A}, 0 0 22px ${A}e6; }
#scsiglost { position:absolute; left:0; top:${bandTop + (b.waveTop || 80)}px; width:${W}px; text-align:center;
font-family:'${dna.fonts.tag}', monospace; font-size:24px; letter-spacing:6px;
color:${EM}; text-shadow:0 0 10px ${EM}b3; opacity:0; }`;
const html =
` <div id="scband">
<div class="schud" id="schudL">${esc(b.hudL || "SPECTRUM // CH1")}</div>
<div class="schud" id="schudR">${esc(b.hudR || FPS + ".0 kS/s · TRIG")}<span id="sctrig"></span></div>
<div id="scgrat"></div>
<div id="scwavewrap">
<svg width="${W}" height="${waveH}" viewBox="0 0 ${W} ${waveH}">
<polyline id="scwave" fill="none" stroke="${A}" stroke-width="2.5"
stroke-linejoin="round" points=""/>
</svg>
</div>
` +
lineData.map((L) => ` <div class="scln" id="${L.id}"></div>`).join("\n") +
`
</div>
<div id="scsiglost">${esc(b.siglost || "— SIGNAL LOST —")}</div>
<div id="scdot"></div>`;
const js = `
// ---- body paradigm: SCOPERAIL (trace-wipe words, waveform alive all clip) ----
const rnd = mulberry32(${b.waveSeed || 7});
const NZ = ${NZ.toFixed(3)}, ENDC = ${(DUR - 0.02).toFixed(3)};
const BURST = ${J(burst)}; // [t, x, big] — word arrivals kick the trace
const allT = BURST.map((q) => q[0]);
const burstAt = {}; const bigAt = {};
BURST.forEach(([t, x, big]) => { burstAt[t] = x; if (big) bigAt[t] = 1; });
// ===== baked waveform states (pure f(t) + seeded jitter; ~2 states/word frame) =====
const timesSet = [];
let t0 = 0;
while (t0 < NZ - 0.015) { timesSet.push(t0); t0 += 1 / 12; }
allT.forEach((wt) => { [wt, wt + F, wt + 2 * F].forEach((x) => { if (x < NZ - 0.015) timesSet.push(x); }); });
for (let te = NZ - 0.01; te <= Math.min(NZ + 0.311, ENDC); te += F) timesSet.push(te);
timesSet.push(Math.min(NZ + 0.325, ENDC), Math.min(NZ + 0.35, ENDC));
timesSet.sort((a, b2) => a - b2);
const times = timesSet.filter((x, i) => i === 0 || x - timesSet[i - 1] > 0.02);
const NPTS = ${Math.round(W / 20) + 1}, MIDY = ${Math.round(waveH / 2)};
function speechEnv(t) {
let e = 0;
allT.forEach((wt) => {
const g = Math.exp(-Math.pow((t - wt - 0.10) / 0.21, 2));
if (g > e) e = g;
});
return e;
}
const wavePts = [];
times.forEach((t) => {
const boom = t > NZ - 0.007 && t < NZ + 0.308;
const flat = t >= NZ + 0.32;
const baseAmp = flat ? 0.5 : (boom ? 34 : 2.2 + 15 * speechEnv(t));
const pts = [];
for (let i = 0; i < NPTS; i++) {
const x = i * 20;
let amp = baseAmp;
if (!boom && !flat) {
allT.forEach((wt) => {
if (t >= wt - 0.005 && t <= wt + 0.13) {
const big = bigAt[wt];
amp += (big ? 30 : 20) * Math.exp(-Math.pow((x - burstAt[wt]) / (big ? 240 : 95), 2));
}
});
}
const jit = (rnd() - 0.5) * 2;
let y = amp * (0.52 * Math.sin(i * 0.55 + t * 16.2 + 0.7)
+ 0.42 * Math.sin(i * 1.27 - t * 23.5 + 2.1)
+ (boom ? 0.85 : 0.42) * jit);
if (y > MIDY - 4) y = MIDY - 4; if (y < -(MIDY - 4)) y = -(MIDY - 4);
pts.push(x + "," + (MIDY + y).toFixed(1));
}
wavePts.push([t, pts.join(" ")]);
});
const scwave = document.getElementById("scwave");
scwave.setAttribute("points", wavePts[0][1]);
wavePts.forEach(([t, str]) => { tl.set(scwave, { attr: { points: str } }, Math.min(t, ENDC)); });
// ===== scope power-on =====
tl.fromTo("#scband", { opacity: 0 }, { opacity: 1, duration: 0.12, ease: "power1.out" }, 0.02);
tl.fromTo("#scwavewrap", { scaleX: 0.04, transformOrigin: "50% 50%" },
{ scaleX: 1, duration: 0.22, ease: "power2.out" }, 0.04);
${
b.yield && !heroInline
? `
// rail yields while the apex trace writes (band carries lines + wave + HUD)
tl.to("#scband", { opacity: ${b.yield.dim}, duration: 0.18, ease: "power1.in" }, ${Math.max(heroIn - (b.yield.pre || 0.13), 0.2).toFixed(3)});
tl.to("#scband", { opacity: 1, duration: 0.25, ease: "power1.out" }, ${Math.min(heroIn + (b.yield.post || 0.97), DUR - 0.4).toFixed(3)});`
: ""
}
// ===== body lines: trace-wipe entrances over reserved-width slots =====
const SRAIL = ${J(lineData)};
function chipVals(r, idx) {
if (idx % 2 === 0) {
let v = 150 + Math.round(r() * 250); const a = [v];
v += 300 + Math.round(r() * 200); a.push(v);
v += 200 + Math.round(r() * 150); a.push(v);
return a.map((x) => x + " Hz");
}
let v = 0.3 + Math.round(r() * 3) * 0.1; const a = [v];
v += 0.4 + Math.round(r() * 2) * 0.1; a.push(v);
v += 0.2 + Math.round(r() * 2) * 0.1; a.push(v);
return a.map((x) => x.toFixed(1) + " kHz");
}
const crnd = mulberry32(${b.chipSeed || 11});
let chipIdx = 0;
SRAIL.forEach((L) => {
const line = document.getElementById(L.id);
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.03);
L.words.forEach(([txt, wt, minor, death]) => {
// reserved-width slot: sizer keeps the centered line static (no
// re-center drift as words arrive); the clip wipes the word in over it.
const wrap = document.createElement("span"); wrap.className = "w";
const sizer = document.createElement("span"); sizer.className = "sz";
sizer.textContent = txt;
const clip = document.createElement("span"); clip.className = "clip";
const inner = document.createElement("span");
inner.textContent = txt;
clip.appendChild(inner); wrap.appendChild(sizer); wrap.appendChild(clip);
line.appendChild(wrap);
// entrance = apex motif at low amplitude: the trace draws it L→R
tl.fromTo(clip, { width: 0 }, { width: "auto", duration: 0.10, ease: "none" }, wt);
// phosphor just-traced flash (full brightness instantly, settle in 2 frames)
const settle = death
? { color: ${J(EM)}, textShadow: "0 0 9px ${EM}8c" }
: { color: ${J(A)}, textShadow: "0 0 8px ${A}73" };
tl.set(inner, { color: ${J(FLSH)}, textShadow: "0 0 16px ${BRT}f2" }, wt);
tl.set(inner, settle, wt + 2 * F);
// trigger LED blink on every word arrival
tl.set("#sctrig", { opacity: 1, scale: 1.35 }, wt);
tl.set("#sctrig", { opacity: 0.45, scale: 1 }, wt + 2 * F);
// emphasis: ticking frequency readout (seeded, ascending)
if (minor) {
const chip = document.createElement("span"); chip.className = "scchip";
const cvs = chipVals(crnd, chipIdx++).map((v) => {
const s = document.createElement("span"); s.className = "cv"; s.textContent = v;
chip.appendChild(s); return s;
});
wrap.appendChild(chip);
tl.set(chip, { opacity: 1 }, wt + 0.12);
cvs.forEach((s, i) => {
tl.set(s, { opacity: 1 }, wt + 0.12 + i * 2 * F);
if (i < cvs.length - 1) tl.set(s, { opacity: 0 }, wt + 0.12 + (i + 1) * 2 * F);
});
}
});
// exit: the line collapses INTO the waveform
tl.to(line, { y: 30, opacity: 0, duration: 0.13, ease: "power2.in" }, L.out);
});
// ===== the death beat: explosion / band shake / flatline / dot / siglost =====
[[3, 0], [-4, 1], [3, 2], [-2, 3], [0, 4]].forEach(([dx, k]) => {
tl.set("#scband", { x: dx }, Math.min(NZ + k * 0.042, ENDC));
});
tl.set("#scband", { x: 0 }, Math.min(NZ + 0.21, ENDC));
// trig LED panics, then dies
[0, 1, 2, 3].forEach((k) => {
tl.set("#sctrig", { opacity: k % 2 ? 0.2 : 1, background: ${J(dna.palette.panic || "#ff5f47")},
boxShadow: "0 0 8px ${(dna.palette.panic || "#ff5f47") + "e6"}" }, Math.min(NZ + k * 2 * F, ENDC));
});
tl.set("#sctrig", { opacity: 0.18 }, Math.min(NZ + 0.35, ENDC));
// flatline → trace shrinks to a single dot
tl.to("#scwavewrap", { scaleX: 0.015, duration: 0.055, ease: "power3.in",
transformOrigin: "50% 50%" }, Math.min(NZ + 0.365, ENDC - 0.06));
tl.set("#scwavewrap", { opacity: 0 }, Math.min(NZ + 0.425, ENDC));
tl.set("#scdot", { opacity: 1 }, Math.min(NZ + 0.39, ENDC));
tl.set("#scdot", { opacity: 0.45 }, Math.min(NZ + 0.465, ENDC));
tl.set("#scdot", { opacity: 1 }, Math.min(NZ + 0.49, ENDC));
// SIGNAL LOST stamp (2-frame flicker in; clamped ≤ DUR0.02)
tl.set("#scsiglost", { opacity: 1 }, Math.min(NZ + 0.43, ENDC - 2 * F));
tl.set("#scsiglost", { opacity: 0.35 }, Math.min(NZ + 0.43 + F, ENDC - F));
tl.set("#scsiglost", { opacity: 1 }, Math.min(NZ + 0.43 + 2 * F, ENDC));`;
return { css, html, js };
}
function paradigmPaperrail() {
// PAPERRAIL: stop-motion craft table — a kraft strip (torn top edge, washi-
// taped corners, handwritten tag) holds cream paper chips that are PLACED
// one by one: opacity full in 0 frames, y 16 + scale 1.06→1 with steps(2),
// seeded ±2.5° crooked = the papermat drop at low amplitude. The strip
// jitters ±0.7px every 4 frames (handmade weave); chips hold a rotation-only
// ~5fps wobble. Emphasis words get a washi-tape slap (chip pressed 1 frame);
// the FINAL word is an accent chip (sign-off); the hero slot keeps a red
// paper star (rail↔climax hand-off) that pulses as the apex chips land.
// Exits: chips peeled off (rotation 8°, y 40, steps(3)), staggered.
const b = dna.body;
const stripW = (b.strip && b.strip.w) || 1140;
const stripH = (b.strip && b.strip.h) || 88;
const SX = (b.strip && b.strip.x) ?? Math.round((W - stripW) / 2);
const ST = H - ((b.strip && b.strip.bottomPx) ?? 26) - stripH;
const k = (b.fontPx || 38) / 38; // chip furniture scales with the type
const r = (v) => Math.round(v * k);
const tapeDelay = b.tapeDelay ?? 0.25;
// hero hand-off slot: the line whose window holds the apex keeps a star chip
const heroLi = heroInline ? -1 : LINES.findIndex((L) => heroIn >= L.in - 0.01 && heroIn < L.out);
const lineData = LINES.map((L, li) => {
const n = L.words.length + (li === heroLi ? 1 : 0);
// peel (0.18s + stagger) always completes on frame; demo cadence L.out0.04
const xo = +(
li === LINES.length - 1 ? Math.min(L.out - 0.04, DUR - 0.2 - (n - 1) * 0.03) : L.out - 0.04
).toFixed(3);
const words = L.words.map((w, wi) => {
const isLast = li === LINES.length - 1 && wi === L.words.length - 1;
const kind = isLast ? 3 : w.minor ? 1 : 0; // 0 chip / 1 taped / 2 star / 3 accent
let tapeT = 0;
if (kind === 1) {
const tt = +Math.min(w.start + tapeDelay, xo - 0.2).toFixed(3);
if (tt >= w.start + 0.08) tapeT = tt;
}
return [w.display, +w.start.toFixed(3), kind, tapeT];
});
if (li === heroLi) {
const at = L.words.filter((w) => w.start < heroIn).length;
words.splice(at, 0, ["*", +heroIn.toFixed(3), 2, 0]);
}
return { id: "pl" + L.id, in: +L.in.toFixed(3), xo, words };
});
const css = `
#pstrip { position:absolute; left:${SX}px; top:${ST}px; width:${stripW}px; height:${stripH}px; opacity:0; }
#pstrip .paper { position:absolute; inset:0;
background: linear-gradient(180deg, #bd9e6f 0%, #b89968 45%, #a8854f 100%);
box-shadow: inset 0 -4px 0 rgba(90,66,30,0.55); }
.pwt { position:absolute; width:84px; height:24px; opacity:0.62; background: rgba(255,99,84,0.85); }
.pwt i { position:absolute; inset:0; display:block;
background: repeating-linear-gradient(90deg, rgba(255,255,255,0.22) 0 6px, rgba(255,255,255,0) 6px 13px); }
#pwtL { left:-16px; top:-11px; } #pwtR { right:-14px; top:-9px; }
#ptag { position:absolute; right:30px; top:${stripH - 38}px; font-family:'${dna.fonts.tag}', cursive;
font-size:24px; color:${dna.palette.tag || "#503d20"}; opacity:0.85; white-space:nowrap; }
#plines { position:absolute; inset:0; }
.pline { position:absolute; left:${W / 2}px; top:${ST + 14}px; white-space:nowrap; opacity:0; }
.pchip { position:relative; display:inline-block; margin-right:${r(10)}px; padding:${r(5)}px ${r(14)}px ${r(7)}px;
background:${dna.palette.chip || "#f6edd8"}; border:1px solid ${dna.palette.chipEdge || "#cdb98e"}; border-radius:3px;
box-shadow: 0 3px 0 rgba(58,38,16,0.38);
font-family:'${dna.fonts.body}', sans-serif; font-weight:600; font-size:${b.fontPx}px; line-height:1.15;
color:${dna.palette.body}; opacity:0; }
.pchip.red { background:${dna.palette.accent}; border-color:${dna.palette.accentEdge || "#c93527"}; color:#fff3e4; }
.ptape { position:absolute; top:-10px; right:-15px; width:${r(58)}px; height:${r(18)}px; opacity:0;
background: rgba(255,79,63,0.66); }
.ptape i { position:absolute; inset:0; display:block;
background: repeating-linear-gradient(90deg, rgba(255,255,255,0.25) 0 5px, rgba(255,255,255,0) 5px 11px); }
.pstarw { position:relative; display:inline-block; width:${r(46)}px; height:${r(44)}px;
margin-right:${r(10)}px; vertical-align:${-r(7)}px; opacity:0; }
.pstar { position:absolute; inset:0; background:${dna.palette.accent};
clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
filter: drop-shadow(0 3px 0 rgba(58,38,16,0.38)); }`;
const html = ` <div id="pstrip">
<div class="paper"></div>
<div class="pwt" id="pwtL"><i></i></div>
<div class="pwt" id="pwtR"><i></i></div>
<div id="ptag">${esc(b.tag || "cut & paste · 12 fps")}</div>
</div>
<div id="plines">
${lineData.map((L) => ` <div class="pline" id="${L.id}"></div>`).join("\n")}
</div>`;
const dimT = +Math.max(0.15, heroIn - ((b.yield && b.yield.pre) || 0.2)).toFixed(3);
const resT = +(heroIn + ((b.yield && b.yield.post) || 0.9)).toFixed(3);
const js = `
// ---- body paradigm: PAPERRAIL (chips placed steps(2) / peel-off exit) ----
const RAILP = ${J(lineData)};
const rrnd = mulberry32(${b.seed || 150372});
// torn top edge on the kraft strip (sides/bottom are the table cut)
(function(){
const pts = []; const SEG = 28;
for (let i = 0; i <= SEG; i++)
pts.push(((${stripW}/SEG)*i).toFixed(1) + "px " + Math.max(0, (rrnd()-0.5)*8 + 4).toFixed(1) + "px");
pts.push("${stripW}px ${stripH}px", "0px ${stripH}px");
document.querySelector("#pstrip .paper").style.clipPath = "polygon(" + pts.join(",") + ")";
})();
gsap.set("#pwtL", { rotation: -38 });
gsap.set("#pwtR", { rotation: 33 });
gsap.set("#ptag", { rotation: -2 });
// ===== STRIP: placed on the table, then handmade jitter every 4 frames =====
tl.set("#pstrip", { opacity: 1 }, 0.10);
tl.fromTo("#pstrip", { y: ${stripH + 26} }, { y: 0, duration: 0.18, ease: "steps(3)" }, 0.10);
for (let t = 0.45; t < ${(DUR - 0.06).toFixed(3)}; t += 1/6)
tl.set("#pstrip", { x: (rrnd()-0.5)*1.4, y: (rrnd()-0.5)*1.4, rotation: (rrnd()-0.5)*0.36 }, t);
${
b.yield && !heroInline
? `
// rail yields while the apex chips land (furniture never contests the hero)
tl.to(["#pstrip", "#plines"], { opacity: ${b.yield.dim}, duration: 0.10 }, ${dimT});
${resT + 0.3 < DUR ? ` tl.to(["#pstrip", "#plines"], { opacity: 1, duration: 0.22 }, ${resT});` : ""}`
: ""
}
// ===== BODY: paper chips placed one by one =====
RAILP.forEach((L) => {
const line = document.getElementById(L.id);
gsap.set(line, { xPercent: -50 });
tl.set(line, { opacity: 1 }, L.in);
L.words.forEach(([txt, st, kind, tapeT], wi) => {
let el;
if (kind === 2) { // apex hand-off: red paper star in the slot
el = document.createElement("span"); el.className = "pstarw";
const s = document.createElement("span"); s.className = "pstar";
el.appendChild(s); line.appendChild(el);
// placement pulse when the apex word starts landing
tl.set(s, { scale: 1.18 }, st + 0.09);
tl.set(s, { scale: 1.06 }, st + 0.17);
tl.set(s, { scale: 1 }, st + 0.25);
} else {
el = document.createElement("span");
el.className = "pchip" + (kind === 3 ? " red" : "");
el.textContent = txt;
line.appendChild(el);
}
const r0 = (rrnd()-0.5)*5; // seeded ±2.5° crooked placement
gsap.set(el, { transformOrigin: "50% 80%" });
// PLACED: the apex drop at low amplitude — opacity full in 0 frames
tl.set(el, { opacity: 1, rotation: r0 }, st);
tl.fromTo(el, { y: -16, scale: 1.06 }, { y: 0, scale: 1, duration: 0.12, ease: "steps(2)" }, st);
// handmade hold wobble (rotation only, ~5fps)
for (let t = st + 0.40; t < L.xo - 0.06; t += 0.21)
tl.set(el, { rotation: r0 + (rrnd()-0.5)*1.0 }, t);
// washi-tape slap pins the emphasis words
if (kind === 1 && tapeT > 0) {
const tape = document.createElement("span"); tape.className = "ptape";
const ti = document.createElement("i"); tape.appendChild(ti);
el.appendChild(tape);
gsap.set(tape, { rotation: -36 });
tl.set(tape, { opacity: 0.92 }, tapeT);
tl.fromTo(tape, { scale: 1.7 }, { scale: 1, duration: 0.0833, ease: "steps(2)" }, tapeT);
tl.set(el, { y: 2 }, tapeT); // chip pressed by the slap
tl.set(el, { y: 0 }, tapeT + 0.0833);
}
// EXIT: peeled off
tl.to(el, { rotation: r0 - 8, y: -40, opacity: 0, duration: 0.18, ease: "steps(3)" },
L.xo + wi*0.03);
});
});`;
return { css, html, js };
}
function paradigmPopuprail() {
// POPUPRAIL: pop-up book page — a cream page strip (center foldline) unfolds
// up from the bottom edge (scaleY back.out, the stage is built); words POP
// UP on a baseline hinge (rotationX 85→0, back.out = the centerfold motif at
// low amplitude) on two reading rows that alternate per line. Emphasis words
// land bigger with a contact squash the strip FEELS (1px dip), then flick-
// wobble; blue paper struts lean in behind emphasis + the sign-off word; the
// hero slot keeps a blue paper star (rail↔climax hand-off). Exits fold flat
// in an L→R wave (the page closing); on the final word the whole page TILTS
// (paper physics) and the strip itself folds shut on the last frame.
const b = dna.body;
const stripW = (b.strip && b.strip.w) || 1230;
const stripH = (b.strip && b.strip.h) || 124;
const rowY = { A: H - (b.rowA ?? 70), B: H - (b.rowB ?? 20) };
const k = (b.fontPx || 42) / 42; // chip furniture scales with the type
const r = (v) => Math.round(v * k);
const emPx = b.emPx || Math.round((b.fontPx || 42) * 1.24);
const maxHold = b.maxHold ?? 0.95;
// hero hand-off slot: the line whose window holds the apex keeps a star chip
const heroLi = heroInline ? -1 : LINES.findIndex((L) => heroIn >= L.in - 0.01 && heroIn < L.out);
const lineData = LINES.map((L, li) => {
const lastSt = L.words[L.words.length - 1].start;
const n = L.words.length + (li === heroLi ? 1 : 0);
// fold as the next line begins, or after maxHold in silence; the fold wave
// + slot hide always complete by DUR0.017 (demo cadence: L4 folds 5.76)
const foldT = +Math.min(L.out, lastSt + maxHold, DUR - 0.147 - 0.045 * (n - 1)).toFixed(3);
const words = L.words.map((w, wi) => {
const isLast = li === LINES.length - 1 && wi === L.words.length - 1;
const kind = isLast ? 2 : w.minor ? 1 : 0; // 0 plain | 1 emph | 2 emph-blue | 3 star
let wobT = 0,
wobD = 0,
strutT = 0;
if (kind === 1) {
// paper-flick after landing, squeezed to fit before the fold
const wt = +(w.start + 0.2).toFixed(3);
const wd = +Math.min(0.5, foldT - wt - 0.04).toFixed(2);
if (wd >= 0.15) {
wobT = wt;
wobD = wd;
}
}
if (kind >= 1) {
const st2 = +Math.min(w.start + (kind === 2 ? 0.15 : 0.26), foldT - 0.1).toFixed(3);
if (st2 >= w.start + 0.08) strutT = st2;
}
return [w.display, +w.start.toFixed(3), kind, wobT, wobD, strutT];
});
if (li === heroLi) {
const at = L.words.filter((w) => w.start < heroIn).length;
words.splice(at, 0, ["*", +heroIn.toFixed(3), 3, 0, 0, 0]);
}
return { id: "pu" + L.id, row: li % 2 === 0 ? "A" : "B", foldT, words };
});
const css = `
#pupage { position:absolute; inset:0; }
#pustrip { position:absolute; left:${W / 2}px; top:${H}px; width:${stripW}px; height:${stripH}px;
opacity:0; border-radius:4px 4px 0 0;
background: linear-gradient(180deg, ${dna.palette.page || "#f7eed8"} 0%, ${dna.palette.page2 || "#f0e4c8"} 55%, ${dna.palette.page3 || "#e7d8b4"} 100%);
border-top: 2px solid ${dna.palette.pageEdge || "rgba(122,96,54,0.55)"};
box-shadow: 0 -12px 26px rgba(18,12,5,0.38), inset 0 14px 18px rgba(255,255,248,0.5); }
#pufold { position:absolute; left:50%; top:0; bottom:0; width:3px; margin-left:-1px;
background: linear-gradient(90deg, rgba(120,95,55,0) 0%, rgba(120,95,55,0.30) 50%, rgba(255,252,240,0.5) 100%); }
.puline { position:absolute; left:${W / 2}px; white-space:nowrap; }
.puslot { display:inline-block; position:relative; margin-right:0.34em; vertical-align:baseline; }
.puw { display:inline-block; position:relative; z-index:1; opacity:0;
font-family:'${dna.fonts.body}', sans-serif; font-weight:600; font-size:${b.fontPx}px;
line-height:1.04; color:${dna.palette.body}; }
.puw.em { font-size:${emPx}px; font-weight:800; }
.puw.blue { color:${dna.palette.blue || dna.palette.accent}; }
.puw.star { width:${r(30)}px; height:${r(30)}px; background:${dna.palette.accent}; vertical-align:${-r(2)}px;
filter: drop-shadow(0 2px 2px rgba(60,40,15,0.35));
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%); }
.pustrut { position:absolute; right:-8px; bottom:0; width:${r(19)}px; height:${r(16)}px;
background: linear-gradient(180deg,${dna.palette.strut1 || "#4f93c4"} 0%, ${dna.palette.strut2 || dna.palette.accent} 70%);
clip-path: polygon(0 100%, 100% 100%, 100% 0); opacity:0; z-index:0; }`;
const html = ` <div id="pupage">
<div id="pustrip"><div id="pufold"></div></div>
${lineData.map((L) => ` <div class="puline" id="${L.id}"></div>`).join("\n")}
</div>`;
const js = `
// ---- body paradigm: POPUPRAIL (baseline-hinge pop-ups / fold-flat exit) ----
const RAILU = ${J(lineData)};
const ROWY = ${J(rowY)};
// ===== PAGE STRIP: unfolds up from the bottom edge (the stage is built) =====
gsap.set("#pustrip", { xPercent: -50, yPercent: -100, transformOrigin: "50% 100%", scaleY: 0.02 });
tl.set("#pustrip", { opacity: 1 }, 0.14);
tl.to("#pustrip", { scaleY: 1, duration: 0.24, ease: "back.out(1.5)" }, 0.14);
// hold life: the whole page breathes very gently
tl.to("#pupage", { keyframes: { y: [0, -1.6, -0.4, -1.4, 0] },
duration: ${Math.min(5.3, DUR - 0.75).toFixed(2)}, ease: "sine.inOut" }, 0.40);
// ===== BODY: words pop up on the baseline hinge =====
RAILU.forEach((L) => {
const line = document.getElementById(L.id);
line.style.top = ROWY[L.row] + "px";
const els = L.words.map(([txt, st, kind]) => {
const slot = document.createElement("span"); slot.className = "puslot";
let strut = null;
if (kind === 1 || kind === 2) {
strut = document.createElement("div"); strut.className = "pustrut";
slot.appendChild(strut);
}
const w = document.createElement("span");
w.className = "puw" + (kind === 1 ? " em" : kind === 2 ? " em blue" : kind === 3 ? " star" : "");
if (kind !== 3) w.textContent = txt;
slot.appendChild(w);
line.appendChild(slot);
return { w, strut, slot };
});
gsap.set(line, { xPercent: -50, yPercent: -100 });
L.words.forEach(([txt, st, kind, wobT, wobD, strutT], wi) => {
const { w, strut, slot } = els[wi];
gsap.set(w, { transformPerspective: 650, transformOrigin: "50% 100%", rotationX: 85 });
// POP UP — baseline hinge, paper overshoot; back-shadow grows as it rises
tl.set(w, { opacity: 1, textShadow: "0 0px 0px rgba(70,50,25,0)" }, st);
tl.to(w, { rotationX: 0, duration: 0.25, ease: "back.out(1.4)" }, st);
tl.to(w, { textShadow: "0 3px 5px rgba(70,50,25,0.38)", duration: 0.25, ease: "power1.out" }, st);
if (kind === 1 || kind === 2) { // contact squash on the big words
tl.set(w, { scaleX: 1.06, scaleY: 0.93 }, st + 0.12);
tl.to(w, { scaleX: 1, scaleY: 1, duration: 0.30, ease: "elastic.out(1, 0.4)" }, st + 0.16);
tl.set("#pustrip", { y: 1 }, st + 0.13); // the page feels the landing
tl.to("#pustrip", { y: 0, duration: 0.16, ease: "power2.out" }, st + 0.18);
}
if (wobT) tl.to(w, { keyframes: { rotationX: [0, -4, 2.8, -1.4, 0.5, 0] },
duration: wobD, ease: "sine.inOut" }, wobT);
if (strut && strutT) { // visible paper strut leans in behind
gsap.set(strut, { transformOrigin: "50% 100%", scaleY: 0 });
tl.set(strut, { opacity: 1 }, strutT);
tl.to(strut, { scaleY: 1, duration: 0.12, ease: "back.out(2)" }, strutT);
}
// EXIT — the page closes: words fold flat in an L→R wave
const ft = L.foldT + wi * 0.045;
tl.to(w, { rotationX: 88, duration: 0.12, ease: "power2.in" }, ft);
tl.to(w, { textShadow: "0 0px 0px rgba(70,50,25,0)", duration: 0.12 }, ft);
if (strut) tl.to(strut, { scaleY: 0, duration: 0.10, ease: "power2.in" }, ft);
tl.set(slot, { opacity: 0 }, ft + 0.13);
});
});
${
b.yield && !heroInline
? `
// APEX OWNS ITS WINDOW — page yields while the centerfold lands, restores
tl.to("#pupage", { opacity: ${b.yield.dim}, duration: 0.12, ease: "power1.out" }, ${(heroIn - (b.yield.pre || 0.03)).toFixed(3)});
tl.to("#pupage", { opacity: 1, duration: 0.22, ease: "power1.in" }, ${(heroIn + (b.yield.post || 0.47)).toFixed(3)});`
: ""
}
${
b.tilt !== false
? `
// paper-world physics: on the final word the whole page TILTS and slides
tl.to("#pupage", { rotation: 2, x: 6, transformOrigin: "50% 100%",
duration: 0.26, ease: "back.out(2.2)" }, ${(LASTWORD.start + 0.02).toFixed(3)});`
: ""
}
// ending: after the last fold the strip itself closes (book shut)
tl.to("#pustrip", { scaleY: 0.03, duration: 0.07, ease: "power3.in" }, ${(DUR - 0.082).toFixed(3)});
tl.set("#pustrip", { opacity: 0 }, ${(DUR - 0.017).toFixed(3)});`;
return { css, html, js };
}
function paradigmChalkrail() {
// CHALKRAIL: night-lecture chalk band — a deep-green board strip (worn top
// hairline, eraser smudges, handwritten header tag, two chalk sticks) docked
// at the bottom edge holds reading rows that ACCUMULATE: lines fill the rows
// top→bottom in phases of rows.length; when the next phase needs the rows an
// ERASER SWIPE clears the board (a blurred light band sweeps across, the
// words SMEAR out scaleX 1.3 + blur(8), faint ghost smudges stay — chalk
// never fully erases). Words are chalk-written fast: 2-frame pop + scribble
// clip-reveal steps(4) + 6 seeded dust specks puffing at the base + seeded
// ±2° tilt = the chalkwrite verb at low amplitude. Emphasis = double
// underline dashes drawn beneath; the FINAL transcript word lands in yellow
// chalk (sign-off). Band yields during the apex landing, restores after.
const b = dna.body;
const stripW = (b.strip && b.strip.w) || 1200;
const stripH = (b.strip && b.strip.h) || 148;
const SX = (b.strip && b.strip.x) ?? Math.round((W - stripW) / 2);
const ST = H - ((b.strip && b.strip.bottomPx) ?? 0) - stripH;
const rows = b.rows || [26, 82];
const R = rows.length;
const kw = stripW / 1200,
kh = stripH / 148; // furniture scales with the strip (demo 1200×148)
const lineData = LINES.map((L, li) => ({
id: "cl" + L.id,
top: rows[li % R],
words: L.words.map((w, wi) => [
w.display,
+w.start.toFixed(3),
w.minor ? 1 : 0,
li === LINES.length - 1 && wi === L.words.length - 1 ? 1 : 0, // yellow sign-off
]),
}));
// eraser passes: one per phase boundary — swipe after the previous phase's
// last word ends, always landing clear of the next phase's first word
const erases = [];
for (let pi = R; pi < LINES.length; pi += R) {
const prevEnd = Math.max(
...LINES.slice(pi - R, pi).map((L) => L.words[L.words.length - 1].end),
);
const nextIn = LINES[pi].in;
const t = +Math.max(
prevEnd + 0.05,
Math.min(prevEnd + (b.eraseDelay ?? 0.23), nextIn - 0.9),
).toFixed(3);
erases.push({ t, lines: lineData.slice(pi - R, pi).map((L) => L.id) });
}
const css = `
#cband { position:absolute; left:${SX}px; top:${ST}px; width:${stripW}px; height:${stripH}px; opacity:0;
background: linear-gradient(180deg, ${dna.palette.bandHi || "#23392d"} 0%, ${dna.palette.board || "#1d3328"} 45%, ${dna.palette.bandLo || "#16271f"} 100%);
border-radius:8px 8px 0 0;
box-shadow: inset 0 0 70px rgba(0,0,0,0.5), inset 0 14px 18px -12px rgba(0,0,0,0.6),
0 -3px 18px rgba(0,0,0,0.35); }
#cband .hairline { position:absolute; top:0; left:0; right:0; height:1px;
background: rgba(240,245,235,0.14); }
#cband .smg { position:absolute; border-radius:50%; }
#csmg1 { left:${Math.round(520 * kw)}px; top:${Math.round(14 * kh)}px; width:${Math.round(300 * kw)}px; height:${Math.round(90 * kh)}px; transform:rotate(-4deg);
background: radial-gradient(50% 50% at 50% 50%, rgba(220,230,220,0.07) 0%, rgba(220,230,220,0) 70%); }
#csmg2 { left:${Math.round(880 * kw)}px; top:${Math.round(60 * kh)}px; width:${Math.round(260 * kw)}px; height:${Math.round(74 * kh)}px; transform:rotate(5deg);
background: radial-gradient(50% 50% at 50% 50%, rgba(220,230,220,0.06) 0%, rgba(220,230,220,0) 70%); }
#chdr { position:absolute; top:5px; left:28px; font-family:'${dna.fonts.tag || dna.fonts.body}', cursive;
font-size:23px; letter-spacing:1.5px; color:rgba(238,244,236,0.5); opacity:0; }
#cstickW { position:absolute; right:30px; bottom:12px; width:52px; height:9px; border-radius:4px;
background: linear-gradient(180deg, #f4f1e4 0%, #d9d4c2 100%);
transform: rotate(9deg); box-shadow: 0 2px 3px rgba(0,0,0,0.4); opacity:0; }
#cstickY { position:absolute; right:96px; bottom:11px; width:27px; height:8px; border-radius:4px;
background: linear-gradient(180deg, #efd680 0%, #d8ba55 100%);
transform: rotate(-5deg); box-shadow: 0 2px 3px rgba(0,0,0,0.4); opacity:0; }
.crow { position:absolute; left:30px; white-space:nowrap;
font-family:'${dna.fonts.body}', cursive; font-size:${b.fontPx}px; line-height:1.0; color:${dna.palette.body};
text-shadow: ${b.glow || "0 0 7px rgba(250,250,240,0.28), 0 1px 0 rgba(255,255,255,0.10)"}; }
.crow .w { display:inline-block; position:relative; margin-right:0.35em; opacity:0; }
.crow .w .txt { display:inline-block; }
.crow .w.yel { color:${dna.palette.em || "#f3d96b"}; text-shadow: 0 0 8px ${(dna.palette.em || "#f3d96b") + "52"}, 0 1px 0 rgba(255,255,255,0.08); }
.crow .w .d { position:absolute; bottom:2px; width:3px; height:3px; border-radius:50%;
background:${dna.palette.dust || "#f1efe2"}; opacity:0; pointer-events:none; }
.crow .w svg.ul { position:absolute; left:2%; bottom:-8px; width:96%; height:18px; overflow:visible; }
.cghost { position:absolute; border-radius:50%; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, rgba(240,244,238,0.10) 0%, rgba(240,244,238,0) 70%); }
#cgh1 { left:${Math.round(110 * kw)}px; top:${Math.round(24 * kh)}px; width:${Math.round(430 * kw)}px; height:${Math.round(62 * kh)}px; }
#cgh2 { left:${Math.round(80 * kw)}px; top:${Math.round(80 * kh)}px; width:${Math.round(360 * kw)}px; height:${Math.round(56 * kh)}px; }
#ceraser { position:absolute; top:-8px; left:0; width:120px; height:${stripH + 16}px; opacity:0;
background: linear-gradient(100deg, rgba(236,242,236,0) 0%, rgba(236,242,236,0.30) 45%, rgba(236,242,236,0) 100%);
filter: blur(5px); }`;
const html = ` <div id="cband">
<div class="hairline"></div>
<div class="smg" id="csmg1"></div>
<div class="smg" id="csmg2"></div>
<div id="chdr">${esc(b.tag || "NIGHT LECTURE · wk 07")}</div>
<div class="cghost" id="cgh1"></div>
<div class="cghost" id="cgh2"></div>
${lineData.map((L) => ` <div class="crow" id="${L.id}" style="top:${L.top}px"></div>`).join("\n")}
<div id="cstickW"></div>
<div id="cstickY"></div>
<div id="ceraser"></div>
</div>`;
const js = `
// ---- body paradigm: CHALKRAIL (chalk scribble pop / eraser-swipe phases) ----
const RAILC = ${J(lineData)};
const ERASES = ${J(erases)};
const rrnd = mulberry32(${b.seed || 13});
const SVGNS = "http://www.w3.org/2000/svg";
// ===== band furniture =====
tl.fromTo("#cband", { opacity: 0, y: 26 }, { opacity: 1, y: 0, duration: 0.22, ease: "power2.out" }, 0.03);
tl.fromTo("#chdr", { opacity: 0 }, { opacity: 1, duration: 0.25 }, 0.30);
tl.fromTo("#cstickW", { opacity: 0 }, { opacity: 0.92, duration: 0.2 }, 0.36);
tl.fromTo("#cstickY", { opacity: 0 }, { opacity: 0.92, duration: 0.2 }, 0.42);
// ===== body words: chalk-dust pop (entrance = the apex motif, low amplitude) =====
RAILC.forEach((L) => {
const line = document.getElementById(L.id);
L.words.forEach(([txt, st, em, yel]) => {
const w = document.createElement("span"); w.className = "w" + (yel ? " yel" : "");
const inner = document.createElement("span"); inner.className = "txt"; inner.textContent = txt;
w.appendChild(inner); line.appendChild(w);
gsap.set(w, { rotation: (rrnd() * 4 - 2), transformOrigin: "50% 100%" });
gsap.set(inner, { clipPath: "inset(-30% 102% -30% -2%)" });
// 2-frame pop + fast scribble reveal + tiny drop-settle
tl.fromTo(w, { opacity: 0 }, { opacity: 1, duration: 0.07, ease: "power1.in" }, st);
tl.to(inner, { clipPath: "inset(-30% -2% -30% -2%)", duration: 0.12, ease: "steps(4)" }, st);
tl.fromTo(w, { y: 4 }, { y: 0, duration: 0.2, ease: "power2.out" }, st);
// 6 seeded dust specks puff at the word base
for (let k = 0; k < 6; k++) {
const d = document.createElement("span"); d.className = "d";
d.style.left = (6 + rrnd() * 84) + "%";
w.appendChild(d);
tl.set(d, { opacity: 0.85 }, st + 0.02);
tl.to(d, { y: 12 + rrnd() * 16, x: rrnd() * 16 - 8, opacity: 0,
duration: 0.36 + rrnd() * 0.15, ease: "power1.in" }, st + 0.02);
}
// emphasis: double underline dashes drawn beneath
if (em) {
const svg = document.createElementNS(SVGNS, "svg");
svg.setAttribute("class", "ul"); svg.setAttribute("viewBox", "0 0 100 18");
svg.setAttribute("preserveAspectRatio", "none");
const col = yel ? ${J(dna.palette.em || "#f3d96b")} : ${J(dna.palette.body)};
[["M 3 5 C 28 2, 66 8, 97 4", Math.min(st + 0.22, ${(DUR - 0.3).toFixed(3)}), 0.12],
["M 6 12 C 36 9, 62 15, 95 11", Math.min(st + 0.38, ${(DUR - 0.2).toFixed(3)}), 0.10]].forEach(([dd, tu, du]) => {
const p = document.createElementNS(SVGNS, "path");
p.setAttribute("d", dd); p.setAttribute("fill", "none");
p.setAttribute("stroke", col); p.setAttribute("stroke-width", "3");
p.setAttribute("stroke-linecap", "round");
p.setAttribute("vector-effect", "non-scaling-stroke");
p.setAttribute("opacity", "0");
svg.appendChild(p);
const len = p.getTotalLength();
gsap.set(p, { strokeDasharray: len, strokeDashoffset: len });
tl.set(p, { opacity: 0.9 }, tu);
tl.to(p, { strokeDashoffset: 0, duration: du, ease: "power2.in" }, tu);
});
w.appendChild(svg);
}
});
});
// ===== ERASER SWIPE between phases: light band sweeps, old words smear out =====
ERASES.forEach((E, ei) => {
tl.set("#ceraser", { opacity: 1, x: -160 }, E.t);
tl.to("#ceraser", { x: ${stripW + 60}, duration: 0.42, ease: "power1.inOut" }, E.t);
tl.set("#ceraser", { opacity: 0 }, E.t + 0.44);
E.lines.forEach((id, r) => {
const kids = document.getElementById(id).children;
for (let i = 0; i < kids.length; i++)
tl.to(kids[i], { scaleX: 1.3, x: "+=24", opacity: 0, filter: "blur(8px)",
transformOrigin: "0% 80%", duration: 0.18, ease: "power2.in" },
E.t + 0.03 + r * 0.01 + i * 0.06);
});
// faint smudges the eraser leaves behind (stay)
if (ei === 0) {
tl.fromTo("#cgh1", { opacity: 0 }, { opacity: 0.8, duration: 0.25 }, E.t + 0.17);
tl.fromTo("#cgh2", { opacity: 0 }, { opacity: 0.8, duration: 0.25 }, E.t + 0.27);
}
});
${
b.yield && !heroInline
? `
// APEX OWNS ITS WINDOW — band yields while the chalk word writes, restores
tl.to("#cband", { opacity: ${b.yield.dim}, duration: 0.14, ease: "power1.in" }, ${(heroIn - (b.yield.pre || 0.07)).toFixed(3)});
${
heroIn + (b.yield.post || 0.41) < DUR - 0.3
? ` tl.to("#cband", { opacity: 1, duration: 0.22, ease: "power1.out" }, ${(heroIn + (b.yield.post || 0.41)).toFixed(3)});`
: ""
}`
: ""
}`;
return { css, html, js };
}
function paradigmMarkerrail() {
// MARKERRAIL: street handstyle rail — marker words FLICK onto a stable
// bottom rail (opacity full in 1 frame + 1-frame squeak skew, seeded crooked
// rotation settling back.out — the spray-write verb at low amplitude);
// emphasis words take an alternating paint color (`palette.em`/`em2`) + a
// spray-underline SWOOSH (dash-revealed squiggle) with 8 seeded overspray
// dots; the FINAL transcript word lands BIG in `palette.em` with mini
// apex-impact physics (crush → squash → elastic — it rides the plate punch).
// Spent lines are ROLLER-BUFFED: a gray paint roller sweeps across, the
// words vanish under it and the wet smear lingers before fading; the last
// line is never buffed (the sign-off stays painted). Rail yields while the
// apex tag writes.
const b = dna.body;
const EMS = [
dna.palette.em || dna.palette.accent,
dna.palette.em2 || dna.palette.em || dna.palette.accent,
];
let emIdx = 0;
const lineData = LINES.map((L, li) => ({
id: "mr" + L.id,
in: +L.in.toFixed(3),
out: +L.out.toFixed(3),
last: li === LINES.length - 1 ? 1 : 0,
words: L.words.map((w, wi) => {
const signoff = li === LINES.length - 1 && wi === L.words.length - 1;
return [
w.display,
+w.start.toFixed(3),
signoff ? EMS[0] : w.minor ? EMS[emIdx++ % EMS.length] : 0,
signoff ? 1 : 0,
];
}),
}));
const css = `
.mline { position:absolute; left:${W / 2}px; top:${H - b.bottomPx}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.body}', cursive; font-size:${b.fontPx}px; line-height:1.25;
color:${dna.palette.body};
text-shadow: ${b.glow || "0 3px 12px rgba(0,0,0,0.72), 0 1px 3px rgba(0,0,0,0.6)"}; }
.mline .w { display:inline-block; opacity:0; margin-right:0.32em; position:relative; }
.mline .w:last-child { margin-right:0; }
.mroller { position:absolute; left:-14px; top:-8px; right:-14px; bottom:-8px; opacity:0;
background: linear-gradient(180deg, ${dna.palette.rollerHi || "#8d8a86"} 0%, ${dna.palette.rollerLo || "#6f6c69"} 45%, ${dna.palette.roller || "#807d79"} 100%);
border-radius:5px; }
.muline { position:absolute; left:-8%; top:100%; width:116%; height:26px; margin-top:-8px;
overflow:visible; }
.modot { position:absolute; width:5px; height:5px; border-radius:50%; opacity:0; }`;
const html = lineData.map((L) => ` <div class="mline" id="${L.id}"></div>`).join("\n");
const js = `
// ---- body paradigm: MARKERRAIL (marker flick in / roller buff out) ----
const RAILM = ${J(lineData)};
const DURM = ${(DUR - 0.02).toFixed(3)};
const mrnd = mulberry32(${b.seed || 20260611});
const SVGNSM = "http://www.w3.org/2000/svg";
function mUnderline(span, color, t0) {
// spray underline swoosh: dash-revealed squiggle + seeded overspray dots
const svg = document.createElementNS(SVGNSM, "svg");
svg.setAttribute("class", "muline");
svg.setAttribute("viewBox", "0 0 120 26");
svg.setAttribute("preserveAspectRatio", "none");
const path = document.createElementNS(SVGNSM, "path");
path.setAttribute("d", "M 5 13 C 28 20, 52 6, 78 12 S 108 16, 115 10");
path.setAttribute("fill", "none"); path.setAttribute("stroke", color);
path.setAttribute("stroke-width", "8"); path.setAttribute("stroke-linecap", "round");
path.setAttribute("opacity", "0");
svg.appendChild(path); span.appendChild(svg);
const len = path.getTotalLength();
gsap.set(path, { strokeDasharray: len, strokeDashoffset: len });
tl.set(path, { opacity: 0.95 }, t0);
tl.to(path, { strokeDashoffset: 0, duration: 0.2, ease: "power2.out" }, t0);
for (let i = 0; i < 8; i++) {
const d = document.createElement("div");
d.className = "modot"; d.style.background = color; span.appendChild(d);
const s = 3 + Math.round(mrnd() * 3);
gsap.set(d, { width: s, height: s, left: (4 + mrnd() * 92) + "%",
bottom: -(2 + mrnd() * 18) });
const td = t0 + 0.03 + mrnd() * 0.15;
tl.set(d, { opacity: 0.9, scale: 0.4 }, td);
tl.to(d, { scale: 1.25, duration: 0.12, ease: "power2.out" }, td);
tl.to(d, { opacity: 0, duration: 0.3, ease: "power1.in" }, td + 0.12);
}
}
RAILM.forEach((L) => {
const line = document.getElementById(L.id);
const txt = document.createElement("div");
txt.style.position = "relative"; line.appendChild(txt);
const roller = document.createElement("div");
roller.className = "mroller"; line.appendChild(roller);
gsap.set(roller, { scaleX: 0, transformOrigin: "0% 50%" });
L.words.forEach(([w]) => {
const s = document.createElement("span");
s.className = "w"; s.textContent = w; txt.appendChild(s);
});
gsap.set(line, { xPercent: -50, yPercent: -100 });
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.02);
L.words.forEach(([w, st, em, big], wi) => {
const el = txt.children[wi];
const rot0 = (mrnd() - 0.5) * 12, rotF = (mrnd() - 0.5) * 4;
// FLICK IN: opacity full in 1 frame, squeak skew for 1 frame, back.out settle
tl.set(el, { opacity: 1, scale: big ? 1.5 : 1.18, rotation: rot0, skewX: 7,
color: em || ${J(dna.palette.body)}, transformOrigin: "50% 88%" }, st);
tl.set(el, { skewX: 0 }, st + F);
if (big) { // sign-off lands with the plate punch — mini apex-impact physics
tl.to(el, { scale: 1, duration: 0.09, ease: "power3.in" }, st + F);
tl.set(el, { scaleX: 1.1, scaleY: 0.9 }, st + F + 0.09);
tl.to(el, { scaleX: 1, scaleY: 1, duration: 0.26, ease: "elastic.out(1, 0.4)" }, st + F + 0.09 + 2 * F);
tl.to(el, { rotation: rotF, duration: 0.2, ease: "power2.out" }, st + F);
} else {
tl.to(el, { scale: 1, rotation: rotF, duration: 0.18, ease: "back.out(2.2)" }, st + F);
}
if (em && !big) mUnderline(el, em, Math.min(st + 0.22, ${(DUR - 0.5).toFixed(3)}));
});
if (!L.last) { // BUFF EXIT: gray roller sweeps the line, smear lingers
const XO = L.out - 0.15;
tl.set(roller, { opacity: 0.92 }, XO);
tl.fromTo(roller, { scaleX: 0 }, { scaleX: 1, duration: 0.15, ease: "power2.in" }, XO);
tl.set(txt, { opacity: 0 }, XO + 0.15);
tl.to(roller, { opacity: 0.15, filter: "blur(2px)", duration: 0.22, ease: "power1.out" }, XO + 0.17);
tl.to(roller, { opacity: 0, duration: 0.35 }, Math.min(XO + 0.55, DURM - 0.36));
tl.set(line, { display: "none" }, Math.min(XO + 0.95, DURM));
}
});
${
b.yield && !heroInline
? ` // APEX OWNS ITS WINDOW — the line visible at the tag's onset yields.
// Restore only with runway before the buff (else the line exits dimmed).
RAILM.forEach((L) => {
if (L.in <= ${heroIn.toFixed(3)} && L.out > ${(heroIn - (b.yield.pre || 0.04)).toFixed(3)}) {
const dimT = Math.max(${(heroIn - (b.yield.pre || 0.04)).toFixed(3)}, L.in + 0.05);
const XO2 = L.out - 0.15;
if (XO2 > dimT + 0.2 || L.last) {
tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.16 }, dimT);
const resT = ${(heroIn + (b.yield.post || 0.17)).toFixed(3)};
if (L.last ? resT < DURM - 0.3 : resT + 0.15 < XO2)
tl.to("#" + L.id, { opacity: 1, duration: 0.18 }, resT);
}
}
});`
: ""
}`;
return { css, html, js };
}
function paradigmBrushrail() {
// BRUSHRAIL: washi paper band — a deckle-edged paper band (two faintly
// rotated under-sheets + a warm translucent main sheet) swipes in L→R behind
// a traveling brush-head shadow (the ink gesture at low amplitude); body
// words are brush-WIPED on (clip reveal 0.16s behind a per-word brush head)
// on centered replace-lines with seeded ±1° tilt; emphasis words get a
// double curved ink underline drawn beneath (fat wash + tight core); spent
// lines are wiped OFF right-to-left like a wet cloth (the reveal in reverse,
// an erase brush head riding the wipe); the band + the line under the
// gesture yield while the apex lands, and the band exits with the last line
// on one final R→L wipe.
const b = dna.body;
const stripW = (b.strip && b.strip.w) || 820;
const stripH = (b.strip && b.strip.h) || 90;
const SX = (b.strip && b.strip.x) ?? Math.round((W - stripW) / 2);
const ST = H - ((b.strip && b.strip.bottomPx) ?? 61) - stripH;
const CX = SX + Math.round(stripW / 2),
CY = ST + Math.round(stripH / 2);
const lineY = CY - 1;
const maxHold = b.maxHold ?? 0.78;
const lineData = LINES.map((L, li) => {
const lastSt = L.words[L.words.length - 1].start;
// erase: as the next line needs the surface, or after maxHold in silence;
// the LAST line is wiped with the band just before the clip ends
let eraseT, eraseEnd;
if (li === LINES.length - 1) {
eraseT = +(DUR - 0.142).toFixed(3);
eraseEnd = +(DUR - 0.032).toFixed(3);
} else {
eraseT = +Math.max(lastSt + 0.18, Math.min(L.out - 0.136, lastSt + maxHold)).toFixed(3);
eraseEnd = +Math.max(eraseT + 0.06, Math.min(L.out + 0.004, eraseT + 0.14)).toFixed(3);
}
return {
id: "br" + L.id,
eraseT,
eraseEnd,
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
};
});
const lastE = lineData[lineData.length - 1].eraseT;
const heroLi = heroInline ? -1 : LINES.findIndex((L) => heroIn >= L.in - 0.01 && heroIn < L.out);
const INKB = dna.palette.body || "#221a12"; // brush-head shadow = the body ink
const breD = Math.min(5.5, DUR - 0.55).toFixed(2);
const breN = Math.max(16, Math.round(breD * 11.6));
const css = `
#band { position:absolute; left:${CX}px; top:${CY}px; width:${stripW}px; height:${stripH}px; }
.bandu { position:absolute; inset:0; border-radius:8px;
background: linear-gradient(180deg, ${dna.palette.paperHi || "#efe8d7"} 0%, ${dna.palette.paperLo || "#e7dfcb"} 100%); }
#bandu1 { transform:rotate(-0.7deg) scale(1.012); opacity:0.42; }
#bandu2 { transform:rotate(0.5deg) scaleX(0.99); opacity:0.42; }
#bandmain { position:absolute; inset:0; border-radius:8px;
background: linear-gradient(180deg, ${dna.palette.sheetHi || "#f5efe2"}ed 0%, ${dna.palette.sheetLo || "#ece4d2"}ed 100%);
box-shadow: 0 5px 20px rgba(18,14,10,0.4), inset 0 0 26px rgba(120,98,70,0.18); }
.bandbh { position:absolute; top:-8%; width:30px; height:116%; opacity:0;
background: linear-gradient(90deg, ${dna.palette.bh || "#281e14"}00 0%, ${dna.palette.bh || "#281e14"}80 100%);
filter: blur(7px); }
.bline { position:absolute; left:${CX}px; top:${lineY}px; white-space:nowrap; opacity:0;
font-family:'${dna.fonts.body}', serif; font-weight:${b.weight || 600}; font-size:${b.fontPx}px;
line-height:1; color:${dna.palette.body};
text-shadow: 0 1px 2px rgba(70,48,26,0.22); }
.bline .cell { display:inline-block; position:relative; margin-right:0.3em; }
.bline .wrap { display:inline-block; position:relative; }
.bline .bh { position:absolute; top:-14%; width:26px; height:128%; opacity:0.55;
background: linear-gradient(90deg, ${INKB}00 0%, ${INKB}8c 100%);
filter: blur(6px); }
.bline svg.ul { position:absolute; left:-3%; bottom:-12px; width:106%; height:14px;
overflow:visible; }
.bline .ebh { position:absolute; top:-18%; width:34px; height:136%; opacity:0;
background: linear-gradient(270deg, ${INKB}00 0%, ${INKB}80 100%);
filter: blur(7px); }`;
const html = ` <div id="band">
<div class="bandu" id="bandu1"></div>
<div class="bandu" id="bandu2"></div>
<div id="bandmain"></div>
<div class="bandbh" id="bandbh"></div>
</div>
<div id="blines">
${lineData.map((L) => ` <div class="bline" id="${L.id}"></div>`).join("\n")}
</div>`;
const js = `
// ---- body paradigm: BRUSHRAIL (brush-wipe reveals / wet-cloth wipe exits) ----
const RAILB = ${J(lineData)};
const brnd = mulberry32(${b.seed || 20260611});
const SVGNSB = "http://www.w3.org/2000/svg";
const ULD1 = "M 5 8 C 30 11.5, 68 4, 95 7";
const ULD2 = "M 2 9 C 32 12.5, 66 4.5, 98 7.5";
// ===== BAND: swipes in L→R behind a brush head (the gesture, low amplitude) =====
gsap.set("#band", { xPercent: -50, yPercent: -50, clipPath: "inset(0% 100% 0% 0%)" });
tl.to("#band", { clipPath: "inset(0% 0% 0% 0%)", duration: 0.2, ease: "power2.out" }, 0.08);
tl.fromTo("#bandbh", { left: -30, opacity: 0.6 },
{ left: "100%", opacity: 0.6, duration: 0.2, ease: "power2.out" }, 0.08);
tl.set("#bandbh", { opacity: 0 }, 0.30);
// paper under light: the deckle under-sheet breathes faintly (baked f(t))
const bnB = ${breN}, bvB = [];
for (let k = 0; k <= bnB; k++) bvB.push(0.40 + 0.05 * Math.sin(2 * Math.PI * 0.32 * (k / bnB * ${breD})));
tl.to("#bandu1", { keyframes: { opacity: bvB }, duration: ${breD}, ease: "none" }, 0.3);
// ===== BODY: words brush-wiped on, lines wiped off like a wet cloth =====
RAILB.forEach((L) => {
const line = document.getElementById(L.id);
const rot = (brnd() - 0.5) * 2; // seeded ±1° tilt
L.words.forEach(([txt,, em]) => {
const cell = document.createElement("span"); cell.className = "cell";
const wrap = document.createElement("span"); wrap.className = "wrap";
const tx = document.createElement("span"); tx.textContent = txt;
const bh = document.createElement("div"); bh.className = "bh";
wrap.appendChild(tx); wrap.appendChild(bh); cell.appendChild(wrap);
if (em) { // double curved ink underline
const svg = document.createElementNS(SVGNSB, "svg");
svg.setAttribute("class", "ul"); svg.setAttribute("viewBox", "0 0 100 14");
svg.setAttribute("preserveAspectRatio", "none");
const mkp = (d, w, op) => {
const p = document.createElementNS(SVGNSB, "path");
p.setAttribute("d", d); p.setAttribute("fill", "none");
p.setAttribute("stroke", ${J(dna.palette.ulInk || dna.palette.body)});
p.setAttribute("stroke-width", w);
p.setAttribute("stroke-opacity", op); p.setAttribute("stroke-linecap", "round");
p.setAttribute("opacity", "0"); svg.appendChild(p); return p;
};
cell._ulFat = mkp(ULD1, 7, 0.3);
cell._ulCore = mkp(ULD2, 2.6, 0.95);
cell.appendChild(svg);
}
line.appendChild(cell);
});
const ebh = document.createElement("div"); ebh.className = "ebh";
line.appendChild(ebh);
gsap.set(line, { xPercent: -50, yPercent: -50, rotation: rot });
tl.set(line, { opacity: 1 }, L.words[0][1] - 0.02);
L.words.forEach(([txt, st, em], wi) => {
const cell = line.children[wi];
const wrap = cell.firstChild, bh = wrap.children[1];
gsap.set(wrap, { clipPath: "inset(-14% 100% -14% 0%)" });
tl.to(wrap, { clipPath: "inset(-14% 0% -14% 0%)", duration: 0.16, ease: "power2.out" }, st);
tl.fromTo(bh, { left: -26, opacity: 0.55 },
{ left: "100%", opacity: 0.55, duration: 0.16, ease: "power2.out" }, st);
tl.set(bh, { opacity: 0 }, st + 0.17);
if (em) {
[[cell._ulFat, 0.12], [cell._ulCore, 0.15]].forEach(([p, dly]) => {
const len = p.getTotalLength();
const tu = Math.min(st + dly, ${(DUR - 0.3).toFixed(3)});
gsap.set(p, { strokeDasharray: len, strokeDashoffset: len });
tl.set(p, { opacity: 1 }, tu);
tl.to(p, { strokeDashoffset: 0, duration: 0.22, ease: "power2.out" }, tu);
});
}
});
// EXIT: the reveal in reverse — a wet cloth wipes R→L, erase head riding
gsap.set(line, { clipPath: "inset(-30% 0% -30% 0%)" });
tl.to(line, { clipPath: "inset(-30% 100% -30% 0%)", duration: L.eraseEnd - L.eraseT,
ease: "power2.in" }, L.eraseT);
tl.fromTo(ebh, { left: "100%", opacity: 0.5 },
{ left: -34, opacity: 0.5, duration: L.eraseEnd - L.eraseT, ease: "power2.in" }, L.eraseT);
tl.set(ebh, { opacity: 0 }, L.eraseEnd + 0.01);
tl.set(line, { opacity: 0 }, L.eraseEnd + 0.02);
});
${
b.yield && !heroInline
? `
// APEX OWNS ITS WINDOW — band + the line under the gesture yield, band restores
tl.to(${heroLi >= 0 ? `["#band", "#br${heroLi}"]` : `"#band"`}, { opacity: ${b.yield.dim}, duration: 0.16 }, ${(heroIn - (b.yield.pre || 0.16)).toFixed(3)});
${
heroIn + (b.yield.post || 0.46) + 0.3 < DUR
? ` tl.to("#band", { opacity: 1, duration: 0.18 }, ${(heroIn + (b.yield.post || 0.46)).toFixed(3)});`
: ""
}`
: ""
}
// ending: the band itself is wiped off with the last line (one final R→L wipe)
tl.to("#band", { clipPath: "inset(0% 100% 0% 0%)", duration: 0.13, ease: "power2.in" }, ${lastE});
tl.fromTo("#bandbh", { left: "100%", opacity: 0.6 },
{ left: -30, opacity: 0.6, duration: 0.13, ease: "power2.in" }, ${lastE});
tl.set("#bandbh", { opacity: 0 }, ${Math.min(lastE + 0.14, DUR - 0.01).toFixed(3)});`;
return { css, html, js };
}
function paradigmInkrail() {
// INKRAIL: sumi ink on still water — up to three fixed reading ROWS at the
// bottom center (rows cycle in pairs, the closing line takes the third row,
// so the final thought ACCUMULATES instead of replacing) where Mincho-serif
// ink words BLEED in over a paper-white halo glow (opacity snaps in 2
// frames; blur 12→0 + scale 1.25→1 carry the bleed — the apex bloom at low
// amplitude) while a duplicate blurred dark halo overshoots then tightens;
// an ink-settling RULE (gradient hairline) draws beneath each line;
// emphasis words shed a faint ink wisp curling off the word top; spent
// lines RE-DISSOLVE (blur up + spread + fade + slight rise — ink dispersing
// back into the water) as the line-after-next approaches (= the next
// occupant of their row); the closing lines persist to clip end; the rows
// visible under the bloom yield while the drop blooms.
const b = dna.body;
const rowGap = b.rowGap ?? 56;
const bottomPx = b.bottomPx ?? 36; // center of the LAST reading row
const n = LINES.length;
const base = n >= 3 ? 0 : 3 - n; // few lines sink to the lower rows
const lineData = LINES.map((L, li) => {
const slot = li === n - 1 && n >= 3 ? 2 : base + (li % 2);
let outT = null,
outDur = 0;
if (li + 2 < n) {
// re-dissolve as the line-after-next approaches; never dissolve mid-read
outDur = +Math.max(0.35, 0.5 - 0.05 * li).toFixed(2);
outT = +Math.max(
L.words[L.words.length - 1].start + 0.25,
LINES[li + 2].words[0].start - outDur - 0.05,
).toFixed(3);
}
return {
id: "ik" + L.id,
in: +L.in.toFixed(3),
y: H - bottomPx - (2 - slot) * rowGap,
outT,
outDur,
words: L.words.map((w) => [w.display, +w.start.toFixed(3), w.minor ? 1 : 0]),
};
});
const INK = dna.palette.body;
const PAP = dna.palette.paper || "#fffaee",
PAP2 = dna.palette.paper2 || "#fff8e8";
const css = `
.irow { position:absolute; left:${W / 2}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.body}', serif; font-weight:${b.weight || 400};
font-size:${b.fontPx}px; line-height:1.25; letter-spacing:${b.letterSpacing || "0.055em"};
color:${INK}; }
.irow .w { display:inline-block; position:relative; margin-right:0.34em; }
.irow .w:last-child { margin-right:0; }
.irow .ink { position:relative; display:inline-block; opacity:0;
text-shadow: 0 0 5px ${PAP}, 0 0 14px ${PAP}f2, 0 0 30px ${PAP2}d9, 0 1px 2px ${PAP}e6; }
.irow .halo { position:absolute; left:0; top:0; opacity:0; color:${INK}; filter:blur(9px); }
.irule { position:absolute; left:50%; bottom:-7px; width:86%; height:2px;
margin-left:-43%; opacity:0; transform-origin:50% 50%;
background: linear-gradient(90deg, ${INK}00 0%, ${INK}8c 18%, ${INK}8c 82%, ${INK}00 100%);
box-shadow: 0 1px 7px ${PAP}8c; filter: blur(0.8px); }
.iwisp { position:absolute; left:50%; top:-16px; width:22px; height:28px;
margin-left:-11px; opacity:0; border:3px solid transparent;
border-top-color:${INK}8c; border-radius:50%; filter:blur(2px); }`;
const html = lineData
.map((L) => ` <div class="irow" id="${L.id}" style="top:${L.y}px"></div>`)
.join("\n");
const js = `
// ---- body paradigm: INKRAIL (ink bleed-in / re-dissolve exits) ----
const RAILI = ${J(lineData)};
const IKLATE = ${(DUR - 0.84).toFixed(3)}; // late words bleed faster (resolve before clip end)
RAILI.forEach((L, li) => {
const line = document.getElementById(L.id);
const rule = document.createElement("div"); rule.className = "irule"; line.appendChild(rule);
L.words.forEach(([txt]) => {
const w = document.createElement("span"); w.className = "w";
const halo = document.createElement("span"); halo.className = "halo"; halo.textContent = txt;
const ink = document.createElement("span"); ink.className = "ink"; ink.textContent = txt;
w.appendChild(halo); w.appendChild(ink); line.appendChild(w);
});
gsap.set(line, { xPercent: -50, yPercent: -50 });
tl.set(line, { opacity: 1 }, L.in);
L.words.forEach(([txt, st, em], wi) => {
const w = line.children[wi + 1]; // children[0] is the rule
const halo = w.children[0], ink = w.children[1];
// BLEED IN: opacity snaps (2 frames); blur 12→0 + scale 1.25→1 carry the
// bleed (late words bleed faster so they fully resolve before clip end)
const late = st > IKLATE;
tl.fromTo(ink, { opacity: 0 }, { opacity: 1, duration: 0.083, ease: "none" }, st);
tl.fromTo(ink, { filter: "blur(12px)", scale: 1.25 },
{ filter: "blur(0px)", scale: 1, duration: late ? 0.4 : 0.6, ease: "power2.out",
transformOrigin: "50% 60%" }, st);
// the dark halo overshoots then tightens (the bloom motif, low amplitude)
tl.fromTo(halo, { opacity: em ? 0.55 : 0.5, scale: 1.5 },
{ scale: 1.04, opacity: em ? 0.3 : 0.25, duration: late ? 0.5 : 0.7,
ease: "power2.out", transformOrigin: "50% 60%" }, st);
// emphasis: a faint ink wisp curls off the word top (skipped near clip end)
if (em && st + 1.55 <= ${(DUR - 0.03).toFixed(3)}) {
const wsp = document.createElement("div"); wsp.className = "iwisp"; w.appendChild(wsp);
const dir = li % 2 === 0 ? 1 : -1;
tl.set(wsp, { opacity: 0.5, transformOrigin: "50% 50%" }, st + 0.55);
tl.fromTo(wsp, { y: 0, x: 0, rotation: 12 * dir, scaleY: 1 },
{ y: -38, x: 10 * dir, rotation: 92 * dir, scaleY: 1.55,
duration: 1.0, ease: "power1.out" }, st + 0.55);
tl.to(wsp, { opacity: 0, duration: 0.42, ease: "power1.in" }, st + 1.12);
}
});
// ink-settling rule beneath the line
tl.set(rule, { opacity: 0.55 }, L.words[0][1] + 0.12);
tl.fromTo(rule, { scaleX: 0 }, { scaleX: 1, duration: 0.9, ease: "power2.out" },
L.words[0][1] + 0.12);
// EXIT: re-dissolve — blur up + spread + fade + slight rise, ink dispersing
// (the closing lines persist — the final thought stays readable)
if (L.outT !== null) {
tl.to(line, { filter: "blur(9px)", scale: 1.13, opacity: 0, y: -8,
duration: L.outDur, ease: "power1.in", transformOrigin: "50% 50%" }, L.outT);
tl.set(line, { display: "none" }, L.outT + L.outDur + 0.02);
}
});
${
b.yield && !heroInline
? `
// rows visible under the bloom yield while the drop blooms, restore after.
// Overlap guards: dim only after the line is IN with runway before its exit;
// restore only with clear runway (else the line exits dimmed — one owner).
RAILI.forEach((L) => {
const dimT = ${(heroIn + (b.yield.delay ?? 0.12)).toFixed(3)};
if (L.in < dimT - 0.05 && (L.outT === null || L.outT > dimT + 0.35)) {
tl.to("#" + L.id, { opacity: ${b.yield.dim}, duration: 0.3, ease: "power1.out" }, dimT);
const resT = ${(heroIn + (b.yield.post ?? 1.07)).toFixed(3)};
if (L.outT === null || resT + 0.33 < L.outT)
tl.to("#" + L.id, { opacity: 1, duration: 0.3, ease: "power1.out" }, resT);
}
});`
: ""
}`;
return { css, html, js };
}
// ---- ransom shared pieces: the cutout-chip recipes and the seeded paper-tear
// polygon are ONE paper stock used by both layers (ransomrail body chips in the
// fg, ransomnote letter chips in the bg) — emitted into each page separately
function ransomChipCss(ls) {
const p = dna.palette;
return `
.ca { background:${p.paper || "#f5f1e4"}; color:${p.paperInk || "#17130d"}; font-family:'${dna.fonts.body}', sans-serif; letter-spacing:${ls}; }
.cb { background:${p.dark || "#141414"}; color:${p.darkInk || "#f7f5ef"}; font-family:'${dna.fonts.dark}', sans-serif; font-weight:800; }
.cc { background:${p.news || "#d8d3c5"}; color:${p.newsInk || "#262017"}; font-family:'${dna.fonts.news}', monospace; }
.cd { background:${p.accent || "#ffd23f"}; color:${p.accentInk || "#1c160c"}; font-family:'${dna.fonts.accent}', cursive; }`;
}
// seeded 12-vertex tear polygon — j() call order matches the demo exactly so
// identical seeds reproduce the demo's torn edges
const RANSOM_TEAR = `function tearPoly(r){
const j = () => +(r()*5).toFixed(1);
const p = [[j(),j()],[33,j()],[66,j()],[100-j(),j()],[100-j(),33],[100-j(),66],
[100-j(),100-j()],[66,100-j()],[33,100-j()],[j(),100-j()],[j(),66],[j(),33]];
return "polygon(" + p.map(q => q[0] + "% " + q[1] + "%").join(",") + ")";
}`;
function paradigmRansomrail() {
// RANSOMRAIL: kidnapper's collage — every transcript word is a cutout chip
// in one of 4 cycled recipes (paper/Anton, black/Inter-800, newsprint/
// Special Elite, accent/Permanent Marker; emphasis words snap to the accent
// chip) PASTED along the bottom rail line with seeded crookedness, seeded
// ±px sizes and misaligned baselines — the paste is the apex slam motif at
// low amplitude (1f appear at scale 1.12 → power3.in crush → 2-frame contact
// squash → elastic settle). Emphasis = 1-frame RE-PASTE lift (shadow grows,
// rotation flips sign for good); exits = chips RIPPED off one by one with
// 1-frame paper-tear flashes under each; lines breathe through their hold;
// the whole railwrap yields while the apex letters slam behind the subject.
const b = dna.body;
const last = LINES.length - 1;
const lineData = LINES.map((L, li) => {
const n = L.words.length;
const stag = li === last ? 0.04 : 0.045;
const ripDur = li === last ? 0.12 : 0.14;
const lastSt = L.words[n - 1].start;
// rip cadence (demo): chips tear off just before the next line pastes —
// the rip tail overlaps the new line a beat (collage chaos, by design);
// a line whose last word just spoke waits lastWord+0.14 first
let ripStart =
li === last
? Math.min(L.out - 0.04, DUR - 0.06 - ripDur - stag * (n - 1))
: Math.max(lastSt + 0.14, LINES[li + 1].in - ripDur - stag * (n - 1) + 0.05);
ripStart = +Math.min(ripStart, DUR - 0.06 - ripDur - stag * (n - 1)).toFixed(3);
const words = L.words.map((w, wi) => {
const st = +w.start.toFixed(3);
const R = +Math.max(ripStart + stag * wi, st + 0.12).toFixed(3);
// re-paste lift: needs its own window before the chip's rip
let emphT = w.minor ? +Math.min(st + (b.emphDelay ?? 0.23), R - 0.27).toFixed(3) : 0;
if (emphT && emphT < st + 0.08) emphT = 0;
return [w.display, st, emphT, R, w.minor ? 1 : 0];
});
// hold life: gentle collage breathe on the whole line (demo cadence)
const bs = +(L.words[0].start + 0.08).toFixed(3);
const bd = +Math.min(1.8, ripStart - 0.03 - bs).toFixed(2);
return { id: "rr" + L.id, in: +L.in.toFixed(3), ripDur, words, bs, bd: bd >= 0.3 ? bd : 0 };
});
const css = `
#rrailwrap { position:absolute; inset:0; }
.rline { position:absolute; left:${W / 2}px; top:${H - b.bottomPx}px; white-space:nowrap; }
.rline .w { display:inline-block; position:relative; margin:0 5px; }
.rflash { position:absolute; inset:-4px; background:${dna.palette.flash || "#fffdf2"}; opacity:0; z-index:0; }
.rmove { display:inline-block; position:relative; z-index:1; opacity:0;
filter: drop-shadow(0 5px 0 rgba(8,6,4,0.5)); }
.rchip { display:inline-block; line-height:1.05; padding:0.08em 0.16em 0.11em;
text-transform:${b.textTransform || "uppercase"}; }
${ransomChipCss(b.letterSpacing || "0.02em")}`;
const html = ` <div id="rrailwrap">
${lineData.map((L) => ` <div class="rline" id="${L.id}"></div>`).join("\n")}
</div>`;
const js = `
// ---- body paradigm: RANSOMRAIL (chips pasted crooked / ripped off with tear flashes) ----
const RAILR = ${J(lineData)};
const crnd = mulberry32(${b.seed || 3940});
${RANSOM_TEAR}
const HARDR = "drop-shadow(0 5px 0 rgba(8,6,4,0.5))";
const LIFTR = "drop-shadow(0 14px 3px rgba(8,6,4,0.35))";
const RECS = ["ca", "cb", "cc", "cd"];
let recC = 0, recPrev = -1; // recipe cycle: emphasis snaps to the accent chip,
function nextRec(em) { // never two identical chips in a row
let rec = em ? 3 : recC % 4;
if (rec === recPrev) { recC++; rec = recC % 4; }
if (!em) recC++;
recPrev = rec;
return rec;
}
RAILR.forEach((L, li) => {
const line = document.getElementById(L.id);
gsap.set(line, { xPercent: -50, yPercent: -100 });
L.words.forEach(([txt, st, emphT, R, em], wi) => {
const w = document.createElement("div"); w.className = "w";
const fl = document.createElement("div"); fl.className = "rflash";
fl.style.clipPath = tearPoly(crnd);
const mv = document.createElement("div"); mv.className = "rmove";
const chip = document.createElement("div"); chip.className = "rchip " + RECS[nextRec(em)];
chip.style.fontSize = (${b.fontPx} - 2 + Math.round(crnd() * 3) + (em ? 3 : 0)) + "px";
chip.style.clipPath = tearPoly(crnd);
chip.textContent = txt;
mv.appendChild(chip); w.appendChild(fl); w.appendChild(mv); line.appendChild(w);
// seeded crookedness: rot sign alternates with line+word parity (demo),
// baseline shove dy leans the other way
const sgn = (li + wi) % 2 === 0 ? -1 : 1;
const rot = sgn * (2 + crnd() * 2.5);
const dy = -sgn * (1 + crnd() * 2);
// PASTE = apex slam motif at low amplitude
tl.set(mv, { opacity: 1, scale: 1.12, rotation: rot * 1.5, y: dy - 8 }, st);
tl.to(mv, { scale: 1, rotation: rot, y: dy, duration: 0.08, ease: "power3.in" }, st + 0.005);
tl.set(mv, { scaleX: 1.05, scaleY: 0.95 }, st + 0.09);
tl.to(mv, { scaleX: 1, scaleY: 1, duration: 0.22, ease: "elastic.out(1, 0.4)" }, st + 0.173);
// EMPHASIS = re-paste: 1-frame lift (shadow grows), rotation flips sign for good
if (emphT) {
tl.set(mv, { y: dy - 7, scale: 1.08, rotation: -rot, filter: LIFTR }, emphT);
tl.to(mv, { y: dy, scale: 1, duration: 0.09, ease: "power3.in" }, emphT + 0.083);
tl.set(mv, { filter: HARDR }, emphT + 0.175);
tl.set(mv, { scaleX: 1.04, scaleY: 0.96 }, emphT + 0.178);
tl.to(mv, { scaleX: 1, scaleY: 1, duration: 0.16, ease: "elastic.out(1, 0.4)" }, emphT + 0.262);
}
// EXIT = ripped off, paper-tear flash 1 frame under the chip
tl.set(fl, { opacity: 0.85 }, R);
tl.set(fl, { opacity: 0 }, R + 0.045);
tl.to(mv, { rotation: rot + (wi % 2 ? 16 : -17), y: dy - 30, opacity: 0,
duration: L.ripDur, ease: "power2.in" }, R);
});
if (L.bd) tl.to(line, { keyframes: { scale: [1, 1.015, 1.005, 1.016, 1] },
duration: L.bd, ease: "none" }, L.bs);
});
${
b.yield && !heroInline
? ` // rail yields while the apex letters slam behind the subject (container
// opacity — never contests the per-chip channels)
tl.to("#rrailwrap", { opacity: ${b.yield.dim}, duration: 0.12 }, ${Math.max(0.05, heroIn - (b.yield.pre ?? 0.02)).toFixed(3)});
${
heroIn + (b.yield.post ?? 0.71) < DUR - 0.3
? ` tl.to("#rrailwrap", { opacity: 1, duration: 0.2 }, ${(heroIn + (b.yield.post ?? 0.71)).toFixed(3)});`
: ""
}`
: ""
}`;
return { css, html, js };
}
const PARADIGMS = {
rail: paradigmRail,
panel: paradigmPanel,
poem: paradigmPoem,
takeover: paradigmTakeover,
lastpage: paradigmLastpage,
flaprail: paradigmFlaprail,
ledboard: paradigmLedboard,
vhsrail: paradigmVhsrail,
hudrail: paradigmHudrail,
carbonstrip: paradigmCarbonstrip,
laserrail: paradigmLaserrail,
stormrail: paradigmStormrail,
holorail: paradigmHolorail,
planktonrail: paradigmPlanktonrail,
sheenrail: paradigmSheenrail,
scoperail: paradigmScoperail,
paperrail: paradigmPaperrail,
popuprail: paradigmPopuprail,
chalkrail: paradigmChalkrail,
markerrail: paradigmMarkerrail,
brushrail: paradigmBrushrail,
inkrail: paradigmInkrail,
ransomrail: paradigmRansomrail,
};
function setpieceCpslam() {
// CP-language EMBED climax: acid-yellow word slams in BEHIND the subject with
// REAL notch cuts (SVG mask = true transparency over footage), a chromatic
// split that settles to a PERMANENT registration error, seeded glitch ticks,
// and a glitch-out exit. The frame stays footage — the word is set dressing.
const h = dna.hero,
p = h.params || {},
I = heroIn;
const YEL = dna.palette.body || "#FCEE0A",
CYN = "#00F0FF",
RED = "#FF003C";
const srnd = (() => {
let a = p.seed || 2077;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
})();
const DISP = heroText.toUpperCase(),
hpx = HG.fontPx;
const halfW = HG.halfW || (DISP.length * 0.56 * hpx) / 2;
const BW = Math.round(halfW * 2 + 140),
BH = Math.round(hpx * 1.5);
const CX = BW - 80 > W ? W / 2 : Math.max(BW / 2 - 40, Math.min(W - BW / 2 + 40, HG.x)),
CY = HG.y;
// seeded diagonal cuts through the letter band (precision is not the point —
// the cuts read as stencil damage wherever they land)
const nN = Math.max(2, Math.floor(DISP.length / 2.2));
const used = [];
while (used.length < nN) {
const i = Math.floor(srnd() * DISP.length);
if (!used.includes(i)) used.push(i);
}
const notches = used
.map((i, k) => {
const cx = BW / 2 - halfW + (i + 0.5) * 0.56 * hpx + (srnd() - 0.5) * 8;
const cy = BH / 2 + (k % 2 ? 0.24 : -0.27) * hpx + (srnd() - 0.5) * 10;
return `<rect x="${(cx - hpx * 0.12).toFixed(0)}" y="${(cy - hpx * 0.038).toFixed(0)}" width="${(hpx * 0.24).toFixed(0)}" height="${(hpx * 0.076).toFixed(0)}" fill="black" transform="rotate(-45 ${cx.toFixed(0)} ${cy.toFixed(0)})"/>`;
})
.join("\n ");
const lay = (id, fill) =>
`<g id="${id}"><rect x="0" y="0" width="${BW}" height="${BH}" fill="${fill}" mask="url(#cpm)"/></g>`;
const css = `
#cpband { position:absolute; left:0; top:-80px; width:100%; height:70px; opacity:0;
background: linear-gradient(180deg, rgba(0,240,255,0) 0%, rgba(0,240,255,0.4) 50%, rgba(0,240,255,0) 100%); }
#cps { position:absolute; left:${CX}px; top:${CY}px; width:0; height:0; opacity:0; }
#cpsW { position:absolute; left:0; top:0; transform:translate(-50%,-50%);
filter: drop-shadow(0 4px 18px rgba(0,0,0,0.5)); }
#cpkana { position:absolute; left:0; top:-${Math.round(hpx * 0.82)}px; transform:translate(-50%,-50%);
font-family:'Hiragino Sans','Hiragino Kaku Gothic ProN',sans-serif; font-weight:600;
font-size:20px; letter-spacing:0.5em; text-indent:0.5em; color:${YEL}; opacity:0;
white-space:nowrap; text-shadow: 0 2px 8px rgba(0,0,0,0.7); }`;
const html = ` <div id="cpband"></div>
<div id="cps">
<svg id="cpsW" width="${BW}" height="${BH}" viewBox="0 0 ${BW} ${BH}">
<defs><mask id="cpm">
<text x="${BW / 2}" y="${BH / 2}" text-anchor="middle" dominant-baseline="central"
font-family="'${dna.fonts.hero}', sans-serif" font-size="${hpx}"
letter-spacing="${(hpx * 0.04).toFixed(1)}" fill="white">${esc(DISP)}</text>
${notches}
</mask></defs>
${lay("cpC", CYN)}
${lay("cpR", RED)}
${lay("cpY", YEL)}
</svg>
<div id="cpkana">${esc(p.kana || "サイバーパンク")}</div>
</div>`;
// a climax is an EVENT: on long clips holding to clip-end turns it into
// wallpaper. Default hold ~2.6s after the slam; hero.exitAt / params.hold override.
const EX = theme.hero.exitAt ?? Math.min(heroOut - 0.2, I + (p.hold ?? 2.6));
const js = `
// ---- setpiece: CPSLAM (band charge -> crush slam -> permanent registration -> glitch-out) ----
const I = ${I.toFixed(3)};
const crnd = mulberry32(${p.seed || 2077});
tl.set("#cpband", { opacity: 1 }, I - 0.35);
tl.fromTo("#cpband", { y: 0 }, { y: ${H + 160}, duration: 0.36, ease: "power1.in" }, I - 0.35);
tl.set("#cpband", { opacity: 0 }, I + 0.01);
// crush slam (weighted), chromatic layers split wide on impact
tl.set("#cps", { opacity: 1 }, I - 0.04);
tl.fromTo("#cpsW", { scale: 2.6, filter: "blur(9px) brightness(2.2) drop-shadow(0 4px 18px rgba(0,0,0,0.5))" },
{ scale: 1, filter: "blur(0px) brightness(1) drop-shadow(0 4px 18px rgba(0,0,0,0.5))", duration: 0.11, ease: "power4.in" }, I - 0.04);
tl.set("#cpsW", { scaleX: 1.09, scaleY: 0.93 }, I + 0.08);
tl.to("#cpsW", { scaleX: 1, scaleY: 1, duration: 0.46, ease: "elastic.out(1.05, 0.36)" }, I + 0.16);
// SVG <g> children move via attr.transform ONLY (banned: gsap x/y component
// props on SVG children — getBBox parse path). Frame-quantized step chain
// replaces the 0.16s recoil tween (identical on screen at 24fps).
const RG = (dx, dy, at) => { tl.set("#cpC", { attr: { transform: "translate(" + dx + " " + dy + ")" } }, at); tl.set("#cpR", { attr: { transform: "translate(" + (-dx) + " " + (-dy) + ")" } }, at); };
RG(9, 0, I - 0.04);
RG(6, 0, I + 0.02); RG(3, 0, I + 0.02 + F); RG(1, 0, I + 0.02 + 2 * F); RG(0, 0, I + 0.02 + 3 * F);
// ...then the print settles WRONG: permanent registration error (the look)
RG(2.5, -1, I + 0.3);
tl.fromTo("#cpkana", { opacity: 0 }, { opacity: 0.92, duration: 0.18 }, I + 0.22);
// living word: seeded glitch ticks (registration jolts / 1f slips)
let gt = I + 0.62;
while (gt < ${EX.toFixed(3)} - 0.25) {
if (crnd() < 0.55) {
RG(8, -1, gt); RG(2.5, -1, gt + F);
} else {
tl.set("#cps", { x: (crnd() - 0.5) * 10 }, gt);
tl.set("#cps", { x: 0 }, gt + F);
}
gt += 0.45 + crnd() * 0.6;
}
tl.to("#cpsW", { scale: 1.035, duration: 1.1, ease: "power1.inOut" }, I + 0.72);
// EXIT: glitch-out
const E = ${EX.toFixed(3)};
tl.set("#cpkana", { opacity: 0 }, E);
RG(15, -1, E);
tl.set("#cps", { x: -7 }, E + F);
tl.set("#cps", { opacity: 0, display: "none" }, E + 2 * F);`;
return { css, html, js };
}
function setpieceCoverword() {
// CP2077 COVER-LETTERFORM slam, precision pass: the spoken apex word set in
// the replica typeface of the official mark (assets/brand/CyberpunkReplica.ttf
// — lowercase glyphs carry the logo's actual brush chops, blade terminals and
// spikes), in logo case (First-upper). The setpiece adds only what the FONT
// does not carry: the solid cyan duplicate offset down-left, the baseline
// streak + cyan pixel debris, the circuit trace off the tail, and the
// tear-in/living-print/tear-out choreography. No synthetic letter surgery.
const h = dna.hero,
p = h.params || {},
I = heroIn;
const YEL = dna.palette.hot || "#FCEE0A",
CYN = dna.palette.accent || "#52BEDC";
const srnd = (() => {
let a = p.seed || 77;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
})();
const CPM = JSON.parse(
fs.readFileSync(path.join(SKILL, "assets/brand/cyberpunk-widths.json"), "utf8"),
);
const fontB64 = fs
.readFileSync(path.join(SKILL, "assets/brand/CyberpunkReplica.ttf"))
.toString("base64");
const DISP = HG.coverDisp || heroText[0].toUpperCase() + heroText.slice(1).toLowerCase();
const hpx = HG.fontPx;
const em =
HG.coverEm ||
[...DISP].reduce((a, c) => a + (CPM.widths[c] || 0.7), 0) + 0.01 * (DISP.length - 1);
const INK = HG.coverInk || { inkTop: 0.5, inkBot: -0.25, inkH: 0.75 };
const IH = INK.inkH * hpx; // visual ink height (px)
const Wd = em * hpx;
const BW = Math.round(Wd + 2.4 * hpx),
BH = Math.round(IH + 0.7 * hpx);
// baseline placed so the measured ink box is vertically centered
const baseY = Math.round(BH / 2 + ((INK.inkTop + INK.inkBot) / 2) * hpx);
const feetY = Math.round(baseY - INK.inkBot * hpx); // lowest ink (px, y-down)
const x0 = Math.round((BW - Wd) / 2),
x1 = Math.round(x0 + Wd);
const CX = BW - 120 > W ? W / 2 : Math.max(BW / 2 - 60, Math.min(W - BW / 2 + 60, HG.x)),
CY = HG.y;
const P = (pts, fill) =>
`<polygon points="${pts.map((q) => q[0].toFixed(0) + "," + q[1].toFixed(0)).join(" ")}" fill="${fill}"/>`;
// baseline streak: thin brush drag just under the glyph feet, pointed right
// tip past the tail (the font's own C/K blades carry the rest of the energy)
let extras = "";
// streak band MERGES with the glyph feet (official: letters melt into it)
const sT = feetY - 0.075 * IH,
sB = feetY + 0.012 * IH;
extras += P(
[
[x0 - 0.16 * hpx, sT + 2],
[x1 + 0.1 * hpx, sT],
[x1 + 0.55 * hpx, sB - 1],
[x0 - 0.05 * hpx, sB],
],
"white",
);
let cuts = "";
for (let k = 0; k < 3; k++) {
const gx = x0 + (0.1 + srnd() * 0.8) * Wd,
gw = (0.05 + srnd() * 0.1) * hpx;
cuts += P(
[
[gx, sT - 1],
[gx + gw, sT - 1],
[gx + gw, sB + 1],
[gx, sB + 1],
],
"black",
);
}
// cyan pixel debris along the streak
let debris = "";
for (let k = 0; k < 8; k++) {
const dx = x0 - 0.3 * hpx + srnd() * (Wd + 0.9 * hpx),
dw = (0.06 + srnd() * 0.3) * hpx;
const dy = sT - 2 + srnd() * (sB - sT + 4);
debris += `<rect x="${dx.toFixed(0)}" y="${dy.toFixed(0)}" width="${dw.toFixed(0)}" height="${(1.5 + srnd() * 2.5).toFixed(1)}" fill="${CYN}" opacity="0.85"/>`;
}
// circuit trace off the tail (the official mark's cyan trace language)
const tx = x1 + 0.25 * hpx,
ty = feetY + 0.14 * IH;
const trace = `<g id="cwT" opacity="0">
<path d="M ${x0 + Wd * 0.45} ${ty} H ${tx} l ${0.14 * hpx} ${0.07 * hpx} h ${0.3 * hpx}" stroke="${CYN}" stroke-width="2" fill="none"/>
<circle cx="${(tx + 0.05 * hpx).toFixed(0)}" cy="${ty.toFixed(0)}" r="3.5" fill="none" stroke="${CYN}" stroke-width="2"/>
<circle cx="${(tx + 0.44 * hpx).toFixed(0)}" cy="${(ty + 0.07 * hpx).toFixed(0)}" r="3.5" fill="${CYN}"/>
</g>`;
const bnd = [
[0, 0.42],
[0.42, 0.6],
[0.6, 1],
];
const clipDefs = bnd
.map(
(b, i) =>
`<clipPath id="cwb${i}"><rect x="-60" y="${(b[0] * BH).toFixed(0)}" width="${BW + 120}" height="${((b[1] - b[0]) * BH).toFixed(0)}"/></clipPath>`,
)
.join("");
const layer = (fill) =>
`<rect x="0" y="0" width="${BW}" height="${BH}" fill="${fill}" mask="url(#cwm)"/>`;
const EX = theme.hero.exitAt ?? Math.min(heroOut - 0.2, I + (p.hold ?? 2.6));
const css = `
@font-face { font-family:'CPReplica'; src: url(data:font/ttf;base64,${fontB64}) format('truetype'); font-display: block; }
#cw { position:absolute; left:${CX}px; top:${CY}px; width:0; height:0; opacity:0; }
#cwW { position:absolute; left:0; top:0; transform:translate(-50%,-50%);
filter: drop-shadow(0 5px 20px rgba(0,0,0,0.5)); }`;
const html = ` <div id="cw">
<svg id="cwW" width="${BW}" height="${BH}" viewBox="0 0 ${BW} ${BH}" style="overflow:visible">
<defs>
<mask id="cwm">
<text x="${x0}" y="${baseY}" font-family="'CPReplica'" font-size="${hpx}"
letter-spacing="${(hpx * 0.01).toFixed(1)}" fill="white">${esc(DISP)}</text>
${extras}
${cuts}
</mask>
${clipDefs}
</defs>
<g id="cwC" transform="translate(-7 8)">${layer(CYN)}</g>
${bnd.map((_, i) => `<g clip-path="url(#cwb${i})"><g id="cwY${i}">${layer(YEL)}</g></g>`).join("\n ")}
<g id="cwD">${debris}</g>
${trace}
</svg>
</div>`;
const js = `
// ---- setpiece: COVERWORD v2 (replica letterforms; tear-in -> living print -> tear-out) ----
const I = ${I.toFixed(3)};
const wrnd = mulberry32(${p.seed || 77});
const TB = (sel, dx, dy, at) => tl.set(sel, { attr: { transform: "translate(" + dx + " " + (dy || 0) + ")" } }, at);
// corrupted boot-flick of the whole lockup during the charge
tl.set("#cw", { opacity: 0.35, filter: "saturate(0) brightness(1.6)" }, I - 0.30);
tl.set("#cw", { opacity: 0 }, I - 0.30 + F);
tl.set("#cw", { opacity: 0.5, filter: "saturate(0) brightness(2)" }, I - 0.13);
tl.set("#cw", { opacity: 0 }, I - 0.13 + F);
// SLAM: crush in with slice displacement, snap into register
tl.set("#cw", { opacity: 1, filter: "none" }, I - 0.02);
tl.fromTo("#cwW", { scale: 2.4 }, { scale: 1, duration: 0.10, ease: "power4.in" }, I - 0.02);
TB("#cwY0", -34, 0, I - 0.02); TB("#cwY1", 26, 0, I - 0.02); TB("#cwY2", -18, 0, I - 0.02);
TB("#cwC", -44, 8, I - 0.02);
tl.set("#cwD", { opacity: 0 }, I - 0.02);
TB("#cwY0", 0, 0, I + 0.10); TB("#cwY1", 0, 0, I + 0.10); TB("#cwY2", 0, 0, I + 0.10);
TB("#cwC", -7, 8, I + 0.10);
tl.set("#cwW", { scaleX: 1.08, scaleY: 0.94 }, I + 0.10);
tl.to("#cwW", { scaleX: 1, scaleY: 1, duration: 0.45, ease: "elastic.out(1.05, 0.38)" }, I + 0.16);
tl.set("#cwD", { opacity: 1 }, I + 0.12);
tl.set("#cwT", { opacity: 0.9 }, I + 0.34);
// living print: seeded slice slips + cyan jolts
let gt = I + 0.55;
while (gt < ${EX.toFixed(3)} - 0.25) {
const r = wrnd();
if (r < 0.4) { const b = Math.floor(wrnd() * 3); TB("#cwY" + b, (wrnd() - 0.5) * 22, 0, gt); TB("#cwY" + b, 0, 0, gt + F); }
else if (r < 0.7) { TB("#cwC", -16, 8, gt); TB("#cwC", -7, 8, gt + F); }
else { tl.set("#cw", { x: (wrnd() - 0.5) * 9 }, gt); tl.set("#cw", { x: 0 }, gt + F); }
gt += 0.4 + wrnd() * 0.55;
}
tl.to("#cwW", { scale: 1.04, duration: 1.1, ease: "power1.inOut" }, I + 0.66);
// EXIT: tear cascade -> gone
const E = ${EX.toFixed(3)};
TB("#cwY0", 52, 0, E); TB("#cwY1", -38, 0, E); TB("#cwY2", 30, 0, E);
TB("#cwC", -30, 8, E);
tl.set("#cwT", { opacity: 0 }, E);
tl.set("#cw", { opacity: 0.45 }, E + F);
tl.set("#cw", { opacity: 0, display: "none" }, E + 2 * F);`;
return { css, html, js };
}
function setpieceSettle() {
const I = heroIn;
const E = theme.hero.exitAt ?? Math.min(heroOut, DUR - 0.12);
const ruleW = Math.round(2 * HG.halfW * 0.62);
const holdDur = Math.max(0.4, E - I - 0.85);
const css = `
${dna.plate.dim ? `#dimP { position:absolute; inset:0; opacity:0; background:#06080c; }` : ""}
#stl { position:absolute; left:${HG.x}px; top:${HG.y}px; transform:translate(-50%,-50%); }
#stl-w { font-family:'${dna.fonts.hero}', sans-serif; font-weight:700; font-size:${HG.fontPx}px;
line-height:1; letter-spacing:0.01em; white-space:nowrap; color:${dna.palette.body};
text-shadow: 0 2px 18px rgba(0,0,0,0.5), 0 1px 4px rgba(0,0,0,0.35); opacity:0; }
#stl-rule { position:absolute; left:50%; bottom:-20px; transform:translateX(-50%);
width:0; height:3px; border-radius:2px; background:${dna.palette.accent};
opacity:0.85; box-shadow: 0 1px 8px rgba(0,0,0,0.4); }`;
const html = ` ${dna.plate.dim ? `<div id="dimP"></div>` : ""}
<div id="stl"><div id="stl-w">${heroDisplay.toUpperCase()}</div><div id="stl-rule"></div></div>`;
const js = `
// ---- setpiece: SETTLE (quiet blur-settle + accent underline; restraint IS the style) ----
const I = ${I.toFixed(3)};
${
dna.plate.dim
? ` tl.fromTo("#dimP", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.35, ease: "power2.in" }, I - 0.3);
tl.to("#dimP", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(E + 0.02).toFixed(3)});`
: ""
}
// opacity lands in ~2 frames; the transform settles separately (no ghost ramp)
tl.fromTo("#stl-w", { opacity: 0 }, { opacity: 1, duration: 0.09, ease: "power1.out" }, I);
tl.fromTo("#stl-w", { y: 14, scale: 1.05, filter: "blur(9px)" },
{ y: 0, scale: 1, filter: "blur(0px)", duration: 0.45, ease: "power3.out" }, I);
tl.fromTo("#stl-rule", { width: 0 }, { width: ${ruleW}, duration: 0.35, ease: "expo.out" }, I + 0.28);
// hold life: one slow visible loom
tl.to("#stl", { scale: 1.018, duration: ${holdDur.toFixed(2)}, ease: "power1.inOut" }, I + 0.6);
// exit: soften and lift (asymmetric to the landing)
tl.to("#stl-rule", { width: 0, opacity: 0, duration: 0.2, ease: "power2.in" }, ${(E - 0.05).toFixed(3)});
tl.to("#stl-w", { filter: "blur(5px)", duration: 0.22, ease: "power1.in" }, ${(E - 0.02).toFixed(3)});
tl.to("#stl", { opacity: 0, y: -10, duration: 0.24, ease: "power2.in" }, ${E.toFixed(3)});
tl.set("#stl", { display: "none" }, ${(E + 0.26).toFixed(3)});`;
return { css, html, js };
}
function setpieceFlapboard() {
// SPLIT-FLAP DEPARTURES BOARD: floodlights swing on, the housing clacks down
// behind the subject, one giant flap tile per char flip-cycles seeded wrong
// glyphs then locks the real char left→right with clack rhythm; rule wipes,
// status ticker types on; one tile re-flutters during the hold; tiles flip
// to blank and the housing flips away.
const h = dna.hero,
p = h.params,
I = heroIn;
const N = heroText.length;
const gap = p.gap || 7;
const tileW = HG.tileW,
tileH = HG.tileH,
fpx = HG.fontPx;
const lockWin = p.lockWindow || 0.34;
const AIN = I - 0.15;
const lockEnd = I + lockWin;
const E = heroOut;
const headH = 34;
const boardH = 10 + headH + tileH + 18;
const boardW = HG.halfW * 2;
const ruleW = Math.round(boardW - 43);
const ruleY = Math.round(HG.y + boardH / 2 + 37);
const tagW = Math.round((p.tag || "").length * 19);
const flutterIdx = Math.min(N - 1, Math.floor(N * 0.6));
const exit0 = E - ((N - 1) * 0.03 + 0.09);
const css = `
#dimF { position:absolute; inset:0; opacity:0; background:#06080c; }
#scrimF { position:absolute; inset:0; opacity:0;
background: linear-gradient(180deg, rgba(5,7,11,0.72) 0%, rgba(5,7,11,0.3) 42%,
rgba(5,7,11,0) 68%); }
#glowF { position:absolute; inset:0; opacity:0;
background: radial-gradient(46% 38% at ${((HG.x / W) * 100).toFixed(1)}% ${((HG.y / H) * 100).toFixed(1)}%, rgba(255,196,110,0.5) 0%,
rgba(255,160,60,0.16) 55%, rgba(0,0,0,0) 78%); }
#persp { position:absolute; inset:0; perspective:950px; }
#board { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; transform-origin:50% 0%;
background: linear-gradient(180deg,#171B21 0%,#101319 100%);
border-radius:8px; border:1px solid #353C47; border-top:3px solid ${dna.palette.hot};
padding:10px 20px 18px;
box-shadow: 0 18px 60px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.06); }
#bhead { display:flex; justify-content:space-between; align-items:baseline; gap:40px;
font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:24px;
letter-spacing:4px; color:${dna.palette.hot}; opacity:0.9; padding:0 4px 8px; }
#bhead .r { color:#8A8F98; letter-spacing:3px; }
#slots { display:flex; gap:${gap}px; }
.atile { position:relative; width:${tileW}px; height:${tileH}px; perspective:620px;
border-radius:4px; background:#0A0D11;
box-shadow: inset 0 0 0 1px #1F242B, inset 0 5px 12px rgba(0,0,0,0.75); }
.aflap { position:absolute; inset:0; transform-origin:50% 0%; backface-visibility:hidden;
border-radius:4px; text-align:center;
background: linear-gradient(180deg,#343A43 0%,#272C34 48%,#1A1E25 52%,#242931 100%);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.08);
font-family:'${dna.fonts.hero}', sans-serif; font-size:${fpx}px; line-height:${tileH + 2}px;
color:${dna.palette.boardBone || dna.palette.body};
text-shadow: 0 4px 14px rgba(0,0,0,0.55); }
.aflap::after { content:""; position:absolute; left:0; right:0; top:50%; height:2px;
margin-top:-1px; background:rgba(0,0,0,0.5); }
.ac { position:absolute; inset:0; }
.ac.wv { display:none; color:#C3C8D0; }
.ac.rl { visibility:hidden; }
#ruleF { position:absolute; left:${HG.x}px; top:${ruleY}px; width:${ruleW}px; height:3px;
margin-left:-${Math.round(ruleW / 2)}px; background:${dna.palette.hot}; opacity:0; transform-origin:50% 50%;
box-shadow: 0 0 12px ${dna.palette.hot}99; }
#tagwrapF { position:absolute; left:${HG.x}px; top:${ruleY + 14}px; transform:translateX(-50%);
overflow:hidden; white-space:nowrap; width:0; }
#tagF { font-family:'${dna.fonts.tag}', sans-serif; font-weight:600; font-size:27px; letter-spacing:7px;
color:${dna.palette.hot}; white-space:nowrap; text-shadow: 0 2px 8px rgba(0,0,0,0.7); }`;
const html = ` <div id="dimF"></div><div id="scrimF"></div><div id="glowF"></div>
<div id="persp">
<div id="board">
<div id="bhead"><span>${esc(p.headerLeft || "★ DEPARTURES")}</span><span class="r">${esc(p.headerRight || "")}</span></div>
<div id="slots"></div>
</div>
</div>
<div id="ruleF"></div>
${p.tag ? `<div id="tagwrapF"><div id="tagF">${esc(p.tag)}</div></div>` : ""}`;
const js = `
// ---- setpiece: FLAPBOARD (housing clack → flip-cycle → lock left→right → board life) ----
const I = ${I.toFixed(3)}, AIN = ${AIN.toFixed(3)}, LOCK0 = I, LSTEP = ${(lockWin / Math.max(1, N - 1)).toFixed(5)};
const WORDB = ${J(heroText)}, BGLYPHS = ${J(p.glyphs || "ABCDEFGHIKLMNOPRSTUVXYZ0123456789")};
const HOTB = ${J(dna.palette.hot)}, BONEB = ${J(dna.palette.boardBone || dna.palette.body)};
const brnd = mulberry32(${p.seed || 8088});
// scene reaction: floodlights swing onto the board
tl.fromTo("#dimF", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.16, ease: "power2.in" }, AIN - 0.08);
tl.fromTo("#scrimF", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.16, ease: "power2.in" }, AIN - 0.08);
tl.to("#dimF", { opacity: ${(dna.plate.dim * 0.59).toFixed(2)}, duration: 0.8, ease: "power2.out" }, ${(lockEnd - 0.23).toFixed(3)});
tl.to("#scrimF", { opacity: ${(dna.plate.charge * 0.61).toFixed(2)}, duration: 0.8, ease: "power2.out" }, ${(lockEnd - 0.23).toFixed(3)});
tl.to(["#dimF", "#scrimF"], { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(E - 0.39).toFixed(3)});
// build the flap tiles (one per char; spaces are blank tiles)
const slots = document.getElementById("slots");
const btiles = [];
for (let i = 0; i < WORDB.length; i++) {
const atile = document.createElement("div"); atile.className = "atile";
if (WORDB[i] === " ") {
atile.style.width = Math.round(${tileW} * 0.45) + "px";
slots.appendChild(atile); btiles.push(null); continue;
}
const flap = document.createElement("div"); flap.className = "aflap";
const n = 3 + Math.floor(brnd() * 3); // 3-5 seeded wrong chars
const wrongs = [];
for (let k = 0; k < n; k++) {
const s = document.createElement("div"); s.className = "ac wv";
s.textContent = BGLYPHS[Math.floor(brnd() * BGLYPHS.length)];
flap.appendChild(s); wrongs.push(s);
}
const rl = document.createElement("div"); rl.className = "ac rl"; rl.textContent = WORDB[i];
flap.appendChild(rl);
atile.appendChild(flap); slots.appendChild(atile);
btiles.push({ atile, flap, rl, wrongs, n });
}
gsap.set("#board", { xPercent: -50, yPercent: -50 });
// housing clacks down (the flap motif at housing scale)
tl.set("#board", { opacity: 1 }, AIN);
tl.fromTo("#board", { rotationX: -52 }, { rotationX: 0, duration: 0.13, ease: "power3.in" }, AIN);
tl.set("#board", { scaleX: 1.012, scaleY: 0.965 }, AIN + 0.13);
tl.to("#board", { scaleX: 1, scaleY: 1, duration: 0.28, ease: "elastic.out(1, 0.42)" }, AIN + 0.16);
// flip-cycle wrong chars, lock left→right with clack rhythm
btiles.forEach((T, i) => {
if (!T) return;
const Li = LOCK0 + i * LSTEP;
const Si = AIN + 0.035 + i * 0.008;
const cyc = (Li - Si) / T.n;
tl.set(T.wrongs[0], { display: "block" }, AIN);
tl.fromTo(T.flap, { rotationX: -85 }, { rotationX: 0, duration: cyc * 0.8, ease: "power1.in" }, Si);
for (let k = 1; k < T.n; k++) {
const c = Si + k * cyc;
tl.set(T.wrongs[k - 1], { display: "none" }, c);
tl.set(T.wrongs[k], { display: "block" }, c);
tl.fromTo(T.flap, { rotationX: -85 }, { rotationX: 0, duration: cyc * 0.8, ease: "power1.in" }, c);
}
// LOCK: real char drops in, hot flash, squash, elastic settle, cool to bone
tl.set(T.wrongs[T.n - 1], { display: "none" }, Li);
tl.set(T.rl, { visibility: "visible" }, Li);
tl.set(T.flap, { color: HOTB, filter: "brightness(2.1)" }, Li);
tl.fromTo(T.flap, { rotationX: -90 }, { rotationX: 0, duration: 0.075, ease: "power2.in" }, Li);
tl.set(T.atile, { scaleX: 1.05, scaleY: 0.92 }, Li + 0.075);
tl.to(T.atile, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.38)" }, Li + 0.1);
tl.to(T.flap, { color: BONEB, filter: "brightness(1)", duration: 0.45, ease: "power2.out" }, Li + 0.15);
});
// clack rhythm: baked y-jitter on the housing across the lock window
tl.to("#board", { keyframes: { y: [0, 2, 0, 1.4, 0, 2, 0, 1.2, 0, 1.8, 0] },
duration: ${(lockWin + 0.08).toFixed(2)}, ease: "none" }, LOCK0 - 0.01);
// lock-complete: the big clack (plate punch lands here)
tl.set("#board", { scaleX: 1.03, scaleY: 0.96 }, ${lockEnd.toFixed(3)});
tl.to("#board", { scaleX: 1, scaleY: 1, duration: 0.42, ease: "elastic.out(1, 0.4)" }, ${(lockEnd + 0.03).toFixed(3)});
tl.fromTo("#glowF", { opacity: 0 }, { opacity: 0.85, duration: 0.12, ease: "power2.out" }, ${lockEnd.toFixed(3)});
tl.to("#glowF", { keyframes: { opacity: [0.85, 0.55, 0.7, 0.45, 0.6, 0.42, 0.5] },
duration: ${Math.max(0.4, E - 0.49 - (lockEnd + 0.15)).toFixed(2)}, ease: "none" }, ${(lockEnd + 0.15).toFixed(3)});
tl.fromTo("#ruleF", { scaleX: 0, opacity: 1 }, { scaleX: 1, duration: 0.3, ease: "expo.out" }, ${(lockEnd + 0.05).toFixed(3)});
${p.tag ? ` tl.to("#tagwrapF", { width: ${tagW}, duration: 0.4, ease: "steps(16)" }, ${(lockEnd + 0.11).toFixed(3)});` : ""}
// hold life: loom + one mechanical re-flutter tic — boards do this
tl.to("#board", { scaleX: 1.022, scaleY: 1.022, duration: ${Math.max(0.4, Math.min(1.0, exit0 - lockEnd - 0.55)).toFixed(2)}, ease: "power1.inOut" }, ${(lockEnd + 0.5).toFixed(3)});
const T6 = btiles[${flutterIdx}];
if (T6 && ${(lockEnd + 1.0).toFixed(3)} < ${(exit0 - 0.2).toFixed(3)}) {
const ft = ${(lockEnd + 0.9).toFixed(3)};
tl.set(T6.rl, { visibility: "hidden" }, ft);
tl.set(T6.wrongs[0], { display: "block" }, ft);
tl.fromTo(T6.flap, { rotationX: -70 }, { rotationX: 0, duration: 0.055, ease: "power1.in" }, ft);
tl.set(T6.wrongs[0], { display: "none" }, ft + 0.06);
tl.set(T6.rl, { visibility: "visible" }, ft + 0.06);
tl.fromTo(T6.flap, { rotationX: -80 }, { rotationX: 0, duration: 0.06, ease: "power2.in" }, ft + 0.06);
}
// exit: tiles flip to blank (0→90) cascading, housing flips away
tl.to("#glowF", { opacity: 0, duration: 0.3, ease: "power1.in" }, ${(E - 0.49).toFixed(3)});
tl.to("#ruleF", { scaleX: 0, opacity: 0, duration: 0.12, ease: "power2.in" }, ${(E - 0.44).toFixed(3)});
${p.tag ? ` tl.to("#tagwrapF", { width: 0, duration: 0.16, ease: "steps(8)" }, ${(E - 0.44).toFixed(3)});` : ""}
btiles.forEach((T, i) => {
if (!T) return;
tl.to(T.flap, { rotationX: 90, duration: 0.085, ease: "power2.in" }, ${exit0.toFixed(3)} + i * 0.03);
});
tl.to("#board", { rotationX: -45, opacity: 0, duration: 0.13, ease: "power2.in" }, ${(E - 0.04).toFixed(3)});
tl.set("#board", { display: "none" }, ${Math.min(E + 0.08, DUR - 0.015).toFixed(3)});`;
return { css, html, js };
}
function setpieceLedwipe() {
// TRANSIT LED BOARD: cool scrim + dim charge the room, a departure-board
// panel EXPANDS upward (stepped paging motif), then the word lights
// column-by-column via a L→R illumination wipe (clipPath steps) inside a
// chasing LED-marquee border; PA "ding" = two brightness pulses; hold life =
// glow breathe + LED dropouts; exit pages the word up out of the panel and
// the panel collapses back down.
const h = dna.hero,
p = h.params,
I = heroIn;
const C = I + (p.wipeDur || 0.3); // wipe completes = contact
const E = heroOut;
const panelW = HG.panelW,
panelH = HG.panelH,
fpx = HG.fontPx;
const px = Math.round(HG.x - panelW / 2),
py = Math.round(HG.y - panelH / 2);
const spillW = panelW - 180,
spillH = Math.round(panelH * 1.9);
const exScroll = +(E - 0.72).toFixed(3);
const wipeSteps = Math.max(8, Math.round(heroText.length * 1.4));
const A = dna.palette.accent,
HOT = dna.palette.hot,
GRN = dna.palette.green || "#36e57a";
// marquee dot lattice scales with the panel; chase runs until the exit
const nTop = Math.max(10, Math.round(panelW / 45));
const nSide = Math.max(2, Math.round((panelH - 19) / 52) - 1);
const kMax = Math.max(4, Math.floor((exScroll - (I - 0.01)) / 0.1));
const css = `
#scrimL { position:absolute; inset:0; opacity:0;
background: radial-gradient(120% 105% at 50% 40%, rgba(2,6,14,0) 30%, rgba(2,6,14,0.85) 100%); }
#dimL { position:absolute; inset:0; opacity:0; background:#020409; }
#spillL { position:absolute; left:${Math.round(HG.x - spillW / 2)}px; top:${Math.round(HG.y - spillH / 2)}px;
width:${spillW}px; height:${spillH}px; opacity:0;
background: radial-gradient(closest-side, ${A}4d, ${A}00 72%); }
#apxL { position:absolute; left:${px}px; top:${py}px; width:${panelW}px; height:${panelH}px; opacity:0; }
#panelL { position:absolute; inset:0; border-radius:8px;
background-color: rgba(8,8,11,0.93);
background-image: radial-gradient(${A}0e 1px, rgba(0,0,0,0) 1.4px);
background-size: 7px 7px;
border:2px solid ${A}4d;
box-shadow: 0 18px 50px rgba(0,0,0,0.55), inset 0 0 70px ${A}0d; }
.ldot { position:absolute; width:7px; height:7px; border-radius:50%;
background:${A}; opacity:0.18; box-shadow:0 0 6px ${A}8c; }
#ptagL, #gtagL { position:absolute; top:16px; font-family:'${dna.fonts.tag}', monospace; font-size:22px;
letter-spacing:0.18em; opacity:0; white-space:nowrap; }
#ptagL { left:28px; color:${A}9e; }
#gtagL { right:28px; color:${GRN}; text-shadow:0 0 8px ${GRN}80; }
#panwinL { position:absolute; left:24px; right:24px; top:44px; bottom:18px; overflow:hidden; }
#scrollL { position:absolute; inset:0; }
#ghostL, #awordL { position:absolute; left:50%; top:50%;
font-family:'${dna.fonts.hero}', monospace; font-size:${fpx}px; line-height:1;
letter-spacing:0.05em; white-space:nowrap; }
#ghostL { color:${A}; opacity:0; }
#awordL { color:${A}; opacity:0;
text-shadow: 0 0 18px ${A}8c, 0 0 2px ${HOT}cc; }`;
const html = ` <div id="scrimL"></div><div id="dimL"></div><div id="spillL"></div>
<div id="apxL">
<div id="panelL">
<div id="ptagL">${esc(p.headerLeft || "NOW ARRIVING ▸ 19:42")}</div>
<div id="gtagL">${esc(p.headerRight || "GATE A4")}</div>
<div id="panwinL">
<div id="scrollL">
<div id="ghostL">${esc(heroText)}</div>
<div id="awordL">${esc(heroText)}</div>
</div>
</div>
</div>
</div>`;
const js = `
// ---- setpiece: LEDWIPE (panel expands → L→R illumination wipe → PA ding → page-up exit) ----
const I = ${I.toFixed(3)}, C = ${C.toFixed(3)};
gsap.set("#panelL", { transformOrigin: "50% 100%", scaleY: 0 });
gsap.set(["#ghostL", "#awordL"], { xPercent: -50, yPercent: -50 });
// ===== scene charge / release =====
tl.fromTo("#scrimL", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.30, ease: "power2.in" }, I - 0.31);
tl.fromTo("#dimL", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.25, ease: "power2.in" }, I - 0.25);
tl.fromTo("#spillL", { opacity: 0 }, { opacity: 0.45, duration: 0.40, ease: "power2.out" }, I + 0.05);
tl.to("#spillL", { opacity: 0, duration: 0.35, ease: "power1.in" }, ${(E - 0.54).toFixed(3)});
tl.to(["#scrimL", "#dimL"], { opacity: 0, duration: 0.40, ease: "power1.in" }, ${(E - 0.44).toFixed(3)});
// ===== board expands UPWARD (stepped paging motif, apex scale) =====
tl.set("#apxL", { opacity: 1 }, I - 0.26);
tl.to("#panelL", { scaleY: 1, duration: 0.20, ease: "steps(6)" }, I - 0.25);
tl.set("#panelL", { borderColor: "${HOT}f2" }, I - 0.05);
tl.set("#panelL", { borderColor: "${A}4d" }, I + 0.07);
// power-on boot: unlit LED cells + header tags flicker alive
tl.set("#ghostL", { opacity: 0.06 }, I - 0.045);
tl.set("#ghostL", { opacity: 0.13 }, I - 0.003);
tl.set(["#ptagL", "#gtagL"], { opacity: 0.4 }, I - 0.045);
tl.set(["#ptagL", "#gtagL"], { opacity: 0.9 }, I - 0.003);
// ===== APEX: L→R illumination wipe — LEDs light column by column =====
tl.set("#awordL", { opacity: 1, color: "${HOT}" }, I);
tl.fromTo("#awordL", { clipPath: "inset(0% 100% 0% 0%)" },
{ clipPath: "inset(0% 0% 0% 0%)", duration: ${(p.wipeDur || 0.3).toFixed(2)}, ease: "steps(${wipeSteps})" }, I);
tl.set("#awordL", { clipPath: "none" }, C + 0.05); // free the glow halo
tl.to("#awordL", { color: "${A}", duration: 0.60, ease: "power1.in" }, C + 0.05); // hot → amber cool
// contact: squash held 2 frames, elastic settle (board cell flexes)
tl.set("#scrollL", { scaleX: 1.06, scaleY: 0.94 }, C);
tl.to("#scrollL", { scaleX: 1, scaleY: 1, duration: 0.42, ease: "elastic.out(1, 0.4)" }, C + 0.083);
// PA "ding" = two quick brightness pulses
tl.set("#panwinL", { filter: "brightness(1.7)" }, C + 0.02);
tl.set("#panwinL", { filter: "brightness(1)" }, C + 0.10);
tl.set("#panwinL", { filter: "brightness(1.45)" }, C + 0.18);
tl.set("#panwinL", { filter: "brightness(1)" }, C + 0.26);
${
C + 0.99 < exScroll
? `
// hold life: glow breathes + LED dropouts
tl.to("#awordL", { textShadow: "0 0 30px ${A}d9, 0 0 4px ${HOT}f2",
duration: 0.45, ease: "sine.inOut" }, C + 0.54);
tl.to("#awordL", { textShadow: "0 0 18px ${A}8c, 0 0 2px ${HOT}cc",
duration: 0.45, ease: "sine.inOut" }, C + 0.99);
tl.set("#awordL", { opacity: 0.82 }, C + 0.61); tl.set("#awordL", { opacity: 1 }, C + 0.652);
tl.set("#awordL", { opacity: 0.90 }, C + 1.04); tl.set("#awordL", { opacity: 1 }, C + 1.082);
tl.set("#ptagL", { opacity: 0.45 }, C + 0.41); tl.set("#ptagL", { opacity: 0.9 }, C + 0.452);`
: ""
}
// ===== exit: word pages up out of the window, board collapses =====
tl.to("#scrollL", { y: ${-(panelH - 62)}, duration: 0.22, ease: "steps(6)" }, ${exScroll.toFixed(3)});
tl.set(["#ptagL", "#gtagL"], { opacity: 0 }, ${(exScroll + 0.2).toFixed(3)});
tl.to("#panelL", { scaleY: 0, duration: 0.18, ease: "steps(6)" }, ${(exScroll + 0.24).toFixed(3)});
tl.set("#apxL", { opacity: 0 }, ${(exScroll + 0.44).toFixed(3)});
// ===== chasing LED marquee border (8-phase set chain) =====
const panelL = document.getElementById("panelL");
const PW = ${panelW}, PH = ${panelH}, groups = [[], [], [], [], [], [], [], []];
const pts = [];
for (let i = 0; i < ${nTop}; i++) pts.push([10 + i * (PW - 27) / ${nTop - 1}, 6]); // top L→R
for (let j = 0; j < ${nSide}; j++) pts.push([PW - 13, 6 + (j + 1) * (PH - 19) / ${nSide + 1}]); // right ↓
for (let i = ${nTop - 1}; i >= 0; i--) pts.push([10 + i * (PW - 27) / ${nTop - 1}, PH - 13]); // bottom R→L
for (let j = ${nSide - 1}; j >= 0; j--) pts.push([6, 6 + (j + 1) * (PH - 19) / ${nSide + 1}]); // left ↑
pts.forEach(([x, y], i) => {
const d = document.createElement("div");
d.className = "ldot"; d.style.left = Math.round(x) + "px"; d.style.top = Math.round(y) + "px";
d.style.opacity = "0"; panelL.appendChild(d);
groups[i % 8].push(d);
});
groups.forEach((g) => tl.set(g, { opacity: 0.18 }, I - 0.02));
for (let k = 0; k <= ${kMax}; k++) {
const t = I - 0.01 + k * 0.1;
for (let q = 0; q < 8; q++) {
const lit = (((q - k) % 8) + 8) % 8 < 2;
tl.set(groups[q], { opacity: lit ? 0.95 : 0.18 }, t);
}
}`;
return { css, html, js };
}
function setpieceVhsosd() {
// REC FREEZE: the word slams in as a huge camcorder OSD readout BEHIND the
// subject. Permanent lens vignette = the camcorder look; a backlight-comp
// scrim kills the bright sky behind the word. Arrival = tracking-sliced
// crush (3 horizontal clip-path bands sheared + blurred, power4.in) that
// SNAPS into register on the contact frame, squash → elastic settle; heavy
// red/cyan misregistration cools from ±arrival px to ±settled px and keeps
// BREATHING through the hold; tape-weave y jitter + slow loom; auto-iris
// pump (white wash up, dark dip, settle). Exit = REWIND streak left with a
// flat-white echo copy.
const h = dna.hero,
p = h.params,
I = heroIn;
const C = I + (p.crush ?? 0.1); // contact: slices snap into register
const X = theme.hero.exitAt ?? Math.min(heroOut, I + (p.hold ?? 1.69)); // REWIND
const clips = p.bandClips || [
[0, 64],
[36, 30],
[70, 0],
];
const shear = p.bandShear || [-46, 40, -34];
const mi = p.misregIn ?? 11, // arrival misregistration (px)
m = p.misreg ?? 3; // settled misregistration (px)
const RED = dna.palette.red || "#FF2E2E",
CYN = dna.palette.cyan || "#3FE8E8";
const yPct = ((HG.y / H) * 100).toFixed(0);
const css = `
#vig { position:absolute; inset:0; opacity:0;
background: radial-gradient(115% 100% at 50% 46%, rgba(0,0,0,0) 52%, rgba(0,0,0,${p.vignette ?? 0.34}) 100%); }
#vscrim { position:absolute; inset:0; opacity:0;
background: radial-gradient(58% 44% at 50% ${yPct}%, rgba(0,0,0,0.62) 0%, rgba(0,0,0,0.34) 55%, rgba(0,0,0,0) 78%); }
#iris { position:absolute; inset:0; opacity:0; background:#ffffff; }
#irisd { position:absolute; inset:0; opacity:0; background:#000000; }
#osdw, #oecho { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
.aw { position:absolute; left:0; top:0; white-space:nowrap;
font-family:'${dna.fonts.hero}', monospace; font-size:${HG.fontPx}px; line-height:1;
letter-spacing:0.02em; }
#mr { color:${RED}; opacity:0.55; }
#mc { color:${CYN}; opacity:0.55; }
.wb2 { color:${dna.palette.body}; text-shadow: 0 4px 24px rgba(0,0,0,0.6), 0 1px 4px rgba(0,0,0,0.45); }
${clips.map((c, i) => `#vb${i + 1} { clip-path: inset(${c[0]}% 0 ${c[1]}% 0); }`).join("\n ")}
#oe { color:#ffffff; opacity:0.4; text-shadow:none; }`;
const html = ` <div id="vig"></div>
<div id="vscrim"></div>
<div id="osdw">
<div id="weave">
<div class="aw" id="mr">${esc(heroText)}</div>
<div class="aw" id="mc">${esc(heroText)}</div>
${clips.map((_, i) => `<div class="aw wb2" id="vb${i + 1}">${esc(heroText)}</div>`).join("\n ")}
</div>
</div>
<div id="oecho"><div class="aw" id="oe">${esc(heroText)}</div></div>
<div id="iris"></div>
<div id="irisd"></div>`;
const bandIds = clips.map((_, i) => "#vb" + (i + 1));
// hold life windows (clamped inside [contact, exit] so nothing fights the rewind)
const weaveTs = [];
const wv = p.weave || [-2, 1, -1, 2, -1, 1, -2, 0];
for (let k = 0; k < wv.length; k++) {
const t = C + 0.19 + k * 0.17;
if (t < X - 0.05) weaveTs.push([+t.toFixed(3), wv[k]]);
}
const brDur = Math.min(1.3, X - (C + 0.21) - 0.05);
const loomDur = Math.min(0.92, X - (C + 0.64) - 0.03);
const js = `
// ---- setpiece: VHSOSD (tracking-sliced OSD slam → misreg breathe → REWIND exit) ----
const I = ${I.toFixed(3)}, C = ${C.toFixed(3)}, X = ${X.toFixed(3)};
gsap.set(${J(["#mr", "#mc", ...bandIds, "#oe"])}, { xPercent:-50, yPercent:-50 });
gsap.set("#weave", { x: 0, y: 0 });
// camcorder lens vignette — present the whole clip
tl.set("#vig", { opacity: 1 }, 0.02);
// backlight-compensation scrim behind the word (the sky is bright up there)
tl.fromTo("#vscrim", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.20, ease: "power2.in" }, I - 0.04);
tl.to("#vscrim", { opacity: ${(dna.plate.charge * 0.69).toFixed(2)}, duration: 0.8, ease: "power2.out" }, C + 0.35);
tl.to("#vscrim", { opacity: 0, duration: 0.30, ease: "power1.in" }, X);
// ===== ARRIVAL: tracking-sliced crush, opacity 1 instantly =====
tl.set("#osdw", { opacity: 1 }, I);
tl.fromTo("#osdw", { scale: 2.9 }, { scale: 1, duration: C - I, ease: "power4.in" }, I);
const SHEAR = ${J(shear)};
${J(bandIds)}.forEach((sel, i) => {
tl.fromTo(sel, { x: SHEAR[i], filter: "blur(10px) brightness(2.6)" },
{ x: Math.round(SHEAR[i] * 0.3), filter: "blur(1px) brightness(1.7)", duration: C - I, ease: "power4.in" }, I);
});
tl.set("#mr", { x: -${mi} }, I);
tl.set("#mc", { x: ${mi} }, I);
// SNAP into register on the contact frame + squash, elastic settle
tl.set(${J(bandIds)}, { x: 0, filter: "brightness(1.85)" }, C);
tl.set("#mr", { x: -${m} }, C);
tl.set("#mc", { x: ${m} }, C);
tl.set("#osdw", { scaleX: 1.10, scaleY: 0.90 }, C);
tl.to("#osdw", { scaleX: 1, scaleY: 1, duration: 0.50, ease: "elastic.out(1, 0.36)" }, C + 0.083);
tl.to(${J(bandIds)}, { filter: "brightness(1)", duration: 0.45, ease: "power2.out" }, C + 0.10);
// AUTO-IRIS PUMP: wash up, then a dark dip, once
tl.to("#iris", { opacity: 0.20, duration: 0.12, ease: "sine.out" }, C + 0.04);
tl.to("#iris", { opacity: 0, duration: 0.33, ease: "power1.in" }, C + 0.16);
tl.to("#irisd", { opacity: ${dna.plate.dim}, duration: 0.28, ease: "sine.out" }, C + 0.55);
tl.to("#irisd", { opacity: 0, duration: 0.40, ease: "sine.in" }, C + 0.85);
${
brDur > 0.3
? `
// ===== HOLD LIFE: misregistration breathes, tape weave, slow loom =====
tl.to("#mr", { keyframes: { x: [-${m}, -${(m * 1.53).toFixed(1)}, -${(m * 1.07).toFixed(1)}, -${(m * 1.67).toFixed(1)}, -${(m * 1.17).toFixed(1)}] }, duration: ${brDur.toFixed(2)}, ease: "none" }, C + 0.21);
tl.to("#mc", { keyframes: { x: [${m}, ${(m * 1.53).toFixed(1)}, ${(m * 1.07).toFixed(1)}, ${(m * 1.67).toFixed(1)}, ${(m * 1.17).toFixed(1)}] }, duration: ${brDur.toFixed(2)}, ease: "none" }, C + 0.21);
${J(weaveTs)}.forEach(([t, v]) => tl.set("#weave", { y: v }, t));`
: ""
}
${
loomDur > 0.3
? ` tl.to("#osdw", { keyframes: { scale: [1, 1.03, 1.015, 1.028] }, duration: ${loomDur.toFixed(2)}, ease: "none" }, C + 0.64);`
: ""
}
// ===== EXIT: REWIND — streak left with echo copy =====
tl.to("#osdw", { x: -190, scaleX: 1.3, opacity: 0, duration: 0.13, ease: "power2.in" }, X);
tl.set("#oecho", { opacity: 1 }, X);
tl.fromTo("#oe", { x: 0, opacity: 0.4 }, { x: -95, opacity: 0, duration: 0.18, ease: "power2.in" }, X + 0.01);
tl.set(["#osdw","#oecho"], { display: "none" }, X + 0.25);`;
return { css, html, js };
}
function setpieceBossintro() {
// BOSS INTRO (bg): the room darkens behind a CRT edge glow (cyan base wash,
// pulses on the last emphasis before the boss and spills magenta on the
// strobe + the final word), WARNING blinks 2x above the word rect, then
// ~COLS×ROWS seeded pixel blocks fall steps(3) into a tight matrix grid
// covering the word rect (1-frame white land tick each — the coin-blip motif
// at block scale), and the word flashes ON (instant, oversized, quantized
// crush → 2-frame squash → elastic settle) with chromatic ghost layers and a
// 3-frame palette strobe. Hold: quantized breathe + CRT glow cycle + baked
// block shimmer blinks. Exit: per-letter checkerboard dissolve; blocks drop
// away quantized. FG furniture (rides rail.html via fg parts): letterbox
// bars slam in with a 1-frame overshoot and a HI-SCORE odometer rolls
// top-right through the hold.
const h = dna.hero,
p = h.params || {},
I = heroIn;
const CY = dna.palette.accent,
MG = dna.palette.magenta || "#FF2E88",
YL = dna.palette.yellow || "#FFE600";
const FLASH = I + (p.flashDelay ?? 0.14);
const EX = theme.hero.exitAt ?? Math.min(heroOut - 0.2, I + (p.hold ?? 1.24));
const ROWS = p.rows || 4;
const CWp = Math.round(HG.fontPx * 0.7),
CHp = Math.round(HG.fontPx * 0.5);
const COLS = Math.ceil((HG.halfW * 2 + 100) / CWp);
const X0 = Math.round(HG.x - (COLS * CWp) / 2),
Y0 = Math.round(HG.y - (ROWS * CHp) / 2);
// scene-reaction pulses: the edge glow pre-echoes the boss on the last
// emphasis word before the takeover; magenta answers on the final word
const minorsBefore = LINES.flatMap((L) => L.words).filter((w) => w.minor && w.start < I - 0.3);
const pulseT = minorsBefore.length ? minorsBefore[minorsBefore.length - 1].start : null;
const endPulse =
LASTWORD.start + 0.384 < DUR - 0.02 && LASTWORD.start > EX + 0.4 ? LASTWORD.start : null;
const lb = p.letterbox || 44;
const css = `
#bdim { position:absolute; inset:0; opacity:0;
background:linear-gradient(180deg, rgba(2,3,12,0.66) 0%, rgba(2,3,12,0.40) 55%, rgba(2,3,12,0.30) 100%); }
#edgeC { position:absolute; inset:0; opacity:0; box-shadow: inset 0 0 130px ${CY}4d; }
#edgeM { position:absolute; inset:0; opacity:0; box-shadow: inset 0 0 130px ${MG}52; }
#blocks{ position:absolute; inset:0; }
.blk { position:absolute; opacity:0; }
#bossWord { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
.bw { position:absolute; left:0; top:0; white-space:nowrap;
font-family:'${dna.fonts.hero}', monospace; font-size:${HG.fontPx}px; line-height:1; }
#bwC { color:${CY}; opacity:0; }
#bwM { color:${MG}; opacity:0; }
#bwMain{ color:#ffffff;
text-shadow:0 6px 0 rgba(0,0,0,0.6), 0 0 16px rgba(0,0,0,0.9), 0 0 5px rgba(0,0,0,0.85); }
#bwMain .bl { display:inline-block; }
#warning { position:absolute; left:${HG.x}px; top:${Math.round(HG.y - HG.fontPx * 1.43)}px; opacity:0;
font-family:'${dna.fonts.tag}', monospace; font-size:26px; letter-spacing:0.4em;
color:${MG};
text-shadow:0 0 14px rgba(0,0,0,0.95), 0 0 6px rgba(0,0,0,0.9), 0 4px 0 rgba(0,0,0,0.8); }`;
const html = ` <div id="bdim"></div>
<div id="edgeC"></div>
<div id="edgeM"></div>
<div id="blocks"></div>
<div id="bossWord">
<div class="bw" id="bwC">${esc(heroText)}</div>
<div class="bw" id="bwM">${esc(heroText)}</div>
<div class="bw" id="bwMain"></div>
</div>
<div id="warning">${esc(p.tag || "WARNING")}</div>`;
const js = `
// ---- setpiece: BOSSINTRO (dim + WARNING -> pixel-block assembly -> strobe flash) ----
const I = ${I.toFixed(3)}, FLASH = ${FLASH.toFixed(3)}, OUT = ${EX.toFixed(3)};
const CYc = ${J(CY)}, MGc = ${J(MG)}, YLc = ${J(YL)}, WHc = "#ffffff";
const brnd = mulberry32(${p.seed || 13013});
// ===== scene reaction: CRT edge glow lives behind the subject =====
tl.set("#edgeC", { opacity: 0.28 }, 0.05);
${
pulseT != null
? ` // pulse on the last emphasis before the boss
tl.set("#edgeC", { opacity: 0.62 }, ${pulseT.toFixed(3)});
tl.set("#edgeC", { opacity: 0.28 }, ${(pulseT + 0.084).toFixed(3)});`
: ""
}
// boss dim: room darkens for the boss intro (helps a bright sky too)
tl.to("#bdim", { opacity: ${dna.plate.dim ?? 0.58}, duration: 0.12, ease: "power2.out" }, I - 0.15);
tl.set("#edgeC", { opacity: 0.8 }, FLASH);
tl.to("#edgeC", { opacity: 0.34, duration: 0.6, ease: "power2.out" }, FLASH + 0.08);
tl.set("#edgeM", { opacity: 0.5 }, FLASH + 0.084); // magenta strobe spill
tl.to("#edgeM", { opacity: 0, duration: 0.25, ease: "power1.out" }, FLASH + 0.126);
tl.to("#bdim", { opacity: 0, duration: 0.35, ease: "power1.inOut" }, OUT + 0.10);
${
endPulse != null
? ` // magenta answers on the final word
tl.set("#edgeM", { opacity: 0.55 }, ${endPulse.toFixed(3)});
tl.to("#edgeM", { opacity: 0, duration: 0.3, ease: "power1.out" }, ${(endPulse + 0.084).toFixed(3)});`
: ""
}
// ===== WARNING blinks 2x above the word =====
tl.set("#warning", { opacity: 1 }, I - 0.135);
tl.set("#warning", { opacity: 0 }, I - 0.05);
tl.set("#warning", { opacity: 1 }, I + 0.035);
tl.set("#warning", { opacity: 0 }, I + 0.12);
gsap.set("#warning", { xPercent: -50, yPercent: -50 });
// ===== pixel-block boss assembly: blocks fall steps(3) into a tight grid =====
const blocksEl = document.getElementById("blocks");
const X0 = ${X0}, Y0 = ${Y0}, COLS = ${COLS}, ROWS = ${ROWS}, CW = ${CWp}, CH = ${CHp};
for (let c = 0; c < COLS; c++) {
for (let r = 0; r < ROWS; r++) {
const b = document.createElement("div");
b.className = "blk"; blocksEl.appendChild(b);
const s = 8 + Math.round(brnd() * 6); // 8–14px
const jx = Math.round((brnd() - 0.5) * 6), jy = Math.round((brnd() - 0.5) * 6);
const px = brnd();
const col = px < 0.4 ? CYc : px < 0.78 ? MGc : px < 0.93 ? YLc : WHc;
gsap.set(b, { width: s, height: s,
left: X0 + c * CW + (CW - s) / 2 + jx,
top: Y0 + r * CH + (CH - s) / 2 + jy,
backgroundColor: col });
const start = Math.min(I - 0.15 + c * 0.010 + brnd() * 0.04, FLASH - 0.10);
let dur = 0.12 + brnd() * 0.06;
if (start + dur > FLASH - 0.02) dur = FLASH - 0.02 - start;
const fall = 240 + brnd() * 260;
tl.set(b, { opacity: 0.9, y: -fall }, start);
tl.to(b, { y: 0, duration: dur, ease: "steps(3)" }, start);
// 1-frame white land tick (the coin-blip motif at block scale)
const land = start + dur;
tl.set(b, { backgroundColor: WHc }, land);
tl.set(b, { backgroundColor: col, opacity: 0.85 }, land + 0.042);
// hold shimmer: baked blink sets (no infinite repeats)
const t1 = FLASH + 0.11 + brnd() * 0.45, t2 = FLASH + 0.61 + brnd() * 0.4;
if (t1 + 0.083 < OUT) { tl.set(b, { opacity: 0.5 }, t1); tl.set(b, { opacity: 0.85 }, t1 + 0.083); }
if (t2 + 0.083 < OUT) { tl.set(b, { opacity: 0.55 }, t2); tl.set(b, { opacity: 0.85 }, t2 + 0.083); }
// exit: blocks drop off, quantized
const tE = Math.min(OUT + brnd() * 0.15, ${(DUR - 0.32).toFixed(2)});
tl.to(b, { y: 220 + brnd() * 140, opacity: 0, duration: 0.2 + brnd() * 0.1,
ease: "steps(3)" }, tE);
}
}
// ===== the word flashes ON over the block grid =====
const main = document.getElementById("bwMain");
const letters = [];
${J(heroText)}.split("").forEach((ch) => {
const s = document.createElement("span");
s.className = "bl"; s.textContent = ch === " " ? "\\u00a0" : ch;
main.appendChild(s); letters.push(s);
});
gsap.set("#bwC", { xPercent: -50, yPercent: -50, x: -4 });
gsap.set("#bwM", { xPercent: -50, yPercent: -50, x: 4 });
gsap.set("#bwMain",{ xPercent: -50, yPercent: -50 });
gsap.set("#bossWord", { transformOrigin: "50% 50%" });
// arrival: instant-on at full opacity, quantized crush, 2-frame squash, elastic settle
tl.set("#bossWord", { opacity: 1, scale: 1.15 }, FLASH);
tl.set(["#bwC", "#bwM"], { opacity: 0.85 }, FLASH);
tl.to("#bossWord", { scale: 1, duration: 0.1, ease: "steps(3)" }, FLASH);
tl.set("#bossWord", { scaleX: 1.07, scaleY: 0.92 }, FLASH + 0.1);
tl.to("#bossWord", { scaleX: 1, scaleY: 1, duration: 0.3,
ease: "elastic.out(1, 0.38)" }, FLASH + 0.142);
// 3-frame palette strobe (white -> cyan -> magenta -> white)
tl.set(main, { color: CYc }, FLASH + 0.042);
tl.set(main, { color: MGc }, FLASH + 0.084);
tl.set(main, { color: WHc }, FLASH + 0.126);
// hold life: quantized breathe + CRT glow cycle (emitted only with runway)
if (FLASH + 0.61 < OUT) {
tl.to("#bossWord", { scale: 1.016, duration: 0.4, ease: "sine.inOut",
repeat: 1, yoyo: true }, FLASH + 0.61);
}
const HALO = ", 0 6px 0 rgba(0,0,0,0.6), 0 0 16px rgba(0,0,0,0.9), 0 0 5px rgba(0,0,0,0.85)";
[[0.2, CYc], [0.5, MGc], [0.8, CYc]].forEach(([dt, gc]) => {
if (FLASH + dt < OUT) tl.set(main, { textShadow: "0 0 22px " + gc + "d9" + HALO }, FLASH + dt);
});
// exit: checkerboard dissolve — letters vanish in 2 alternating set passes
letters.forEach((s, i) => {
tl.set(s, { opacity: 0 }, i % 2 === 0 ? OUT : OUT + 0.083);
});
tl.set(["#bwC", "#bwM"], { opacity: 0 }, OUT + 0.042);
tl.set("#bossWord", { opacity: 0, display: "none" }, OUT + 0.17);`;
// fg furniture: letterbox bars + HI-SCORE odometer ride the fg alpha layer
// (in FRONT of the subject — the cabinet frames the player)
const fgCss = `
#lbTop { position:absolute; left:0; top:-${lb}px; width:${W}px; height:${lb}px; background:#000; z-index:14; }
#lbBot { position:absolute; left:0; bottom:-${lb}px; width:${W}px; height:${lb}px; background:#000; z-index:14; }
#hi { position:absolute; right:36px; top:${lb + 18}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.tag}', monospace; font-size:18px; z-index:15;
text-shadow:0 3px 0 rgba(0,0,0,0.75), 0 0 10px rgba(0,0,0,0.6); }
#hi .lab { color:${CY}; margin-right:0.5em; }
#hi #hsv { color:${YL}; }`;
const fgHtml = ` <div id="lbTop"></div>
<div id="lbBot"></div>
<div id="hi"><span class="lab">HI-SCORE</span><span id="hsv">0000000</span></div>`;
const fgJs = `
// ---- bossintro furniture (fg): letterbox slam + HI-SCORE odometer ----
{
const BI = ${I.toFixed(3)}, BF = ${FLASH.toFixed(3)}, BO = ${EX.toFixed(3)};
const hrnd = mulberry32(${(p.seed || 13013) + 7});
// letterbox bars slam in (quantized), 1-frame overshoot, retract after
tl.to("#lbTop", { y: ${lb}, duration: 0.12, ease: "steps(3)" }, BI - 0.15);
tl.to("#lbBot", { y: -${lb}, duration: 0.12, ease: "steps(3)" }, BI - 0.15);
tl.set("#lbTop", { y: ${lb + 4} }, BI - 0.02); tl.set("#lbTop", { y: ${lb} }, BI + 0.022);
tl.set("#lbBot", { y: -${lb + 4} }, BI - 0.02); tl.set("#lbBot", { y: -${lb} }, BI + 0.022);
tl.to("#lbTop", { y: 0, duration: 0.15, ease: "steps(3)" }, BO + 0.13);
tl.to("#lbBot", { y: 0, duration: 0.15, ease: "steps(3)" }, BO + 0.13);
// HI-SCORE counter odometer-rolls during the hold
const hsv = document.getElementById("hsv");
tl.set("#hi", { opacity: 1 }, BF + 0.03);
tl.fromTo("#hi", { scale: 0 }, { scale: 1, duration: 0.1, ease: "steps(2)",
transformOrigin: "100% 50%" }, BF + 0.03);
let score = 1370;
for (let t = BF + 0.09; t < BO - 0.03; t += 0.066) {
score += 4000 + Math.round(hrnd() * 5500);
tl.set(hsv, { textContent: String(score).padStart(7, "0") }, t);
}
tl.set("#hi", { color: "#ffffff" }, BO + 0.08); // 1-frame white tick out
tl.set("#hi", { opacity: 0 }, BO + 0.122);
}`;
return { css, html, js, fgCss, fgHtml, fgJs };
}
function setpieceRubberstamp() {
// RUBBER STAMP: the footage becomes an archival page at the apex — vignette
// charge (plate.charge) + manila sepia wash + a dim kick (plate.dim) that
// cools — then the verdict slams in BEHIND the subject as a bordered rubber
// stamp at params.rotation: crush 2.2→1 power4.in, 2-frame contact squash,
// double-strike ghost print, two thin-ink patches (clip-path circles in
// paper color), wet-ink saturate/brightness settle, and a paper indent
// shadow that blooms under the face. Stamps don't move: the hold is DEAD
// STILL, sold by the breathing indent + a slow paper loom. Exit at
// C + params.hold = mechanical 3-frame yank-down cut (the page is filed
// away); the archive plate releases 0.25s before it.
const h = dna.hero,
p = h.params || {},
I = heroIn;
const C = I + (p.crush ?? 0.074); // stamp contact (plate punch lands here via punchOffset)
const X = theme.hero.exitAt ?? Math.min(heroOut - 0.06, C + (p.hold ?? 2.02));
const ROT = p.rotation ?? -8;
const RED = dna.palette.accent || "#b3271e";
const s = HG.fontPx / 120; // demo face was cut at 120px — chrome scales with it
const bw = Math.max(3, Math.round(6 * s)),
rad = Math.round(14 * s);
const padT = Math.round(20 * s),
padX = Math.round(40 * s),
padB = Math.round(14 * s);
const paperW = Math.round(HG.halfW * 2 * 1.18),
paperH = Math.round(HG.fontPx * 2.83);
const indW = Math.round(HG.halfW * 2 * 1.06),
indH = Math.round(HG.fontPx * 2.08);
const patches = p.patches || [
[27, 36, 0.72],
[71, 68, 0.57],
];
const loomDur = Math.min(1.7, DUR - (C + 0.35) - 0.03);
const breDur = Math.min(1.6, DUR - (C + 0.45) - 0.03);
const css = `
#dsep { position:absolute; inset:0; opacity:0; background:${dna.palette.paper || "#d8c49a"}; }
#dvig { position:absolute; inset:0; opacity:0;
background: radial-gradient(115% 100% at 50% 40%, rgba(0,0,0,0) 30%, rgba(20,14,8,0.85) 100%); }
#ddim { position:absolute; inset:0; opacity:0; background:#140d06; }
#dapex { position:absolute; left:${HG.x}px; top:${HG.y}px; width:0; height:0; }
#dpaper { position:absolute; width:${paperW}px; height:${paperH}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, rgba(222,203,160,0.92) 0%, rgba(216,196,154,0.55) 52%, rgba(216,196,154,0) 78%); }
#dindent { position:absolute; width:${indW}px; height:${indH}px; opacity:0; filter:blur(8px);
background: radial-gradient(50% 50% at 50% 50%, rgba(26,16,9,0.62) 0%, rgba(26,16,9,0.30) 48%, rgba(26,16,9,0) 72%); }
#dstamp { position:absolute; }
#dsface { position:relative; display:inline-block; }
.dface { font-family:'${dna.fonts.hero}', serif; font-size:${HG.fontPx}px; line-height:1;
white-space:nowrap; letter-spacing:0.015em; color:${RED};
border:${bw}px solid currentColor; border-radius:${rad}px;
padding:${padT}px ${padX}px ${padB}px; }
#dghost, #dp1, #dp2 { position:absolute; inset:0; }
#dghost { opacity:0; }
#dp1 .dface, #dp2 .dface { color:rgba(219,199,156,0.55); }
#dp1 { clip-path: circle(${Math.round(patches[0][2] * HG.fontPx)}px at ${patches[0][0]}% ${patches[0][1]}%); }
#dp2 { clip-path: circle(${Math.round(patches[1][2] * HG.fontPx)}px at ${patches[1][0]}% ${patches[1][1]}%); }`;
const html = ` <div id="dsep"></div>
<div id="dvig"></div>
<div id="ddim"></div>
<div id="dapex">
<div id="dpaper"></div>
<div id="dindent"></div>
<div id="dstamp">
<div id="dsface">
<div id="dghost"><div class="dface">${esc(heroText)}</div></div>
<div id="dmain" class="dface">${esc(heroText)}</div>
<div id="dp1"><div class="dface">${esc(heroText)}</div></div>
<div id="dp2"><div class="dface">${esc(heroText)}</div></div>
</div>
</div>
</div>`;
const js = `
// ---- setpiece: RUBBERSTAMP (archive charge → stamp crush → dead-still hold → filed away) ----
const I = ${I.toFixed(3)}, C = ${C.toFixed(3)}, X = ${X.toFixed(3)};
// static geometry
gsap.set("#dpaper", { xPercent:-50, yPercent:-50, rotation: ${p.paperRot ?? 2.4} });
gsap.set("#dindent", { xPercent:-50, yPercent:-50, rotation: ${ROT} });
gsap.set("#dstamp", { xPercent:-50, yPercent:-50, rotation: ${ROT}, opacity: 0,
transformOrigin: "50% 50%" });
gsap.set("#dghost", { x: ${Math.round(5 * s) || 5}, y: -${Math.round(4 * s) || 4}, rotation: 1.3 });
// ===== SCENE REACTION: the footage becomes an archive page =====
tl.fromTo("#dvig", { opacity: 0 }, { opacity: ${dna.plate.charge}, duration: 0.18, ease: "power2.in" }, C - 0.18);
tl.fromTo("#dsep", { opacity: 0 }, { opacity: ${p.sepia ?? 0.14}, duration: 0.28, ease: "power1.out" }, C - 0.12);
tl.set("#ddim", { opacity: ${dna.plate.dim} }, C);
tl.to("#ddim", { opacity: ${(dna.plate.dim * 0.385).toFixed(2)}, duration: 0.5, ease: "power2.out" }, C + 0.05);
tl.to("#dvig", { opacity: ${(dna.plate.charge * 0.5).toFixed(2)}, duration: 0.8, ease: "power2.out" }, C + 0.12);
tl.to(["#dvig","#dsep","#ddim"], { opacity: 0, duration: 0.4, ease: "power1.in" }, X - 0.25);
// the page lights up an instant before contact (what the stamp lands ON)
tl.fromTo("#dpaper", { opacity: 0, scale: 0.92 },
{ opacity: 0.6, scale: 1, duration: 0.10, ease: "expo.out" }, C - 0.05);
tl.to("#dpaper", { keyframes: { scale: [1, 1.016, 1.005, 1.018, 1.008, 1.015] },
duration: ${loomDur.toFixed(2)}, ease: "none" }, C + 0.35);
// ===== ARRIVAL: rubber stamp crush → contact squash → dead still =====
tl.set("#dstamp", { opacity: 1, scale: 2.2 }, C - 0.115);
tl.to("#dstamp", { scale: 1, duration: 0.115, ease: "power4.in" }, C - 0.115);
tl.set("#dstamp", { scaleX: 1.07, scaleY: 0.93 }, C); // contact squash
tl.to("#dstamp", { scaleX: 1, scaleY: 1, duration: 0.09, ease: "power2.out" }, C + 0.083);
// double-strike ghost prints on contact
tl.set("#dghost", { opacity: 0.35 }, C);
// wet ink settles
tl.set("#dmain", { filter: "saturate(1.5) brightness(1.3)" }, C);
tl.to("#dmain", { filter: "saturate(1) brightness(1)", duration: 0.5, ease: "power2.out" }, C + 0.06);
// paper indent shadow blooms under the stamp, then breathes (the hold-life;
// the stamp itself stays DEAD STILL)
tl.set("#dindent", { opacity: 0.5, scale: 0.7 }, C);
tl.to("#dindent", { scale: 1, opacity: 0.42, duration: 0.35, ease: "expo.out" }, C + 0.02);
tl.to("#dindent", { keyframes: { opacity: [0.42, 0.30, 0.40, 0.31, 0.38, 0.33] },
duration: ${breDur.toFixed(2)}, ease: "none" }, C + 0.45);
// ===== EXIT: page filed away — mechanical 3-frame yank-down cut =====
tl.set("#dapex", { y: 10 }, X);
tl.set("#dapex", { y: 28, opacity: 0.5 }, X + F);
tl.set("#dapex", { opacity: 0 }, X + 2 * F);`;
return { css, html, js };
}
function setpieceLasercage() {
// LASER CAGE: concert blackout — the plate dims and haze cones breathe in
// the top corners; a 6-beam laser array (alternating accent/magenta,
// emitters along the top edge) snaps on and SWEEPS to converge on the word
// center, a hot core pops at convergence, and the word IGNITES behind the
// subject with an additive glow stack (wide blur + magenta fringe + tight
// blur + core): crush → contact squash → elastic settle + 2 controlled
// strobe pops. A criss-cross beam CAGE draws itself around the word rect
// and flickers; during the hold the array FANS OUT slowly behind the word
// (the iconic concert fan) while the word looms. Exit: the beams sweep away
// L→R carrying the letters (streak + blur) and the room lifts back up.
const I = heroIn;
const X0 = +(heroOut - 0.38).toFixed(3); // beams-carry-the-letters exit
const A = dna.palette.accent,
MG = dna.palette.magenta || "#ff4df0";
const CORE = dna.palette.core || "#eefff5",
SOFT = dna.palette.glowSoft || "#c9ffe2";
const fpx = HG.fontPx,
halfW = Math.round(HG.halfW),
cx = HG.x,
cy = HG.y;
// emitters along the top edge, initial aim points, fan-out floor targets
// (demo geometry expressed as frame fractions)
const EMX = [-0.023, 0.141, 0.352, 0.648, 0.859, 1.023].map((f) => Math.round(W * f));
const EMY = [-20, -45, -55, -55, -45, -20];
const STX = [0.164, 0.828, 0.313, 0.688, 0.406, 0.594].map((f) => Math.round(W * f));
const FANX = [0.094, 0.258, 0.426, 0.574, 0.742, 0.906].map((f) => Math.round(W * f));
const FANY = [0.75, 0.9, 0.97, 0.97, 0.9, 0.75].map((f) => Math.round(H * f));
// beam-grid cage around the word rect (extents derive from halfW + fontPx)
const CAGE = [
[cx - (halfW + 90), cy + 0.76 * fpx, cx + (halfW + 90), cy - 0.68 * fpx, A, 0.5],
[cx - (halfW + 90), cy - 0.68 * fpx, cx + (halfW + 90), cy + 0.76 * fpx, MG, 0.42],
[cx - (halfW - 80), cy + 1.0 * fpx, cx + (halfW - 80), cy - 0.92 * fpx, A, 0.5],
[cx - (halfW - 80), cy - 0.92 * fpx, cx + (halfW - 80), cy + 1.0 * fpx, MG, 0.42],
[cx - (halfW + 40), cy - 0.74 * fpx, cx + (halfW + 40), cy - 0.74 * fpx, A, 0.45],
[cx - (halfW + 40), cy + 0.82 * fpx, cx + (halfW + 40), cy + 0.82 * fpx, MG, 0.4],
].map((c) => c.map((v) => (typeof v === "number" ? Math.round(v) : v)));
const spillW = 2 * halfW + 240,
spillH = Math.round(fpx * 5.2);
const hazeW = Math.round(W * 0.656),
hazeH = H + 60;
const blurBig = Math.round(0.22 * fpx),
blurTight = Math.max(3, Math.round(0.07 * fpx)),
fringeOff = Math.max(2, Math.round(0.03 * fpx));
// hold-life choreography only with runway before the exit
const strobeTs = [I + 0.5, I + 1.08].filter((t) => t < X0 - 0.25);
const fanDur = +Math.max(0.4, Math.min(1.0, X0 - (I + 0.62 + 5 * 0.04) - 0.05)).toFixed(2);
const flickDur = +Math.max(0.4, Math.min(1.2, X0 - 0.05 - (I + 0.6))).toFixed(2);
const lstag = +Math.min(
0.025,
Math.max(0.008, (DUR - 0.25 - (X0 + 0.02)) / Math.max(1, heroText.length - 1)),
).toFixed(4);
const css = `
#dimC { position:absolute; inset:0; background:#000; opacity:0; }
#hazeLc { position:absolute; left:-150px; top:-130px; width:${hazeW}px; height:${hazeH}px; opacity:0;
background: radial-gradient(58% 95% at 12% 4%, ${A}80 0%, ${A}00 68%);
filter: blur(10px); }
#hazeRc { position:absolute; right:-150px; top:-130px; width:${hazeW}px; height:${hazeH}px; opacity:0;
background: radial-gradient(58% 95% at 88% 4%, ${MG}6b 0%, ${MG}00 68%);
filter: blur(10px); }
#spillC { position:absolute; left:${cx}px; top:${cy}px; width:${spillW}px; height:${spillH}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, ${A}8c 0%, ${A}00 70%);
mix-blend-mode:screen; }
#lasersC { position:absolute; inset:0; }
#hotC { position:absolute; left:${cx}px; top:${cy}px; width:130px; height:130px; opacity:0;
background: radial-gradient(circle, rgba(235,255,244,0.95) 0%, ${A}99 42%, ${A}00 72%);
filter: blur(4px); mix-blend-mode:screen; }
#apexC { position:absolute; left:${cx}px; top:${cy}px; opacity:0; white-space:nowrap;
font-family:'${dna.fonts.hero}', sans-serif; font-size:${fpx}px; line-height:1; letter-spacing:0.02em; }
.clayer { position:absolute; left:0; top:0; }
#cglow { color:${A}; filter:blur(${blurBig}px); opacity:0; mix-blend-mode:screen; }
#cglow2 { color:${SOFT}; filter:blur(${blurTight}px); opacity:0; mix-blend-mode:screen; }
#cfringe{ color:${MG}; left:${fringeOff}px; top:${fringeOff}px; opacity:0; }
#cword { position:relative; color:${CORE};
text-shadow: 0 0 16px ${A}d9, 0 0 44px ${A}80, 0 2px 8px rgba(0,0,0,0.55); }
#cword .L { display:inline-block; }`;
const html = ` <div id="dimC"></div>
<div id="hazeLc"></div><div id="hazeRc"></div>
<div id="spillC"></div>
<svg id="lasersC" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
<g id="gB"></g><g id="gC"></g>
</svg>
<div id="hotC"></div>
<div id="apexC">
<div class="clayer" id="cglow">${esc(heroText)}</div>
<div class="clayer" id="cfringe">${esc(heroText)}</div>
<div class="clayer" id="cglow2">${esc(heroText)}</div>
<div id="cword"></div>
</div>`;
const js = `
// ---- setpiece: LASERCAGE (6-beam converge → ignite → cage → fan-out → carried exit) ----
const I = ${I.toFixed(3)}, X0 = ${X0.toFixed(3)};
const CACC = ${J(A)}, CMAG = ${J(MG)};
const NSC = "http://www.w3.org/2000/svg";
gsap.set("#apexC", { xPercent: -50, yPercent: -50, transformOrigin: "50% 50%" });
gsap.set(["#spillC", "#hotC"], { xPercent: -50, yPercent: -50 });
// apex word as letters (for the carried-away exit)
const cw = document.getElementById("cword");
const cletters = [];
for (const ch of ${J(heroText)}) {
const s = document.createElement("span");
s.className = "L"; s.textContent = ch === " " ? "\\u00a0" : ch;
cw.appendChild(s); cletters.push(s);
}
// ---- 6-beam array (emitters along the top edge, alternating accent/magenta)
const gB = document.getElementById("gB"), gC = document.getElementById("gC");
const EM = ${J(EMX.map((x, i) => [x, EMY[i]]))};
const STARTX = ${J(STX)};
const FAN = ${J(FANX.map((x, i) => [x, FANY[i]]))};
function mkLnC(parent, x1, y1, x2, y2, col, w) {
const l = document.createElementNS(NSC, "line");
l.setAttribute("x1", x1); l.setAttribute("y1", y1);
l.setAttribute("x2", x2); l.setAttribute("y2", y2);
l.setAttribute("stroke", col); l.setAttribute("stroke-width", w);
l.setAttribute("stroke-linecap", "round");
l.style.opacity = 0; parent.appendChild(l); return l;
}
const cbeams = [];
for (let i = 0; i < 6; i++) {
const col = i % 2 ? CMAG : CACC;
const halo = mkLnC(gB, EM[i][0], EM[i][1], STARTX[i], ${cy + 4}, col, 6);
const core = mkLnC(gB, EM[i][0], EM[i][1], STARTX[i], ${cy + 4}, col, 1.6);
core.style.filter = "drop-shadow(0 0 6px " + col + ")";
cbeams.push([halo, core]);
}
// ---- beam-grid cage around the word rect
const ccage = ${J(CAGE)}.map(([x1, y1, x2, y2, col, op]) => {
const el = mkLnC(gC, x1, y1, x2, y2, col, 1.2);
const len = Math.hypot(x2 - x1, y2 - y1);
el.setAttribute("stroke-dasharray", len);
el.setAttribute("stroke-dashoffset", len);
return { el, len, op };
});
// ===== WORLD: concert blackout + haze cones
tl.fromTo("#dimC", { opacity: 0 }, { opacity: ${dna.plate.dim}, duration: 0.35, ease: "power1.out" }, 0.03);
tl.to(["#hazeLc","#hazeRc"], { opacity: 0.08, duration: 0.5, ease: "power1.out" }, 0.15);
// ===== CHARGE: room deepens, 6 beams snap on and sweep to center
tl.to("#dimC", { opacity: ${dna.plate.charge}, duration: 0.26, ease: "power2.in" }, I - 0.31);
tl.to(["#hazeLc","#hazeRc"], { opacity: 0.10, duration: 0.28 }, I - 0.29);
cbeams.forEach((els, i) => {
const t0 = I - 0.26 + i * 0.022;
tl.set(els[0], { opacity: 0.26 }, t0);
tl.set(els[1], { opacity: 0.95 }, t0);
tl.to(els, { attr: { x2: ${cx}, y2: ${cy} }, duration: 0.16, ease: "power2.inOut" }, t0);
});
tl.fromTo("#hotC", { opacity: 0, scale: 0.4 },
{ opacity: 0.6, scale: 1, duration: 0.1, ease: "power2.in" }, I - 0.11);
tl.to("#hotC", { scale: 2.8, opacity: 0, duration: 0.2, ease: "expo.out" }, I);
// ===== IGNITE: crush in → contact squash → elastic settle; additive glow stack
tl.set("#apexC", { opacity: 1, scale: 1.16 }, I - 0.02);
tl.to("#apexC", { scale: 1, duration: 0.09, ease: "power4.in" }, I - 0.018);
tl.set("#apexC", { scaleX: 1.09, scaleY: 0.92 }, I + 0.08);
tl.to("#apexC", { scaleX: 1, scaleY: 1, duration: 0.5, ease: "elastic.out(1, 0.38)" }, I + 0.165);
tl.set("#cglow", { opacity: 0.95 }, I);
tl.set("#cglow2", { opacity: 0.60 }, I);
tl.set("#cfringe",{ opacity: 0.30 }, I);
tl.set("#cword", { filter: "brightness(1.9)" }, I);
tl.to("#cword", { filter: "brightness(1)", duration: 0.3, ease: "power2.out" }, I + 0.08);
tl.to("#cglow", { opacity: 0.55, duration: 0.45, ease: "power2.out" }, I + 0.15);
tl.to("#cglow2", { opacity: 0.38, duration: 0.45, ease: "power2.out" }, I + 0.15);
tl.set("#dimC", { opacity: ${(dna.plate.charge - 0.06).toFixed(2)} }, I + 0.05);
tl.to("#dimC", { opacity: ${(dna.plate.charge - 0.1).toFixed(2)}, duration: 0.8, ease: "power1.out" }, I + 0.2);
tl.set("#spillC", { opacity: 0.22 }, I + 0.02);
tl.to("#spillC", { opacity: 0.12, duration: 0.2, ease: "power2.out" }, I + 0.25);
// ===== CAGE draws around the word
ccage.forEach((c, i) => {
const t = I + 0.08 + i * 0.03;
tl.set(c.el, { opacity: c.op }, t);
tl.to(c.el, { attr: { "stroke-dashoffset": 0 }, duration: 0.18, ease: "power2.out" }, t);
});
tl.to(ccage.map((c) => c.el),
{ keyframes: { opacity: [0.48, 0.36, 0.44, 0.34, 0.42, 0.38] }, duration: ${flickDur}, ease: "none" },
I + 0.6);
// ===== controlled strobe pops (with the ignite flash: events spread > 1s)
${J(strobeTs.map((t) => +t.toFixed(3)))}.forEach((t) => {
tl.set("#apexC", { filter: "brightness(2.05)" }, t);
tl.set("#apexC", { filter: "brightness(1)" }, t + 0.05);
tl.set("#spillC", { opacity: 0.27 }, t);
tl.to("#spillC", { opacity: 0.12, duration: 0.3, ease: "power2.out" }, t + 0.05);
});
// ===== FAN OUT: the iconic fan opens behind the word during the hold
cbeams.forEach((els, i) => {
const t = I + 0.62 + i * 0.04;
tl.to(els, { attr: { x2: FAN[i][0], y2: FAN[i][1] }, duration: ${fanDur}, ease: "power1.inOut" }, t);
tl.to(els[0], { opacity: 0.18, duration: 0.4 }, t);
tl.to(els[1], { opacity: 0.50, duration: 0.4 }, t);
});
tl.to(["#hazeLc","#hazeRc"], { opacity: 0.16, duration: 0.6, ease: "power1.inOut" }, I + 0.35);
// ===== HOLD LIFE: loom breathe + glow flicker
tl.to("#apexC", { scale: 1.03, duration: 0.6, ease: "sine.inOut" }, I + 0.8);
tl.to("#apexC", { scale: 1.0, duration: 0.6, ease: "sine.inOut" }, I + 1.4);
tl.to("#cglow", { keyframes: { opacity: [0.55, 0.42, 0.52, 0.40, 0.48, 0.45] },
duration: ${Math.min(1.15, Math.max(0.4, X0 - 0.06 - (I + 0.62))).toFixed(2)}, ease: "none" }, I + 0.62);
// ===== EXIT: beams sweep away L→R carrying the letters
ccage.forEach((c, i) => {
tl.to(c.el, { attr: { "stroke-dashoffset": c.len }, duration: 0.16, ease: "power2.in" }, X0 + i * 0.02);
tl.set(c.el, { opacity: 0 }, X0 + i * 0.02 + 0.17);
});
cletters.forEach((l, i) => {
tl.to(l, { x: 44, opacity: 0, filter: "blur(6px)", duration: 0.16, ease: "power2.in" },
X0 + 0.02 + i * ${lstag});
});
tl.to(["#cglow","#cglow2","#cfringe"], { x: 60, opacity: 0, duration: 0.22, ease: "power2.in" }, X0 + 0.06);
cbeams.forEach((els, i) => {
tl.to(els, { attr: { x2: "+=${Math.round(W * 0.742)}" }, duration: 0.28, ease: "power2.in" }, X0 + 0.02 + i * 0.03);
tl.to(els, { opacity: 0, duration: 0.22 }, X0 + 0.04 + i * 0.03);
});
tl.to("#spillC", { opacity: 0, duration: 0.25 }, X0 - 0.01);
tl.to("#dimC", { opacity: 0.12, duration: 0.4, ease: "power1.in" }, X0 + 0.06);
tl.to(["#hazeLc","#hazeRc"], { opacity: 0.03, duration: 0.4 }, X0 + 0.06);
tl.set("#apexC", { display: "none" }, ${(DUR - 0.02).toFixed(3)});`;
return { css, html, js };
}
function setpieceBoltstrike() {
// THUNDER apex: the storm is the editor. A slate storm mood sits on the room
// from t=0 and the sky "kisses" (1-frame brighten) on every body word —
// double-kiss on emphasis words, re-flashes during the hold. Before the
// strike the room charge-dims under a radial word scrim; a hand-authored
// branched bolt (haze/glow/core stack) flashes 2 frames BEFORE the word
// (leader → return stroke at contact) with a sky flash, then the word slams
// in lit HARD (brightness 2.5 → 1) with its text-shadow flipped toward the
// bolt for the flash frames; an ozone under-glow blooms and breathes through
// the hold (loom + 2-frame lit blips on the sheet re-flashes); exit washes
// downward with the rain.
const p = dna.hero.params || {},
I = heroIn;
const A = dna.palette.accent, // ozone / bolt haze
GL = dna.palette.glow || "#cfe8ff", // bolt glow
CORE = dna.palette.core || "#ffffff", // bolt core
HC = dna.palette.hero || dna.palette.body;
const EX = theme.hero.exitAt ?? Math.min(heroOut - 0.2, I + (p.hold ?? 2.05));
const hpx = HG.fontPx,
halfW = HG.halfW || heroText.length * 0.28 * hpx;
// word scrim sized from the word rect (demo 1100×330 around a 720×145 word)
const scW = Math.round(2 * halfW + 380),
scH = Math.round(hpx * 2.28);
// hand-authored branched bolt (5-seg zigzag + 2 branches) measured off the
// demo cut — offsets from the word center in halfW (x) / fontPx (y) units;
// the leader always enters from above the frame
const sgn = p.side === "right" ? -1 : 1;
const kx = ((halfW || 360) / 360) * sgn,
ky = hpx / 145;
const pts = (arr, top) =>
arr
.map(([dx, dy], k) => {
const x = HG.x + dx * kx;
let y = HG.y + dy * ky;
if (top && k === 0) y = Math.min(y, -25);
return `${Math.round(x)},${Math.round(y)}`;
})
.join(" ");
const MAINP = pts(
[
[-45, -282],
[-107, -134],
[-23, -56],
[-113, 66],
[-17, 150],
[-85, 268],
],
true,
);
const BAP = pts([
[-107, -134],
[-183, -56],
[-157, 10],
]);
const BBP = pts([
[-113, 66],
[-187, 140],
[-163, 200],
]);
const wS = (w) => (w * ky).toFixed(1);
const boltSvg = (color, w, wb) =>
`<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">` +
`<polyline points="${MAINP}" fill="none" stroke="${color}" stroke-width="${wS(w)}" stroke-linejoin="miter" stroke-linecap="round"/>` +
`<polyline points="${BAP}" fill="none" stroke="${color}" stroke-width="${wS(wb)}" stroke-linecap="round"/>` +
`<polyline points="${BBP}" fill="none" stroke="${color}" stroke-width="${wS(wb)}" stroke-linecap="round"/>` +
`</svg>`;
// the sky kisses on every BODY word (the hero word is consumed by the slam)
const kissData = LINES.flatMap((L) =>
L.words.map((w) => [+w.start.toFixed(3), w.minor ? 1 : 0]),
).filter(([t]) => t < DUR - 0.1);
const css = `
#mood { position:absolute; inset:0; background:rgba(38,62,96,${p.mood ?? 0.1}); }
#dimP { position:absolute; inset:0; opacity:0; background:#0a1422; }
#kiss { position:absolute; inset:0; opacity:0;
background:linear-gradient(to bottom, rgba(222,236,255,0.95) 0%, rgba(222,236,255,0.40) 38%, rgba(222,236,255,0) 72%); }
#wordscrim { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${scW}px; height:${scH}px;
margin-left:-${Math.round(scW / 2)}px; margin-top:-${Math.round(scH / 2)}px; opacity:0; border-radius:50%;
background:radial-gradient(50% 50% at 50% 50%, rgba(5,10,20,0.85) 0%, rgba(5,10,20,0.45) 55%, rgba(5,10,20,0) 100%); }
#apex { position:absolute; left:${HG.x}px; top:${HG.y}px; opacity:0; }
.aw { position:absolute; left:0; top:0; transform:translate(-50%,-50%);
font-family:'${dna.fonts.hero}', sans-serif; font-size:${hpx}px; line-height:1;
letter-spacing:0.012em; white-space:nowrap; }
#ozone { color:${A}; filter:blur(${Math.max(10, Math.round(26 * ky))}px); opacity:0; top:${Math.round(8 * ky)}px; }
#aword { color:${HC};
text-shadow: 0 8px 26px rgba(3,8,18,0.85), 0 2px 6px rgba(3,8,18,0.7), 0 0 18px ${A}59; }
#bolt { position:absolute; inset:0; opacity:0; }
.boltlayer { position:absolute; inset:0; }
.boltlayer svg { position:absolute; inset:0; width:${W}px; height:${H}px; }
#boltHaze { filter:blur(16px); opacity:0.55; }
#boltGlow { filter:blur(5px); opacity:0.9; }
#skyflash { position:absolute; inset:0; opacity:0;
background:radial-gradient(120% 90% at 42% 22%, rgba(240,248,255,0.95) 0%, rgba(200,222,250,0.55) 45%, rgba(120,150,190,0) 100%); }`;
const html = ` <div id="mood"></div>
<div id="dimP"></div>
<div id="kiss"></div>
<div id="wordscrim"></div>
<div id="apex">
<div class="aw" id="ozone">${esc(heroText)}</div>
<div class="aw" id="aword">${esc(heroText)}</div>
</div>
<div id="bolt">
<div class="boltlayer" id="boltHaze">${boltSvg(A, 22, 13)}</div>
<div class="boltlayer" id="boltGlow">${boltSvg(GL, 9, 5)}</div>
<div class="boltlayer" id="boltCore">${boltSvg(CORE, 3.5, 2.2)}</div>
</div>
<div id="skyflash"></div>`;
// breathe/loom clamp inside [contact, exit] for any clip length
const breDur = Math.max(0.6, Math.min(1.5, EX - 0.23 - (I + 0.3)));
const loomDur = Math.max(0.4, EX - 0.04 - (I + 0.74));
const js = `
// ---- setpiece: BOLTSTRIKE (storm charge → branched-bolt leader → word slams in lit) ----
const I = ${I.toFixed(3)}, EXB = ${EX.toFixed(3)};
// distant lightning KISS: 1-frame sky brighten on every body word
const KISS = ${J(kissData)};
KISS.forEach(([t, em]) => {
tl.set("#kiss", { opacity: 0.06 }, t);
tl.set("#kiss", { opacity: 0 }, t + F);
if (em && t + 0.20 + 3 * F < ${(DUR - 0.04).toFixed(2)}) {
// emphasis words get the sheet-lightning double kiss (matches the rail double flash)
const t0 = t + 0.20;
tl.set("#kiss", { opacity: 0.07 }, t0);
tl.set("#kiss", { opacity: 0 }, t0 + F);
tl.set("#kiss", { opacity: 0.06 }, t0 + 2 * F);
tl.set("#kiss", { opacity: 0 }, t0 + 3 * F);
}
});
// sheet re-flashes during the apex hold
[I + 0.84, I + 1.44].forEach((t) => {
if (t < EXB - 0.2) { tl.set("#kiss", { opacity: 0.08 }, t); tl.set("#kiss", { opacity: 0 }, t + F); }
});
// CHARGE: the storm gathers before the strike (ends before the bolt frames)
tl.fromTo("#dimP", { opacity: 0 }, { opacity: ${dna.plate.charge || 0.3}, duration: 0.30, ease: "power2.in" }, I - 0.39);
tl.fromTo("#wordscrim", { opacity: 0 }, { opacity: 0.42, duration: 0.30, ease: "power2.in" }, I - 0.39);
// THE STRIKE: bolt flashes 2 frames before the word, return stroke at contact
tl.set("#bolt", { opacity: 1 }, I - 2 * F);
tl.set("#bolt", { opacity: 0.35 }, I - F);
tl.set("#bolt", { opacity: 1 }, I);
tl.to("#bolt", { opacity: 0, duration: 0.07, ease: "power2.in" }, I + 0.045);
tl.set("#skyflash", { opacity: 0.26 }, I - 2 * F);
tl.set("#skyflash", { opacity: 0.10 }, I - F);
tl.set("#skyflash", { opacity: 0.30 }, I + 0.02);
tl.to("#skyflash", { opacity: 0, duration: 0.22, ease: "expo.out" }, I + 0.06);
// dim released by the flash, settles to a storm hold, lifts at the end
tl.set("#dimP", { opacity: 0.08 }, I - 2 * F);
tl.to("#dimP", { opacity: ${dna.plate.dim || 0.22}, duration: 0.25, ease: "power2.out" }, I + 0.10);
tl.to("#dimP", { opacity: 0, duration: 0.40, ease: "power1.in" }, EXB - 0.14);
tl.set("#wordscrim", { opacity: 0.32 }, I - 2 * F);
tl.to("#wordscrim", { opacity: 0.36, duration: 0.30 }, I + 0.10);
tl.to("#wordscrim", { opacity: 0, duration: 0.30 }, EXB - 0.04);
// APEX WORD: slam transit → contact squash (2 frames) → elastic settle
tl.set("#apex", { opacity: 1 }, I - 2 * F);
tl.fromTo("#apex", { scale: 3.1 }, { scale: 1, duration: 0.105, ease: "power4.in" }, I - 2 * F);
tl.fromTo("#aword", { filter: "blur(14px) brightness(3.2)" },
{ filter: "blur(0px) brightness(2.5)", duration: 0.105, ease: "power4.in" }, I - 2 * F);
tl.set("#apex", { scaleX: 1.10, scaleY: 0.90 }, I + 0.025);
tl.to("#apex", { scaleX: 1, scaleY: 1, duration: 0.55, ease: "elastic.out(1.05, 0.34)" }, I + 0.025 + 2 * F);
// lit HARD for 2 frames, then cools
tl.to("#aword", { filter: "blur(0px) brightness(1)", duration: 0.45, ease: "power2.out" }, I + 0.025 + 2 * F);
// SIGNATURE: shadow flips toward the bolt (light from the strike side) for the flash frames
tl.set("#aword", { textShadow: "${16 * sgn}px 6px 18px rgba(2,6,14,0.9), ${4 * sgn}px 2px 6px rgba(2,6,14,0.75), 0 0 26px ${A}b3" }, I - 2 * F);
tl.set("#aword", { textShadow: "0 8px 26px rgba(3,8,18,0.85), 0 2px 6px rgba(3,8,18,0.7), 0 0 18px ${A}59" }, I + 0.09);
// ozone under-glow blooms with the hit, breathes during hold
tl.set("#ozone", { opacity: 0.85 }, I + 0.02);
tl.to("#ozone", { keyframes: { opacity: [0.85, 0.5, 0.65, 0.42, 0.55, 0.38] },
duration: ${breDur.toFixed(2)}, ease: "none" }, I + 0.30);
tl.to("#ozone", { opacity: 0.32, duration: 0.18 }, EXB - 0.22);
// hold life: loom + 2-frame lit blips on the sheet re-flashes
tl.to("#apex", { scale: 1.035, duration: ${loomDur.toFixed(2)}, ease: "power1.inOut" }, I + 0.74);
[[I + 0.84, 1.45], [I + 1.44, 1.35]].forEach(([t, b2]) => {
if (t < EXB - 0.2) {
tl.set("#aword", { filter: "brightness(" + b2 + ")" }, t);
tl.set("#aword", { filter: "brightness(1)" }, t + F);
}
});
// EXIT: washes downward with the rain
tl.to("#apex", { y: 38, opacity: 0, filter: "blur(6px)", duration: 0.22, ease: "power2.in" }, EXB);
tl.set("#apex", { display: "none" }, ${Math.min(EX + 0.26, DUR - 0.02).toFixed(3)});`;
return { css, html, js };
}
function setpieceHoloboot() {
// HOLOGRAM apex: full volumetric boot behind the subject. A desk emitter dot
// pops, the projection cone SNAPS wide (scaleX expo), a seed beam fires up
// from the emitter, and the word boots with a RISING clip sweep (opacity is
// instant — the reveal is a wipe) into a 7-layer projection stack (dark
// backing shadow / wide glow / magenta+cyan chromatic fringes / core /
// scanline mask / climbing interference bands). Contact = over-bright flash
// + squash held 2 frames + elastic settle + 1-frame x interference jumps;
// fringes blow wide then settle to a constant registration offset. Hold
// life: bands climb, rotationY oscillates, glow/cone breathe, emitter dot
// pulses, slow loom; a hand "passes through" the projection (skew + blur +
// fringe blowout, pure projection logic). Exit: the projector cut — the
// word collapses to a horizontal line, then to the base dot. A second,
// smaller emitter cone rises from the desk under the holorail plate all
// video (the body rail is cast from the SAME projector grammar).
const p = dna.hero.params || {},
I = heroIn;
const A = dna.palette.accent,
MG = dna.palette.magenta || "#ff5fd6";
const GLOW = dna.palette.glow || "#3eccf2",
CORE = dna.palette.core || "#eafbff";
const fpx = HG.fontPx,
halfW = Math.round(HG.halfW || heroText.length * 0.4 * fpx),
cx = HG.x,
cy = HG.y;
const k = fpx / 118; // demo scale unit (Orbitron 700 @ 118px)
// apex rig geometry derives from the word rect
const coneW = Math.min(2 * halfW + 80, W - 40);
const coneTop = Math.round(cy - 0.55 * fpx);
const emitY = Math.min(H - 30, Math.round(cy + 2.4 * fpx));
const coneH = emitY - coneTop;
const beamTop = coneTop + 40,
beamH = emitY - beamTop;
const cxP = ((cx / W) * 100).toFixed(1),
cyP = ((cy / H) * 100).toFixed(1);
// body-zone projector under the holorail plate (cone behind the subject)
const plateBot = Math.round(H - (dna.body.bottomPx || 108) + (dna.body.plateH || 96) / 2);
const bcW = p.coneW || 800,
bcTop = plateBot - 4,
bcH = H - bcTop - 4;
// interference-band pitch scales with the type
const bandH = Math.max(20, Math.round(fpx * 0.39));
const bp = (f) => Math.round(bandH * f);
// projector-cut exit + hold-life windows (all runway-clamped)
const cutT = +Math.min(heroOut - 0.22, DUR - 0.3).toFixed(3);
const C = I + 0.07; // contact
const holdD = +Math.max(0.6, Math.min(1.72, cutT - 0.05 - (I + 0.24))).toFixed(2);
const holdD2 = +Math.max(0.5, holdD - 0.1).toFixed(2);
const reactD = +Math.max(0.3, Math.min(0.9, cutT - 0.3 - (I + 0.24))).toFixed(2);
const loomD = +Math.min(0.62, Math.max(0.2, cutT - 0.3 - (I + 0.74))).toFixed(2);
// the hand-pass needs the loom done (scale channel) and clear exit runway
const passT = +Math.max(I + (p.passAt ?? 1.29), I + 1.25).toFixed(3);
const doPass = passT + 0.65 < cutT - 0.05;
const css = `
#dimH { position:absolute; inset:0; opacity:0; background:#02101a; }
#scrimH { position:absolute; inset:0; opacity:0;
background: radial-gradient(58% 46% at ${cxP}% ${cyP}%, rgba(2,9,15,0.88) 0%, rgba(2,9,15,0.55) 48%, rgba(2,9,15,0) 78%); }
#bcone { position:absolute; left:${Math.round(W / 2 - bcW / 2)}px; top:${bcTop}px; width:${bcW}px; height:${bcH}px; opacity:0;
clip-path: polygon(0% 0%, 100% 0%, 51.6% 100%, 48.4% 100%);
background: linear-gradient(to top, ${A}4d 0%, ${A}1a 55%, ${A}05 100%); }
#bdot { position:absolute; left:${Math.round(W / 2 - 4.5)}px; top:${H - 10}px; width:9px; height:9px; border-radius:50%;
background:${dna.palette.dot || "#bff0ff"}; opacity:0;
box-shadow: 0 0 10px ${A}e6, 0 0 24px ${A}8c; }
#apxcone { position:absolute; left:${Math.round(cx - coneW / 2)}px; top:${coneTop}px; width:${coneW}px; height:${coneH}px; opacity:0;
clip-path: polygon(0% 0%, 100% 0%, 53% 100%, 47% 100%);
background: linear-gradient(to top, ${A}57 0%, ${A}1f 50%, ${A}05 100%); }
#apxbeam { position:absolute; left:${Math.round(cx - 2.5)}px; top:${beamTop}px; width:5px; height:${beamH}px; opacity:0;
background: linear-gradient(to top, ${CORE}f2, ${CORE}1f);
filter: blur(0.6px); }
#apxdot { position:absolute; left:${Math.round(cx - 6.5)}px; top:${emitY - 6}px; width:13px; height:13px; border-radius:50%;
background:${dna.palette.dotHot || "#dff8ff"}; opacity:0;
box-shadow: 0 0 12px ${A}f2, 0 0 34px ${A}99; }
#apx { position:absolute; left:${cx}px; top:${cy}px; opacity:0; }
#apxclip { position:relative; }
.aw { font-family:'${dna.fonts.hero}', sans-serif; font-weight:700; font-size:${fpx}px; line-height:1;
letter-spacing:0; white-space:nowrap; }
#adark { position:absolute; left:0; top:${Math.max(2, Math.round(3 * k))}px; color:rgba(1,12,20,0.92);
filter:blur(${Math.max(4, Math.round(7 * k))}px); transform:scale(1.03); }
#aglow { position:absolute; left:0; top:0; color:${GLOW}f2;
filter:blur(${Math.max(8, Math.round(16 * k))}px); transform:scale(1.05); opacity:0.55; }
#cmag { position:absolute; left:0; top:0; color:${MG}8c; }
#ccyn { position:absolute; left:0; top:0; color:${A}99; }
#acore { position:relative; color:${CORE};
text-shadow: 0 0 22px ${A}73, 0 3px 14px rgba(1,10,16,0.55); }
#ascan { position:absolute; left:0; top:0; color:transparent;
-webkit-background-clip:text; background-clip:text;
background-image: repeating-linear-gradient(180deg, rgba(0,0,0,0) 0px, rgba(0,0,0,0) 2.5px, rgba(2,12,18,0.62) 2.5px, rgba(2,12,18,0.62) 4px); }
#abands { position:absolute; left:0; top:0; color:transparent; opacity:0;
-webkit-background-clip:text; background-clip:text;
background-image: repeating-linear-gradient(180deg, rgba(255,255,255,0) 0px, rgba(255,255,255,0) ${bp(0.717)}px, ${dna.palette.band || "#d8faff"}f2 ${bp(0.783)}px, ${dna.palette.band || "#d8faff"}f2 ${bp(0.87)}px, rgba(255,255,255,0) ${bp(0.935)}px, rgba(255,255,255,0) ${bandH}px);
background-size: 100% ${bandH}px; }`;
const html = ` <div id="dimH"></div>
<div id="scrimH"></div>
<div id="bcone"></div>
<div id="bdot"></div>
<div id="apxcone"></div>
<div id="apxbeam"></div>
<div id="apxdot"></div>
<div id="apx">
<div id="apxclip">
<div class="aw" id="adark">${esc(heroText)}</div>
<div class="aw" id="aglow">${esc(heroText)}</div>
<div class="aw" id="cmag">${esc(heroText)}</div>
<div class="aw" id="ccyn">${esc(heroText)}</div>
<div class="aw" id="acore">${esc(heroText)}</div>
<div class="aw" id="ascan">${esc(heroText)}</div>
<div class="aw" id="abands">${esc(heroText)}</div>
</div>
</div>`;
// body-projector flicker cycles, baked to the clip length
const coneFlicks = [];
for (let t = 0.6; t + 2.2 <= DUR - 0.25; t += 2.3) coneFlicks.push(+t.toFixed(2));
const dotPulses = [];
let dt = 0.5;
for (; dt + 2.0 <= DUR - 0.3; dt += 2.15) dotPulses.push(+dt.toFixed(2));
const dotTail = dt + 1.0 <= DUR - 0.24 ? +dt.toFixed(2) : null;
const js = `
// ---- setpiece: HOLOBOOT (emitter pop → cone snap → seed beam → rising-sweep word boot) ----
const I = ${I.toFixed(3)}, CUT = ${cutT.toFixed(3)};
gsap.set("#apx", { xPercent: -50, yPercent: -50, transformPerspective: 900, transformOrigin: "50% 50%" });
gsap.set("#apxcone", { transformOrigin: "50% 100%" });
gsap.set("#apxbeam", { transformOrigin: "50% 100%" });
gsap.set("#bcone", { transformOrigin: "50% 100%" });
gsap.set("#apxclip", { clipPath: "inset(100% 0% 0% 0%)" });
// ===== body-zone projector boots with the rail (cone behind the subject)
tl.set("#bdot", { opacity: 1 }, 0.06);
tl.fromTo("#bdot", { scale: 0.2 }, { scale: 1, duration: 0.12, ease: "back.out(2)" }, 0.06);
tl.set("#bcone", { opacity: 0.9 }, 0.08);
tl.fromTo("#bcone", { scaleY: 0.05 }, { scaleY: 1, duration: 0.14, ease: "expo.out" }, 0.08);
tl.set("#bcone", { opacity: 0.5 }, 0.22);
tl.set("#bcone", { opacity: 0.9 }, 0.26);
${J(coneFlicks)}.forEach((t, i) => {
tl.to("#bcone", { keyframes: { opacity: i % 2 ? [0.86, 0.72, 0.84, 0.7, 0.84] : [0.9, 0.74, 0.86, 0.72, 0.86] },
duration: 2.2, ease: "none" }, t);
});
${J(dotPulses)}.forEach((t) => {
tl.to("#bdot", { keyframes: { scale: [1, 1.3, 1, 1.25, 1] }, duration: 2.0, ease: "none" }, t);
});
${dotTail != null ? `tl.to("#bdot", { keyframes: { scale: [1, 1.25, 1] }, duration: 1.0, ease: "none" }, ${dotTail});` : ""}
// projector cut at the very end
tl.to("#bcone", { opacity: 0, duration: 0.18, ease: "power1.in" }, ${(DUR - 0.2).toFixed(3)});
tl.set("#bdot", { scale: 1.6 }, ${(DUR - 0.12).toFixed(3)});
tl.set("#bdot", { opacity: 0 }, ${(DUR - 0.05).toFixed(3)});
// ===== scene reaction: the room dims while the volumetric apex burns
tl.fromTo("#scrimH", { opacity: 0 }, { opacity: ${dna.plate.scrim ?? 0.58}, duration: 0.34, ease: "power2.in" }, I - 0.21);
tl.set("#dimH", { opacity: ${dna.plate.charge ?? 0.28} }, I - 0.05);
tl.to("#dimH", { opacity: ${dna.plate.dim ?? 0.18}, duration: ${reactD}, ease: "power2.out" }, I + 0.24);
tl.to("#scrimH", { opacity: ${((dna.plate.scrim ?? 0.58) * 0.79).toFixed(2)}, duration: ${reactD}, ease: "power2.out" }, I + 0.24);
tl.to(["#dimH","#scrimH"], { opacity: 0, duration: 0.4, ease: "power1.in" }, CUT - 0.12);
// ===== APEX: full volumetric boot
// 1. emitter dot pops, cone SNAPS wide
tl.set("#apxdot", { opacity: 1 }, I - 0.135);
tl.fromTo("#apxdot", { scale: 0.2 }, { scale: 1, duration: 0.12, ease: "back.out(2.4)" }, I - 0.135);
tl.set("#apxcone", { opacity: 1 }, I - 0.125);
tl.fromTo("#apxcone", { scaleX: 0.07 }, { scaleX: 1, duration: 0.11, ease: "expo.out" }, I - 0.125);
tl.set("#apxcone", { opacity: 0.55 }, I - 0.01);
tl.set("#apxcone", { opacity: 0.95 }, I + 0.035);
// 2. seed beam fires up from the emitter
tl.set("#apxbeam", { opacity: 1 }, I - 0.095);
tl.fromTo("#apxbeam", { scaleY: 0 }, { scaleY: 1, duration: 0.085, ease: "power3.in" }, I - 0.095);
tl.to("#apxbeam", { opacity: 0, duration: 0.3, ease: "power1.in" }, I + 0.11);
// 3. the word boots with a RISING sweep (opacity instant, reveal is a wipe)
tl.set("#apx", { opacity: 1 }, I - 0.055);
tl.to("#apxclip", { clipPath: "inset(0% 0% 0% 0%)", duration: 0.125, ease: "power2.in" }, I - 0.055);
tl.set("#apxclip", { clipPath: "none" }, I + 0.08); // free the glow bloom after the sweep
// 4. contact: over-bright flash + squash held 2 frames + elastic settle
tl.set("#apxclip", { filter: "brightness(2.3)" }, ${C.toFixed(3)});
tl.to("#apxclip", { filter: "brightness(1)", duration: 0.36, ease: "power2.out" }, ${(C + 0.05).toFixed(3)});
tl.set("#apx", { scaleX: 1.07, scaleY: 0.91 }, ${C.toFixed(3)});
tl.to("#apx", { scaleX: 1, scaleY: 1, duration: 0.5, ease: "elastic.out(1, 0.38)" }, ${(C + 0.084).toFixed(3)});
// interference glitch on arrival (1-frame x jumps)
tl.set("#apxclip", { x: 3 }, ${(C + 0.02).toFixed(3)});
tl.set("#apxclip", { x: -3 }, ${(C + 0.062).toFixed(3)});
tl.set("#apxclip", { x: 0 }, ${(C + 0.104).toFixed(3)});
// chromatic fringes blow wide then settle to the constant offset
tl.fromTo("#cmag", { x: -8 }, { x: -2, duration: 0.4, ease: "power2.out" }, ${C.toFixed(3)});
tl.fromTo("#ccyn", { x: 8 }, { x: 2, duration: 0.4, ease: "power2.out" }, ${C.toFixed(3)});
// ===== hold life: interference bands climb, rotateY oscillates, glow breathes, loom
tl.set("#abands", { opacity: 0.55, backgroundPosition: "0px 0px" }, I + 0.24);
tl.to("#abands", { backgroundPosition: "0px -${6 * bandH}px", duration: ${holdD}, ease: "none" }, I + 0.24);
tl.to("#apx", { keyframes: { rotationY: [0, 6, -7, 5, 0] }, duration: ${holdD}, ease: "none" }, I + 0.24);
tl.to("#aglow", { keyframes: { opacity: [0.55, 0.4, 0.52, 0.38, 0.5] }, duration: ${holdD2}, ease: "none" }, I + 0.29);
tl.to("#apxcone", { keyframes: { opacity: [0.95, 0.78, 0.9, 0.76, 0.88] }, duration: ${holdD2}, ease: "none" }, I + 0.29);
tl.to("#apxdot", { keyframes: { scale: [1, 1.4, 1, 1.35, 1, 1.3, 1] }, duration: ${holdD2}, ease: "none" }, I + 0.29);
${loomD >= 0.2 ? `tl.to("#apx", { scale: 1.028, duration: ${loomD}, ease: "sine.inOut" }, I + 0.74);` : ""}
${
doPass
? `
// ===== a hand "passes through" the projection: pure projection logic
const P = ${passT.toFixed(3)};
tl.set("#apx", { skewX: 6 }, P);
tl.set("#apxclip", { filter: "blur(2.5px) brightness(1.35)" }, P);
tl.set("#cmag", { x: -7 }, P);
tl.set("#ccyn", { x: 7 }, P);
tl.set("#apx", { skewX: -4 }, P + 0.042);
tl.set("#apxclip", { filter: "blur(1px)" }, P + 0.083);
tl.set("#apx", { skewX: 0 }, P + 0.125);
tl.set("#apxclip", { filter: "none" }, P + 0.125);
tl.to("#cmag", { x: -2, duration: 0.18, ease: "power2.out" }, P + 0.05);
tl.to("#ccyn", { x: 2, duration: 0.18, ease: "power2.out" }, P + 0.05);
tl.set("#apx", { scaleY: 0.97 }, P + 0.13);
tl.to("#apx", { scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.4)" }, P + 0.172);`
: ""
}
// ===== EXIT: projector cut — collapse to a horizontal line, then to the base dot
tl.set("#apxclip", { filter: "brightness(2.4)" }, CUT - 0.02);
tl.to("#apx", { scaleY: 0.018, duration: 0.09, ease: "power3.in" }, CUT);
tl.to("#apx", { scaleX: 0.015, duration: 0.075, ease: "power3.in" }, CUT + 0.10);
tl.set("#apx", { opacity: 0 }, CUT + 0.18);
tl.to("#apxcone", { opacity: 0, duration: 0.16, ease: "power1.in" }, CUT + 0.02);
tl.set("#apxdot", { scale: 2.2 }, CUT + 0.12);
tl.to("#apxdot", { opacity: 0, duration: 0.09, ease: "power2.in" }, CUT + 0.18);`;
return { css, html, js };
}
function setpieceBiobloom() {
// BIOLUME apex: a bioluminescent BLOOM behind the subject. The abyss grade
// (params.mood) + a breathing vignette sit on the room from t=0 — the world
// IS underwater before anything speaks. A creature pre-glow gathers with a
// double-beat anticipation and crushes to a point; a dark pocket scrim
// (plate.scrim) opens behind the word zone; the word CONDENSES in (scale
// 2.35 crush power4.in 0.11s, blur 15→2, brightness 2.6→1.7) → 2-frame
// contact squash → elastic settle while cyan light SPILLS onto the scene;
// 6 luminous tendrils (blurred SVG strokes, cyan/violet alternating) draw
// outward from the word rect, staggered; through the hold the glow breathes
// 8–14%, two counter-drifting caustic gradients shimmer over the text zone
// and the bloom drifts slowly with the current (params.drift). Exit: the
// light SINKS — every glowing thing drifts down while dimming. Tendril
// geometry is hand-authored from the demo and baked at compile time as
// offsets from the word center (halfW/x × fontPx/120 units, frame-clamped).
const p = dna.hero.params || {},
I = heroIn;
const A = dna.palette.accent,
VIO = dna.palette.violet || "#9d7bff",
ICE = dna.palette.ice || "#8fe8ff",
CORE = dna.palette.core || "#f2fffd";
const fpx = HG.fontPx,
halfW = Math.round(HG.halfW || heroText.length * 0.33 * fpx),
cx = HG.x,
cy = HG.y;
const ky = fpx / 120; // demo scale unit (Fredoka 700 @ 120px)
const demoHalf = (wordPx("RECOVER", dna.fonts.hero, 120, 0.01) || 562) / 2;
const kx = halfW / demoHalf;
const TX = (x) => Math.round(Math.max(16, Math.min(W - 16, cx + (x - 560) * kx)));
const TY = (y) => Math.round(Math.max(16, Math.min(H - 16, cy + (y - 300) * ky)));
// 6 tendril cubics: anchors on the word rect edge, filaments reaching out
const TENDRILS = [
[
[340, 268],
[255, 230],
[195, 185],
[135, 110],
],
[
[525, 244],
[500, 185],
[560, 140],
[520, 70],
],
[
[725, 262],
[795, 222],
[850, 180],
[915, 112],
],
[
[365, 332],
[280, 372],
[215, 425],
[150, 492],
],
[
[580, 352],
[605, 415],
[545, 465],
[585, 530],
],
[
[715, 335],
[790, 380],
[855, 425],
[920, 475],
],
].map((pts) => {
const q = pts.map(([x, y]) => TX(x) + " " + TY(y));
return `M ${q[0]} C ${q[1]}, ${q[2]}, ${q[3]}`;
});
const TCOL = [A, VIO, ICE, A, VIO, A];
const C = +(I + (p.crush ?? 0.11)).toFixed(3); // contact
const SINK = +Math.min(heroOut - 0.2, DUR - 0.3).toFixed(3);
// tendril stagger auto-clamps so the last filament completes before the sink
const stag = +Math.max(0.02, Math.min(0.05, (SINK - 0.46 - (C + 0.02)) / 5)).toFixed(3);
const breatheD = +Math.min(0.72, Math.max(0.3, DUR - 0.06 - (C + 0.12))).toFixed(2);
const flickD = +Math.min(0.7, SINK - 0.02 - (C + 0.34)).toFixed(2);
const shimD = +Math.min(0.5, Math.max(0.2, SINK + 0.03 - (C + 0.21))).toFixed(2);
const driftD = +Math.min(0.64, Math.max(0.2, SINK - (I + 0.21))).toFixed(2);
const wordFlickT = I + 0.59 + 0.32 <= SINK + 0.05 ? +(I + 0.59).toFixed(3) : null;
// geometry derived from the word rect
const pkW = Math.round(2 * halfW + 280),
pkH = Math.round(2.5 * fpx);
const pgW = Math.round(1.083 * fpx),
pgH = Math.round(0.717 * fpx);
const cxP = ((cx / W) * 100).toFixed(1),
cyP = ((cy / H) * 100).toFixed(1);
const css = `
#abyss { position:absolute; inset:0; opacity:${p.mood ?? 0.92};
background:linear-gradient(180deg, rgba(3,11,26,0.52) 0%, rgba(3,11,26,0.30) 30%,
rgba(4,9,20,0.10) 52%, rgba(2,7,16,0.18) 78%, rgba(1,5,12,0.34) 100%); }
#vig { position:absolute; inset:0; opacity:0.75;
background:radial-gradient(120% 110% at 50% 46%, rgba(0,0,0,0) 36%,
rgba(2,8,20,0.45) 66%, rgba(1,4,12,0.95) 100%); }
#pocket { position:absolute; left:${cx - Math.round(pkW / 2)}px; top:${cy - Math.round(pkH / 2)}px;
width:${pkW}px; height:${pkH}px; opacity:0;
background:radial-gradient(50% 50% at 50% 50%, rgba(1,8,18,0.72) 0%,
rgba(1,8,18,0.40) 55%, rgba(1,8,18,0) 78%); }
#spill { position:absolute; inset:0; opacity:0;
background:radial-gradient(58% 46% at ${cxP}% ${cyP}%, ${A}80 0%,
${A}2b 42%, ${A}00 74%); }
.cau { position:absolute; left:${Math.round(W * 0.0625)}px; top:${Math.round(H * 0.069)}px;
width:${Math.round(W * 0.781)}px; height:${Math.round(H * 0.75)}px; opacity:0; }
#cauA { background:radial-gradient(46% 42% at 42% 46%, ${ICE}d9 0%, ${ICE}00 68%); }
#cauB { background:radial-gradient(44% 46% at 58% 52%, ${VIO}bf 0%, ${VIO}00 66%); }
#preglow { position:absolute; left:${cx}px; top:${cy}px; width:${pgW}px; height:${pgH}px;
margin-left:-${Math.round(pgW / 2)}px; margin-top:-${Math.round(pgH / 2)}px;
border-radius:50%; opacity:0;
background:radial-gradient(50% 50% at 50% 50%, rgba(170,255,242,0.95) 0%,
${A}80 45%, ${A}00 75%);
filter:blur(${Math.max(6, Math.round(10 * ky))}px); }
#tndwrap { position:absolute; inset:0; }
#tnd { position:absolute; inset:0; width:${W}px; height:${H}px; }
#gGlow { filter:blur(5px); }
#gCore { filter:blur(1.1px); }
#tnd path { fill:none; stroke-linecap:round; opacity:0; }
#apex { position:absolute; left:${cx}px; top:${cy}px; opacity:0; }
.aw { position:absolute; left:0; top:0; white-space:nowrap;
font-family:'${dna.fonts.hero}', sans-serif; font-weight:700; font-size:${fpx}px;
line-height:1; letter-spacing:0.01em; }
#aglow { color:${A}; filter:blur(${Math.max(10, Math.round(18 * ky))}px); opacity:0; }
#aword { color:${CORE};
text-shadow: 0 0 ${Math.round(18 * ky)}px ${A}f2, 0 0 ${Math.round(46 * ky)}px ${A}8c,
0 0 ${Math.round(100 * ky)}px ${VIO}80, 0 4px ${Math.round(22 * ky)}px rgba(1,8,16,0.7); }`;
const html = ` <div id="abyss"></div>
<div id="vig"></div>
<div id="pocket"></div>
<div id="spill"></div>
<div class="cau" id="cauA"></div>
<div class="cau" id="cauB"></div>
<div id="preglow"></div>
<div id="tndwrap">
<svg id="tnd" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
<g id="gGlow"></g>
<g id="gCore"></g>
</svg>
</div>
<div id="apex">
<div class="aw" id="aglow">${esc(heroText)}</div>
<div class="aw" id="aword">${esc(heroText)}</div>
</div>`;
const js = `
// ---- setpiece: BIOBLOOM (pre-glow gathers → word condenses → tendrils draw → light sinks) ----
const BI = ${I.toFixed(3)}, BC = ${C.toFixed(3)}, SINK = ${SINK.toFixed(3)};
gsap.set(["#aglow", "#aword"], { xPercent: -50, yPercent: -50 });
gsap.set("#apex", { transformOrigin: "0px 0px" });
gsap.set("#preglow", { scale: 0.6 });
// ===== the abyss breathes (whole clip) =====
tl.to("#vig", { keyframes: { opacity: [0.8, 0.72, 0.83, 0.74, 0.81, 0.76] },
duration: ${(DUR - 0.09).toFixed(2)}, ease: "sine.inOut" }, 0.03);
// ===== light tendrils (SVG, drawn outward from the word rect) =====
const TND = ${J(TENDRILS)};
const TCOL = ${J(TCOL)};
const NS = "http://www.w3.org/2000/svg";
const gGlow = document.getElementById("gGlow");
const gCore = document.getElementById("gCore");
const tpaths = [];
TND.forEach((d, i) => {
const mk = (g, w) => {
const pp = document.createElementNS(NS, "path");
pp.setAttribute("d", d); pp.setAttribute("stroke", TCOL[i]);
pp.setAttribute("stroke-width", w); g.appendChild(pp);
const L = pp.getTotalLength();
gsap.set(pp, { strokeDasharray: L, strokeDashoffset: L });
return pp;
};
tpaths.push([mk(gGlow, ${Math.max(6, Math.round(9 * ky))}), mk(gCore, ${Math.max(2, Math.round(3 * ky))})]);
});
// ===== anticipation: a creature approaches — double-beat pre-glow =====
tl.to("#preglow", { keyframes: { opacity: [0.34, 0.14, 0.46, 0.34],
scale: [0.9, 0.72, 1.08, 0.95] },
duration: 0.19, ease: "sine.inOut" }, ${Math.max(0.05, I - 0.31).toFixed(3)});
tl.to("#preglow", { scale: 0.32, opacity: 0.95, duration: 0.1, ease: "power4.in" }, ${Math.max(0.17, I - 0.115).toFixed(3)});
tl.set("#preglow", { opacity: 0 }, BI);
tl.to("#pocket", { opacity: ${dna.plate.scrim ?? 0.55}, duration: 0.22, ease: "power2.out" }, BI - 0.03);
// ===== bloom arrival: crush -> contact squash (2f) -> elastic settle =====
tl.set("#apex", { opacity: 1 }, BI);
tl.fromTo("#apex", { scale: 2.35 }, { scale: 1.0, duration: ${(C - I).toFixed(3)}, ease: "power4.in" }, BI);
tl.fromTo("#aword", { filter: "blur(15px) brightness(2.6)" },
{ filter: "blur(2px) brightness(1.7)", duration: ${(C - I).toFixed(3)}, ease: "power4.in" }, BI);
tl.set("#apex", { scaleX: 1.09, scaleY: 0.93 }, BC);
tl.set("#aword", { filter: "blur(0px) brightness(1.85)" }, BC);
tl.set("#aglow", { opacity: 0.9 }, BC);
tl.set("#spill", { opacity: 0.55 }, BC);
tl.to("#apex", { scaleX: 1, scaleY: 1, duration: 0.5, ease: "elastic.out(1, 0.42)" }, BC + 0.085);
tl.to("#aword", { filter: "blur(0px) brightness(1.45)", duration: 0.34, ease: "power2.out" }, BC + 0.1);
tl.to("#spill", { opacity: 0.2, duration: 0.12, ease: "expo.out" }, BC + 0.07);
// ===== tendrils draw outward, staggered =====
tpaths.forEach(([pg, pc], i) => {
const t0 = BC + 0.02 + i * ${stag};
tl.set(pg, { opacity: 0.5 }, t0);
tl.set(pc, { opacity: 0.95 }, t0);
tl.to([pg, pc], { strokeDashoffset: 0, duration: 0.42, ease: "power2.out" }, t0);
});
${
flickD >= 0.25
? ` tl.to("#gGlow", { keyframes: { opacity: [0.78, 1, 0.72, 1, 0.82] },
duration: ${flickD}, ease: "sine.inOut" }, BC + 0.34);`
: ""
}
// ===== hold: glow breathes strongly (8-14%), caustics counter-drift =====
tl.to("#aglow", { keyframes: { scale: [1.1, 1.04, 1.13, 1.07],
opacity: [0.78, 0.9, 0.7, 0.85] },
duration: ${breatheD}, ease: "sine.inOut" }, BC + 0.12);
${
wordFlickT != null
? ` tl.to("#aword", { keyframes: { filter: ["blur(0px) brightness(1.6)", "blur(0px) brightness(1.4)",
"blur(0px) brightness(1.62)", "blur(0px) brightness(1.5)"] },
duration: 0.32, ease: "sine.inOut" }, ${wordFlickT.toFixed(3)});`
: ""
}
tl.to("#cauA", { opacity: 0.06, duration: 0.3, ease: "power1.out" }, BC + 0.02);
tl.to("#cauB", { opacity: 0.06, duration: 0.3, ease: "power1.out" }, BC + 0.07);
tl.fromTo("#cauA", { x: -45 }, { x: 85, duration: ${Math.min(0.95, DUR - 0.06 - (C + 0.02)).toFixed(2)}, ease: "none" }, BC + 0.02);
tl.fromTo("#cauB", { x: 55 }, { x: -75, duration: ${Math.min(0.95, DUR - 0.06 - (C + 0.02)).toFixed(2)}, ease: "none" }, BC + 0.02);
tl.to("#spill", { keyframes: { opacity: [0.13, 0.19, 0.15] },
duration: ${shimD}, ease: "sine.inOut" }, BC + 0.21);
// deep-sea current: the bloom drifts slowly, revealing letters from behind
// the subject (x channel otherwise unused on #apex)
tl.to("#apex", { x: ${p.drift ?? -30}, duration: ${driftD}, ease: "sine.out" }, BI + 0.21);
// ===== exit: the light SINKS back into the deep =====
tl.to("#tndwrap", { y: 14, opacity: 0, duration: 0.2, ease: "power2.in" }, SINK);
tl.to(["#cauA", "#cauB"], { opacity: 0, duration: 0.18, ease: "power1.in" }, SINK + 0.04);
tl.to(["#pocket", "#spill"], { opacity: 0, duration: 0.22, ease: "power1.in" }, SINK + 0.04);
tl.to("#apex", { y: 26, opacity: 0, duration: 0.2, ease: "power2.in" }, SINK + 0.06);
tl.to("#aword", { filter: "blur(6px) brightness(1.2)", duration: 0.2, ease: "power1.in" }, SINK + 0.06);
tl.set("#apex", { display: "none" }, ${Math.min(SINK + 0.28, DUR - 0.02).toFixed(3)});`;
return { css, html, js };
}
function setpieceSilkribbon() {
// ---- setpiece: SILKRIBBON (aurora silk written into the night sky) ----
// The hero word is WRITTEN behind the subject as a silk ribbon: sequential
// Hershey-stroke reveal (one <path> per pen stroke, constant pen speed) in
// a blurred halo + tight core, BOTH stroked by ONE userSpaceOnUse aurora
// linearGradient (rose→teal→violet, reflect spread). SIGNATURE: the
// gradient's x1/x2 translate f(t) from I0.07 to the end so the aurora
// colors FLOW THROUGH the drawn ribbon during the hold. A soft light mote
// (blurred halo + core dot) rides the pen, hops at lifts, and blooms out
// at the final stroke. Scene reaction: two huge aurora blobs drift the sky
// f(t) from t=0 (~params.wash) and DEEPEN as the ribbon writes; a top
// scrim (plate.scrim) charges in at I0.34 (sky washout guard); a light
// spill tints the sky once the ribbon completes, flickering gently; the
// halo breathes through the hold. Gentle — no shake, no contact squash:
// the apex verb is the writing itself.
const h = dna.hero,
p = h.params,
I = heroIn;
// stroke path baked at COMPILE time — any word, zero tuning (drawon pattern)
const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
const tw = HG.ribbonW || Math.min(p.targetWidth || 880, W - 240);
const svgW = tw + 130,
svgH = 280;
const D = execFileSync(
"python3",
[
gen,
fontPath,
heroDisplay.toLowerCase(),
String(tw),
"200",
String(Math.round((svgW - tw) / 2)),
],
{ encoding: "utf8" },
).trim();
const ENDT = +(DUR - 0.04).toFixed(3);
// writing window: keep the demo's 0.68s unless the hero lands too late to
// finish + hold before the end of the clip
const WIN = Math.min(p.window || 0.68, Math.max(0.36, ENDT - 0.42 - I));
const ROSE = dna.palette.rose,
TEAL = dna.palette.accent,
VIO = dna.palette.violet,
DEEP = dna.palette.deep || "#070C24";
const scrim = (dna.plate || {}).scrim ?? 0.72;
// aurora gradient span rides the ribbon ink (demo: 560 over 880, shift 1.5×span)
const gx1 = Math.round((svgW - tw) / 2),
span = Math.round(tw * 0.6364);
const spillW = tw + 60,
spillH = Math.round((tw + 60) * 0.51);
const css = `
.aur { position:absolute; border-radius:50%; opacity:0; will-change:transform; }
#aurA { left:${Math.round(-0.141 * W)}px; top:${Math.round(-0.278 * H)}px; width:${Math.round(0.859 * W)}px; height:${Math.round(0.778 * H)}px;
background: radial-gradient(50% 50% at 50% 50%, ${TEAL}d9 0%, ${TEAL}00 70%); }
#aurB { left:${Math.round(0.406 * W)}px; top:${Math.round(-0.361 * H)}px; width:${Math.round(0.922 * W)}px; height:${Math.round(0.833 * H)}px;
background: radial-gradient(50% 50% at 50% 50%, ${ROSE}cc 0%, ${ROSE}00 70%); }
#apexScrim { position:absolute; left:0; top:0; width:${W}px; height:${Math.round(0.653 * H)}px; opacity:0;
background: linear-gradient(to bottom, ${DEEP}${Math.round(scrim * 255)
.toString(16)
.padStart(2, "0")} 0%, ${DEEP}${Math.round(scrim * 0.694 * 255)
.toString(16)
.padStart(2, "0")} 55%, ${DEEP}00 100%); }
#spill { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${spillW}px; height:${spillH}px;
margin-left:-${Math.round(spillW / 2)}px; margin-top:-${Math.round(spillH / 2)}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, ${TEAL}80 0%, ${VIO}47 45%, ${ROSE}00 75%); }
#silk { position:absolute; left:${HG.x}px; top:${HG.y}px; margin-left:-${Math.round(svgW / 2)}px;
margin-top:-${Math.round(svgH / 2)}px; overflow:visible; }
#coreG { filter: drop-shadow(0 0 6px rgba(255,255,255,0.55)); }`;
const html = ` <div class="aur" id="aurA"></div>
<div class="aur" id="aurB"></div>
<div id="apexScrim"></div>
<div id="spill"></div>
<svg id="silk" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
<defs>
<linearGradient id="agrad" gradientUnits="userSpaceOnUse"
x1="${gx1}" y1="0" x2="${gx1 + span}" y2="0" spreadMethod="reflect">
<stop offset="0" stop-color="${ROSE}"/>
<stop offset="0.5" stop-color="${TEAL}"/>
<stop offset="1" stop-color="${VIO}"/>
</linearGradient>
<filter id="haloblur" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="7"/>
</filter>
</defs>
<g id="haloG" filter="url(#haloblur)" opacity="0.85"></g>
<g id="coreG"></g>
<circle id="nib" r="7" fill="#fff" opacity="0" filter="url(#haloblur)"/>
<circle id="nibCore" r="3.5" fill="#fff" opacity="0"/>
</svg>`;
const js = `
// ---- setpiece: SILKRIBBON (sequential silk-stroke reveal, flowing aurora gradient) ----
const I9 = ${I.toFixed(3)}, WIN = ${WIN.toFixed(3)}, DRAWN = I9 + WIN, ENDT = ${ENDT};
const D = ${J(D)};
// aurora wash: 2 huge soft blobs drifting f(t) (baked keyframes, ease none)
tl.to("#aurA", { opacity: ${p.wash ?? 0.12}, duration: 0.5, ease: "sine.out" }, 0.05);
tl.to("#aurB", { opacity: ${p.wash ?? 0.12}, duration: 0.5, ease: "sine.out" }, 0.15);
tl.to("#aurA", { keyframes: { x: [0, 34, 58, 44, 16, -8, 0], y: [0, 10, 22, 30, 22, 8, 0] },
duration: ENDT, ease: "none" }, 0);
tl.to("#aurB", { keyframes: { x: [0, -28, -52, -60, -38, -12, 0], y: [0, 14, 8, -6, -16, -8, 0] },
duration: ENDT, ease: "none" }, 0);
// sky reaction: the aurora deepens while the ribbon writes
tl.to("#aurA", { opacity: ${p.deepen ?? 0.2}, duration: 0.5, ease: "sine.inOut" }, I9 - 0.1);
tl.to("#aurB", { opacity: ${p.deepen ?? 0.2}, duration: 0.5, ease: "sine.inOut" }, I9 - 0.05);
// apex scrim charges in before the ribbon (sky washout guard)
tl.to("#apexScrim", { opacity: 1, duration: 0.38, ease: "sine.inOut" }, I9 - 0.34);
// silk ribbon: sequential stroke draw-on, halo + core share the gradient
const subs = D.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
const SVGNS = "http://www.w3.org/2000/svg";
function mk(parent, d, w) {
const el = document.createElementNS(SVGNS, "path");
el.setAttribute("d", d); el.setAttribute("fill", "none");
el.setAttribute("stroke", "url(#agrad)"); el.setAttribute("stroke-width", w);
el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
el.setAttribute("opacity", "0"); // hidden until its turn (kills round-cap dot)
document.getElementById(parent).appendChild(el);
return el;
}
const strokes = subs.map((d) => ({ halo: mk("haloG", d, ${p.haloWidth ?? 18}),
core: mk("coreG", d, ${p.coreWidth ?? 6}) }));
let total = 0;
strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
let t9 = I9;
strokes.forEach((s) => {
const d = WIN * s.len / total;
gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
tl.set([s.core, s.halo], { opacity: 1 }, t9);
tl.fromTo([s.core, s.halo], { strokeDashoffset: s.len },
{ strokeDashoffset: 0, duration: d, ease: "none" }, t9);
// nib (a soft light mote) rides each stroke, hops at pen lifts
const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
for (let k = 0; k <= n; k++) {
const pt = s.core.getPointAtLength(s.len * k / n);
xs.push(pt.x); ys.push(pt.y);
}
tl.set(["#nib", "#nibCore"], { x: xs[0], y: ys[0] }, t9);
tl.to("#nib", { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t9);
tl.to("#nibCore", { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t9);
t9 += d;
});
gsap.set(["#nib", "#nibCore"], { attr: { cx: 0, cy: 0 } });
tl.set("#nib", { opacity: 0.85 }, I9 - 0.01);
tl.set("#nibCore", { opacity: 1 }, I9 - 0.01);
tl.to("#nib", { scale: 2.4, opacity: 0, duration: 0.22, ease: "sine.in",
transformOrigin: "50% 50%" }, DRAWN);
tl.to("#nibCore", { scale: 2.4, opacity: 0, duration: 0.16, ease: "sine.in",
transformOrigin: "50% 50%" }, DRAWN);
// SIGNATURE: the gradient FLOWS through the drawn ribbon — translate the
// userSpaceOnUse gradient (x1/x2 together); reflect spread makes the aurora
// colors march continuously along the silk during the hold
tl.fromTo("#agrad", { attr: { x1: ${gx1}, x2: ${gx1 + span} } },
{ attr: { x1: ${gx1 - Math.round(1.5 * span)}, x2: ${gx1 + span - Math.round(1.5 * span)} },
duration: ENDT - (I9 - 0.07), ease: "none" }, I9 - 0.07);
// light spill tints the sky as the ribbon completes
tl.to("#spill", { opacity: ${p.spill ?? 0.3}, duration: 0.7, ease: "sine.out" }, I9 + 0.1);
if (ENDT - (I9 + 0.8) > 0.15) {
const SP = ${p.spill ?? 0.3};
tl.to("#spill", { keyframes: { opacity: [SP, SP - 0.05, SP + 0.01, SP - 0.04, SP] },
duration: ENDT - (I9 + 0.8), ease: "none" }, I9 + 0.8);
}
// hold life: the halo breathes gently (aurora shimmer)
if (ENDT - DRAWN > 0.1)
tl.to("#haloG", { keyframes: { opacity: [0.85, 0.7, 0.9, 0.72, 0.86] },
duration: ENDT - DRAWN, ease: "none" }, DRAWN);`;
return { css, html, js };
}
function setpieceScopetrace() {
// ---- setpiece: SCOPETRACE (the trace leaps off the scope and WRITES the word) ----
// A surge column jumps from the scope band up to the pen-start point, then
// the waveform WRITES the hero word in a thin phosphor stroke (sequential
// Hershey stroke reveal — core only, no halo: it is a trace, not a tube).
// The phosphor FILL flicker-arrives 2 frames + a 5-step blink AFTER the
// write completes (scope persistence), hums + breathes 1.6% through the
// hold, then the word COLLAPSES to a flatline (scaleY crush + horizontal
// flash) clearing before the next spoken word. Scene reaction: phosphor
// scrim + green spill behind the subject; a 1-frame green pulse answers the
// final transcript word (the scope-death beat the paradigm owns up front).
const h = dna.hero,
p = h.params,
I = heroIn;
const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
const tw = HG.traceW;
// strip punctuation for the pen (the demo wrote "pixel size", period dropped)
const traceText = heroDisplay.toLowerCase().replace(/[^\p{L}\p{N} ']/gu, "");
const svgW = tw + 140,
svgH = 320,
BY = 215, // local baseline; ink center ≈ BY 28 (demo registration)
CY = BY - 28;
const D = execFileSync(
"python3",
[gen, fontPath, traceText, String(tw), String(BY), String(70)],
{ encoding: "utf8" },
).trim();
// surge column lands at the pen-start point (first M of the path)
const m0 = D.match(/M ([\d.]+) ([\d.]+)/);
const surgeX = Math.round(HG.x - svgW / 2 + (m0 ? +m0[1] : 70));
const WIN = p.window || 0.6;
const FLICK = +(I + WIN + (p.flickGap ?? 0.12)).toFixed(3);
// exit: collapse to flatline CLEAR of the next spoken word (the demo's
// 3.33 before "It's" 3.545); falls back to the hero window end
const nextT = tWords
.filter((w) => w.start > hero.end + 0.01)
.map((w) => w.start)
.sort((a, b) => a - b)[0];
let EXIT = nextT != null ? nextT - (p.clearGap ?? 0.215) : heroOut - 0.2;
EXIT = +Math.max(FLICK + 0.45, Math.min(EXIT, heroOut)).toFixed(3);
const A = dna.palette.accent,
BRT = dna.palette.bright || "#d6ffe4";
const scrim = dna.plate.scrim ?? 0.62;
const ox = ((HG.x / W) * 100).toFixed(1),
oy = ((HG.y / H) * 100).toFixed(1);
const spillW = tw + 260,
spillH = Math.round((tw + 260) * 0.47);
const css = `
#scdim { position:absolute; inset:0; opacity:0;
background: radial-gradient(90% 75% at ${ox}% ${oy}%, rgba(1,14,7,${scrim}) 0%, rgba(1,10,5,${(scrim * 0.55).toFixed(2)}) 60%, rgba(1,8,4,${(scrim * 0.19).toFixed(2)}) 100%); }
#scspill { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${spillW}px; height:${spillH}px;
margin-left:-${Math.round(spillW / 2)}px; margin-top:-${Math.round(spillH / 2)}px; border-radius:50%; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, ${A}33 0%, ${A}00 70%); }
#scsurge { position:absolute; left:${surgeX}px; top:0; width:3px; height:${H}px; opacity:0;
background: linear-gradient(180deg, ${A}00 0%, ${BRT}f2 45%, ${A}80 100%);
box-shadow: 0 0 12px ${A}cc; }
#scblk { position:absolute; inset:0; }
#scsvg { position:absolute; left:${HG.x}px; top:${HG.y}px; margin-left:-${Math.round(svgW / 2)}px;
margin-top:-${CY}px; overflow:visible; }
#schaloG { filter: blur(7px); opacity:0.85; }
#sccoreG { filter: drop-shadow(0 0 4px ${BRT}e6) drop-shadow(0 0 14px ${A}); }
#scpen { filter: drop-shadow(0 0 7px #fff) drop-shadow(0 0 20px ${A}); }
#scflat { position:absolute; left:${Math.round(HG.x - (tw + 36) / 2)}px; top:${HG.y - 2}px;
width:${tw + 36}px; height:4px; opacity:0;
background:${BRT}; box-shadow: 0 0 18px ${A}, 0 0 42px ${A}b3; }
#scpulse { position:absolute; inset:0; opacity:0;
background: radial-gradient(70% 60% at 50% 60%, ${A}38 0%, rgba(0,0,0,0) 70%); }`;
const html = ` <div id="scdim"></div>
<div id="scspill"></div>
<div id="scsurge"></div>
<div id="scblk">
<svg id="scsvg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
<g id="schaloG"></g>
<g id="sccoreG"></g>
<circle id="scpen" r="5" fill="#fff" opacity="0"/>
</svg>
</div>
<div id="scflat"></div>
<div id="scpulse"></div>`;
const js = `
// ---- setpiece: SCOPETRACE (surge → pen write → fill flicker → hum → flatline) ----
const I = ${I.toFixed(3)}, WIN = ${WIN}, DRAWN = I + WIN, FLICK = ${FLICK.toFixed(3)}, EXIT = ${EXIT.toFixed(3)};
const D = ${J(D)};
// scene reaction: phosphor scrim + spill while the trace owns the room
tl.fromTo("#scdim", { opacity: 0 }, { opacity: 1, duration: 0.22, ease: "power2.in" }, I - 0.18);
tl.to("#scdim", { opacity: 0, duration: 0.30, ease: "power1.in" }, EXIT + 0.05);
tl.fromTo("#scspill", { opacity: 0 }, { opacity: ${p.spill ?? 0.55}, duration: WIN, ease: "power1.in" }, I);
tl.to("#scspill", { opacity: 0, duration: 0.18, ease: "power2.in" }, EXIT);
// surge: the trace leaps up from the scope band to the pen-start point
tl.fromTo("#scsurge", { opacity: 0, scaleY: 0, transformOrigin: "50% 100%" },
{ opacity: 1, scaleY: 1, duration: 0.10, ease: "power3.in" }, I - 0.12);
tl.set("#scsurge", { opacity: 0 }, I + 0.04);
// sequential stroke draw-on (one <path> per pen lift; core writes, halo waits)
const subs = D.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
const SVGNS = "http://www.w3.org/2000/svg";
function mkP(parent, d, stroke, w) {
const el = document.createElementNS(SVGNS, "path");
el.setAttribute("d", d); el.setAttribute("fill", "none");
el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
el.setAttribute("opacity", "0");
document.getElementById(parent).appendChild(el);
return el;
}
const strokes = subs.map((d) => ({
halo: mkP("schaloG", d, ${J(A)}, ${p.haloWidth || 14}),
core: mkP("sccoreG", d, ${J(BRT)}, ${p.coreWidth || 4}),
}));
let total = 0;
strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
const scpen = document.getElementById("scpen");
let t = I;
strokes.forEach((s) => {
const d = WIN * s.len / total;
gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
gsap.set(s.halo, { opacity: 0 }); // halo arrives at FLICK, not during write
tl.set(s.core, { opacity: 1 }, t);
tl.fromTo(s.core, { strokeDashoffset: s.len },
{ strokeDashoffset: 0, duration: d, ease: "none" }, t);
tl.fromTo(s.halo, { strokeDashoffset: s.len },
{ strokeDashoffset: 0, duration: d, ease: "none" }, t);
const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
for (let k = 0; k <= n; k++) {
const pt = s.core.getPointAtLength(s.len * k / n);
xs.push(pt.x); ys.push(pt.y);
}
tl.set(scpen, { x: xs[0], y: ys[0] }, t);
tl.to(scpen, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, t);
t += d;
});
gsap.set(scpen, { attr: { cx: 0, cy: 0 } });
tl.set(scpen, { opacity: 1 }, I - 0.01);
tl.to(scpen, { scale: 2.4, opacity: 0, duration: 0.12, ease: "power2.in",
transformOrigin: "50% 50%" }, DRAWN);
// fill flicker-on (halo = phosphor "fill"; full within 2 frames)
tl.set("#schaloG", { opacity: 0 }, 0);
const haloPaths = strokes.map(s => s.halo);
tl.set(haloPaths, { opacity: 1 }, FLICK - 2 * F); // pre-arm strokes
[[1, 0], [0.25, 1], [1, 2], [0.55, 3], [1, 4]].forEach(([o, k]) => {
tl.set("#schaloG", { opacity: o }, FLICK + k * F);
});
tl.set("#sccoreG", { opacity: 0.55 }, FLICK + F);
tl.set("#sccoreG", { opacity: 1 }, FLICK + 2 * F);
// hold life: glow hum + 1.6% breathe (breathe ends exactly at the collapse)
const humStart = FLICK + 5 * F, humDur = Math.max(0.2, EXIT - humStart);
const NH = Math.max(6, Math.round(humDur / (2 * F)));
const humVals = [];
for (let k = 0; k <= NH; k++) {
const tt = k / NH * humDur;
humVals.push(0.80 + 0.10 * Math.sin(2 * Math.PI * 7 * tt) + 0.06 * Math.sin(2 * Math.PI * 11.3 * tt + 0.9));
}
tl.to("#schaloG", { keyframes: { opacity: humVals }, duration: humDur, ease: "none" }, humStart);
tl.fromTo("#scblk", { scale: 1, transformOrigin: "${ox}% ${oy}%" },
{ scale: 1.016, duration: humDur, ease: "sine.inOut" }, humStart);
// exit: trace collapses back to a flatline (the scope takes the word back)
tl.to("#scblk", { scaleY: 0.02, duration: 0.14, ease: "power3.in",
transformOrigin: "${ox}% ${oy}%" }, EXIT);
tl.set("#scblk", { opacity: 0 }, EXIT + 0.15);
tl.set("#scflat", { opacity: 1 }, EXIT + 0.13);
tl.to("#scflat", { opacity: 0, scaleX: 0.1, duration: 0.16, ease: "power2.in",
transformOrigin: "50% 50%" }, EXIT + 0.15);
// second beat: 1-frame phosphor pulse behind the subject on the death word
tl.set("#scpulse", { opacity: 1 }, ${Math.min(LASTWORD.start, DUR - 0.15).toFixed(3)});
tl.set("#scpulse", { opacity: 0.4 }, ${Math.min(LASTWORD.start + F, DUR - 0.1).toFixed(3)});
tl.set("#scpulse", { opacity: 0 }, ${Math.min(LASTWORD.start + 2 * F, DUR - 0.05).toFixed(3)});`;
return { css, html, js };
}
function setpiecePapermat() {
// STOP-MOTION PAPERCUT EMBED: a torn-edge kraft mat drops onto the scene
// (steps(2) slap), then one torn cream chip per char DROPS with steps(3)
// gravity + 1-frame overshoot, lands crooked (seeded ±3° ±4px), settles in
// three 12fps poses and holds an 8fps wobble (baked set chain) until the
// FINAL word bumps the table and the chips SCATTER off down-right
// (disperse-on-last-word). Scene reaction: warm vignette charges with the
// landing. All geometry derives from HG (papermat branch).
const p = dna.hero.params || {},
I = heroIn;
const N = heroText.length;
const STEP = p.step ?? 0.09;
const tileW = HG.tileW,
tileH = HG.tileH,
fpx = HG.fontPx,
pitch = HG.pitch,
matW = HG.matW,
matH = HG.matH;
const matT = +Math.max(0.02, I - 0.212).toFixed(3);
// scatter trigger: the final word bumps the table (hero.exitAt overrides);
// the whole exit (chips 0.30 + mat sweep 0.26 at +0.21) lands inside DUR
const scatT = +Math.min(
Math.max(
I + (N - 1) * STEP + 0.5,
Math.min(
theme.hero.exitAt ?? LASTWORD.start + 0.02,
DUR - 0.48,
DUR - 0.32 - (N - 1) * 0.045,
),
),
DUR - 0.48,
).toFixed(3);
const VIG = p.vig ?? 0.3;
const dropM = -(HG.y + matH / 2 + 50); // mat fully offscreen above
const dropL = -(HG.y + tileH / 2 + 100); // chips fully offscreen above
// tableau breathe windows (camera push feel), clamped before the scatter
const T1 = +(I + 0.83).toFixed(3);
const D1 = +Math.min(2.2, scatT - 0.3 - T1).toFixed(2);
const T2 = +(T1 + Math.max(0, D1)).toFixed(3);
const D2 = +Math.min(1.9, scatT - 0.15 - T2).toFixed(2);
const vigSettleT = +Math.min(matT + 0.79, scatT - 0.85).toFixed(3);
const css = `
#vigP { position:absolute; inset:0; opacity:0;
background: radial-gradient(120% 105% at ${((HG.x / W) * 100).toFixed(0)}% ${((HG.y / H) * 100).toFixed(0)}%, rgba(0,0,0,0) 38%, rgba(26,16,5,0.85) 100%); }
#apx { position:absolute; left:${HG.x}px; top:${HG.y}px; width:0; height:0; }
#mat { position:absolute; left:${-Math.round(matW / 2)}px; top:${-Math.round(matH / 2)}px; width:${matW}px; height:${matH}px; }
#mat .pap { position:absolute; inset:0; background:linear-gradient(178deg,#bd9d6c 0%,#b08f5d 55%,#a17f4c 100%); }
#mat .pap2 { position:absolute; inset:7px; background:rgba(140,110,62,0.35); }
#matshad { position:absolute; left:${-Math.round(matW / 2) + 2}px; top:${Math.round(matH / 2) - 18}px; width:${matW + 6}px; height:30px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, rgba(25,15,5,0.55) 0%, rgba(25,15,5,0) 72%);
filter: blur(5px); }
.lshad { position:absolute; width:${Math.round(tileW * 1.08)}px; height:24px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, rgba(28,17,6,0.6) 0%, rgba(28,17,6,0) 70%);
filter: blur(4px); }
.lchip { position:absolute; width:${tileW}px; height:${tileH}px; }
.lchip .pap { position:absolute; inset:0; background:${dna.palette.paper || "#f8f0dd"}; }
.lchip .pap2 { position:absolute; inset:5px; background:${dna.palette.paper2 || "#efe3c6"}; }
.lchip .gl { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:'${dna.fonts.hero}', sans-serif; font-size:${fpx}px; line-height:1; color:${dna.palette.ink || "#2b241b"}; }`;
const html = ` <div id="vigP"></div>
<div id="apx">
<div id="matshad"></div>
<div id="mat"><div class="pap"></div><div class="pap2"></div></div>
</div>`;
const js = `
// ---- setpiece: PAPERMAT (mat slap → stepped-gravity chip drops → 8fps wobble → table-bump scatter) ----
const I = ${I.toFixed(3)}, STEP = ${STEP}, SCAT = ${scatT};
const prnd = mulberry32(${p.seed || 151515});
const apx = document.getElementById("apx");
// seeded torn-paper polygon (px coords on a w×h box)
function torn(w, h, jag, sx, sy, rr){
const pts = [], j = () => Math.min(jag, Math.max(0, jag*0.5 + (rr()-0.5)*jag));
for (let i=0; i<=sx; i++) pts.push([(w/sx)*i, j()]);
for (let i=1; i<=sy; i++) pts.push([w - j(), (h/sy)*i]);
for (let i=sx-1; i>=0; i--) pts.push([(w/sx)*i, h - j()]);
for (let i=sy-1; i>=1; i--) pts.push([j(), (h/sy)*i]);
return "polygon(" + pts.map(q => q[0].toFixed(1)+"px "+q[1].toFixed(1)+"px").join(",") + ")";
}
document.querySelector("#mat .pap").style.clipPath = torn(${matW}, ${matH}, 12, 12, 4, prnd);
document.querySelector("#mat .pap2").style.clipPath = torn(${matW - 14}, ${matH - 14}, 10, 12, 4, prnd);
// ===== MAT: the animator slaps the backdrop down first =====
gsap.set("#mat", { transformOrigin: "50% 100%" });
tl.fromTo("#mat", { y: ${dropM}, rotation: -5 },
{ y: 6, rotation: -1.2, duration: 0.14, ease: "steps(2)" }, ${matT});
tl.set("#mat", { scaleY: 0.97, scaleX: 1.02 }, ${(matT + 0.14).toFixed(3)});
tl.set("#mat", { y: 0, scaleY: 1, scaleX: 1 }, ${(matT + 0.223).toFixed(3)});
tl.set("#matshad", { opacity: 0.32 }, ${(matT + 0.14).toFixed(3)});
for (let t = ${(matT + 0.54).toFixed(3)}; t < ${(scatT - 0.08).toFixed(3)}; t += 0.25)
tl.set("#mat", { x: (prnd()-0.5)*1.6, y: (prnd()-0.5)*1.6, rotation: -1.2 + (prnd()-0.5)*0.7 }, t);
// mat leaves last, swept off down-right
tl.to("#mat", { x: 430, y: ${H - 40}, rotation: 13, duration: 0.26, ease: "steps(3)" }, ${(scatT + 0.21).toFixed(3)});
tl.set("#matshad", { opacity: 0 }, ${(scatT + 0.25).toFixed(3)});
// ===== SCENE REACTION: warm vignette charges with the landing =====
tl.fromTo("#vigP", { opacity: 0 }, { opacity: ${VIG}, duration: 0.24, ease: "power2.in" }, ${matT});
tl.to("#vigP", { opacity: ${(VIG * 0.4).toFixed(2)}, duration: 0.7, ease: "power2.out" }, ${vigSettleT});
tl.to("#vigP", { opacity: 0, duration: 0.35, ease: "power1.in" }, ${(scatT - 0.11).toFixed(3)});
// ===== LETTERS: torn chips drop with stepped gravity =====
const GLYPHS = ${J([...heroText])};
GLYPHS.forEach((ch, i) => {
if (ch === " ") return;
const cx = (i - ${((N - 1) / 2).toFixed(1)}) * ${pitch} + (prnd()-0.5)*8; // seeded ±4px misalignment
const jy = (prnd()-0.5)*8;
const r0 = (prnd()-0.5)*6; // seeded ±3° crooked landing
const C = I + i * STEP;
const shad = document.createElement("div");
shad.className = "lshad";
shad.style.left = (cx - ${Math.round((tileW * 1.08) / 2)}) + "px";
shad.style.top = (${Math.round(tileH / 2) + 3} + jy*0.3) + "px";
apx.appendChild(shad);
const chip = document.createElement("div");
chip.className = "lchip";
chip.style.left = (cx - ${Math.round(tileW / 2)}) + "px";
chip.style.top = (${-Math.round(tileH / 2)} + jy) + "px";
const pap = document.createElement("div"); pap.className = "pap";
const pap2 = document.createElement("div"); pap2.className = "pap2";
const gl = document.createElement("div"); gl.className = "gl"; gl.textContent = ch;
pap.style.clipPath = torn(${tileW}, ${tileH}, 8, 6, 7, prnd);
pap2.style.clipPath = torn(${tileW - 10}, ${tileH - 10}, 6, 6, 7, prnd);
chip.appendChild(pap); chip.appendChild(pap2); chip.appendChild(gl);
apx.appendChild(chip);
gsap.set(chip, { transformOrigin: "50% 100%" });
// anticipation shadow blob appears on the landing spot 3 frames early
tl.set(shad, { opacity: 0.16, scaleX: 1.35 }, C - 0.125);
// DROP: two airborne poses then contact — steps(3) gravity, 1-frame overshoot
tl.fromTo(chip, { y: ${dropL}, rotation: r0 - 8 },
{ y: 8, rotation: r0, duration: 0.21, ease: "steps(3)" }, C - 0.21);
tl.set(chip, { scaleX: 1.07, scaleY: 0.90 }, C); // contact squash
tl.set(shad, { opacity: 0.50, scaleX: 1 }, C); // shadow grows on land
tl.set(chip, { y: -4, scaleY: 1.05, scaleX: 0.97 }, C + 0.0833); // 12fps settle pose 1
tl.set(chip, { y: 2, scaleY: 0.98, scaleX: 1.01 }, C + 0.1667); // pose 2
tl.set(chip, { y: 0, scaleY: 1, scaleX: 1 }, C + 0.25); // rest
tl.to(shad, { opacity: 0.34, duration: 0.3, ease: "power1.out" }, C + 0.30);
// HOLD: 8fps wobble — baked sets every 3 frames (the stop-motion heartbeat)
for (let t = C + 0.50; t < SCAT - 0.13; t += 0.125)
tl.set(chip, { rotation: r0 + (prnd()-0.5)*1.4, x: (prnd()-0.5)*1.4, y: (prnd()-0.5)*1.4 }, t);
// SCATTER on the final word — chips slide off-table down-right, steps(4)
const S = SCAT + i*0.045;
tl.to(chip, { x: 250 + prnd()*200, y: ${H - 110} + prnd()*110, rotation: r0 + 28 + prnd()*24,
duration: 0.30, ease: "steps(4)" }, S);
tl.set(shad, { opacity: 0 }, S + 0.06);
});
${
D1 >= 0.4
? `
// hold loom: the whole tableau breathes very slowly (camera push feel)
tl.to("#apx", { scale: 1.016, duration: ${D1}, ease: "sine.inOut" }, ${T1});
${D2 >= 0.3 ? ` tl.to("#apx", { scale: 1.004, duration: ${D2}, ease: "sine.inOut" }, ${T2});` : ""}`
: ""
}
// table bump, 1 frame before the scatter begins
tl.set("#apx", { y: 4 }, ${(scatT - 0.041).toFixed(3)});
tl.set("#apx", { y: 0 }, ${(scatT + 0.001).toFixed(3)});`;
return { css, html, js };
}
function setpieceCenterfold() {
// POP-UP BOOK CENTERFOLD: blue + cream 12-point paper starbursts unfold on a
// baseline hinge (scaleY 0.03→1 back.out, staggered), then the hero word
// pops up in hinged clip-path segments (rotationX 85→0, the body motif at
// full amplitude), kraft struts lean in, backdrop + word wobble-settle
// TOGETHER (one wrapper owns rotationX), breathe through the hold, and the
// whole centerfold folds flat at heroOut (the book closing). A dusk scrim
// charges behind it (bright-sky insurance). Geometry derives from HG.
const p = dna.hero.params || {},
I = heroIn;
const segs = p.segs || 3,
stag = p.segStagger ?? 0.07;
const u = HG.burstR / 220; // prop scale (demo burst R 220)
const boxW = HG.boxW,
boxH = HG.boxH;
const cx = Math.round(boxW / 2),
cy = Math.round(boxH / 2);
const lineH = Math.round(HG.fontPx * 1.1667);
const wordTop = Math.round(cy - 24 * u - lineH / 2); // word center 24u above box center
const R1 = HG.burstR,
r1 = Math.round(0.673 * R1),
R2 = Math.round(0.9 * R1),
r2 = Math.round(0.555 * R1);
const strokeW = Math.max(2, Math.round(3 * u));
// exit start: fold flat before the next sentence (demo 3.28 = heroOut0.33),
// never before the pop + wobble have read, always complete by DUR0.08
const XO = +Math.max(I + (segs - 1) * stag + 0.45, Math.min(heroOut - 0.33, DUR - 0.39)).toFixed(
3,
);
const scrimT = +Math.max(0.02, I - 0.272).toFixed(3);
const blueT = +Math.max(0.04, I - 0.272).toFixed(3);
const wobT = +(I + 0.428).toFixed(3);
const wobD = +Math.min(0.7, XO - 0.05 - (I + 0.428)).toFixed(2);
const brT = +(I + 1.178).toFixed(3);
const brD = +Math.min(1.7, XO - 0.15 - (I + 1.178)).toFixed(2);
const segClips = Array.from({ length: segs }, (_, i) => {
const lp = ((i * 100) / segs).toFixed(1),
rp = (((segs - 1 - i) * 100) / segs).toFixed(1);
return ` #cfsg${i} { clip-path: inset(0 ${rp}% 0 ${lp}%); }`;
}).join("\n");
const css = `
#cfscrim { position:absolute; inset:0; opacity:0;
background: radial-gradient(46% 54% at ${((HG.x / W) * 100).toFixed(0)}% ${(((HG.y + 24 * u) / H) * 100).toFixed(0)}%,
rgba(16,24,42,0.62) 0%, rgba(16,24,42,0.34) 55%, rgba(16,24,42,0) 100%); }
#cf { position:absolute; left:${HG.x}px; top:${Math.round(HG.y + 24 * u)}px; width:${boxW}px; height:${boxH}px; }
#cfWob { position:absolute; inset:0; }
.cfburst { position:absolute; inset:0; opacity:0; }
#cfShadow { position:absolute; left:50%; top:${Math.round(cy + 172 * u)}px; width:${Math.round(380 * u)}px; height:${Math.round(30 * u)}px;
margin-left:${-Math.round(190 * u)}px; opacity:0; filter:blur(6px); border-radius:50%;
background: radial-gradient(closest-side, rgba(22,14,5,0.55), rgba(22,14,5,0)); }
.cfstrut { position:absolute; width:${Math.round(36 * u)}px; height:${Math.round(30 * u)}px; opacity:0;
background: linear-gradient(135deg,${dna.palette.kraft1 || "#a98f5e"} 0%, ${dna.palette.kraft2 || "#cdbb92"} 45%, ${dna.palette.kraft3 || "#e6d6af"} 100%);
filter: drop-shadow(0 2px 2px rgba(40,26,10,0.35));
clip-path: polygon(0 100%, 100% 100%, 100% 0); }
#cfs1 { left:${Math.round(cx - 134 * u)}px; top:${Math.round(cy + 28 * u)}px; }
#cfs2 { left:${Math.round(cx + 98 * u)}px; top:${Math.round(cy + 28 * u)}px; transform:scaleX(-1); }
#cfword { position:absolute; left:0; right:0; top:${wordTop}px; height:${lineH}px; }
.cfseg { position:absolute; inset:0; opacity:0; text-align:center;
font-family:'${dna.fonts.hero}', sans-serif; font-weight:800; font-size:${HG.fontPx}px;
line-height:${lineH}px; letter-spacing:0.02em; color:${dna.palette.ink || dna.palette.body};
text-shadow: 0 3px 10px rgba(56,38,14,0.32); }
${segClips}`;
const html = ` <div id="cfscrim"></div>
<div id="cf">
<div id="cfWob">
<svg class="cfburst" id="cfbBlue" width="${boxW}" height="${boxH}" viewBox="0 0 ${boxW} ${boxH}">
<polygon id="cfpB" fill="${dna.palette.accent}" stroke="${dna.palette.accentEdge || "#3f7fae"}" stroke-width="${strokeW}"/>
</svg>
<svg class="cfburst" id="cfbCream" width="${boxW}" height="${boxH}" viewBox="0 0 ${boxW} ${boxH}">
<polygon id="cfpC" fill="${dna.palette.cream || "#f6ecd4"}" stroke="${dna.palette.creamEdge || "#8a6f45"}" stroke-width="${strokeW}"/>
</svg>
<div id="cfShadow"></div>
<div class="cfstrut" id="cfs1"></div>
<div class="cfstrut" id="cfs2"></div>
<div id="cfword">
${Array.from({ length: segs }, (_, i) => ` <div class="cfseg" id="cfsg${i}"><span>${esc(heroText)}</span></div>`).join("\n")}
</div>
</div>
</div>`;
const js = `
// ---- setpiece: CENTERFOLD (starburst unfold → hinged segment pop → wobble-settle → fold flat) ----
const I = ${I.toFixed(3)}, XO = ${XO};
// deterministic starburst geometry (paper sun)
function starPts(cx, cy, n, R, r, rot) {
const p = [];
for (let i = 0; i < n * 2; i++) {
const a = rot + Math.PI * i / n, rad = (i % 2 === 0) ? R : r;
p.push((cx + Math.cos(a) * rad).toFixed(1) + "," + (cy + Math.sin(a) * rad).toFixed(1));
}
return p.join(" ");
}
document.getElementById("cfpB").setAttribute("points", starPts(${cx}, ${cy}, 12, ${R1}, ${r1}, -Math.PI/2 + 0.13));
document.getElementById("cfpC").setAttribute("points", starPts(${cx}, ${cy}, 12, ${R2}, ${r2}, -Math.PI/2));
gsap.set("#cf", { xPercent: -50, yPercent: -50 });
gsap.set("#cfWob", { transformPerspective: 900, transformOrigin: "50% 82%" });
gsap.set(".cfburst", { transformOrigin: "50% 90%", scaleY: 0.03 });
gsap.set(".cfseg", { transformPerspective: 800, transformOrigin: "50% 100%", rotationX: 85 });
gsap.set(".cfstrut", { transformOrigin: "50% 100%", scaleY: 0 });
gsap.set("#cfShadow", { scaleX: 0.3 });
// scrim charges before the unfold (bright sky insurance)
tl.to("#cfscrim", { opacity: ${p.scrim ?? 0.42}, duration: 0.28, ease: "power2.out" }, ${scrimT});
// backdrop unfolds FIRST — baseline hinge, paper overshoot
tl.set("#cfbBlue", { opacity: 1 }, ${blueT});
tl.to("#cfbBlue", { scaleY: 1, duration: 0.26, ease: "back.out(1.6)" }, ${blueT});
tl.set("#cfbCream", { opacity: 1 }, ${(+blueT + 0.06).toFixed(3)});
tl.to("#cfbCream", { scaleY: 1, duration: 0.26, ease: "back.out(1.6)" }, ${(+blueT + 0.06).toFixed(3)});
tl.to("#cfShadow", { opacity: 0.45, scaleX: 1, duration: 0.30, ease: "power2.out" }, ${(+blueT + 0.03).toFixed(3)});
// the word pops up in hinged segments (left→right), staggered
for (let i = 0; i < ${segs}; i++) {
const t0 = I + i * ${stag}, el = "#cfsg" + i;
tl.set(el, { opacity: 1 }, t0); // 1 within 2 frames
tl.to(el, { rotationX: 0, duration: 0.26, ease: "back.out(1.4)" }, t0);
}
// visible paper struts lean in behind the word
tl.set(".cfstrut", { opacity: 1 }, ${(I + 0.328).toFixed(3)});
tl.to("#cfs1", { scaleY: 1, duration: 0.12, ease: "back.out(2)" }, ${(I + 0.328).toFixed(3)});
tl.to("#cfs2", { scaleY: 1, duration: 0.12, ease: "back.out(2)" }, ${(I + 0.378).toFixed(3)});
${
wobD >= 0.2
? `
// backdrop + word wobble-settle TOGETHER (one wrapper owns it)
tl.to("#cfWob", { keyframes: { rotationX: [0, -3.6, 2.4, -1.3, 0.6, 0] },
duration: ${wobD}, ease: "sine.inOut" }, ${wobT});`
: ""
}
${
brD >= 0.5
? `
// hold life: slow paper breathing through the silence
tl.to("#cfWob", { keyframes: { rotationX: [0, 1.5, -1.1, 1.3, 0] },
duration: ${brD}, ease: "sine.inOut" }, ${brT});
tl.to("#cfShadow", { keyframes: { scaleX: [1, 1.05, 0.98, 1.04, 1] },
duration: ${brD}, ease: "sine.inOut" }, ${brT});`
: ""
}
// EXIT — the centerfold folds flat (book closing)
for (let i = 0; i < ${segs}; i++) {
const ft = XO + i * 0.05, el = "#cfsg" + i;
tl.to(el, { rotationX: 88, duration: 0.14, ease: "power2.in" }, ft);
tl.set(el, { opacity: 0 }, ft + 0.15);
}
tl.to(".cfstrut", { scaleY: 0, duration: 0.10, ease: "power2.in" }, XO + 0.02);
tl.to("#cfbCream", { scaleY: 0.03, duration: 0.13, ease: "power2.in" }, XO + 0.12);
tl.set("#cfbCream", { opacity: 0 }, XO + 0.26);
tl.to("#cfbBlue", { scaleY: 0.03, duration: 0.12, ease: "power2.in" }, XO + 0.18);
tl.set("#cfbBlue", { opacity: 0 }, XO + 0.31);
tl.to("#cfShadow", { opacity: 0, scaleX: 0.3, duration: 0.16, ease: "power1.in" }, XO + 0.14);
tl.to("#cfscrim", { opacity: 0, duration: 0.28, ease: "power1.in" }, XO + 0.02);`;
return { css, html, js };
}
function setpieceChalkwrite() {
// ---- setpiece: CHALKWRITE (the board grows and a hand WRITES the word) ----
// The lower chalk band grows into a FULL deep-green board behind the subject
// 0.3s before the apex (scaleY power3.out from the bottom edge, the room
// dims with it); the hero word is HAND-WRITTEN in chalk — sequential Hershey
// stroke reveal (drawon pattern), dry/rough via seeded ±1px jitter baked
// onto every pen point, a blurred halo under a tight core, a chalk-stick
// nib riding the pen — while dust specks fall from the nib. On completion a
// hand-slap CLAP shakes the board 1 frame (the dust burst lives in the
// rail, in FRONT of the subject: fx.chalkclap), then TWO violent
// accelerating underlines slash beneath (nib rides them, dust kicks at the
// ends), the chalk flicks away, and the halo breathes through the hold with
// two slow motes drifting off the letters.
const h = dna.hero,
p = h.params,
I = heroIn;
const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
const tw = HG.chalkW;
const x0 = Math.round(HG.x - tw / 2);
let D = execFileSync(
"python3",
[gen, fontPath, heroDisplay.toLowerCase(), String(tw), "450", String(x0)],
{ encoding: "utf8" },
).trim();
// register the measured ink box on HG (ink CENTER lands on HG.y), then the
// board rect, underlines and the fg clap all derive from the same box
const pts = [...D.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map((m) => [+m[1], +m[2]]);
const ys0 = pts.map((q) => q[1]);
const dy = +(HG.y - (Math.min(...ys0) + Math.max(...ys0)) / 2).toFixed(1);
D = D.replace(/([ML]) ([-\d.]+) ([-\d.]+)/g, (m, c, x, y) => `${c} ${x} ${(+y + dy).toFixed(1)}`);
const inkTop = Math.min(...ys0) + dy,
inkBot = Math.max(...ys0) + dy,
inkH = inkBot - inkTop;
const inkL = Math.min(...pts.map((q) => q[0])),
inkR = Math.max(...pts.map((q) => q[0]));
// board: full strip width, grows from the bottom edge up past the ink
// (demo: ink top 320, board top 248 → gap 0.554 × ink height 130)
const stripW = (dna.body.strip && dna.body.strip.w) || 1200;
const SX = (dna.body.strip && dna.body.strip.x) ?? Math.round((W - stripW) / 2);
const boardTop = Math.max(40, Math.round(inkTop - (p.boardGap ?? 0.554) * Math.max(inkH, 90)));
const boardH = H - boardTop;
const kw = stripW / 1200,
kb = boardH / 472; // board furniture scales with the demo rect (1200×472)
const ENDT = +(DUR - 0.04).toFixed(3);
// writing window: keep the demo's 0.78s unless the hero lands too late to
// finish + underline + hold before the end of the clip
const WIN = Math.min(p.window || 0.78, Math.max(0.4, ENDT - 0.95 - I));
const DRAWN = +(I + WIN).toFixed(3);
// hand-off to the fg dust clap: the pen's final contact point, at write end
HG.clapX = Math.round(pts[pts.length - 1][0]);
HG.clapY = Math.round(pts[pts.length - 1][1] + dy);
HG.clapT = DRAWN;
const CHALK = dna.palette.chalk || dna.palette.accent || "#fbf9ef";
const DUST = dna.palette.dust || "#f6f4e9";
const css = `
#cwDim { position:absolute; inset:0; background:${p.dimColor || "rgba(4,14,9,0.95)"}; opacity:0; }
#cwVig { position:absolute; inset:0; opacity:0;
background: radial-gradient(75% 70% at 50% 45%, rgba(0,0,0,0) 55%, rgba(2,10,6,0.55) 100%); }
#cwUnit { position:absolute; inset:0; }
#cwBoard { position:absolute; left:${SX}px; top:${boardTop}px; width:${stripW}px; height:${boardH}px;
background: linear-gradient(180deg, ${dna.palette.boardHi || "#22392c"} 0%, ${dna.palette.board || "#1d3328"} 40%, ${dna.palette.boardLo || "#182b22"} 100%);
border-radius:6px 6px 0 0;
box-shadow: inset 0 0 90px rgba(0,0,0,0.55), 0 6px 24px rgba(0,0,0,0.45); }
#cwRim { position:absolute; top:0; left:0; right:0; height:7px; opacity:0.85;
border-radius:6px 6px 0 0;
background: linear-gradient(180deg, ${dna.palette.rim || "#8a6a48"} 0%, ${dna.palette.rimLo || "#5f452c"} 100%); }
#cwBoard .bsm { position:absolute; border-radius:50%; }
#cwBsm1 { left:${Math.round(120 * kw)}px; top:${Math.round(90 * kb)}px; width:${Math.round(430 * kw)}px; height:${Math.round(150 * kb)}px; transform:rotate(-5deg);
background: radial-gradient(50% 50% at 50% 50%, rgba(214,226,214,0.08) 0%, rgba(214,226,214,0) 70%); }
#cwBsm2 { left:${Math.round(660 * kw)}px; top:${Math.round(230 * kb)}px; width:${Math.round(380 * kw)}px; height:${Math.round(130 * kb)}px; transform:rotate(4deg);
background: radial-gradient(50% 50% at 50% 50%, rgba(214,226,214,0.07) 0%, rgba(214,226,214,0) 70%); }
#cwSvg { position:absolute; left:0; top:0; overflow:visible; }
#cwHaloG { opacity:${p.halo ?? 0.18}; }
#cwCoreG { opacity:0.92; }`;
const html = ` <div id="cwDim"></div>
<div id="cwVig"></div>
<div id="cwUnit">
<div id="cwBoard">
<div id="cwRim"></div>
<div class="bsm" id="cwBsm1"></div>
<div class="bsm" id="cwBsm2"></div>
</div>
<svg id="cwSvg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs>
<filter id="chalkblur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="3.2"/>
</filter>
</defs>
<g id="cwHaloG" filter="url(#chalkblur)"></g>
<g id="cwCoreG"></g>
<g id="cwDustG"></g>
<rect id="cwNib" x="-9" y="-3" width="18" height="6" rx="2.5" fill="#fffef6" opacity="0"/>
</svg>
</div>`;
const js = `
// ---- setpiece: CHALKWRITE (board grows; the hero word is hand-written in chalk) ----
const I8 = ${I.toFixed(3)}, WIN8 = ${WIN.toFixed(3)}, DRAWN = ${DRAWN}, ENDT = ${ENDT};
const D8 = ${J(D)};
const crnd = mulberry32(${p.seed ?? 7});
// scene reaction: vignette early; the room dims as the board grows
tl.fromTo("#cwVig", { opacity: 0 }, { opacity: ${p.vig ?? 0.35}, duration: 0.35, ease: "power1.out" }, 0.10);
tl.fromTo("#cwDim", { opacity: 0 }, { opacity: ${p.dim ?? 0.22}, duration: 0.30, ease: "power1.in" }, ${(I - 0.3).toFixed(3)});
// the board grows taller 0.3s before the writing
gsap.set("#cwBoard", { scaleY: 0, transformOrigin: "50% 100%" });
tl.to("#cwBoard", { scaleY: 1, duration: 0.30, ease: "power3.out" }, ${(I - 0.3).toFixed(3)});
// chalk strokes: dry/rough = baked ±1px y jitter on every pen point
const jrnd = mulberry32(${p.jitterSeed ?? 21});
const jD = D8.replace(/([ML]) ([-\\d.]+) ([-\\d.]+)/g,
(m, c, x, y) => c + " " + x + " " + (parseFloat(y) + (jrnd()*2 - 1)).toFixed(1));
const subs = jD.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
const SVGNS8 = "http://www.w3.org/2000/svg";
function mk(parent, d, stroke, w) {
const el = document.createElementNS(SVGNS8, "path");
el.setAttribute("d", d); el.setAttribute("fill", "none");
el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
el.setAttribute("opacity", "0");
document.getElementById(parent).appendChild(el);
return el;
}
const strokes = subs.map((d) => ({
halo: mk("cwHaloG", d, "#ffffff", ${p.haloWidth ?? 14}),
core: mk("cwCoreG", d, ${J(CHALK)}, ${p.coreWidth ?? 7}),
}));
let total = 0;
strokes.forEach((s) => { s.len = s.core.getTotalLength(); total += s.len; });
const nib = document.getElementById("cwNib");
gsap.set(nib, { rotation: -36, transformOrigin: "50% 50%" });
let acc = 0;
const cum = []; // cumulative starts per stroke
strokes.forEach((s) => { cum.push(acc); acc += s.len; });
strokes.forEach((s, si) => {
const d = WIN8 * s.len / total, st = I8 + WIN8 * cum[si] / total;
gsap.set([s.core, s.halo], { strokeDasharray: s.len, strokeDashoffset: s.len });
tl.set([s.core, s.halo], { opacity: 1 }, st);
tl.fromTo([s.core, s.halo], { strokeDashoffset: s.len },
{ strokeDashoffset: 0, duration: d, ease: "none" }, st);
const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
for (let k = 0; k <= n; k++) {
const pt = s.core.getPointAtLength(s.len * k / n);
xs.push(pt.x); ys.push(pt.y);
}
tl.set(nib, { x: xs[0], y: ys[0] }, st);
tl.to(nib, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, st);
});
tl.set(nib, { opacity: 1 }, I8 - 0.01);
// ${p.dustN ?? 8} dust specks fall from the nib along the path
function speck(parent, x, y, r, fill) {
const c = document.createElementNS(SVGNS8, "circle");
c.setAttribute("cx", x); c.setAttribute("cy", y); c.setAttribute("r", r);
c.setAttribute("fill", fill); c.setAttribute("opacity", "0");
document.getElementById(parent).appendChild(c);
return c;
}
for (let k = 0; k < ${p.dustN ?? 8}; k++) {
const f = (k + 0.6) / ${p.dustN ?? 8}, target = f * total;
let si = 0; while (si < strokes.length - 1 && cum[si + 1] <= target) si++;
const pt = strokes[si].core.getPointAtLength(target - cum[si]);
const c = speck("cwDustG", pt.x, pt.y + 4, 1.6 + crnd() * 1.4, ${J(DUST)});
const ts = I8 + f * WIN8;
tl.set(c, { opacity: 0.9 }, ts);
tl.to(c, { y: 26 + crnd() * 20, x: crnd() * 14 - 7, duration: 0.5, ease: "power1.in" }, ts);
tl.to(c, { opacity: 0, duration: 0.42, ease: "power1.in" }, ts + 0.08);
}
// hand-slap CLAP: 1-frame board shake (dust burst lives in the rail, in front)
tl.set("#cwUnit", { y: 3 }, DRAWN);
tl.set("#cwUnit", { y: -2 }, DRAWN + F);
tl.set("#cwUnit", { y: 1 }, DRAWN + 2*F);
tl.set("#cwUnit", { y: 0 }, DRAWN + 3*F);
// two violent underlines (fast accelerating slashes), seeded waviness
const UL = [
{ x1: ${Math.round(inkL + 14)}, x2: ${Math.round(inkR - 4)}, y: ${Math.round(inkBot + 30)}, n: 4, t: DRAWN + 0.09, dur: 0.10 },
{ x1: ${Math.round(inkL + 58)}, x2: ${Math.round(inkR - 44)}, y: ${Math.round(inkBot + 50)}, n: 3, t: DRAWN + 0.23, dur: 0.09 },
];
UL.forEach((u) => {
if (u.t + u.dur > ENDT - 0.1) return;
let ud = "M " + u.x1 + " " + (u.y + (crnd()*9 - 4.5)).toFixed(1);
for (let k = 1; k <= u.n; k++)
ud += " L " + (u.x1 + (u.x2 - u.x1) * k / u.n).toFixed(1) + " " + (u.y + (crnd()*9 - 4.5)).toFixed(1);
const halo = mk("cwHaloG", ud, "#ffffff", ${p.haloWidth ?? 14});
const core = mk("cwCoreG", ud, ${J(CHALK)}, ${(p.coreWidth ?? 7) + 1});
const len = core.getTotalLength();
gsap.set([core, halo], { strokeDasharray: len, strokeDashoffset: len });
tl.set([core, halo], { opacity: 1 }, u.t);
tl.fromTo([core, halo], { strokeDashoffset: len },
{ strokeDashoffset: 0, duration: u.dur, ease: "power3.in" }, u.t);
// nib rides the slash
const n = 8, xs = [], ys = [];
for (let k = 0; k <= n; k++) { const pt = core.getPointAtLength(len * k / n); xs.push(pt.x); ys.push(pt.y); }
tl.set(nib, { x: xs[0], y: ys[0] }, u.t);
tl.to(nib, { keyframes: { x: xs, y: ys }, duration: u.dur, ease: "none" }, u.t);
// dust kicked up where the slash ends
for (let k = 0; k < 3; k++) {
const c = speck("cwDustG", xs[n] - 4 + crnd() * 8, ys[n], 1.4 + crnd(), ${J(DUST)});
tl.set(c, { opacity: 0.85 }, u.t + u.dur);
tl.to(c, { y: 16 + crnd() * 14, x: crnd() * 18 - 6, opacity: 0, duration: 0.38, ease: "power1.in" }, u.t + u.dur);
}
});
// chalk flicks away after the second underline
tl.to(nib, { x: "+=30", y: "+=22", scale: 2, opacity: 0, duration: 0.14, ease: "power2.in" },
Math.min(DRAWN + 0.35, ENDT - 0.15));
// hold life: halo breathe (baked f(t), deterministic)
const humStart = Math.min(DRAWN + 0.46, ENDT - 0.3), humDur = ENDT - humStart;
const N8 = Math.max(6, Math.round(humDur / (2 * F)));
const humVals = [];
for (let k = 0; k <= N8; k++) {
const tt = k / N8 * humDur;
humVals.push(${p.halo ?? 0.18} + 0.05 * Math.sin(2 * Math.PI * 1.6 * tt) + 0.02 * Math.sin(2 * Math.PI * 3.7 * tt + 0.8));
}
tl.to("#cwHaloG", { keyframes: { opacity: humVals }, duration: humDur, ease: "none" }, humStart);
// two slow motes drifting off the letters during the hold
[[humStart + 0.10, ${Math.round(inkL + 0.43 * (inkR - inkL))}], [humStart + 0.50, ${Math.round(inkL + 0.71 * (inkR - inkL))}]].forEach(([ts, mx]) => {
if (ts + 0.5 > ENDT) return;
const c = speck("cwDustG", mx + crnd() * 30, ${Math.round((inkTop + inkBot) / 2 + 42)}, 1.5 + crnd(), ${J(DUST)});
tl.set(c, { opacity: 0.6 }, ts);
tl.to(c, { y: 30, x: crnd() * 10 - 5, opacity: 0, duration: 0.5, ease: "power1.in" }, ts);
});`;
return { css, html, js };
}
function setpieceSpraytag() {
// ---- setpiece: SPRAYTAG (a writer steps up and TAGS the hero word) ----
// The dusk wall dims + a radial scrim charges as the writer steps up; the
// can RATTLES at the start point (seeded set-chain nib jitter), then the
// hero word is spray-written stroke by stroke — sequential Hershey dash
// reveal (drawon pattern) — as a TRIPLE stack per stroke: fat dark
// under-stroke (sky-contrast armor) + blurred overspray halo + wet paint
// core, the glowing nib riding each stroke. Seeded overspray dots pop near
// the nib as it passes; the nib throws off at the finish (the magenta splat
// lives in the rail, in FRONT of the subject: fx.paintsplat). Through the
// hold, seeded DRIPS grow from painted low points (signature) and the halo
// hums while the whole tag slowly looms.
const h = dna.hero,
p = h.params,
I = heroIn;
const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
const tw = HG.sprayW;
const x0 = Math.round(HG.x - tw / 2);
let D = execFileSync(
"python3",
[gen, fontPath, heroDisplay.toLowerCase(), String(tw), "450", String(x0)],
{ encoding: "utf8" },
).trim();
// register the measured ink box on HG (ink CENTER lands on HG.y); the loom
// origin and the fg paint splat derive from the same box
const pts = [...D.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map((m) => [+m[1], +m[2]]);
const ys0 = pts.map((q) => q[1]);
const dy = +(HG.y - (Math.min(...ys0) + Math.max(...ys0)) / 2).toFixed(1);
D = D.replace(/([ML]) ([-\d.]+) ([-\d.]+)/g, (m, c, x, y) => `${c} ${x} ${(+y + dy).toFixed(1)}`);
const ENDT = +(DUR - 0.04).toFixed(3);
// writing window: keep the demo's 0.58s unless the hero lands too late to
// finish + splat + drip before the end of the clip
const WIN = Math.min(p.window || 0.58, Math.max(0.35, ENDT - 0.55 - I));
const DRAWN = +(I + WIN).toFixed(3);
// hand-off to the fg paint splat: the pen-finish point, at write end
HG.splatX = Math.round(pts[pts.length - 1][0]);
HG.splatY = Math.round(pts[pts.length - 1][1] + dy);
HG.splatT = DRAWN;
const PAINT = dna.palette.accent || "#ffd23f";
const UNDER = dna.palette.under || "#241008";
const NIB = dna.palette.nib || "#fff6d9";
const css = `
#stDim { position:absolute; inset:0; opacity:0; background:${p.dimColor || "#0a0608"}; }
#stScrim { position:absolute; inset:0; opacity:0;
background: radial-gradient(92% 78% at 50% 36%, rgba(0,0,0,0) 28%, rgba(10,4,12,0.78) 100%); }
#stWrap { position:absolute; inset:0; }
#stSvg { position:absolute; left:0; top:0; overflow:visible; }
#stCoreG { filter: drop-shadow(0 3px 7px rgba(0,0,0,0.55)); }
#stNib { filter: blur(0.8px) drop-shadow(0 0 9px ${PAINT}e6); }
.stdrip { position:absolute; height:0; opacity:0;
background: linear-gradient(180deg, ${PAINT} 0%, ${dna.palette.dripMid || "#eab928"} 78%, ${dna.palette.dripLo || "#d8a51d"} 100%);
border-radius: 0 0 4px 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.35); }`;
const html = ` <div id="stDim"></div><div id="stScrim"></div>
<div id="stWrap">
<svg id="stSvg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs>
<filter id="sprayblur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="7"/>
</filter>
</defs>
<g id="stUnderG" opacity="0.9"></g>
<g id="stHaloG" filter="url(#sprayblur)" opacity="${p.haloOpacity ?? 0.5}"></g>
<g id="stCoreG"></g>
<g id="stOsprayG"></g>
<circle id="stNib" r="9" fill="${NIB}" opacity="0"/>
</svg>
</div>`;
const js = `
// ---- setpiece: SPRAYTAG (rattle → sequential spray write → drips + hum) ----
const Ig = ${I.toFixed(3)}, WINg = ${WIN.toFixed(3)}, DRAWNg = ${DRAWN}, ENDTg = ${ENDT};
const Dg = ${J(D)};
const grnd = mulberry32(${p.seed ?? 140987});
// scene reaction: the wall darkens as the writer steps up (charge)
tl.fromTo("#stDim", { opacity: 0 }, { opacity: ${p.dim ?? 0.22}, duration: 0.40, ease: "power2.in" }, ${(I - 0.42).toFixed(3)});
tl.fromTo("#stScrim", { opacity: 0 }, { opacity: ${p.scrim ?? 0.62}, duration: 0.40, ease: "power2.in" }, ${(I - 0.42).toFixed(3)});
// per-stroke TRIPLE stack (split at pen lifts) — sequential dash reveal
const subsG = Dg.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
const SVGNSG = "http://www.w3.org/2000/svg";
function mkG(parent, d, stroke, w) {
const el = document.createElementNS(SVGNSG, "path");
el.setAttribute("d", d); el.setAttribute("fill", "none");
el.setAttribute("stroke", stroke); el.setAttribute("stroke-width", w);
el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
el.setAttribute("opacity", "0");
document.getElementById(parent).appendChild(el);
return el;
}
const strokesG = subsG.map((d) => ({
under: mkG("stUnderG", d, ${J(UNDER)}, ${p.underWidth ?? 21}), // dark edge — sky-contrast armor
halo: mkG("stHaloG", d, ${J(PAINT)}, ${p.haloWidth ?? 36}), // fat soft overspray spread
core: mkG("stCoreG", d, ${J(PAINT)}, ${p.coreWidth ?? 13}), // wet paint core
}));
let totalG = 0;
strokesG.forEach((s) => { s.len = s.core.getTotalLength(); totalG += s.len; });
// helper: point at a global arc-length along the whole word
function pointAtTotal(L) {
let acc = 0;
for (const s of strokesG) {
if (L <= acc + s.len) return s.core.getPointAtLength(L - acc);
acc += s.len;
}
return strokesG[strokesG.length - 1].core.getPointAtLength(strokesG[strokesG.length - 1].len);
}
// ANTICIPATION: the can RATTLES at the start point before painting
const nibG = document.getElementById("stNib");
gsap.set(nibG, { attr: { cx: 0, cy: 0 } });
const p0g = pointAtTotal(0);
tl.set(nibG, { opacity: 1, x: p0g.x, y: p0g.y }, Ig - 0.30);
for (let k = 0; k < 6; k++) { // set-chain rattle (sanctioned jitter)
tl.set(nibG, { x: p0g.x + (grnd() - 0.5) * 10, y: p0g.y + (grnd() - 0.5) * 7 }, Ig - 0.28 + k * 2 * F);
}
tl.set(nibG, { x: p0g.x, y: p0g.y }, Ig - 0.02);
// SEQUENTIAL spray write: constant pen speed, nib rides each stroke
let tg = Ig;
strokesG.forEach((s) => {
const d = WINg * s.len / totalG;
gsap.set([s.under, s.halo, s.core], { strokeDasharray: s.len, strokeDashoffset: s.len });
tl.set([s.under, s.halo, s.core], { opacity: 1 }, tg);
tl.fromTo([s.under, s.halo, s.core], { strokeDashoffset: s.len },
{ strokeDashoffset: 0, duration: d, ease: "none" }, tg);
const n = Math.max(4, Math.round(s.len / 14)), xs = [], ys = [];
for (let k = 0; k <= n; k++) {
const pt = s.core.getPointAtLength(s.len * k / n);
xs.push(pt.x); ys.push(pt.y);
}
tl.set(nibG, { x: xs[0], y: ys[0] }, tg);
tl.to(nibG, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, tg);
tg += d;
});
// nib throw-off at the finish (the splat ring itself lives on the rail layer)
tl.to(nibG, { scale: 2.6, opacity: 0, duration: 0.12, ease: "power2.in",
transformOrigin: "50% 50%" }, DRAWNg);
// OVERSPRAY: seeded dots popping near the nib as it passes
for (let i = 0; i < ${p.osprayN ?? 6}; i++) {
const c = document.createElementNS(SVGNSG, "circle");
c.setAttribute("r", (2 + grnd() * 2.4).toFixed(1));
c.setAttribute("fill", ${J(PAINT)}); c.setAttribute("opacity", "0");
document.getElementById("stOsprayG").appendChild(c);
const f = 0.08 + 0.84 * grnd();
const ti = Ig + WINg * f;
const pt = pointAtTotal(totalG * f);
const ox = (grnd() < 0.5 ? -1 : 1) * (9 + grnd() * 18);
const oy = (grnd() < 0.5 ? -1 : 1) * (7 + grnd() * 14);
tl.set(c, { opacity: 0.85 }, ti);
tl.fromTo(c, { attr: { cx: pt.x + ox, cy: pt.y + oy } },
{ attr: { cx: pt.x + ox * 1.9, cy: pt.y + oy * 1.9 },
duration: 0.30, ease: "power2.out" }, ti);
tl.to(c, { opacity: 0, duration: 0.26, ease: "power1.in" }, ti + 0.05);
}
// DRIPS (signature): seeded drips grow from painted low points and KEEP
// growing through the hold
const samplesG = [];
{ let acc = 0;
strokesG.forEach((s) => {
const n = Math.max(6, Math.round(s.len / 10));
for (let k = 0; k <= n; k++) {
const L = s.len * k / n;
const pt = s.core.getPointAtLength(L);
samplesG.push({ x: pt.x, y: pt.y, L: acc + L });
}
acc += s.len;
});
}
const maxYg = Math.max(...samplesG.map(q => q.y));
const lowsG = samplesG.filter(q => q.y > maxYg - 8).sort((a, b) => a.x - b.x);
const pickedG = [];
for (let tries = 0; tries < 60 && pickedG.length < ${p.drips ?? 3}; tries++) {
const cand = lowsG[Math.floor(grnd() * lowsG.length)];
if (cand && pickedG.every(q => Math.abs(q.x - cand.x) > 110)) pickedG.push(cand);
}
const stageG = document.getElementById("stage");
pickedG.forEach((q) => {
const el = document.createElement("div");
el.className = "stdrip"; stageG.appendChild(el);
const w = 6 + Math.round(grnd() * 3);
gsap.set(el, { left: q.x - w / 2, top: q.y - 4, width: w });
const paintT = Ig + WINg * (q.L / totalG);
const start = Math.min(paintT + 0.22 + grnd() * 0.22, ENDTg - 0.3);
const dur = Math.min(1.25, ENDTg - start);
const hh = (36 + grnd() * 36) * Math.max(0.55, dur / 1.25);
tl.set(el, { opacity: 1 }, start);
tl.to(el, { height: hh, duration: dur, ease: "power1.in" }, start);
});
// HOLD LIFE: halo hum (baked f(t), deterministic) + slow loom on the whole tag
const humStartG = DRAWNg + 0.06, humDurG = Math.max(0.2, ENDTg - DRAWNg - 0.06);
const NG = Math.max(6, Math.round(humDurG / (2 * F)));
const humValsG = [];
for (let k = 0; k <= NG; k++) {
const tt = k / NG * humDurG;
humValsG.push(${p.haloOpacity ?? 0.5} + 0.07 * Math.sin(2 * Math.PI * 7 * tt) + 0.05 * Math.sin(2 * Math.PI * 11 * tt + 0.9));
}
tl.to("#stHaloG", { keyframes: { opacity: humValsG }, duration: humDurG, ease: "none" }, humStartG);
gsap.set("#stWrap", { transformOrigin: "${HG.x}px ${HG.y}px" });
tl.to("#stWrap", { scale: 1.014, duration: 0.5, ease: "power1.inOut" }, DRAWNg + 0.08);`;
return { css, html, js };
}
function setpieceBrushwrite() {
// ---- setpiece: BRUSHWRITE (sumi-e — ONE violent calligraphic gesture) ----
// A warm paper wash charges behind the word; the brush SLAPS down at the
// first pen point (start-splat: irregular ink blob + seeded satellites —
// the fg fleck pass rides fx.brushflecks), then the hero word is written in
// ONE sequential gesture — Hershey dash reveal at constant pen speed — as a
// FOUR-layer stack per stroke (blurred bleed under fat/mid/core widths =
// pressure-tapered wet brush), the round ink nib riding the pen and
// flicking off at the finish. Spatter droplets eject perpendicular at the
// sharpest-curvature points of the gesture. Through the hold the ink
// BLEEDS into the paper (the blurred under-layer swells visible) and the
// whole work breathes; then the vermilion SEAL chop stamps beside the ink
// (power4 crush → 2-frame squash → elastic settle, red flecks kicked) —
// the plate punch lands on the chop contact.
const h = dna.hero,
p = h.params,
I = heroIn;
const fontPath = path.join(SKILL, "assets/strokefonts", dna.fonts.strokeFont);
const gen = path.join(SKILL, "scripts/gen-stroke-path.py");
const tw = HG.brushW;
const x0 = Math.round(HG.x - tw / 2);
let D = execFileSync(
"python3",
[gen, fontPath, heroDisplay.toLowerCase(), String(tw), "450", String(x0)],
{ encoding: "utf8" },
).trim();
// register the measured ink box on HG (ink CENTER lands on HG.y); the wash,
// breathe origin, seal chop and the fg flecks all derive from the same box
const pts = [...D.matchAll(/[ML] ([-\d.]+) ([-\d.]+)/g)].map((m) => [+m[1], +m[2]]);
const ys0 = pts.map((q) => q[1]);
const dy = +(HG.y - (Math.min(...ys0) + Math.max(...ys0)) / 2).toFixed(1);
D = D.replace(/([ML]) ([-\d.]+) ([-\d.]+)/g, (m, c, x, y) => `${c} ${x} ${(+y + dy).toFixed(1)}`);
const inkBot = Math.max(...ys0) + dy;
const inkR = Math.max(...pts.map((q) => q[0]));
const ENDT = +(DUR - 0.04).toFixed(3);
// writing window: keep the demo's 0.45s unless the gesture lands too late to
// finish + seal + bleed before the end of the clip
const WIN = Math.min(p.window || 0.45, Math.max(0.3, ENDT - 0.9 - I));
const DRAWN = +(I + WIN).toFixed(3);
// hand-off to the fg ink flecks: the brush's FIRST contact point, at slap-down
HG.fleckX = Math.round(pts[0][0]);
HG.fleckY = Math.round(pts[0][1] + dy);
HG.fleckT = +(I + 0.01).toFixed(3);
// seal chop: below-right of the ink (demo offsets), kept on frame
const SEALC = +(DRAWN + (p.sealDelay ?? 0.04)).toFixed(3);
const sealS = p.sealSize ?? 64;
const sealX = Math.min(W - 48, Math.max(60, Math.round(inkR + (p.sealDx ?? -103))));
const sealY = Math.min(H - 80, Math.round(inkBot + (p.sealDy ?? 72)));
const INK = dna.palette.ink || "#19120b";
const WASH = dna.palette.wash || "#f6f0e2";
const WASH2 = dna.palette.wash2 || "#f4edde";
const washW = tw + 380,
washH = p.washH ?? 420;
const bleedT = +(DRAWN + 0.1).toFixed(3);
const bleedDur = +Math.max(0.4, Math.min(1.7, ENDT - DRAWN - 0.1)).toFixed(2);
const breT = +Math.min(SEALC, ENDT - 0.4).toFixed(3);
const breDur = +Math.max(0.3, Math.min(1.78, ENDT - breT)).toFixed(2);
const breN = Math.max(8, Math.round(breDur / (2 * F)));
const css = `
#bwWash { position:absolute; left:${HG.x}px; top:${HG.y}px; width:${washW}px; height:${washH}px;
margin-left:-${Math.round(washW / 2)}px; margin-top:-${Math.round(washH / 2)}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%,
${WASH}d9 0%, ${WASH2}73 52%, ${WASH2}00 75%); }
#bwSvg { position:absolute; left:0; top:0; overflow:visible; }
#bwSeal { position:absolute; left:${sealX}px; top:${sealY}px; width:${sealS}px; height:${sealS}px; opacity:0;
border-radius:6px;
background: radial-gradient(70% 70% at 42% 38%, ${dna.palette.seal || "#d24a32"} 0%, ${dna.palette.sealMid || "#c03422"} 62%, ${dna.palette.sealLo || "#a92a1a"} 100%);
box-shadow: 0 3px 10px rgba(20,10,6,0.45), inset 0 0 9px rgba(255,140,110,0.35); }
#bwSeal span { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:'${dna.fonts.seal || dna.fonts.body}', serif; font-weight:700;
font-size:${Math.round(sealS * 0.656)}px; color:${dna.palette.sealText || "#f7efe2"}; }
.bwfleck { position:absolute; border-radius:50%; background:${dna.palette.sealMid || "#c03422"}; opacity:0; }`;
const html = ` <div id="bwWash"></div>
<svg id="bwSvg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<defs>
<filter id="bwbleedblur" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="6"/>
</filter>
</defs>
<g id="bwInkAll">
<g id="bwBleedG" filter="url(#bwbleedblur)" opacity="0"></g>
<g id="bwFatG"></g>
<g id="bwMidG"></g>
<g id="bwCoreG"></g>
</g>
<g id="bwSplatG" opacity="0"></g>
<g id="bwSpatG"></g>
<circle id="bwNib" r="7.5" fill="${dna.palette.nib || "#1a130c"}" opacity="0"/>
</svg>
<div id="bwSeal"><span>${esc(p.sealGlyph || "観")}</span></div>`;
const js = `
// ---- setpiece: BRUSHWRITE (slap-down → one gesture → bleed + seal chop) ----
const Ib = ${I.toFixed(3)}, WINb = ${WIN.toFixed(3)}, DRAWNb = ${DRAWN}, ENDTb = ${ENDT};
const Db = ${J(D)};
const wrnd = mulberry32(${p.seed ?? 380611});
// scene reaction: the paper wash charges in just before the gesture
tl.fromTo("#bwWash", { opacity: 0 }, { opacity: ${p.wash ?? 0.34}, duration: 0.22, ease: "power2.in" }, ${(I - 0.18).toFixed(3)});
${
I + 1.3 < ENDT
? ` tl.to("#bwWash", { opacity: ${p.washSettle ?? 0.27}, duration: ${Math.min(1.2, ENDT - I - 1.0).toFixed(2)}, ease: "power1.out" }, ${(I + 1.0).toFixed(3)});`
: ""
}
// strokes split at pen lifts; FOUR stacked widths = pressure-tapered wet brush
const subsB = Db.split(/(?=M )/).map(s => s.trim()).filter(Boolean);
const SVGNSB2 = "http://www.w3.org/2000/svg";
function mkB(parent, d, w, op) {
const el = document.createElementNS(SVGNSB2, "path");
el.setAttribute("d", d); el.setAttribute("fill", "none");
el.setAttribute("stroke", ${J(INK)}); el.setAttribute("stroke-width", w);
el.setAttribute("stroke-opacity", op);
el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linejoin", "round");
el.setAttribute("opacity", "0");
document.getElementById(parent).appendChild(el);
return el;
}
const strokesB = subsB.map((d) => ({
bleed: mkB("bwBleedG", d, ${p.bleedWidth ?? 16}, 1),
fat: mkB("bwFatG", d, ${p.fatWidth ?? 18}, 0.25),
mid: mkB("bwMidG", d, ${p.midWidth ?? 10}, 0.6),
core: mkB("bwCoreG", d, ${p.coreWidth ?? 5}, 1),
}));
let totalB = 0;
strokesB.forEach((s) => { s.len = s.core.getTotalLength(); totalB += s.len; });
// start-splat: irregular ink blob at the first pen point (the brush SLAPS down)
const SXB = ${Math.round(pts[0][0])}, SYB = ${Math.round(pts[0][1] + dy)};
(function buildSplat() {
const g = document.getElementById("bwSplatG");
const main = document.createElementNS(SVGNSB2, "ellipse");
main.setAttribute("cx", SXB - 4); main.setAttribute("cy", SYB + 2);
main.setAttribute("rx", 24); main.setAttribute("ry", 15);
main.setAttribute("transform", "rotate(-24 " + (SXB - 4) + " " + (SYB + 2) + ")");
main.setAttribute("fill", ${J(INK)}); main.setAttribute("fill-opacity", "0.9");
g.appendChild(main);
for (let i = 0; i < 5; i++) {
const c = document.createElementNS(SVGNSB2, "circle");
const a = wrnd() * Math.PI * 2, dist = 16 + wrnd() * 26;
c.setAttribute("cx", SXB - 4 + Math.cos(a) * dist);
c.setAttribute("cy", SYB + 2 + Math.sin(a) * dist * 0.7);
c.setAttribute("r", (1.8 + wrnd() * 3.4).toFixed(1));
c.setAttribute("fill", ${J(INK)}); c.setAttribute("fill-opacity", "0.85");
g.appendChild(c);
}
})();
tl.set("#bwSplatG", { opacity: 1 }, Ib);
tl.fromTo("#bwSplatG", { scale: 0.35, svgOrigin: "${Math.round(pts[0][0])} ${Math.round(pts[0][1] + dy)}" },
{ scale: 1, duration: 0.12, ease: "power3.out" }, Ib);
// ONE sequential gesture: constant pen speed, the round ink nib riding
const nibB = document.getElementById("bwNib");
let tb = Ib, accB = 0;
const curvB = []; // spatter candidates: {x,y,ang,dA,te}
strokesB.forEach((s) => {
const d = WINb * s.len / totalB;
const layers = [s.bleed, s.fat, s.mid, s.core];
layers.forEach(pp => gsap.set(pp, { strokeDasharray: s.len, strokeDashoffset: s.len }));
tl.set(layers, { opacity: 1 }, tb);
tl.fromTo(layers, { strokeDashoffset: s.len },
{ strokeDashoffset: 0, duration: d, ease: "none" }, tb);
const n = Math.max(6, Math.round(s.len / 12)), xs = [], ys = [];
let prevAng = null;
for (let k = 0; k <= n; k++) {
const pt = s.core.getPointAtLength(s.len * k / n);
xs.push(pt.x); ys.push(pt.y);
if (k > 0) {
const ang = Math.atan2(ys[k] - ys[k-1], xs[k] - xs[k-1]);
if (prevAng !== null) {
let dA = Math.abs(ang - prevAng); if (dA > Math.PI) dA = 2 * Math.PI - dA;
if (dA > 0.55)
curvB.push({ x: xs[k-1], y: ys[k-1], ang: prevAng, dA,
te: Ib + WINb * (accB + s.len * (k-1) / n) / totalB });
}
prevAng = ang;
}
}
tl.set(nibB, { x: xs[0], y: ys[0] }, tb);
tl.to(nibB, { keyframes: { x: xs, y: ys }, duration: d, ease: "none" }, tb);
tb += d; accB += s.len;
});
gsap.set(nibB, { attr: { cx: 0, cy: 0 } });
tl.set(nibB, { opacity: 1 }, Ib - 0.01);
// the brush lifts off with a flick at the end of the gesture
tl.to(nibB, { x: "+=42", y: "-=30", scale: 1.9, opacity: 0, duration: 0.1,
ease: "power2.in", transformOrigin: "50% 50%" }, DRAWNb);
// spatter droplets ejected at the sharpest-curvature points of the gesture
curvB.sort((a, b) => b.dA - a.dA);
const pickedB = [];
for (const c of curvB) {
if (pickedB.length >= ${p.spatterN ?? 6}) break;
if (pickedB.every(q => Math.abs(q.te - c.te) > 0.045)) pickedB.push(c);
}
const spatB = document.getElementById("bwSpatG");
pickedB.forEach((c) => {
if (c.te + 0.33 > ENDTb) return;
const el = document.createElementNS(SVGNSB2, "circle");
el.setAttribute("cx", c.x); el.setAttribute("cy", c.y);
el.setAttribute("r", (2.2 + wrnd() * 2.2).toFixed(1));
el.setAttribute("fill", ${J(INK)}); el.setAttribute("opacity", "0");
spatB.appendChild(el);
const side = wrnd() > 0.5 ? 1 : -1;
const ea = c.ang + side * Math.PI / 2;
const dist = 34 + wrnd() * 60;
const dx = Math.cos(ea) * dist, dyv = Math.sin(ea) * dist - 18;
tl.set(el, { opacity: 1 }, c.te);
tl.to(el, { keyframes: { x: [0, dx * 0.6, dx], y: [0, dyv * 0.6, dyv + 22],
opacity: [1, 0.9, 0] },
duration: 0.32, ease: "power2.out" }, c.te);
});
// HOLD: the ink BLEEDS into the paper (blurred under-layer swells) + breathe
tl.to("#bwBleedG", { opacity: ${p.bleed ?? 0.38}, duration: ${bleedDur}, ease: "sine.inOut" }, ${bleedT});
const bNb = ${breN}, bValsB = [];
for (let k = 0; k <= bNb; k++) {
const tt = k / bNb * ${breDur};
bValsB.push(1 + 0.006 * Math.sin(2 * Math.PI * ${p.breatheHz ?? 0.55} * tt));
}
tl.to("#bwInkAll", { keyframes: { scale: bValsB }, duration: ${breDur}, ease: "none",
svgOrigin: "${HG.x} ${HG.y}" }, ${breT});
${
SEALC + 0.38 <= ENDT
? `
// vermilion SEAL chop stamps the work — the plate punch lands on the contact
gsap.set("#bwSeal", { xPercent: -50, yPercent: -50, rotation: -6, transformOrigin: "50% 50%" });
tl.set("#bwSeal", { opacity: 1, scale: 1.7 }, ${(SEALC - 0.1).toFixed(3)});
tl.to("#bwSeal", { scale: 1, duration: 0.09, ease: "power4.in" }, ${(SEALC - 0.095).toFixed(3)});
tl.set("#bwSeal", { scaleX: 1.07, scaleY: 0.93 }, ${SEALC});
tl.to("#bwSeal", { scaleX: 1, scaleY: 1, duration: 0.28, ease: "elastic.out(1, 0.4)" }, ${(SEALC + 0.08).toFixed(3)});
{ const stg = document.getElementById("stage");
for (let i = 0; i < 3; i++) {
const el = document.createElement("div"); el.className = "bwfleck";
stg.appendChild(el);
const s = 2 + Math.round(wrnd() * 2);
gsap.set(el, { width: s, height: s, left: ${sealX}, top: ${sealY} });
const a = wrnd() * Math.PI * 2, dist = 32 + wrnd() * 38;
tl.set(el, { opacity: 0.9 }, ${(SEALC + 0.01).toFixed(3)});
tl.to(el, { x: Math.cos(a) * dist, y: Math.sin(a) * dist + 12, opacity: 0,
duration: 0.3, ease: "power2.out" }, ${(SEALC + 0.01).toFixed(3)});
}
}`
: ""
}`;
return { css, html, js };
}
function setpieceInkbloom() {
// INKWATER apex: a sumi ink drop falls into still water BEHIND the subject.
// Scene reaction: paper-white radial washes behind the two text zones — the
// body rows get a PERSISTENT paper backing from t≈0.15 (the closing lines
// keep their ground) and the apex zone charges just before the drop — plus
// a gentle vignette that STILLS the world while the ink blooms. The drop
// appears above the word point, swells, falls (power2.in, scaleY stretch),
// squashes on contact (2 frames) and merges into the bloom: 3 stacked
// blurred irregular ellipses scaling at different rates with counter-
// rotations + 2 thin concentric ripple rings expanding on the water
// surface; the hero words RESOLVE out of the blob (opacity snaps, blur
// 16→0 + scale 1.12→1 carries the bleed) — the first at contact, the
// following at their own spoken times; through the hold the glyph edges
// BREATHE (baked blur keyframes), the whole group looms 1.018 and faint
// ink wisps curl off the word top; exit: RE-DISSOLVE (blur up + spread +
// fade — the ink disperses back into the water), timed to clear before the
// next body line needs the reader. Blob / ring / wisp geometry is the
// demo's, scaled from the measured word rect (kx = halfW/demoHalf,
// ky = fontPx/132); the rail wash registers on the body row geometry.
const p = dna.hero.params || {},
D = heroIn;
const INK = dna.palette.ink || dna.palette.body;
const PAP = dna.palette.paper || "#fffaee",
PAP2 = dna.palette.paper2 || "#fff8e8";
const WASH = dna.palette.wash || "#fff9ee",
WAPX = dna.palette.washApex || "#fffaf0";
const fpx = HG.fontPx,
halfW = Math.round(HG.halfW || heroDisplay.length * 0.25 * fpx),
cx = HG.x,
cy = HG.y;
const ky = fpx / 132; // demo scale unit (Shippori Mincho 700 @ 132px)
const demoHalf = (wordPx("pixel size.", dna.fonts.hero, 132, 0.02) || 650) / 2;
const kx = halfW / demoHalf;
const SX = (v) => Math.round(v * kx),
SY = (v) => Math.round(v * ky);
const C = +(D + (p.fall ?? 0.15)).toFixed(3); // contact with the water surface
// hero words resolve on their own spoken times (the first at contact)
const heroWords = heroDisplay.trim().split(/\s+/);
const RES = heroWords.map((wtx, k) => {
const tw = tWords[Math.min(hero.idx + k, hero.idx + hero.len - 1)];
return +Math.max(C + 0.02, k === 0 ? 0 : tw.start).toFixed(3);
});
// the bloom re-dissolves before the next body line needs the reader
const nextL = LINES.find((L) => L.words[0].start > hero.end + 0.05);
const XOUT = +Math.max(
C + 0.55,
Math.min(
D + (p.maxHold ?? 1.75),
nextL ? nextL.words[0].start - (p.clearGap ?? 0.24) : DUR - 0.6,
DUR - 0.6,
),
).toFixed(3);
const loomT = +(D + 0.373).toFixed(3),
loomD = +Math.min(1.3, XOUT - (D + 0.373) - 0.03).toFixed(2);
const vigOutT = +(XOUT - 0.15).toFixed(3),
vigOutD = +Math.min(0.6, DUR - 0.05 - (XOUT - 0.15)).toFixed(2);
// rail wash centers on the body reading rows (middle row)
const railY = H - ((dna.body && dna.body.bottomPx) ?? 36) - ((dna.body && dna.body.rowGap) ?? 56);
const wrW = p.washRailW ?? 1180,
wrH = p.washRailH ?? 300;
const waW = 2 * halfW + 370,
waH = p.washApexH ?? 430;
const blr = (v) => Math.max(8, SY(v));
const css = `
#ikvig { position:absolute; inset:0; opacity:0;
background: radial-gradient(115% 100% at 50% 40%, rgba(0,0,0,0) 42%, rgba(8,6,4,0.55) 100%); }
#ikwashRail { position:absolute; left:${W / 2}px; top:${railY}px; width:${wrW}px; height:${wrH}px;
margin-left:-${Math.round(wrW / 2)}px; margin-top:-${Math.round(wrH / 2)}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, ${WASH}7a 0%, ${WASH}3d 48%, ${WASH}00 75%); }
#ikwashApex { position:absolute; left:${cx}px; top:${cy}px; width:${waW}px; height:${waH}px;
margin-left:-${Math.round(waW / 2)}px; margin-top:-${Math.round(waH / 2)}px; opacity:0;
background: radial-gradient(50% 50% at 50% 50%, ${WAPX}4d 0%, ${WAPX}21 50%, ${WAPX}00 76%); }
#inkG { position:absolute; left:${cx}px; top:${cy}px; opacity:0; }
#ikdrop { position:absolute; left:${-SY(9)}px; top:${-SY(228)}px; width:${SY(18)}px; height:${SY(18)}px;
border-radius: 46% 54% 50% 50% / 38% 40% 62% 60%;
background:${INK}; opacity:0; box-shadow: 0 0 6px ${INK}73; }
.ikblob { position:absolute; opacity:0; background:${INK}; }
#ikb1 { left:${SX(-115)}px; top:${SY(-64)}px; width:${SX(230)}px; height:${SY(128)}px;
border-radius: 47% 53% 44% 56% / 58% 42% 60% 40%; filter: blur(${blr(16)}px); }
#ikb2 { left:${SX(-205)}px; top:${SY(-44)}px; width:${SX(190)}px; height:${SY(108)}px;
border-radius: 56% 44% 58% 42% / 44% 60% 40% 56%; filter: blur(${blr(20)}px); }
#ikb3 { left:${SX(-13)}px; top:${SY(-66)}px; width:${SX(206)}px; height:${SY(96)}px;
border-radius: 42% 58% 50% 50% / 62% 38% 56% 44%; filter: blur(${blr(24)}px); }
.ikring { position:absolute; left:${SX(-80)}px; top:${SY(-8)}px; width:${SX(160)}px; height:${SY(44)}px;
border-radius:50%; opacity:0; }
#ikr1 { border:2px solid ${INK}cc; filter: blur(1.2px); }
#ikr2 { border:2px solid ${INK}8c; filter: blur(2.2px); }
#ikword { position:absolute; left:0; top:0; white-space:nowrap;
transform: translate(-50%,-50%);
font-family:'${dna.fonts.hero}', serif; font-weight:700;
font-size:${fpx}px; line-height:1; letter-spacing:0.02em; color:${INK}; }
#ikword .ikw { display:inline-block; opacity:0; margin-right:0.30em;
text-shadow: 0 0 14px ${PAP}f2, 0 0 36px ${PAP2}b3, 0 2px 4px ${PAP}99; }
#ikword .ikw:last-child { margin-right:0; }
.ikwisp { position:absolute; width:${SY(30)}px; height:${SY(38)}px; opacity:0;
border:4px solid transparent; border-top-color:${INK}80;
border-radius:50%; filter: blur(2.5px); }
#ikwisp1 { left:${SX(-210)}px; top:${SY(-118)}px; }
#ikwisp2 { left:${SX(118)}px; top:${SY(-122)}px; }`;
const html = ` <div id="ikvig"></div>
<div id="ikwashRail"></div>
<div id="ikwashApex"></div>
<div id="inkG">
<div class="ikblob" id="ikb2"></div>
<div class="ikblob" id="ikb3"></div>
<div class="ikblob" id="ikb1"></div>
<div class="ikring" id="ikr1"></div>
<div class="ikring" id="ikr2"></div>
<div id="ikword">${heroWords.map((t, k) => `<span class="ikw" id="ikw${k}">${esc(t)}</span>`).join("")}</div>
<div class="ikwisp" id="ikwisp1"></div>
<div class="ikwisp" id="ikwisp2"></div>
<div id="ikdrop"></div>
</div>`;
const js = `
// ---- setpiece: INKBLOOM (drop falls → ink blooms → words resolve → re-dissolve) ----
// paper washes (the scene offers paper to the ink); the rail wash PERSISTS
tl.fromTo("#ikwashRail", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power1.out" }, 0.15);
tl.fromTo("#ikwashApex", { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power1.out" }, ${Math.max(0.02, D - 0.077).toFixed(3)});
tl.to("#ikwashApex", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${XOUT});
// the world stills while the ink blooms
tl.to("#ikvig", { opacity: ${p.vig ?? 0.34}, duration: 0.45, ease: "power2.out" }, ${C});
tl.to("#ikvig", { opacity: 0, duration: ${vigOutD}, ease: "power1.in" }, ${vigOutT});
// ===== drop: appears, swells, falls, squashes on contact, merges into the blob =====
tl.set("#inkG", { opacity: 1 }, ${(D - 0.08).toFixed(3)});
tl.set("#ikdrop", { opacity: 1, transformOrigin: "50% 50%" }, ${(D - 0.08).toFixed(3)});
tl.fromTo("#ikdrop", { scale: 0.55 }, { scale: 1, duration: 0.08, ease: "power2.out" }, ${(D - 0.08).toFixed(3)});
tl.to("#ikdrop", { y: ${SY(219)}, duration: ${(C - D).toFixed(3)}, ease: "power2.in" }, ${D.toFixed(3)});
tl.to("#ikdrop", { scaleY: 1.45, scaleX: 0.8, duration: ${(C - D).toFixed(3)}, ease: "power2.in" }, ${D.toFixed(3)});
tl.set("#ikdrop", { scaleX: 1.7, scaleY: 0.35 }, ${C}); // contact squash, 2 frames
tl.to("#ikdrop", { opacity: 0, duration: 0.07 }, ${(C + 0.083).toFixed(3)});
// ===== bloom: 3 stacked blurred ellipses, different rates; opacity snaps then decays =====
const IKB = [["#ikb1", 0.82, 0.50, 3.0, -4, 5, 0, 1.15, 0.18, 1.05],
["#ikb2", 0.62, 0.42, 2.4, 7, -7, 0.04, 1.35, 0.22, 1.15],
["#ikb3", 0.50, 0.55, 3.8, -9, 8, 0.08, 1.50, 0.30, 1.20]];
IKB.forEach(([sel, op, s0, s1, r0, r1, dt, gd, ft, fd]) => {
tl.set(sel, { opacity: op, transformOrigin: "50% 50%" }, ${C} + dt);
tl.fromTo(sel, { scale: s0, rotation: r0 },
{ scale: s1, rotation: r1, duration: gd, ease: "expo.out" }, ${C} + dt);
tl.to(sel, { opacity: 0, duration: fd, ease: "power1.in" }, ${C} + ft);
});
// ripple rings on the water surface
tl.set("#ikr1", { opacity: 0.7, transformOrigin: "50% 50%" }, ${(C + 0.02).toFixed(3)});
tl.fromTo("#ikr1", { scale: 1 }, { scale: 7.2, duration: 1.45, ease: "expo.out" }, ${(C + 0.02).toFixed(3)});
tl.to("#ikr1", { opacity: 0, duration: 1.25, ease: "power1.in" }, ${(C + 0.22).toFixed(3)});
tl.set("#ikr2", { opacity: 0.5, transformOrigin: "50% 50%" }, ${(C + 0.16).toFixed(3)});
tl.fromTo("#ikr2", { scale: 1 }, { scale: 9.6, duration: 1.7, ease: "power2.out" }, ${(C + 0.16).toFixed(3)});
tl.to("#ikr2", { opacity: 0, duration: 1.4, ease: "power1.in" }, ${(C + 0.4).toFixed(3)});
// ===== the words resolve out of the blob (opacity snaps, BLUR carries the bleed) =====
const IKRES = ${J(RES)};
IKRES.forEach((rt, k) => {
const el = document.getElementById("ikw" + k);
const rd = Math.min(0.8, Math.max(0.3, ${XOUT} - rt - 0.25));
tl.set(el, { opacity: 1, transformOrigin: "50% 60%" }, rt);
tl.fromTo(el, { scale: 1.12, filter: "blur(16px)" },
{ scale: 1, filter: "blur(0px)", duration: rd, ease: "power2.out" }, rt);
// hold life: glyph edges breathe (blur 0 → ~0.8 → 0, slow)
const bt = rt + Math.max(0.82, rd + 0.02), bd = k % 2 ? 0.5 : 0.66;
if (bt + bd <= ${XOUT} + 0.02) {
tl.to(el, { keyframes: { filter: ["blur(0px)","blur(0.8px)","blur(0.15px)","blur(0.7px)","blur(0px)"] },
duration: bd, ease: "none" }, bt);
}
});
${
loomD >= 0.2
? ` // gentle loom on the group through the hold
tl.to("#inkG", { scale: 1.018, duration: ${loomD}, ease: "power1.inOut", transformOrigin: "50% 50%" }, ${loomT});`
: ""
}
// faint ink wisps curl off the word top during the hold
${
C + 0.573 + 0.6 <= XOUT
? ` tl.set("#ikwisp1", { opacity: 0.45, transformOrigin: "50% 50%" }, ${(C + 0.573).toFixed(3)});
tl.fromTo("#ikwisp1", { y: 0, x: 0, rotation: 14, scaleY: 1 },
{ y: -44, x: 12, rotation: 96, scaleY: 1.6, duration: 0.95, ease: "power1.out" }, ${(C + 0.573).toFixed(3)});
tl.to("#ikwisp1", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(C + 1.123).toFixed(3)});`
: ""
}
${
C + 0.753 + 0.6 <= XOUT
? ` tl.set("#ikwisp2", { opacity: 0.4, transformOrigin: "50% 50%" }, ${(C + 0.753).toFixed(3)});
tl.fromTo("#ikwisp2", { y: 0, x: 0, rotation: -18, scaleY: 1 },
{ y: -40, x: -14, rotation: -98, scaleY: 1.5, duration: 0.9, ease: "power1.out" }, ${(C + 0.753).toFixed(3)});
tl.to("#ikwisp2", { opacity: 0, duration: 0.38, ease: "power1.in" }, ${(C + 1.273).toFixed(3)});`
: ""
}
// exit: re-dissolve — blur up + spread + fade, ink dispersing
tl.to("#inkG", { scale: 1.15, filter: "blur(11px)", opacity: 0,
duration: 0.52, ease: "power1.in" }, ${XOUT});
tl.set("#inkG", { display: "none" }, ${Math.min(XOUT + 0.6, DUR - 0.02).toFixed(3)});`;
return { css, html, js };
}
function setpieceRansomnote() {
// ---- setpiece: RANSOMNOTE (letter-by-letter cutout slam → collective jolt → ripped off) ----
// The hero word slams in BEHIND the subject as a ransom note: one cutout
// chip per letter (4 recipes cycled, seeded ±13% sizes / alternating-sign
// rotations / misaligned baselines), each slamming in oversized with a
// flight shadow that POPS tight on contact (power3.in crush → 2-frame
// squash → elastic settle) at S_i = I + 0.01 + step·i. When the LAST letter
// lands the whole word takes a collective 1-frame JOLT + a scene pulse
// (the plate punch anchors here — punchOffset is recomputed from the real
// letter count so the punch always lands on the jolt). Hold = slow loom
// keyframes (the crooked grid never straightens). Exit = letters RIPPED
// off one by one, a 1-frame paper-tear flash under each. Scene reaction =
// a collage scrim darkens the bright sky for the apex window.
const p = dna.hero.params || {},
I = heroIn;
const STEP = p.step ?? 0.06,
CRUSH = p.crush ?? 0.09,
RSTAG = p.ripStag ?? 0.045;
const BASE = HG.fontPx;
const kf = BASE / 130; // chip chrome scales with the demo's 130px cut
const N = [...heroText].filter((c) => c !== " ").length;
const JOLT = +(I + 0.01 + STEP * (N - 1) + CRUSH).toFixed(3);
// rip schedule: letters tear off while the closing line speaks (demo:
// lastWord 0.19), clamped so the last rip completes on frame and the
// note holds ≥0.45s after the jolt
let ripT = Math.min(
theme.hero.exitAt ?? LASTWORD.start - (p.ripLead ?? 0.19),
DUR - 0.22 - RSTAG * (N - 1),
);
ripT = +Math.max(Math.min(ripT, DUR - 0.22 - RSTAG * (N - 1)), JOLT + 0.45).toFixed(3);
// the plate punch lands on the collective jolt, whatever the word length
if (dna.plate && dna.plate.punch) dna.plate.punchOffset = +(JOLT - I).toFixed(3);
const scrimT = +Math.max(0.02, I - 0.07).toFixed(3);
const loomT = +(JOLT + 0.1).toFixed(3);
const loomD = +Math.min(0.95, ripT - 0.08 - loomT).toFixed(2);
const css = `
#rscrim { position:absolute; inset:0; opacity:0;
background: linear-gradient(180deg, rgba(12,8,5,0.66) 0%, rgba(12,8,5,0.48) 38%,
rgba(12,8,5,0.22) 62%, rgba(12,8,5,0.05) 100%); }
#rpulse { position:absolute; inset:0; opacity:0; background:rgba(10,6,3,0.75); }
#rnote { position:absolute; left:${HG.x}px; top:${HG.y}px; white-space:nowrap; }
.aw { display:inline-block; position:relative; margin:0 3px; }
.asp { display:inline-block; width:${Math.round(BASE * 0.3)}px; }
.aflash { position:absolute; inset:-5px; background:${dna.palette.flash || "#fffdf2"}; opacity:0; z-index:0; }
.awm { display:inline-block; position:relative; z-index:1; opacity:0;
filter: drop-shadow(0 ${Math.max(4, Math.round(7 * kf))}px 0 rgba(8,6,4,0.55)); }
.achip { display:inline-block; line-height:1.04; padding:0.05em 0.10em 0.09em; }
${ransomChipCss("0.01em")}`;
const html = ` <div id="rscrim"></div>
<div id="rpulse"></div>
<div id="rnote"></div>`;
const js = `
// ---- setpiece: RANSOMNOTE (letter chips slam → collective jolt → ripped off) ----
const I = ${I.toFixed(3)}, STEP = ${STEP}, CRUSH = ${CRUSH}, RIPT = ${ripT};
const arnd = mulberry32(${p.seed || 3939});
${RANSOM_TEAR}
const HARDA = "drop-shadow(0 ${Math.max(4, Math.round(7 * kf))}px 0 rgba(8,6,4,0.55))";
const FLIGHTA = "drop-shadow(0 ${Math.max(10, Math.round(24 * kf))}px 5px rgba(8,6,4,0.30))";
const group = document.getElementById("rnote");
gsap.set(group, { xPercent: -50, yPercent: -50 });
// scene reacts: collage scrim darkens the bright sky for the apex window
tl.to("#rscrim", { opacity: ${p.scrim ?? 0.44}, duration: 0.12, ease: "power2.out" }, ${scrimT});
// ===== letter-by-letter ransom slam: S_k = I + 0.01 + STEP*k, contact = S_k + CRUSH
const RECSA = ["ca", "cb", "cc", "cd"];
let k = 0;
${J([...heroText])}.forEach((ch) => {
if (ch === " ") {
const sp = document.createElement("div"); sp.className = "asp";
group.appendChild(sp);
return;
}
const aw = document.createElement("div"); aw.className = "aw";
const fl = document.createElement("div"); fl.className = "aflash";
fl.style.clipPath = tearPoly(arnd);
const mv = document.createElement("div"); mv.className = "awm";
const chip = document.createElement("div"); chip.className = "achip " + RECSA[k % 4];
chip.style.fontSize = Math.round(${BASE} * (0.85 + arnd() * 0.27)) + "px";
chip.style.clipPath = tearPoly(arnd);
chip.textContent = ch;
mv.appendChild(chip); aw.appendChild(fl); aw.appendChild(mv); group.appendChild(aw);
// seeded crooked grid (demo grammar): rot sign alternates per letter,
// baseline shove dy leans the other way, the rip throws with the rot sign
const sgn = k % 2 ? 1 : -1;
const rot = sgn * (2 + arnd() * 3);
const dy = -sgn * (2 + arnd() * 4);
const rr = sgn * (15 + arnd() * 7);
const S = I + 0.01 + STEP * k, C = S + CRUSH;
tl.set(mv, { opacity: 1, scale: 1.5, rotation: rot * 1.8, y: dy - 20, filter: FLIGHTA }, S);
tl.to(mv, { scale: 1, rotation: rot, y: dy, duration: 0.085, ease: "power3.in" }, S + 0.005);
tl.set(mv, { filter: HARDA }, C); // shadow pop: snaps tight on contact
tl.set(mv, { scaleX: 1.07, scaleY: 0.93 }, C + 0.004); // contact squash, held 2 frames
tl.to(mv, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1, 0.4)" }, C + 0.087);
// ===== exit: RIPPED off one by one, tear flash 1 frame under each
const R = RIPT + ${RSTAG} * k;
tl.set(fl, { opacity: 0.85 }, R);
tl.set(fl, { opacity: 0 }, R + 0.045);
tl.to(mv, { rotation: rot + rr, y: dy - 34, opacity: 0,
duration: 0.16, ease: "power2.in" }, R);
k++;
});
// collective 1-frame jolt when the last letter lands + scene pulse
tl.set("#rnote", { y: 6 }, ${JOLT});
tl.to("#rnote", { y: 0, duration: 0.14, ease: "power3.out" }, ${(JOLT + 0.042).toFixed(3)});
tl.set("#rpulse", { opacity: ${p.pulse ?? 0.2} }, ${JOLT});
tl.to("#rpulse", { opacity: 0, duration: 0.13, ease: "power1.out" }, ${(JOLT + 0.042).toFixed(3)});
${
loomD >= 0.35
? `
// hold life: the pasted word looms (the crooked grid never straightens)
tl.to("#rnote", { keyframes: { scale: [1, 1.02, 1.006, 1.018, 1] },
duration: ${loomD}, ease: "none" }, ${(JOLT + 0.1).toFixed(3)});`
: ""
}
tl.to("#rscrim", { opacity: 0, duration: 0.3, ease: "power1.in" }, ${(ripT + 0.06).toFixed(3)});`;
return { css, html, js };
}
const SETPIECES = {
detonation: setpieceDetonation,
decode: setpieceDecode,
drawon: setpieceDrawon,
cpslam: setpieceCpslam,
coverword: setpieceCoverword,
settle: setpieceSettle,
flapboard: setpieceFlapboard,
ledwipe: setpieceLedwipe,
vhsosd: setpieceVhsosd,
bossintro: setpieceBossintro,
rubberstamp: setpieceRubberstamp,
lasercage: setpieceLasercage,
boltstrike: setpieceBoltstrike,
holoboot: setpieceHoloboot,
biobloom: setpieceBiobloom,
silkribbon: setpieceSilkribbon,
scopetrace: setpieceScopetrace,
papermat: setpiecePapermat,
centerfold: setpieceCenterfold,
chalkwrite: setpieceChalkwrite,
spraytag: setpieceSpraytag,
brushwrite: setpieceBrushwrite,
inkbloom: setpieceInkbloom,
ransomnote: setpieceRansomnote,
};
if (!PARADIGMS[dna.body.paradigm])
throw new Error("[make-theme] unknown body paradigm: " + dna.body.paradigm);
const body = PARADIGMS[dna.body.paradigm]();
let setp = { css: "", html: "", js: "" };
if (!heroInline && !HEROLESS) {
if (!SETPIECES[dna.hero.setpiece])
throw new Error("[make-theme] unknown setpiece: " + dna.hero.setpiece);
setp = SETPIECES[dna.hero.setpiece]();
}
const fx = frontFx();
// bg file: plate reaction + embedded setpiece (+ body if body.layer === "bg")
const bodyInBg = dna.body.layer === "bg";
const poemScrimInBg = dna.body.paradigm === "poem" && dna.body.scrim;
let bgExtra = { css: "", html: "", js: "" };
if (poemScrimInBg) {
// poem keeps its scrim BEHIND the subject (subject stays lit) while the poem
// itself rides the fg alpha layer
const bgScrimSide = typeof CLEARER !== "undefined" && CLEARER === "right" ? "right" : "left";
bgExtra.css = `
#pscrimBg { position:absolute; inset:0; opacity:0;
background: linear-gradient(${bgScrimSide === "left" ? "100deg" : "260deg"},
rgba(4,8,16,${dna.body.scrim.opacity}) 0%, rgba(4,8,16,${(dna.body.scrim.opacity * 0.6).toFixed(2)}) 38%, rgba(4,8,16,0) 62%); }`;
bgExtra.html = ` <div id="pscrimBg"></div>`;
bgExtra.js = `
tl.fromTo("#pscrimBg", { opacity: 0 }, { opacity: 1, duration: 0.6, ease: "power1.out" }, 0.15);
tl.to("#pscrimBg", { opacity: 0, duration: 0.4, ease: "power1.in" }, ${(LASTWORD.start + 0.15).toFixed(3)});`;
}
const bgHtml = bgSkeleton(
[bgExtra.html, setp.html, bodyInBg ? body.html : ""].filter(Boolean).join("\n"),
[bgExtra.css, setp.css, bodyInBg ? body.css : ""].filter(Boolean).join("\n"),
[bgExtra.js, setp.js, bodyInBg ? body.js : ""].filter(Boolean).join("\n"),
);
fs.writeFileSync(path.join(PROJECT, "index.html"), bgHtml);
// fg file (rail.html): body paradigm (when fg) + setpiece fg parts + front fx
// (a setpiece may emit fgHtml/fgCss/fgJs — e.g. a verbatim caption chip that
// must ride ABOVE the matte while the setpiece scenery stays embedded behind)
if (!bodyInBg || fx.html || fx.js || setp.fgHtml) {
const fgHtml = fgSkeleton(
[!bodyInBg ? body.html : "", setp.fgHtml || "", fx.html].filter(Boolean).join("\n"),
[!bodyInBg ? body.css : "", setp.fgCss || "", fx.css].filter(Boolean).join("\n"),
[!bodyInBg ? body.js : "", setp.fgJs || "", fx.js].filter(Boolean).join("\n"),
);
fs.writeFileSync(path.join(PROJECT, "rail.html"), fgHtml);
} else if (fs.existsSync(path.join(PROJECT, "rail.html"))) {
fs.unlinkSync(path.join(PROJECT, "rail.html"));
}
// _postfx.sh: plate reaction after the matte composite (subject+text move as one)
const P = HEROLESS ? { grain: (dna.plate || {}).grain || 5 } : dna.plate || {};
// punchOffset: themes whose impact is NOT the hero onset (e.g. flapboard's
// lock-complete clack) shift the plate punch anchor; default keeps onset+2f
const anchorT = (heroIn + (P.punchOffset ?? 0.045)).toFixed(3);
let filter;
// zoompan resamples EVERY frame (even z=1) -> run it 2x supersampled and
// lanczos back down so the always-on resample costs no sharpness (shake amps
// are in pixels -> doubled at 2x)
const SS = 2;
if (P.punch) {
const minor = P.minorPunch && LINES.find((L) => L.words.some((w) => w.minor));
const minorT = minor ? (minor.words.find((w) => w.minor).start + 0.01).toFixed(3) : null;
filter = `scale=${W * SS}:${H * SS}:flags=lanczos,zoompan=
z='1+${P.punch}*exp(-${P.punchDecay || 9}*(time-${anchorT}))*between(time,${anchorT},${anchorT}+${P.shakeWindow || 0.6})${minorT ? `+${P.minorPunch}*exp(-10*(time-${minorT}))*between(time,${minorT},${minorT}+0.4)` : ""}':
x='iw/2-(iw/zoom/2)${P.shakeAmpX ? `+${P.shakeAmpX * SS}*exp(-${P.shakeDecay || 7}*(time-${anchorT}))*sin(2*PI*${P.shakeHz || 12}*(time-${anchorT}))*between(time,${anchorT},${anchorT}+${P.shakeWindow || 0.6})` : ""}':
y='ih/2-(ih/zoom/2)${P.shakeAmpY ? `+${P.shakeAmpY * SS}*exp(-${P.shakeDecay || 7}*(time-${anchorT}))*cos(2*PI*${((P.shakeHz || 12) * 1.31).toFixed(2)}*(time-${anchorT}))*between(time,${anchorT},${anchorT}+${P.shakeWindow || 0.6})` : ""}':
d=1:s=${W * SS}x${H * SS}:fps=${FPS},scale=${W}:${H}:flags=lanczos`;
} else if (P.pushIn) {
filter = `scale=${W * SS}:${H * SS}:flags=lanczos,zoompan=z='1+${P.pushIn}*time/${DUR.toFixed(2)}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s=${W * SS}x${H * SS}:fps=${FPS},scale=${W}:${H}:flags=lanczos`;
} else {
filter = `null`;
}
const rgba = P.rgbashift
? `,rgbashift=rh=-${P.rgbashift}:bh=${P.rgbashift}:enable='between(t,${anchorT},${(heroIn + 0.125).toFixed(3)})+between(t,${(LASTWORD.start + 0.04).toFixed(3)},${(LASTWORD.start + 0.17).toFixed(3)})',format=yuv420p`
: "";
fs.writeFileSync(
path.join(PROJECT, "_postfx.sh"),
`#!/usr/bin/env bash
# generated by make-theme.cjs — plate reaction for theme "${dna.name}"
set -euo pipefail
cd "$(dirname "$0")"
ffmpeg -y -v error -i final.mp4 -filter_complex "
[0:v]${filter}${rgba},
noise=alls=${P.grain || 5}:allf=t+u[v]" \\
-map "[v]" -map 0:a -c:v libx264 -crf 14 -preset slow -profile:v high -c:a copy \\
final_fx.mp4
echo "[postfx] ${dna.name} → final_fx.mp4"
`,
);
console.log(
`[make-theme] ${dna.name}: index.html${!bodyInBg || fx.html ? " + rail.html" : ""} + _postfx.sh`,
);
console.log(
`[make-theme] body=${dna.body.paradigm}(${dna.body.layer}) hero=${HEROLESS ? "none (pure body)" : heroInline ? "inline:" + dna.hero.setpiece : dna.hero.setpiece + " @" + heroIn.toFixed(2) + "s"} lines=${LINES.length} dur=${DUR}s`,
);