/ references / animation-pitfalls.md
animation-pitfalls.md
  1  # Animation Pitfalls: HTML Animation Bugs and Rules
  2  
  3  The most common bugs when building animations and how to avoid them. Every rule comes from a real failure case.
  4  
  5  Read this before writing animation code. It saves an entire iteration.
  6  
  7  ## 1. Stacking Context -- `position: relative` is a Default Obligation
  8  
  9  **The bug**: A sentence-wrap element contained 3 bracket-layer children (`position: absolute`). sentence-wrap didn't have `position: relative`, so the absolute brackets used `.canvas` as their coordinate system, floating 200px below the screen.
 10  
 11  **Rule**:
 12  - Any container with `position: absolute` children **must** explicitly set `position: relative`
 13  - Even if you don't need visual offset, set `position: relative` as the coordinate anchor
 14  - If you're writing `.parent { ... }` and a child has `.child { position: absolute }`, instinctively add relative to parent
 15  
 16  **Quick check**: For every `position: absolute`, trace up the ancestor chain and ensure the nearest positioned ancestor is the coordinate system you *intend*.
 17  
 18  ## 2. Character Trap -- Don't Depend on Rare Unicode
 19  
 20  **The bug**: Tried using `␣` (U+2423 OPEN BOX) to visualize "space token". Noto Serif SC / Cormorant Garamond don't have this glyph, rendered as blank/tofu, audience couldn't see it at all.
 21  
 22  **Rule**:
 23  - **Every character that appears in animation must exist in your chosen fonts**
 24  - Common rare character blacklist: `␣ ␀ ␐ ␋ ␨ ↩ ⏎ ⌘ ⌥ ⌃ ⇧ ␦ ␖ ␛`
 25  - To represent meta-characters like "space / enter / tab", use **CSS-constructed semantic boxes**:
 26    ```html
 27    <span class="space-key">Space</span>
 28    ```
 29    ```css
 30    .space-key {
 31      display: inline-flex;
 32      padding: 4px 14px;
 33      border: 1.5px solid var(--accent);
 34      border-radius: 4px;
 35      font-family: monospace;
 36      font-size: 0.3em;
 37      letter-spacing: 0.2em;
 38      text-transform: uppercase;
 39    }
 40    ```
 41  - Emoji also needs verification: some emoji fall back to gray boxes outside Noto Emoji, use `emoji` font-family or SVG
 42  
 43  ## 3. Data-Driven Grid/Flex Templates
 44  
 45  **The bug**: Code had `const N = 6` tokens, but CSS hardcoded `grid-template-columns: 80px repeat(5, 1fr)`. The 6th token had no column, entire matrix misaligned.
 46  
 47  **Rule**:
 48  - When count comes from a JS array (`TOKENS.length`), CSS template should also be data-driven
 49  - Option A: Inject via CSS variable from JS
 50    ```js
 51    el.style.setProperty('--cols', N);
 52    ```
 53    ```css
 54    .grid { grid-template-columns: 80px repeat(var(--cols), 1fr); }
 55    ```
 56  - Option B: Use `grid-auto-flow: column` for browser auto-expansion
 57  - **Banned: "Fixed number + JS constant" combination**, N changes but CSS doesn't sync
 58  
 59  ## 4. Transition Gaps -- Scene Switches Must Be Continuous
 60  
 61  **The bug**: Between zoom1 (13-19s) and zoom2 (19.2-23s), the main sentence was already hidden. zoom1 fade out (0.6s) + zoom2 fade in (0.6s) + stagger delay (0.2s+) = ~1 second of pure blank. Audience thought the animation froze.
 62  
 63  **Rule**:
 64  - When switching scenes continuously, fade out and fade in must **cross-fade**, not "previous fully disappears before next starts"
 65    ```js
 66    // Bad:
 67    if (t >= 19) hideZoom('zoom1');      // 19.0s out
 68    if (t >= 19.4) showZoom('zoom2');    // 19.4s in -> 0.4s blank in between
 69  
 70    // Good:
 71    if (t >= 18.6) hideZoom('zoom1');    // Start fade out 0.4s early
 72    if (t >= 18.6) showZoom('zoom2');    // Simultaneous fade in (cross-fade)
 73    ```
 74  - Or use an "anchor element" (like the main sentence) as visual continuity between scenes, briefly reappearing during zoom transitions
 75  - Calculate CSS transition durations carefully, avoid triggering the next transition before the previous one finishes
 76  
 77  ## 5. Pure Render Principle -- Animation State Should Be Seekable
 78  
 79  **The bug**: Used `setTimeout` + `fireOnce(key, fn)` chains to trigger animation state. Normal playback was fine, but when doing frame-by-frame recording or seeking to an arbitrary time point, the setTimeouts had already fired and couldn't "go back in time".
 80  
 81  **Rule**:
 82  - `render(t)` function should ideally be a **pure function**: given t, output unique DOM state
 83  - If side effects are necessary (e.g., class toggle), use a `fired` set with explicit reset:
 84    ```js
 85    const fired = new Set();
 86    function fireOnce(key, fn) { if (!fired.has(key)) { fired.add(key); fn(); } }
 87    function reset() { fired.clear(); /* clear all .show classes */ }
 88    ```
 89  - Expose `window.__seek(t)` for Playwright / debugging:
 90    ```js
 91    window.__seek = (t) => { reset(); render(t); };
 92    ```
 93  - Animation-related setTimeout shouldn't span >1 second, otherwise seek-back will break things
 94  
 95  ## 6. Pre-Font-Load Measurement = Wrong Measurement
 96  
 97  **The bug**: Called `charRect(idx)` to measure bracket positions right at DOMContentLoaded, before fonts loaded. Every character width was the fallback font's width, positions all wrong. Once fonts loaded (~500ms later), bracket `left: Xpx` still had the old values, permanently offset.
 98  
 99  **Rule**:
