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

121 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`; `<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).