fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design

This commit is contained in:
2026-08-24 22:16:41 -04:00
parent 22a6121411
commit 0adc9b5801
24 changed files with 797 additions and 183 deletions
@@ -0,0 +1,186 @@
# Phase 22 / Task 01 — Background animation diagnosis + fix
**Owner report (2026-08-24):** "background animation not working, just
blinking". **Method:** live dev server (`uv run uvicorn app.main:app`,
db up), headless Chromium 1280×800 (Playwright), ≥15s observation per
session, computed-style sampling + pixel-diffed screenshots. All
artifacts in `.agent/screenshots/22_background_animation/`.
## Diagnosis (per the 00_phase.md checklist)
**1. Per-layer visibility.** Both pseudo-layers exist, computed
`position: fixed`, `z-index: -1`, `pointer-events: none`, both animating.
Per-layer screenshots (`before_glow_only.png`, `before_grid_only.png`):
the glow is clearly visible in its two corners; the grid is drawn **only
in a narrow band below the header** (top ~25% of the viewport) and is
barely distinguishable from the page bg.
**2. Grid layer — moving but imperceptible.** `background-position` on
`body::before` sampled over 16.8s: `0.86px → 13.2px` (≈0.79px/s — the
spec'd 44px/60s, seamless one-cell drift). The drift is *real*.
Isolating it (glow hidden, two frames 5s apart,
`before_gridonly_frame1/2.png`):
| metric | value |
|---|---|
| pixels changed (>12/765) | 1.5% |
| mean pixel change | **0.48/765** (≈0.2/255 per channel) |
| bottom-half quadrants | **≈0.07** (no grid there at all — masked out) |
Why: 1px lines at **35% of `--line`** blend to ≈rgb(20,26,41) on
rgb(10,14,23) — a ≈10/12/18-level delta — and the radial mask
(`120% 90% at 50% 0%, black 25%, transparent 78%`) fades the layer to
~zero past ~560px at center (probe: lines gone by y≈254 at the edges).
A 0.73px/s drift of that faint texture sits below the perception
threshold → the grid contributes no visible motion.
**3. Glow layer — the only visible motion = the "blink".** Full-background
frame diff (5s apart, `before_frame1/2.png`): 1.4% of pixels changed,
concentrated exactly on the two glow centers — top-left mean 3.07,
bottom-right 2.92 (glow positions 12%/8% and 88%/92%), top-right 1.23,
bottom-left 0.70. The 0.65↔1.0 opacity swing (35% amplitude, + scale)
is the entire perceived animation → reads as a blink/pulse, not
breathing.
**4. Occlusion check — ruled out.** Forced the grid lines to bright red:
3127 red-line pixels render (`before_occlusion_probe.png`, x 132–1276,
y 64–254 — the mask shape). Computed: `html` bg `rgb(10,14,23)`
(= `var(--bg)`), `body` bg `rgba(0,0,0,0)` (transparent), no later rule
paints over the layers. Assumption (c) **does not fire**.
### Root cause
**Assumptions (a)+(b) confirmed, (c) disproven.** The grid drift works
but is invisible (faint 35%-alpha lines × small masked region × 0.73px/s),
so the only perceived motion is the glow's large opacity swing — which
is exactly what "just blinking" is.
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
| property | before (phase 08) | after (phase 22) |
|---|---|---|
| grid line alpha | `rgb(38 48 74 / 0.35)` ×2 | **`rgb(38 48 74 / 0.6)`** ×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** (60s, 0→44px — one-cell seamless) |
| glow opacity | 0.65 ↔ 1 | **0.85 ↔ 1** |
| glow period/scale | 14s ease-in-out alternate, 1↔1.05 | **unchanged** |
- Grid: +71% line contrast and a wider fully-visible mask radius make
the same 60s one-cell drift readable as smooth motion; the grid now
extends across most of the viewport, fading to the corners.
- Glow: amplitude cut 35%→15% — breathing, not pulsing.
- Durations deliberately untouched: the owner-confirmed intended effect
is the phase-08 design (60s drift + 14s breathe), and
`tests/e2e/test_dark_tech_theme.py::test_animated_background` pins the
live 60s/14s durations. Both layers keep `fixed; inset: 0; z-index:
-1; pointer-events: none`; `html` keeps `var(--bg)`, `body` stays
transparent; reduced-motion stilling block untouched.
## After (same viewport, same instrumentation)
| metric | before | after |
|---|---|---|
| grid-only 5s diff, mean \|d\| | 0.48 | **1.82** (3.8×) |
| grid-only, pixels changed | 1.5% | **6.1%** |
| grid-only bottom-half | ≈0.07 (absent) | **1.43–1.47** (present + moving) |
| full 5s diff, pixels changed | 1.4% | **4.2%** |
| full quadrant means (TL/TR/BL/BR) | 3.07 / 1.23 / 0.70 / 2.92 | 2.92 / 2.11 / 1.69 / 2.48 |
Motion is now distributed across the whole viewport (drift everywhere +
gentler corner breathing) instead of concentrated in the two glow
corners; after frames (`after_frame1/2.png`, 5s apart) confirm no static
frame. Visual check of `after_frame1.png`: grid clearly visible, still
subtle — no text/contrast impact, no jank (compositor-friendly
`background-position`/`opacity`/`transform` only), no 360px overflow
(layers remain `fixed; inset: 0`; `test_responsive_polish.py` 7/7 green).
## Screenshots
`before_frame1/2.png` (5s pair), `before_glow_only.png`,
`before_grid_only.png`, `before_occlusion_probe.png`,
`before_gridonly_frame1/2.png` (isolated grid pair), `after_frame1/2.png`
(5s pair), `after_gridonly_frame1/2.png`, `diagnosis_log.txt` (timeline
samples), `diagnosis_diff.txt` (all diff numbers).
## Tests (task 01)
- New: `tests/unit/test_background_animation.py` (10 source pins on the
final values + the no-occlusion + no-blur/zero-JS anchors) — green.
- `uv run pytest --cov=app` — 329 passed, app coverage 99%.
- `uv run ruff check . && uv run pyright` — clean.
- Regressions (isolated): `test_dark_tech_theme.py` 6/6,
`test_responsive_polish.py` 7/7.
---
# Task 02 — E2E story suite, story file, validation, commit
## Story gate: `tests/e2e/test_background_animation.py` (5 tests)
The layers are CSS pseudo-elements, so the suite asserts via computed
style + the Web Animations API. **Implementation note (deviation from
the task file, documented per the plan's "found, not guessed" rule):**
the task assumed Chromium reports pseudo-element CSS animations through
`element.getAnimations()` — verified false on Chromium 151
(`document.body.getAnimations()` returns `[]` for pseudo-layers); the
animations ARE enumerated by `document.getAnimations()`, with
`animationName` / `playState` / `currentTime` intact. The suite uses the
document-level list and matches on `animationName`. A second finding:
headless Chromium starts the document animation timeline ~1.4s after
navigation (currentTime stays 0 until then), so `test_animations_advance`
polls until both timelines are alive before sampling.
1. `test_grid_layer_animation_running` — computed `body::before`:
`animationName = bg-grid-drift`, timing `linear`, iterations
`infinite`; matching `CSSAnimation` with `playState === "running"`.
2. `test_glow_layer_animation_running` — same for `body::after`
(`bg-glow-breathe`); `playState === "running"`.
3. `test_animations_advance` — `currentTime` of both layers advances by
≥200ms over a 500ms wait — the timelines are truly running, not
paused (no static frame).
4. `test_background_layers_contracts` — both pseudo-elements: `position:
fixed`, `z-index: -1`, `pointer-events: none`, `inset: 0` (0px on all
four edges); `<html>` computed background `rgb(10, 14, 23)` (canvas
stays on html), `<body>` computed background `rgba(0, 0, 0, 0)`
(no occlusion).
5. `test_no_horizontal_overflow_with_layers` — 360px viewport:
`documentElement.scrollWidth <= clientWidth` (phase-07 pin,
replicated locally).
## Results (all commands, final state)
| command | result |
|---|---|
| `uv run pytest tests/e2e/test_background_animation.py -v --no-cov` | **5 passed** (run 4× — stable) |
| `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov` | **6 passed** (isolated) |
| `uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov` | **7 passed** (isolated) |
| `uv run pytest` | **329 passed** |
| `uv run pytest --cov=app --cov-report=term-missing` | 329 passed, **app coverage 99%** (≥90% gate; unchanged from task 01) |
| `uv run ruff check . && uv run pyright` | **clean** (0 errors) |
Prerequisite honored: `podman compose up -d db` — `brain-of-reese_db_1`
Up (healthy) throughout.
## UI Structure Check (AGENTS.md rule 5)
- **Layers behind content:** E2E `test_background_layers_contracts`
pins `position: fixed`, `z-index: -1`, `pointer-events: none`,
`inset: 0` on both pseudo-layers in the live viewport (plus the
html-canvas/body-transparent no-occlusion pair).
- **No text/contrast impact:** the fix touches only line alpha, mask
radius, and glow opacity — no palette token, no text on the layers,
all WCAG AA pairs unchanged (phase-08 suite green).
- **No 360px overflow:** E2E pin `scrollWidth <= clientWidth` at
360×740 — live check `scrollWidth=360 clientWidth=360`; the
`fixed; inset: 0` layers add no width (phase-07 suite green).
## Final screenshots (fresh server, headless Chromium 151)
- `final_frame1.png` / `final_frame2.png` — 1280×800, 3s apart: grid
drift visible across the viewport (live sample at frame 2:
`background-position 2.55px` after ≈3.5s ≈ 0.73px/s — the spec'd
one-cell/60s drift), glow corners breathing gently.
- `final_360px.png` — 360×740, no horizontal overflow.
- Task-01 before/after evidence (root-cause + fix delta) in the same
directory (`before_*`, `after_*`, `diagnosis_log.txt`,
`diagnosis_diff.txt`).
## Story file
`.agent/user_stories/background-animation.md` — goal, bug report
verbatim (TODO.md L5), owner-confirmed A3 decisions, found root cause,
fix table, acceptance criteria, UI structure, Playwright mapping table.