GSAP explainer — the produced-video tier
Builds a self-contained HTML/SVG/GSAP explainer that reads like a produced YouTube animation, not a slideshow: one GSAP timeline per scene driving physics-feeling choreography, a persistent flying host mascot, layered gradient worlds with depth, character populations, a vignette-and-grain "camera", and a seekable player. Use a CSS-only harness for tiny funnel embeds; use THIS for YouTube-grade content videos.
The reference build is scaffold.html in this skill folder — a runnable 5-scene worked example (topic: "How Compound Interest Snowballs") with every pattern below in context, retention + zero-overlap passed. It ships with GSAP stripped: run python3 inline-gsap.py scaffold.html once (fetches GSAP 3.13 into a gitignored vendor/, then inlines it) and open the file — it must play. Read it whenever a pattern below is unclear.
Start here — the path that cannot fail
- Write the shot script FIRST as a markdown doc: per scene — timestamp, duration, VISUAL, ON-SCREEN text, VO line. End with an accuracy checklist and verify every claim in it. Scene durations become the
SCENESarray; VO lines become thesr-onlytranscript. - Copy
scaffold.htmlto<topic>.html. It has every gotcha pre-fixed: harness clock, seek chrome, host flights, world backgrounds, camera overlays, population characters, reduced-motion, transcript. Runinline-gsap.pyon it and open it before touching anything — it must play. - Replace scenes one at a time, keeping the five worked examples as your idiom source until each is replaced: S0 = dark hook with a flip-reveal; S1 = drop-with-bounce physics; S2 = pour/motionPath flight + DrawSVG; S3 = the forced-overlap collision proof (the peak pattern); S4 = CTA hold. Update
SCENES,FL,ACTS,CH_STARTStogether — they are parallel structures keyed by scene index. - QA every scene with freeze-frames (see QA), fix, then publish (self-contained file).
Never write a scene's choreography from scratch when a pattern below matches — adapt the pattern. That is how you hit the bar regardless of model.
Architecture contract (already wired in the scaffold)
- Fixed 800×500 stage scaled to container (
fitScale). Scene clock is setTimeout (SCENESarray, seconds per scene; last entry999= CTA hold;advance()callsdone()on it). - Text/chips/captions enter via CSS primitives (
.fufade-up,.pppop,.fifade-in) with inlineanimation-delay— cheap and replayable. Hero choreography is GSAP:runScene(i)(called fromshowScene) kills the previous timeline and buildsgsap.timeline()fromFL[i](host flight) +ACTS[i](scene act). - Every
ACTS[i]MUST start withgsap.set(...)resets for all elements it touches — scenes re-activate on replay/seek and killed tweens leave stale inline styles. - GSAP-owned elements get class
g-hero(or live inside one) and never carry.fu/.pp/.fi— one owner per element. pause()/resume()callcurTl.pause()/play(); CSS side is frozen by the.pausedclass. Navigation always resumes playback (a paused freshly-cut scene sits at opacity-0 entrances — never "seek and stay paused").- Player: click-to-seek progress bar (
sceneAt(frac)), prev/next buttons, ←/→ keys, chapter ticks (CH_STARTS), autoplay on scroll, auto-pause on tab-hide. - Deliverable is the silent visual track (master stays silent); VO ships as a separate
<name>-vo.htmlpreview variant built by the ElevenLabs pipeline (see VO production). Thesr-onlytranscript IS the VO script. Retiming to a read = EXTENDSCENES[i]to clip+1.5–2s (extending never strands beats; only trims do).
The pattern library (adapt, don't invent)
All of these exist verbatim in scaffold.html — copy the nearest one.
- Host flight —
FL[i] = [[fraction, x, y, pointing], ...]. First waypoint = previous scene's last (seamless cuts). Long legs auto-swoop via MotionPath (infly()). The pencil tip sits at actor-offset ≈ (−10, +95): to point at a target, park the host above-right of it. Send the host offstage (x=850) when a scene needs the frame. - Drop with weight —
gsap.set(els,{y:-300,opacity:0})then.to(els,{y:0,opacity:1,ease:'bounce.out',stagger:.09}). The stagger + bounce IS the physics feel. (layMarblespacks rows inside the vessel; row width is 168, not 180 — wider and drops poke through the wall.) - Forced-overlap collision (the proof peak) — two bars drop into one track that can't hold both; the second tries to dodge (
xyoyo), fails, settles; the overlap segment pops in withback.outand glows (boxShadowyoyo repeat:-1); then two both-badge character minions pop inside it. Intent → failure → consequence is what makes it feel alive. - Pour/transform — container rotates (
transformOriginat the spout), contents fly out one-by-one onmotionPatharcs ({path:[{x,y},{x,y}],curviness:1.6}, ease:'power1.in'), land in a row, cross-fade into the new shape drawn with DrawSVG. - Flip-reveal — a tempting ✓ pops (
back.out(2.4)), wiggles, then swaps for the ✗ slamming in (scale:3→1, power4.out) + a background flash (backgroundColoryoyo). - Tempt-then-shatter — answer glows/pulses (
scaleyoyo ×3), then dies (opacity:.25, scale:.92, rotation:-3, power2.in); the right answer stamps in after. - Stamp slam —
gsap.set(stamp,{opacity:0,scale:2.6,rotation:-14})→.to({opacity:1,scale:1,rotation:0,duration:.35,ease:'power4.out'}). - DrawSVG —
gsap.set(path,{drawSVG:'0%'})→.to({drawSVG:'100%'}); snap-back = draw to 72% then reverse fast. DrawSVG overwritesstroke-dasharray— dashed lines render solid while drawing; design accordingly. - Population as characters (the single most Kurzgesagt move) —
minion(size, badges)mini-character factory: quantities are little characters, not dots. Ambient 3-character swarm on 35–55s CSS loops; groups contain their character cast; the proof moment is characters wearing both badges. - Scene-cut wipe (built into
runScene) — two skewed panels (accent leads, ink trails .09s) sweep the stage at every cut. Leave it on; it's the cheapest "real transitions" win. Prop-rich scene ideas worth stealing: a living page character, a highlighter ruin, a file cabinet, a gauge needle, a progress-bar race.
The free plugin suite (GSAP 3.13 — ALL free since Webflow's acquisition)
inline-gsap.py fetches + inlines core + DrawSVG + MotionPath + MorphSVG, SplitText, Physics2D, PhysicsProps, CustomEase, CustomWiggle, CustomBounce, Flip, ScrambleText. Everything is free-licensed. inline-gsap.py <file> injects everything + a registerPlugin block at the <!--GSAP_INLINE--> marker; --refresh swaps the suite in an already-inlined file. Proven patterns (all live in the scaffold):
- SplitText char cascade — split ONCE at init via a registry (
splitOnce(key,sel)caching inSPL{}— re-splitting on every scene re-run nests spans). Kicker: chars from{opacity:0,y:14}, stagger .016, start ≤.15s. Hero word: chars from{y:-64,rotation:-9}withback.out(1.9)stagger .07; or{scale:2.7}power4 stagger .11 = stamp-slam. Scenes hide via opacity (not display:none) so init-time splitting is safe. - ScrambleText definition lock — a payoff line scrambles into place:
scrambleText:{text:'≥ 1',chars:'<>=~≠≤≥',speed:.6}. Prefer JS\uescapes for symbols in JS strings. Themed char sets (math symbols for definitions) read better than 'upperCase' — a long random-caps scramble reads as a loading glitch. - CustomWiggle settle —
CustomWiggle.create('wigSettle',{wiggles:4,type:'easeOut'}), thenrotation:'+=10', ease:'wigSettle'— oscillates and RETURNS TO START, so relative values never accumulate across replays. Use for: stamp landings, character reactions, the host's pencil tap-tap on arrival (if(wp[i][3]&&!wp[i-1][3])infly()). - CustomBounce drop with squash —
CustomBounce.create('thud',{strength:.58,squash:2.4})creates paired eases'thud'+'thud-squash'. Thedrop(tl,targets,time,dur,stagger)helper runs 3 synced tweens: opacity pop,ywith 'thud',scaleX:1.45,scaleY:.55with 'thud-squash' (transformOrigin:'50% 100%'in the reset). Strictly better thanbounce.out— the squash IS the weight. - Physics2D burst —
burst(tl,scene,x,y,n,time,colors): celebration/debris particles where landing doesn't matter (collision peaks, victory stamps). Index-seeded angles/velocities ((i*53)%140) = replay-identical, no Math.random. Removes stale.pfxon re-entry; fade-out before scene end so nothing lingers. - MorphSVG fuse — the top Kurzgesagt move: N objects "become" the concept. Items land in a row → a capsule path fades in over them →
morphSVGto the arrow silhouette whilefilltweens ("same objects, new shape", literally). Reset needsgsap.set(path,{morphSVG:ORIGINAL_D})— a morphed path keeps its mutatedd. - Flip: DON'T, here — in a fixed 800×500 absolute-coordinate stage you always know the delta; a plain
y:tween is deterministic undertl.pause(t)QA, while Flip's measure-at-runtime breaks freeze-frame scrubbing (and callbacks don't fire on suppressed seeks). Flip earns its keep only in real reflow layouts (DOM-driven lists, responsive containers). A "claim the vacated spot" beat is a known-delta tween.
Asset craft (10x-pass patterns — how props stop looking like styled divs)
Deep-dive: the full asset-generation discipline (build order, factories, SVG contracts, quality gates) lives in the explainer-assets skill — use it whenever CREATING or rebuilding an asset; this section is the quick reference.
- The one-recipe sticker — depth entirely from layered
box-shadow: hard down-right ink drop (3px 3px 0 var(--ink)) +inset 0 2px 0 rgba(255,255,255,.3-.5)top gloss +inset 0 -3px 0 rgba(0,0,0,.1)bottom shade +inset 0 0 0 1-2px rgba(255,255,255,.14)die-cut rim. Translucent insets ride ANY background color, so one CSS pass on.xchip/.qbar/.stamp/.l3 spanelevates every variant, light and dark. Keep one radius scale (sm 10 / base 13 / lg 16) and ONE light direction (top-left) film-wide. - Two-tone any flat fill — swap
background:rgba(...)forlinear-gradient(180deg, lighter, base)+ the gloss/shade insets. Semantic color survives; the surface reads lit. - Crafted-vessel recipe (buckets/jars/cups) — base-shadow ellipse → gradient body → 2 tapered hoop bands with 1px white highlight → rim ring + a DARKER INNER-OPENING ELLIPSE (that one dark ellipse is what makes a mouth read 3D) → soft specular curve. Interpolate band endpoints along the trapezoid sides.
- Crafted line-art in one selector — static
filter:drop-shadow(0 1.5px .5px rgba(0,0,0,.17))on all drawn strokes gives every arrow/box a 2-tone underside and reveals WITH the DrawSVG animation (filter is independent of dashoffset). - Decorate GSAP-driven props with pseudo-elements —
::before/::after(ruled lines, tape strips, cracks, meniscus lines) ride the timeline's transform automatically: zero new choreography, zero footprint change. Track which pseudos are "taken" per class — collisions are silent. - Factory characters: ids only on singletons — the host (one instance) may carry
uid-suffixed gradients/speculars; a many-inlined factory (mini-characters) must stay id-FREE (plain fills only) or ids duplicate across the document. - Preserve the choreography contract while re-crafting — build a selector inventory from the script FIRST; positional selectors are landmines (
.bkt3 path= FIRST path must stay the tweened body — lead with ellipses, not paths). New decorative transforms go on wrapper<g>s. - Text-safe gloss caveat — on chips carrying SplitText/hero text, gloss must be inset box-shadows (paint UNDER text), never a positioned
::beforeoverlay (paints OVER the chars and dims them). - Paper-stack + dog-ear recipe (any sheet prop) — 2 offset back-rects (±0.6–1.5° rotate, darker cream,
overflow:visibleon the svg), corner-cut shadow triangle + folded flap, a margin rule, top-left sheen path. Footprint unchanged, only visual bleed. - Drawer/furniture front kit — front rect + top gloss strip + label plate + recessed handle bar + inter-unit depth-shadow line; parameterize palette for the highlighted unit, keep its animation class.
- 2-tone vector cloud — tinted base ellipse + 3 overlapping white circles + one brighter top ellipse; recolor the base tint per mood variant.
- Blueprint-card overlay — absolutely-positioned child svg (faint ruled grid + 4 corner ticks) as a card's first child: technical-drawing texture with zero effect on the card's tween class or text child.
The cinema layer (Kurzgesagt-pass patterns)
- Camera rig
#xplCam— a content-only wrapper INSIDE#xplStageholding the scenes (env layers, actor, vig/grain stay outside). Per-beat push-ins on payoffs (~1.02–1.05, transformOrigin at the hero), pull-outs on reveals, reset scale to 1 inrunSceneevery cut. Never scale#xplStageitself — that'sfitScale's. The peak scene earns ONE unbroken slow creep across its whole act. Check clip risk against the VISIBLE pill/prop bounds, not full-width containers. - Host acting handle
actorBody—.xpl-actor > [data-host]div, independent offly()(outer x/y) andarmEl. Helpers: hop (y-8 yoyo), lean (±4° toward pointed target), startle (recoil+tilt), 360 spin at the biggest win. Reset each cut. Mini-character entrance personality: peek from behind an edge (x offset → 0) BEFORE the pop. - Payoff light — one shared
#xplGlowblurred radial (mix-blend-mode:screen, tint via CSS var), positioned + bloomed behind the hero object at each scene's biggest beat, reset per cut. Instant "lit" feel for ~15 lines. - Time-of-day drift — seed a base
filteron#envFarat init (so tweens interpolate, never jump fromnone), then ease brightness/hue per chapter inrunScene. Composes with the CSS translate loop and the GSAP x dolly because it's a different property. - Hybrid injection into a CSS-only harness — CSS beats stay the base layer; a guarded
GACTS{}+runG(i)bridge (called from showScene, wired into pause/resume, exposetl()on__xpl) adds GSAP hero accents to money scenes only. Everything additive,typeof gsapguarded, FX classes swept per cut. The file must still play if GSAP never loads.
Movement craft (10x Wave B patterns)
- Anticipation windup on any slam — one pre-frame of reverse motion sells weight:
scale .975(cards) or.86(stamps) withpower2.inBEFORE the overshoot + elastic/back settle. One keyframe, deterministic undertl.pause(). - CSS-owned-motion rule (hybrid files) — if a CSS keyframe animates the
transformSHORTHAND on a node, GSAP cannot layer rotation/x/y there (the running animation overrides inline styles). Let CSS own the motion; GSAP sells it with impact FX synced to the CSS times (dust puffs, impact rings, a racing bloom along the cascade). Bonus: no reset burden on nodes GSAP never touches. - Frame-shake in a CSS-base file → actor startle instead — the scene container's transform is reserved by the Ken-Burns animation, so container shakes are invisible; use a host recoil+tilt (
wigSettle, returns to rest). dust()vs confetti — impact dust = low velocity (54–94), low gravity (~140), near-horizontal angles, round,box-shadow:none, quick fade; keep.pfxclass for the per-cut sweep.- Tight-band placement audit — measure in logical stage coords (
(rect − stageRect)/scale); when a caption is squeezed between a prop and a FROZEN lower-third with <1.3× line-height clearance, move the PROP, not the caption. - Follow-through = trailing micro-tween, not a bigger yoyo — knees-give dip after a hop landing, overshoot-past-neutral on a lean return; 1–2 frames, always ending at 0 (reset-safe).
- Anticipation on rotators: relative windup, absolute swing —
rotation:'-=6'cock-back then rotation to an ABSOLUTE target; the absolute overwrites the relative so nothing accumulates across cuts. - Host no-fly zones — the kicker band (y<70) and any full-width sub/caption line are the two chronic overlap traps at final-beat freeze. Park in the gap between text bands, at a diagram's arrowhead, or in the right margin (
x ≈ stageW−90) so only the pencil tip crosses onto the target ("ticking it off"). Body-edge-to-text gap ≥24px; verify at the exact HOLD fraction, then re-chain the NEXT scene's fraction-0 waypoint (seam continuity). - Programmatic lower-third sweep before publish — goto each scene, compare every content box vs the
.l3 spanbox; only full-stage glow/flash overlays may overlap. - Sync a swell-FX's PEAK, not its start — if
bloom()peaks at start+0.5s, call it at beat−0.5. #xplCamx is a free impact-shake channel — scale carries the creep, x/y are zeroed every cut by runScene, so a decaying x-shake (amp 3→0, 5 steps) composes with an ongoing push at zero reset cost.- MotionPath + back.out = arc-and-settle in one tween — for ≥150px non-collision slides; the ease returns exactly to the final path point (deterministic under pause). Keep head-on collisions and card-stack reveals STRAIGHT — arcing weakens the metaphor.
- Even 1px yoyos need their axis in the reset — a cut mid-oscillation strands the offset; the replay-reset audit (interrupt mid-beat → re-enter → getProperty) is the tool that catches it.
VO-sync choreography (the "say it = show it" contract)
- The named object moves ON its name. Walk the transcript sentence-by-sentence; estimate speech time word-proportionally within the scene; the corresponding visual must have a beat within ±1.5s. Classify every content phrase SYNCED / MISTIMED / WRONG-OBJECT / DEAD before calling a scene done.
- Enumerations reveal WITH the roll-call, not before. The chronic defect: multi-item reveals (4 bricks, 6 icons, 3 rows) all land in the first 2-8s while the VO names them across 15-20s. Space each item's entrance to its name; let the item's dust/label/stamp travel with it.
- Payoffs resolve WITH their reasoning, never before. A ✓ that stamps 5s before the VO finishes the argument spoils the peak. Gate winner-stamps, shatters, and confetti to the phrase that earns them.
- Every hard concept gets a dedicated animated diagram — if a phrase asserts a mechanism, a static caption is not teaching it; build the 3-6s mini-diagram (object transforms as the mechanism happens). Longer and clearer beats shorter.
- Timing note: word-proportional estimates get beats matching the WRITTEN script; re-nudge ±0.5s against the recorded read at retime.
Pre-publish gates (run ALL of these, every time)
node --checkthe last script block.- Selector inventory: every script-targeted class resolves.
- Replay-reset audit: interrupt mid-beat → re-enter → getProperty on every animated property.
- Beat-audit no-regression (
tools/beat-audit.mjs, from the retention-pass skill). - Pairwise overlap audit —
tools/overlap-audit.js(inject into the page). THE RULE: nothing overlaps anything unless the overlap IS the lesson — no words on words, host on nothing ever, arrows never cross text, ambient characters stay out of text bands. Deliberate teaching overlaps getdata-ovl-ok="sharedToken"on BOTH elements; everything else is a defect. Sample at 0.5s steps — 1s sampling passed a flight that struck a headline 96px deep mid-transit; fast legs live between coarse samples. Zero non-whitelisted violations before publish. Why this tool exists: the frozen preview reports CSS-entrance elements at opacity 0, so box-sweeps that read live opacity SILENTLY EXCLUDE half the content — the tool forces each CSS element visible per its own animation-delay before measuring. Auditability disciplines: hide via autoAlpha (visibility:hidden) not bare opacity/scale so hidden elements don't leave phantom boxes; keep entrance delay+opacity on the SAME element that carries the text (parent-wrapper delays blind the audit); entrance drop-columns may mutually whitelist (transient fall-throughs read as physics); when a caption is squeezed, move the PROP row, not the caption into the other band — and re-run, because a fix can create the inverse collision. - VO-sync pass (section above) on any scene whose choreography changed.
Layout laws (overlap prevention by construction)
- Sequential occupancy — a zone is VACATED before it's reused: prior occupants exit (fade+shrink or slide off) as a real narrative beat, they don't just dim underneath. Dense scenes = the #1 overlap source; if a scene needs 3 acts, it needs 3 exits.
- Host paths, not just holds — flight lines must route around text bands; a transit through a caption reads exactly as bad as a parked overlap.
- Ambient characters live in margins — drifting mini-characters/motes never cross text bands; park their base positions in empty corners.
- Symmetry pass on any touched layout — equal gaps in stacks/rows, true centering, one consistent margin rhythm. Unsymmetrical spacing reads as a mistake even when nothing overlaps.
- The crafted pencil (host prop standard, lives in
host()): a hex body gradient + 2 facet lines + 30% white specular, silver ferrule with crimp grooves, blush eraser, cream wood taper with grain line, graphite cone. Same wrapper/anchor as the arm rotation expects; gradient ids uid-suffixed.
The look levers (what makes it "produced")
All present in the scaffold — keep them when replacing scenes; reuse the data-URIs:
- Real 3-layer parallax environments — scene backgrounds are TRANSPARENT; the world lives in shared stage layers:
#envFar(sky gradient, sun/moon glow, stars, far hills — below scenes),#envMid(close hills, a landmark prop, foreground detail — below scenes),#envNear(blurred foreground at the frame corners — above content,filter:blur(2.2px), corners only so it never covers captions). Each has 4 mood variants (.vwarm/.vpaper/.vcold/.vdark) cross-faded bystage[data-mood], whichshowScenesets from the scene'ss-*class. Layers drift on independent 32–64s loops (translateproperty), andrunSceneadds a per-cut camera dolly — far 6px / mid 14px / near 26px, direction alternating by scene index (GSAPxcomposes with the CSS drift). New scenes get the whole world for free: just pick ans-*class. - The camera —
.xpl-vigvignette +.xpl-grain(feTurbulence at 5%) overlays above everything. Do not remove; instant produced feel. - Two-tone shading on every object — rim highlight + inner bottom shade on containers; radial-gradient + specular on drops (
.marble);::beforegloss on bars (.qbar). A new prop with flat fill is below the bar — give it light. - Glow on dark scenes — colored multi-layer
box-shadowon chips overs-black(see the[data-scene="0"]rule). - Ambient life everywhere — chip float (desynced per kind), dust motes, sparks on payoff beats, Ken-Burns push-in per scene, drifting light wash, host wing-flutter. All CSS, all already global.
- Theme (swappable) — palette tokens live in
:root; the default is a warm honey/ink look with a friendly bee host mascot (like Kurzgesagt's birds). To rebrand: change the:rootcolor tokens and replace thehost()SVG string. Semantic color slots in the demo: accent = the highlighted term, ink = the contrast term, green = valid, red = trap, violet = the collision/overlap. Serif (--font-serif) headings, a handwriting font for accents ONLY, mono for lower-third captions (.l3).
Retention pass (run before publishing — this is what holds the YouTube curve)
The full method lives in the retention-pass skill (3 rounds: STRUCTURE → BEAT COVERAGE → POLISH, plus tools/beat-audit.mjs which walks ACTS vs SCENES and prints coverage/dead zones). Run it BEFORE VO is recorded — that's when durations are still free.
While BUILDING (so the pass has less to fix), hold the big five: first beat ≤0.5s; the hook opens a loop the PEAK closes (build the hook from the peak scene's own prop classes, held as a question); every scene gets a content B-beat past its 60% mark; kickers re-hook rather than label (best: close a loop by name); never announce duration. Field calibration: in production use every scene overran its choreography by 30–100% by default, and hooks drifted toward teasing the trap instead of the peak — expect both.
QA — how to verify in a preview browser (its animation clock may be FROZEN)
Some embedded preview browsers never tick rAF, so neither CSS animations nor GSAP visually advance. This is a preview artifact — real browsers are fine. Verify like this:
- GSAP moments (deterministic):
window.__xpl.goto(i)thenwindow.__xpl.tl().pause(t)— sets every tween's state at timetsynchronously. Screenshot. This is your main tool; freeze each scene at 2–3 beat times. - CSS entrances: inject a freeze style so text shows its end state:
.xpl-scene.is-active{animation:none!important;opacity:1!important}.xpl-scene.is-active .fu,.xpl-scene.is-active .pp,.xpl-scene.is-active .fi{opacity:1!important;translate:0 0!important;scale:1!important;animation:none!important} - Syntax-check after any scripted surgery: extract the last
<script>block and runnode --checkon it. A silent syntax error = no__xpl, no host, blank harness. - Check console logs after every reload; serve the folder with
python3 -m http.server. - Finish with a multi-lens critic pass (motion craft, persuasion, brand, a11y/robustness, fresh creative-director) — integrate the top fixes, re-verify.
Gotchas (every one of these cost real debugging time)
- Self-contained/published HTML can't load CDNs — GSAP must be INLINE. The scaffold ships stripped with a
<!--GSAP_INLINE-->marker; runpython3 inline-gsap.py <file>(fetches GSAP 3.13 from jsDelivr into a gitignoredvendor/, full free plugin suite;--refreshupgrades an old file in place). - SplitText re-split — never
new SplitTextinsideACTS[i](re-runs nest spans); use thesplitOnceregistry. - Wiggle accumulation — relative
'+=n'tweens are safe ONLY with wiggle eases (they return to start). If a scene can be cut mid-wiggle, the killed tween leaves a partial offset — every ACTS reset must includerotation:0on wiggled elements. - Entrances use
translate/scale/rotateproperties; idle/ambient life usestransform— they compose. Never animate thetransformshorthand in an entrance keyframe or you'll clobber inline centering transforms. .pp/.fuon an element that also needstransform:translateX(-50%)centering: wrap — outer div centers, inner carries the class.- SVG motion rule: keyframes/tweens go on a dedicated wrapper
<g>, never on a node with atransformattribute (the animation overwrites the attribute). - px
transformOriginon an SVG group is BBOX-relative, not user-space —transformOrigin:'120px 150px'means "120px right of the group's bounding-box top-left", so rebuilding the group's geometry silently moves the pivot (a gauge needle swung off-stage for a whole scene this way). For a fixed pivot in SVG coordinates always usesvgOrigin:'120 150'. Audit every px-origin whenever an SVG asset is rebuilt. layMarblesrow width 168 (vessel walls slant); drops at 180 poke through.- Charset: without
<meta charset="utf-8">,python http.serverserves files as latin-1 and symbols mojibake. The scaffold ships the charset tag — keep it in every new file. HTML entities (— ✓…) are fine for DOM text;\uescapes for JS strings. - Scene indexes are DOM order —
SCENES,FL,ACTS,data-scene, and the CTA-nudge CSS selector must all agree after any add/remove. Re-run the node syntax check + a__xpl.scenes()count after surgery. - The final scene entry is
999andadvance()must hitdone()on it, or the toggle sticks on "Pause". - Keydown guard:
e.target.closest && e.target.closest('input,...')— document-targeted synthetic events have no.closest. - Host/actor overlap: after building a scene, freeze-frame every waypoint hold and check the host isn't parked on top of text; fix
FLcoordinates, not the content.
VO production (ElevenLabs pipeline)
- Voice: pick any ElevenLabs voice id — it's a required argument to the helper. Key =
ELEVENLABS_API_KEY(environment or./.env). REST call:POST api.elevenlabs.io/v1/text-to-speech/{voice}?output_format=mp3_44100_64, headerxi-api-key, modeleleven_multilingual_v2, settings stability .5 / similarity .75. Helper:tools/vo-generate.py <script.md> <outdir> <voiceId>. - Write scripts at ≈2.5 words/sec × scene duration — a cloned/TTS voice reads ~2.6 w/s; estimating 3+ w/s runs clips over their scenes. Target 85–94% fill per scene (continuous narration, natural breath at cuts); CTA hold stays short.
- Continuous + valuable: every added phrase narrates an on-screen beat (count the re-reads aloud with the counter, speak the enumeration as items land, autopsy the trap as it dies). No filler.
- Per-scene clips, not one track: one mp3 per scene → duration-check each vs
SCENES[i](ffprobe) → extend scenes for overruns → base64VO[]array injected before the main script +voPlay(i)fromshowScene(kills prior clip on cut),voCur.pause()/play()wired into pause/resume. Browser autoplay policy: first click enables sound —play().catch(()=>{})and let the poster click be the gesture. - The
-vo.htmlvariant is a rebuilt COPY of the master (master stays silent); regenerate it after any master edit.
Publishing & production
- Publish as a self-contained HTML file — everything is inline, so it's portable and CSP-safe.
- YouTube pipeline: screen-record fullscreen playback → lay VO → export. Shorts = seek to a chapter, record that scene range at 9:16 crop.
- When VO is recorded: retime each scene's
SCENES[i]to the read;FLfractions usually survive unchanged; GSAP beat times insideACTS[i]are absolute seconds — nudge them to the read's emphasis points.