fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user