Lab Films — Round 3 To-Do (for the working agent)
Lab Films — Round 3 To-Do (for the working agent)
You are continuing the “bring all 8 lab films to 3Blue1Brown quality” effort on ozgurural.github.io (a Jekyll academic portfolio). This document is self-contained. Read CLAUDE.md first for repo doctrine, then this whole file before touching anything.
═══════════════════════════════════════════════════════════════════════════
0. PROCESS — READ FIRST. Violating any of these fails the whole review.
═══════════════════════════════════════════════════════════════════════════
- Branch, then open a Merge Request / PR. DO NOT MERGE. DO NOT PUSH TO
master. Your final deliverable is a PR againstmasterthat a reviewer (Claude) will read, test, and merge. If you merge it yourself the review cannot happen and the work will be reverted. (Last round an agent self-merged a film that was broken on page load and it shipped to production. That is the exact failure this rule prevents.)- Branch name:
agent/films-r3 - One commit per logical task block, descriptive messages.
- The PR description must follow the template in section 5.
- Branch name:
Every lab film you touch MUST be smoke-loaded in a real browser before you commit it.
node --checkonly catches syntax; it does NOT catch calling an engine method that doesn’t exist (e.g.s.erase()), which throws at scene-build time and makes the ENTIRE film fail to register — a blank box in production. After editing a film, load its page and confirmwindow.LabAnim.films.length > 0and the film’sduration/sceneslook right, and that the console has zero errors. See the harness in section 3.Determinism is law. Every rendered frame must be a pure function of the film clock. NO
Math.random()and NODate.now()/new Date()inside any scenecanvas()callback or cue. Derive pseudo-randomness fromlt:var r = Math.sin(Math.floor(lt*24) * 127.1) * 43758.5453; r -= Math.floor(r);A paused frame and a re-seek to the same t must render identically. Anything that writes DOM/attributes outside the reset/commit state machine (e.g.el.setAttribute('fill', …)in a cue) must be written so that scrubbing BACKWARDS restores the earlier state — make the cue span from t=0 and compute the value from the eased local progress, don’t start the cue late.LF line endings only. Do not let an editor rewrite files to CRLF. Verify with
file assets/js/lab-films/*.js— none should say “CRLF”.- Narration is generated, not hand-written into audio. If you change ANY
lower(s, "…")text in a film, you MUST regenerate that film’s audio:node scripts/extract-narration.js python scripts/generate-narration.py <film-prefix> # edge-tts is installedand commit the changed
assets/audio/lab/*.mp3plusscripts/narration.json. Film prefixes: block-race, gradient-pinball, jira, model-heist, oracles, proof-of-learning, redundancy-reactor, wm-compare. Voice isen-US-AndrewMultilingualNeuralat -5% (already set in the script — do not change it). - Never invent engine API. The engine is
assets/js/lab-anim.js. Before using anys.<method>()orfilm.<method>(), confirm it exists there. Available scene primitives include:caption, title, tex2, value, canvas, line, rect, poly, plot, vector, axes, dot, group, draw, write, fadeIn, fadeOut, show, hide, move, moveAlong, scaleTo, pulse, countUp, morph, audio. There is NOerase, nowipe, nospawn.countUpis scrub-safe (routes text throughst.html);morph(a,b)captures from-state before mutating.
═══════════════════════════════════════════════════════════════════════════
1. WHAT IS ALREADY DONE — do not redo, do not “improve” without cause
═══════════════════════════════════════════════════════════════════════════
gradient-pinball.js: full 19-finding quality pass. Clean.- Engine
lab-anim.js: 14-fix batch (keyboard defer, scrub Home/End/PageUp/Dn, scrub-safecountUpviast.html,morphfrom-state capture, canvas-layer crossfade at scene boundaries, rAF dt clamp 100ms, narration-hold 4s stall release, hidden-container fit-on-intersection, edge-triggered outro stinger, subtitle exposed asrole=status aria-live=polite,aria-pressedon mute, palette de-aliasindigo #6D7CDE+ realamber #FBBF24, Signature outro positions fromfilm.W/H, dead code removed). Clean. - Generative music: chord progressions + phrase structure + sub-bass + phrase-end rests + stereo + octave shimmer + slow master breath. Clean.
- Narration: all clips regenerated with the Andrew voice.
model-heist.js,block-race.js,jira.js: full fix passes + big cinematic set-pieces (block-race §11 sum assembly, curve-riding dots; model-heist conservation-of-ink bar, sweeping ROC, count-up finale, gold-dot bookend; jira engine-primitive AMM rebuild, match-cut, payout loop). Reviewed & clean.- All 8 films: geometry-swept clean at 1280px AND 375px (0 out-of-stage, 0 text overlaps) as of commit
63b36de, console clean,jekyll buildexit 0.
═══════════════════════════════════════════════════════════════════════════
2. THE VERIFICATION HARNESS — run for EVERY film you touch
═══════════════════════════════════════════════════════════════════════════
Dev server: bundle exec jekyll serve --port 4000 --force_polling (PowerShell, prepend C:\Ruby33-x64\bin to PATH; ruby does NOT run from Git Bash on this box). Or, if you have the Browser/preview tooling, preview_start {name:"jekyll"}.
For each touched film, load its page (section 4 has the URL map) and run this in the page console. Expect total: 0 and seekErr: null. Run once at desktop width and once after a FRESH load at 375px (mobile scaling differs).
(() => {
const films = (window.LabAnim && window.LabAnim.films) || [];
if (!films.length) return JSON.stringify({FATAL:'film did not register — build threw'});
const f = films[0], stage = document.querySelector('.labf__stage'), root = stage.parentElement;
const vis = el => { for (let n=el;n&&n!==root;n=n.parentElement){const c=getComputedStyle(n);if(c.display==='none'||c.visibility==='hidden')return 0;} return 1; };
const opA = el => { let o=1; for(let n=el;n&&n!==root;n=n.parentElement)o*=parseFloat(getComputedStyle(n).opacity||1); return o; };
const ev={}, rec=(k,t)=>{const e=ev[k]||(ev[k]={n:0,t0:t});e.n++;e.t1=t;};
let seekErr=null;
try { for (let t=0.05;t<f.duration;t+=0.4){ f.seek(t);
const SR=stage.getBoundingClientRect();
const R=[...stage.querySelectorAll('.labf__node')].filter(el=>vis(el)&&opA(el)>0.85&&(el.textContent||'').trim()).map(el=>({el,r:el.getBoundingClientRect()}));
for(const {el,r} of R){ if(r.width===0)continue;
if(r.left<SR.left-6||r.right>SR.right+6||r.top<SR.top-6||r.bottom>SR.bottom+6) rec('OUT|'+el.textContent.trim().slice(0,22),t); }
for(let i=0;i<R.length;i++)for(let j=i+1;j<R.length;j++){ const a=R[i],b=R[j];
if(a.el.contains(b.el)||b.el.contains(a.el))continue;
if(!!a.el.closest('.labf__lower')!==!!b.el.closest('.labf__lower'))continue;
const ix=Math.min(a.r.right,b.r.right)-Math.max(a.r.left,b.r.left), iy=Math.min(a.r.bottom,b.r.bottom)-Math.max(a.r.top,b.r.top);
if(ix>10&&iy>8) rec('OVR|'+a.el.textContent.trim().slice(0,15)+'<->'+b.el.textContent.trim().slice(0,15),t); }
} } catch(e){ seekErr=String(e); }
f.seek(0);
return JSON.stringify({dur:Math.round(f.duration), seekErr, defects:Object.entries(ev).filter(([k,e])=>e.n>=2).map(([k,e])=>({k,t:e.t0.toFixed(1)+'-'+e.t1.toFixed(1)})), total:Object.values(ev).filter(e=>e.n>=2).length});
})()
CAVEAT the sweep CANNOT catch (you must check these by hand):
- Canvas-drawn text/bars are invisible to this sweep (it only measures DOM
.labf__nodes). When you move or resize anything drawn viactx.fillText/ctx.fillRect, compute its pixel extent yourself and make sure it doesn’t collide with DOM captions/equations. - Binary/un-eased pops: seek across a suspected transition in 0.1s steps and read the element’s computed opacity/geometry; a jump from 0→1 or 0.4→1 in one step is a defect. Everything should ease.
- Scrub reversibility: seek forward past a
countUp/move/attribute cue, then seek back before it; the on-screen value/position/color must return to its initial state.
Also: read console messages, errors only must be empty on every page.
═══════════════════════════════════════════════════════════════════════════
3. TASK GROUP A (PRIMARY) — deep-review the 4 never-deeply-reviewed films
═══════════════════════════════════════════════════════════════════════════
These four got only a light pass last round (Math.random removal + easing some binary pops). They have NEVER had a line-by-line 3b1b review. This is the main job. Film → page URL → source file → id:
| Film source | Page URL | id |
|---|---|---|
| assets/js/lab-films/oracles.js | /lab/oracles/ | oracles-film |
| assets/js/lab-films/proof-of-learning.js | /lab/training-fingerprint/ | pol-film |
| assets/js/lab-films/redundancy-reactor.js | /lab/redundancy-reactor/ | tmr-film |
| assets/js/lab-films/wm-compare.js | /lab/watermarking-comparison/ | wm-compare-film |
For EACH film: read the whole source, then review every scene against this rubric and fix what you find (concrete defects only — no taste rewrites):
MOTION — no un-eased binary alpha (lt > X ? 1 : 0) or single-frame state swaps; entrances AND exits animated; Math.random() in a canvas callback is a determinism defect; a moveAlong/move should ease, not jump.
ATTENTION — one idea on screen at a time; when focus moves, the previous element should dim (fadeOut to ~0.3–0.4), not stay at full brightness; never more than ~3 things entering at the same instant (stagger with at: base + i*0.08).
PACING — no static stretch > ~2.5s while narration is silent (seek through and watch for “nothing changes”); nothing lands 3-at-once on the same frame; scene durations should match their content (a 21s scene whose last event is at 8s has 13s of dead air — pull events later or shorten).
PRECISION — verify every on-screen number, formula, axis label, and counter against the actual math. Cross-check each lower() narration string against the film’s host _pages/lab-*.md “What did you just learn?” text — where they disagree the PAGE is usually right (fix the film, and regenerate audio).
CONTINUITY / COLOR — recurring objects keep geometry+color across scenes; use the film’s own palette constants, no one-off hexes; and the page legend chips must match the film’s actual colors (see the concrete mismatch below).
A-known-1 (CONCRETE, verified): page legend colors ≠ film colors.
The _pages/lab-*.md colour-key chips were authored with a different palette than the films actually draw. Reconcile each — change the chip hex to the film’s real color (or vice-versa if the film’s is the off-one). Measured today:
_pages/lab-oracles.mdchips:#34d399 #3b82f6 #fb7185 #fbbf24— but oracles.js draws cyan#58C4DD, green#83C167, violet#9A72AC, rose#FC6255, yellow#FFFF00. Blue#3b82f6and emerald#34d399are not in the film. Align them._pages/lab-tmr.mdchips:#36d6e7 #ec4899 #f0a000 #fb7185vs the film’s actual colors — reconcile._pages/lab-pol.mdand_pages/lab-wm-compare.md: same audit; make chips and film agree, and make sure thearia-labelcolour description on the legend<p>matches too.
A-known-2 (CONCRETE): stray pure-yellow #FFFF00.
oracles.js and redundancy-reactor.js still contain literal #FFFF00, which vibrates on the dark surface. The engine palette’s real amber is #FBBF24 (window.LabAnim.palette.amber). Replace pure-yellow accents with #FBBF24 (match whatever role they play; keep genuinely-white text white).
A-known-3: prefer engine palette constants.
Where a film hardcodes "#58C4DD" etc., switching to var PAL = window.LabAnim.palette; var CY = PAL.sky; is fine and encouraged (gradient-pinball/model-heist already do this) — but ONLY if you re-verify the film renders identically after.
Deliver one commit per film: A: <film> deep review — <one-line what changed>.
═══════════════════════════════════════════════════════════════════════════
4. TASK GROUP B (SECONDARY) — engine cinematic primitives
═══════════════════════════════════════════════════════════════════════════
Only start these after Group A is solid. These touch lab-anim.js, which every film depends on, so re-run the harness on ALL 8 films after any engine change (a regression here breaks everything). Each is its own commit.
B1 — stagger() helper + stagger-reveal for group draw(). Currently draw() on a group handle (axes, vector — anything with no single stroke path) falls back to a full-duration opacity fade. Add a first-class Scene.prototype.stagger(handles, {at, dur, lag, ease}) that lags each handle’s entrance by lag (in seconds or as a fraction of dur), and optionally have axes() expose its child lines/ticks so they can be staggered on reveal. Keep the old draw(group) behavior working for callers that don’t opt in.
B2 — make morph() a true ReplacementTransform. Today morph(a,b) crossfades in place while nudging scale. Upgrade it to also translate+scale A’s bounding box onto B’s box during the crossfade (compute both boxes at cue time via getBBox() for SVG / getBoundingClientRect() mapped to stage coords for HTML), so A visibly becomes B in B’s location. Must stay scrub-safe and must not regress the existing in-place callers (jira/model-heist use morph) — if a true transform is risky for HTML/KaTeX handles, gate the translate on a {replace:true} opt-in and leave the default as the current crossfade.
B3 — promote pathOfArc into the engine. The screen-space arc-length path parameterization currently lives as a film-local helper (duplicated in gradient-pinball.js and block-race.js). Add it to the engine (e.g. film.arcPath(points, coords) or as an option on moveAlong, {arcLength:true}) so a moveAlong dot stays locked to a dash-reveal draw() tip by construction. Then de-duplicate the two film-local copies to use it. Verify the lockstep numerically on gradient-pinball scene 2 (dot rect center vs path.getPointAtLength(len*(1-dashoffset/len)) ≈ 0px).
═══════════════════════════════════════════════════════════════════════════
5. PR / MERGE-REQUEST REQUIREMENTS
═══════════════════════════════════════════════════════════════════════════
Open ONE PR from agent/films-r3 → master. Do not merge it. The description must contain, per touched film/engine:
- What changed (bullet list, grouped by file).
- Harness results: for every touched film, paste the sweep JSON at BOTH 1280px and 375px (must show
total: 0,seekErr: null), and confirmwindow.LabAnim.films.length > 0on each (proves it registered). - Console: “0 errors on all touched pages.”
- Determinism: confirm no new
Math.random()/Date.now()in canvas callbacks (grep -rn "Math.random\|Date.now\|new Date" assets/js/lab-films/). - Narration: list any
lower()texts you changed and confirm the matching mp3s were regenerated and committed (or state “no narration changed”). - Build:
bundle exec jekyll buildexit code (must be 0). - Line endings: confirm no file became CRLF.
- Note anything you deliberately did NOT do and why.
Delete this LAB_FILMS_TODO.md file in your final commit.
═══════════════════════════════════════════════════════════════════════════
6. WHAT THE REVIEWER (me) WILL CHECK — so you can pre-empt it
═══════════════════════════════════════════════════════════════════════════
On review I will: diff every file (git diff master...agent/films-r3, --ignore-all-space to separate real changes from reformatting); re-run the harness myself on all 8 films at both widths; smoke-load every page to confirm each film registers (the s.erase()-class failure); step across every transition you added looking for un-eased pops and canvas/DOM collisions the sweep can’t see; verify scrub-reversibility on any new countUp/attribute cue; check that changed narration has regenerated audio; confirm the page legends now match the films; run jekyll build. Findings get fixed on the branch, then I merge with --no-ff, push, delete the branch, and verify the live site after Pages redeploys. Make my job boring.