100  - Any layout code depending on DOM measurement (`getBoundingClientRect`, `offsetWidth`) **must** be wrapped in `document.fonts.ready.then()`
101    ```js
102    document.fonts.ready.then(() => {
103      requestAnimationFrame(() => {
104        buildBrackets(...);  // Fonts ready, measurement accurate
105        tick();              // Animation starts
106      });
107    });
108    ```
109  - Extra `requestAnimationFrame` gives the browser a frame to commit layout
110  - If using Google Fonts CDN, `<link rel="preconnect">` speeds up initial load
111  
112  ## 7. Recording Prep -- Leave Handshake Points for Video Export
113  
114  **The bug**: Playwright `recordVideo` defaults to 25fps, starts recording from context creation. Page load, font load -- the first 2 seconds all get recorded. Delivered video has 2 seconds of blank/white flash at the start.
115  
116  **Rule**:
117  - `render-video.js` handles this: warmup navigate -> reload to restart animation -> wait duration -> ffmpeg trim head + convert to H.264 MP4
118  - Animation's **frame 0** should be the final layout's complete initial state (not blank or loading)
119  - Want 60fps? Use ffmpeg `minterpolate` post-processing, don't expect browser source framerate
120  - Want GIF? Two-stage palette (`palettegen` + `paletteuse`), can compress a 30s 1080p animation to 3MB
121  
122  See `video-export.md` for complete script usage.
123  
124  ## 8. Batch Export -- tmp Directory Must Include PID to Prevent Concurrent Conflicts
125  
126  **The bug**: Used `render-video.js` to record 3 HTMLs in parallel. TMP_DIR only used `Date.now()` for naming, 3 processes starting the same millisecond shared the same tmp directory. The first process to finish cleaned up tmp, the other two got `ENOENT` when reading the directory, all crashed.
127  
128  **Rule**:
129  - Any temporary directory that multiple processes might share must include **PID or random suffix** in its name:
130    ```js
131    const TMP_DIR = path.join(DIR, '.video-tmp-' + Date.now() + '-' + process.pid);
132    ```
133  - If you genuinely want multi-file parallelism, use shell `&` + `wait` rather than forking in one node script
134  - For batch recording multiple HTMLs, conservative approach: **serial** execution (2 or fewer can be parallel, 3+ just queue)
135  
136  ## 9. Progress Bar / Replay Button in Recording -- Chrome Elements Pollute Video
137  
138  **The bug**: Animation HTML had `.progress` progress bar, `.replay` replay button, `.counter` timestamp for human debugging convenience. When recorded to MP4 these elements appeared in the video bottom, like DevTools got screenshotted into the delivery.
139  
140  **Rule**:
141  - Human-facing "chrome elements" (progress bar / replay button / footer / masthead / counter / phase labels) and video content must be managed separately
142  - **Convention class name** `.no-record`: any element with this class is automatically hidden by the recording script
143  - Script side (`render-video.js`) injects CSS by default to hide common chrome class names:
144    ```
145    .progress .counter .phases .replay .masthead .footer .no-record [data-role="chrome"]
146    ```
147  - Injected via Playwright's `addInitScript` (fires before each navigate, stable across reloads)
148  - To see the original HTML (with chrome), add `--keep-chrome` flag
149  
150  ## 10. First Few Seconds of Recording Show Animation Repeating -- Warmup Frame Leak
151  
152  **The bug**: Old `render-video.js` flow was `goto -> wait fonts 1.5s -> reload -> wait duration`. Recording starts at context creation, warmup phase already played part of the animation, reload restarts from 0. Result: first few seconds show "animation mid-point + cut + animation starts from 0", strong repetition feel.
153  
154  **Rule**:
155  - **Warmup and Record must use independent contexts**:
156    - Warmup context (no `recordVideo` option): Only loads URL, waits for fonts, then closes
157    - Record context (with `recordVideo`): Fresh state, animation starts from t=0
158  - ffmpeg `-ss trim` can only cut Playwright's tiny startup latency (~0.3s), **cannot** be used to mask warmup frames; source must be clean
159  - Recording context close = webm file written to disk, this is Playwright's constraint
160  - Relevant code pattern:
161    ```js
162    // Phase 1: warmup (throwaway)
163    const warmupCtx = await browser.newContext({ viewport });
164    const warmupPage = await warmupCtx.newPage();
165    await warmupPage.goto(url, { waitUntil: 'networkidle' });
166    await warmupPage.waitForTimeout(1200);
167    await warmupCtx.close();
168  
169    // Phase 2: record (fresh)
170    const recordCtx = await browser.newContext({ viewport, recordVideo });
171    const page = await recordCtx.newPage();
172    await page.goto(url, { waitUntil: 'networkidle' });
173    await page.waitForTimeout(DURATION * 1000);
174    await page.close();
175    await recordCtx.close();
176    ```
177  
178  ## 11. Don't Draw "Fake Chrome" Inside the Canvas -- Decorative Player UI Clashes with Real Chrome
179  
180  **The bug**: Animation used the `Stage` component, which already has a built-in scrubber + timecode + pause button (these are `.no-record` chrome, auto-hidden on export). Then a decorative progress bar `00:60 --- CC-DESIGN / ANATOMY` was drawn at the bottom for "magazine page-number feel". **Result**: User sees two progress bars -- one from Stage controls, one decorative. Complete visual collision, identified as a bug. "Why is there another progress bar in the video?"
181  
182  **Rule**:
183  
184  - Stage already provides: scrubber + timecode + pause/replay button. **Do not draw** additional progress indicators, current timecodes, copyright strips, chapter counters inside the canvas -- they either clash with chrome or are filler slop (violating the "earn its place" principle).
185  - "Page-number feel" / "magazine feel" / "bottom attribution strip" are **decorative urges** that AI auto-adds frequently. Be alert for each one -- does it convey irreplaceable information, or just fill empty space?
186  - If you firmly believe a bottom strip must exist (e.g., the animation's theme IS about player UI), it must be **narratively necessary** and **visually distinct from Stage scrubber** (different position, different form, different color tone).
187  
188  **Element Attribution Test** (every element drawn into the canvas must answer):
189  
190  | What it belongs to | Treatment |
191  |------------|------|
192  | A specific scene's narrative content | OK, keep it |
193  | Global chrome (control/debug use) | Add `.no-record` class, hidden on export |
194  | **Neither belongs to any scene, nor is chrome** | **Delete**. This is orphaned filler slop |
195  
196  **Self-check (3 seconds before delivery)**: Take a static screenshot and ask yourself:
197  
198  - Is there anything that "looks like video player UI" (horizontal progress bar, timecode, control-button appearance)?
199  - If yes, does removing it harm the narrative? If not, delete it.
200  - Is the same type of information (progress/time/attribution) appearing twice? Merge into chrome at one location.
201  
202  **Anti-pattern**: Drawing `00:42 --- PROJECT NAME` at the bottom, `CH 03 / 06` chapter counter at bottom-right, version number `v0.3.1` at screen edge -- all are fake chrome filler.
203  
204  ## 12. Recording Lead Blank + Recording Start Offset -- `__ready` x tick x lastTick Triple Trap
205  
206  **Bug A (Lead blank)**: 60-second animation exported to MP4, first 2-3 seconds are blank page. `ffmpeg --trim=0.3` can't cut it.
207  
208  **Bug B (Start offset, real incident)**: Exported 24-second video, user perception: "video doesn't start playing until 19 seconds in". Actually the animation recorded from t=5, recorded to t=24 then looped back to t=0, recorded 5 more seconds -- so the last 5 seconds of the video are the animation's actual beginning.
209  
210  **Root cause** (both bugs share one root cause):
211  
212  Playwright `recordVideo` starts writing WebM from `newContext()` moment. Babel/React/font loading takes L seconds (2-6s). The recording script waits for `window.__ready = true` as the "animation starts here" anchor -- it must be strictly paired with animation `time = 0`. Two common mistakes:
213  
214  | Mistake | Symptom |
215  |------|------|
216  | `__ready` set in `useEffect` or synchronous setup phase (before tick's first frame) | Recording script thinks animation started, but WebM is still recording blank page -> **lead blank** |
217  | tick's `lastTick = performance.now()` initialized at **script top level** | Font loading L seconds get counted into first frame's `dt`, `time` instantly jumps to L -> recording is offset L seconds throughout -> **start offset** |
218  
219  **Correct complete starter tick template** (hand-written animations must use this skeleton):
220  
221  ```js
222  // --- state ---
223  let time = 0;
224  let playing = false;   // Default off, wait for fonts ready to start
225  let lastTick = null;   // Sentinel -- tick's first frame forces dt to 0 (don't use performance.now())
226  const fired = new Set();
227  
228  // --- tick ---
229  function tick(now) {
230    if (lastTick === null) {
231      lastTick = now;
232      window.__ready = true;   // Pair: "recording start point" and "animation t=0" same frame
233      render(0);               // Render once more to ensure DOM is ready (fonts are ready at this point)
234      requestAnimationFrame(tick);
235      return;
236    }
237    const dt = (now - lastTick) / 1000;   // dt only starts advancing after first frame
238    lastTick = now;
239  
240    if (playing) {
241      let t = time + dt;
242      if (t >= DURATION) {
243        t = window.__recording ? DURATION - 0.001 : 0;  // Don't loop when recording, keep 0.001s to preserve last frame
244        if (!window.__recording) fired.clear();
245      }
246      time = t;
247      render(time);
248    }
249    requestAnimationFrame(tick);
250  }
251  
252  // --- boot ---
253  // Don't immediately rAF at top level -- start only after fonts loaded
254  document.fonts.ready.then(() => {
255    render(0);                 // Draw initial frame first (fonts ready)
256    playing = true;
257    requestAnimationFrame(tick);  // First tick will pair __ready + t=0
258  });
259  
260  // --- seek interface (for render-video defensive correction) ---
261  window.__seek = (t) => { fired.clear(); time = t; lastTick = null; render(t); };
262  ```
263  
264  **Why this template is correct**:
265  
266  | Aspect | Why it must be this way |
267  |------|-------------|
268  | `lastTick = null` + first frame `return` | Prevents the L seconds from "script load to tick first execution" being counted as animation time |
269  | `playing = false` default | During font loading, even if `tick` runs it doesn't advance time, preventing render misalignment |
270  | `__ready` set in tick's first frame | Recording script starts timing at this moment, the corresponding frame is animation's true t=0 |
271  | `document.fonts.ready.then(...)` before starting tick | Avoids font fallback width measurement, prevents first-frame font jump |
272  | `window.__seek` exists | Lets `render-video.js` actively correct -- second line of defense |
273  
274  **Recording script's corresponding defense**:
275  1. `addInitScript` injects `window.__recording = true` (before page goto)
276  2. `waitForFunction(() => window.__ready === true)`, records this moment's offset as ffmpeg trim
277  3. **Additionally**: After `__ready`, actively `page.evaluate(() => window.__seek && window.__seek(0))`, force-resetting any time offset in the HTML -- second line of defense against HTMLs that don't strictly follow the starter template
278  
279  **Verification method**: After exporting MP4
280  ```bash
281  ffmpeg -i video.mp4 -ss 0 -vframes 1 frame-0.png
282  ffmpeg -i video.mp4 -ss $DURATION-0.1 -vframes 1 frame-end.png
283  ```
284  First frame must be animation's t=0 initial state (not mid-point, not black), last frame must be animation's final state (not a point from the second loop).
285  
286  **Reference implementation**: `assets/animations.jsx`'s Stage component and `scripts/render-video.js` both implement this protocol. Hand-written HTML must use the starter tick template -- every line guards against a specific bug.
287  
288  ## 13. No Loop During Recording -- `window.__recording` Signal
289  
290  **The bug**: Animation Stage defaults to `loop=true` (convenient for browser viewing). `render-video.js` waits 300ms buffer after recording duration seconds before stopping, this 300ms lets Stage enter the next loop. ffmpeg `-t DURATION` truncates, but the last 0.5-1s falls into the next loop -- video suddenly jumps back to frame 1 (Scene 1), audience thinks the video is broken.
291  
292  **Root cause**: No "I'm being recorded" handshake between recording script and HTML. HTML doesn't know it's being recorded, still loops as in browser interaction mode.
293  
294  **Rule**:
295  
296  1. **Recording script**: Inject `window.__recording = true` in `addInitScript` (before page goto):
297     ```js
298     await recordCtx.addInitScript(() => { window.__recording = true; });
299     ```
300  
301  2. **Stage component**: Detect this signal, force loop=false:
302     ```js
303     const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
304     // ...
305     if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
306     //                                                       ^ keep 0.001 to prevent Sprite end=duration from turning off
307     ```
308  
309  3. **Final Sprite's fadeOut**: In recording scenarios should be `fadeOut={0}`, otherwise the video ending will fade to transparent/dark -- users expect to stop on a clear final frame, not fade out. For hand-written HTML, recommend all final Sprites use `fadeOut={0}`.
310  
311  **Reference implementation**: `assets/animations.jsx`'s Stage and `scripts/render-video.js` both have built-in handshake. Hand-written Stage must implement `__recording` detection -- otherwise recording will hit this bug.
312  
313  **Verification**: After exporting MP4, `ffmpeg -ss 19.8 -i video.mp4 -frames:v 1 end.png`, check if 0.2s before the end is still the expected final frame, without a sudden switch to another scene.
314  
315  ## 14. 60fps Video Uses Frame Duplication by Default -- minterpolate Has Poor Compatibility
316  
317  **The bug**: `convert-formats.sh` generated 60fps MP4 using `minterpolate=fps=60:mi_mode=mci...`, which couldn't open in some versions of macOS QuickTime / Safari (black screen or refused to play). VLC / Chrome could open it.
318  
319  **Root cause**: minterpolate outputs an H.264 elementary stream containing SEI / SPS fields that some players have trouble parsing.
320  
321  **Rule**:
322  
323  - Default 60fps uses simple `fps=60` filter (frame duplication), wide compatibility (QuickTime/Safari/Chrome/VLC all work)
324  - High-quality interpolation uses `--minterpolate` flag to explicitly enable -- but **must test locally** on target player before delivery
325  - 60fps label value is **platform algorithm recognition** (Bilibili / YouTube prioritize streaming for 60fps-tagged content), actual perceived smoothness improvement for CSS animations is minimal
326  - Add `-profile:v high -level 4.0` to improve H.264 general compatibility
327  
328  **`convert-formats.sh` has switched to compatibility mode by default**. If you need high-quality interpolation:
329  ```bash
330  bash convert-formats.sh input.mp4 --minterpolate
331  ```
332  
333  ## 15. `file://` + External `.jsx` CORS Trap -- Single-File Delivery Must Inline the Engine
334  
335  **The bug**: Animation HTML used `<script type="text/babel" src="animations.jsx"></script>` to load the engine externally. Double-click to open locally (`file://` protocol) -> Babel Standalone uses XHR to fetch `.jsx` -> Chrome reports `Cross origin requests are only supported for protocol schemes: http, https, chrome, chrome-extension...` -> entire page is black screen, no `pageerror` reported only console error, easy to misdiagnose as "animation didn't trigger".
336  
337  Even starting an HTTP server may not help -- with a global proxy, `localhost` can get proxied too, returning 502 / connection failure.
338  
339  **Rule**:
340  
341  - **Single-file delivery (double-click to open HTML)** -> `animations.jsx` must be **inlined** into a `<script type="text/babel">...</script>` tag, don't use `src="animations.jsx"`
342  - **Multi-file project (running HTTP server for demo)** -> External loading is fine, but clearly document the `python3 -m http.server 8000` command
343  - Decision criterion: Is the deliverable an "HTML file" or "a project directory with a server"? Former uses inline
344  - Stage component / animations.jsx is often 200+ lines -- pasting into HTML `<script>` block is perfectly acceptable, don't worry about file size
345  
346  **Minimum verification**: Double-click your generated HTML, **don't** use any server to open it. If Stage displays the animation's first frame correctly, it passes.
347  
348  ## 16. Cross-Scene Inverted Color Context -- Don't Hardcode Colors on In-Canvas Elements
349  
350  **The bug**: In a multi-scene animation, `ChapterLabel` / `SceneNumber` / `Watermark` etc. (elements that **appear across all scenes**) had hardcoded `color: '#1A1A1A'` (dark text) in the component. First 4 scenes with light backgrounds were fine, but at scene 5 with a dark background, "05" and the watermark completely disappeared -- no error, no check triggered, critical information invisible.
351  
352  **Rule**:
353  
354  - **Cross-scene reusable in-canvas elements** (chapter labels / scene numbers / timecodes / watermarks / copyright strips) **must not hardcode color values**
355  - Use one of three approaches instead:
356    1. **`currentColor` inheritance**: Element only sets `color: currentColor`, parent scene container sets `color: calculated-value`
357    2. **invert prop**: Component accepts `<ChapterLabel invert />` to manually switch light/dark
358    3. **Auto-calculate based on background**: `color: contrast-color(var(--scene-bg))` (CSS 4 new API, or JS detection)
359  - Before delivery, use Playwright to capture **a representative frame from each scene**, visually check that cross-scene elements are all visible
360  
361  The insidiousness of this bug is that **there are no error reports**. Only human eyes or OCR can catch it.
362  
363  ## Quick Self-Check List (5 Seconds Before Starting)
364  
365  - [ ] Every `position: absolute`'s parent has `position: relative`?
366  - [ ] Special characters in animation (`␣` `⌘` `emoji`) exist in chosen fonts?
367  - [ ] Grid/Flex template count matches JS data length?
368  - [ ] Scene transitions have cross-fade, no >0.3s pure blank?
369  - [ ] DOM measurement code wrapped in `document.fonts.ready.then()`?
370  - [ ] `render(t)` is pure, or has explicit reset mechanism?
371  - [ ] Frame 0 is a complete initial state, not blank?
372  - [ ] No "fake chrome" decorations in canvas (progress bar/timecode/bottom attribution strip clashing with Stage scrubber)?
373  - [ ] Animation tick's first frame synchronously sets `window.__ready = true`? (Built into animations.jsx; hand-written HTML add yourself)
374  - [ ] Stage detects `window.__recording` to force loop=false? (Hand-written HTML must add)
375  - [ ] Final Sprite's `fadeOut` set to 0 (video end stops on clear frame)?
376  - [ ] 60fps MP4 uses frame duplication mode by default (compatible), only add `--minterpolate` for high-quality interpolation?
377  - [ ] After export, extract frame 0 + last frame to verify they are animation's initial/final states?
378  - [ ] For specific brands (Stripe/Anthropic/etc.): Completed the brand asset protocol (SKILL.md section 1.a five steps)? Have you written `brand-spec.md`?
379  - [ ] Single-file deliverable HTML: `animations.jsx` is inlined, not `src="..."`? (External .jsx under file:// causes CORS black screen)
380  - [ ] Cross-scene elements (chapter labels/watermark/scene numbers) don't have hardcoded colors? Visible against every scene's background?