6.1 KiB
Story: Animated Background That Actually Animates
Phase: 22_background_animation · E2E: tests/e2e/test_background_animation.py
Narrative
As the owner, I reported (2026-08-24) that the Brain of Reese background "just blinks". I want the background the phase-08 design promised — a slow, seamless grid drift plus a gentle glow breathe — to be perceived as smooth motion in a real browser, so the app feels alive but calm, not like a broken flicker.
- Given the phase-08 pure-CSS background (a 60s grid drift masked into a small top band + a 14s glow opacity swing)
- When the page is observed in a real Chromium viewport
- Then both layers visibly and smoothly animate — the grid drift reads as continuous motion and the glow reads as breathing — with no blink, no static frame, no jank, no new overflow at 360px, and no impact on text contrast or interactivity.
Bug report (verbatim, TODO.md L5)
Fix background animation not working, just blinking
Owner-confirmed (2026-08-24, roadmap A3)
- Intended effect = the phase-08 design comments: a seamless 60s grid drift (one cell per loop) + a 14s eased glow breathing. The fix serves that design — it is not a redesign.
- Pure CSS, zero JS (phase-08 anchor) — no animation JS, no new
assets, no
filter: blur.
Root cause (found in task 01 — .agent/reports/22_background_animation/)
Both layers were animating and nothing occluded them (occlusion disproven with a red-line probe) — but:
- The grid drift was invisible. 1px lines at 35%
--linealpha blend to a ≈10/12/18-level delta over the page bg, and the radial mask faded the layer to ~zero past the top ~25% of the viewport. A 0.73px/s drift of that faint texture sits below the perception threshold (measured ≈1.4/765 mean pixel change over 5s in the grid zone; ≈0 in the bottom half — no grid there at all). - The glow swing was the only visible motion. The 0.65↔1.0 opacity swing (35% amplitude, frame-diff concentrated exactly on the two glow centers) was 100% of the perceived animation → it read as a blink/pulse, not breathing.
Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
| property | phase 08 | phase 22 |
|---|---|---|
| grid line alpha | 35% --line ×2 |
60% --line ×2 |
| grid mask | 120% 90% at 50% 0%, black 25%, transparent 78% |
140% 110% at 50% 0%, black 40%, transparent 90% |
| grid drift | 60s, 0→44px | unchanged (one-cell seamless loop) |
| glow opacity swing | 0.65↔1 | 0.85↔1 |
| glow period/scale | 14s ease-in-out alternate, 1↔1.05 | unchanged |
Both layers keep position: fixed; inset: 0; z-index: -1; pointer-events: none; <html> keeps the var(--bg) canvas and
<body> stays transparent (the no-occlusion contract); the
prefers-reduced-motion stilling block is untouched.
Acceptance criteria
- In a real Chromium viewport, both background layers run their animations (grid drift + glow breathe) and the animation timelines advance — the background is not a static or paused frame.
- No blink: the glow reads as breathing (0.85↔1), not a pulse (0.65↔1); the grid drift reads as smooth continuous motion.
- Layers stay behind content:
position: fixed,z-index: -1,pointer-events: none, full-viewportinset: 0;<html>keeps the page background and<body>stays transparent (no occlusion). - No new horizontal overflow at 360px (the phase-07 pin).
- Pure CSS, zero JS, no
filter: blur, no new assets (phase-08 anchor); WCAG AA palette untouched (the layers carry no text). - Regressions green in isolation:
test_dark_tech_theme.py(pins the live 60s/14s durations + reduced-motion stilling) andtest_responsive_polish.py. - Unit + integration green,
app/coverage ≥90%, story E2E green in isolation, ruff + pyright clean.
UI Visualization & Structure
- Grid layer (
body::before): 44px cells, 1px lines at 60% of--line(rgb(38 48 74 / 0.6)), radial mask fully visible to 40% of the radius and faded out by 90%, 60s linear infinite drift of exactly one cell (0→44px) — seamless loop. - Glow layer (
body::after): indigorgb(109 120 242 / 0.14)56rem circle at 12%/8% + cyanrgb(34 211 238 / 0.10)60rem circle at 88%/92%; 14s ease-in-out infinite alternate, opacity 0.85↔1 + scale 1↔1.05. - Stacking: the
<html>canvas (var(--bg)=#0a0e17) sits under bothz-index: -1layers;<body>stays transparent and non-stacking, so nothing can paint over the layers. - Motion: compositor-friendly
background-position/opacity/transformonly; no blur filter;prefers-reduced-motionstills both layers (phase-08 guard, unchanged).
Playwright Mapping Rule
Test Scenario → tests/e2e/test_background_animation.py (the
layers are CSS pseudo-elements — asserted via computed style + the Web
Animations API; Chromium enumerates pseudo-element CSS animations in
document.getAnimations(), not document.body.getAnimations()):
test_grid_layer_animation_running— computed style ofbody::before:animationName=bg-grid-drift, timing functionlinear, iteration countinfinite; a matching entry in the document animation list withplayState === "running".test_glow_layer_animation_running— same forbody::afterwithbg-glow-breathe;playState === "running".test_animations_advance— samplecurrentTimeof both layers, wait ~500ms, assert both advanced — truly running, not paused (the headless document animation timeline starts ~1s after load, so the sample polls until the timeline is alive first).test_background_layers_contracts— both pseudo-elements:position: fixed,z-index: -1,pointer-events: none,inset: 0;document.documentElementcomputed background is the palette bg (canvas stays onhtml);document.bodycomputed background isrgba(0, 0, 0, 0)(no occlusion).test_no_horizontal_overflow_with_layers— 360px viewport:documentElement.scrollWidth <= clientWidth(the phase-07 pin, replicated locally).