# 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) 1. **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. 2. **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: 1. **The grid drift was invisible.** 1px lines at 35% `--line` alpha 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). 2. **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`; `` keeps the `var(--bg)` canvas and `` stays transparent (the no-occlusion contract); the `prefers-reduced-motion` stilling block is untouched. ## Acceptance criteria 1. 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. 2. No blink: the glow reads as breathing (0.85↔1), not a pulse (0.65↔1); the grid drift reads as smooth continuous motion. 3. Layers stay behind content: `position: fixed`, `z-index: -1`, `pointer-events: none`, full-viewport `inset: 0`; `` keeps the page background and `` stays transparent (no occlusion). 4. No new horizontal overflow at 360px (the phase-07 pin). 5. Pure CSS, zero JS, no `filter: blur`, no new assets (phase-08 anchor); WCAG AA palette untouched (the layers carry no text). 6. Regressions green in isolation: `test_dark_tech_theme.py` (pins the live 60s/14s durations + reduced-motion stilling) and `test_responsive_polish.py`. 7. 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`):** indigo `rgb(109 120 242 / 0.14)` 56rem circle at 12%/8% + cyan `rgb(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 `` canvas (`var(--bg)` = `#0a0e17`) sits under both `z-index: -1` layers; `` stays transparent and non-stacking, so nothing can paint over the layers. - **Motion:** compositor-friendly `background-position` / `opacity` / `transform` only; no blur filter; `prefers-reduced-motion` stills 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()`): 1. `test_grid_layer_animation_running` — computed style of `body::before`: `animationName` = `bg-grid-drift`, timing function `linear`, iteration count `infinite`; a matching entry in the document animation list with `playState === "running"`. 2. `test_glow_layer_animation_running` — same for `body::after` with `bg-glow-breathe`; `playState === "running"`. 3. `test_animations_advance` — sample `currentTime` of 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). 4. `test_background_layers_contracts` — both pseudo-elements: `position: fixed`, `z-index: -1`, `pointer-events: none`, `inset: 0`; `document.documentElement` computed background is the palette bg (canvas stays on `html`); `document.body` computed background is `rgba(0, 0, 0, 0)` (no occlusion). 5. `test_no_horizontal_overflow_with_layers` — 360px viewport: `documentElement.scrollWidth <= clientWidth` (the phase-07 pin, replicated locally).