Files
heygen-com--hyperframes/skills/embedded-captions/scripts/make-theme.cjs
T
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 lines
458 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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); // 814px
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
// 814%, 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`,
);