Files
brain-of-reese/.agent/user_stories/background-animation.md
T

6.5 KiB
Raw Blame History

Story: Animated Background That Actually Animates

SUPERSEDED (owner direction 2026-08-25): the phase-22 motion design (grid drift + whole-layer breathe) is superseded by the owner's "no movement, only fading light" direction — see .agent/user_stories/background-no-motion.md (phase 25_background_no_motion). The phase-22 history below is preserved as-is; the old E2E suite now pins the phase-25 contract as a regression.

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; <html> keeps the var(--bg) canvas and <body> 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; <html> keeps the page background and <body> 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 <html> canvas (var(--bg) = #0a0e17) sits under both z-index: -1 layers; <body> 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).