# 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); `` computed background `rgb(10, 14, 23)` (canvas stays on html), `` 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.