9.4 KiB
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_backgroundpins the live 60s/14s durations. Both layers keepfixed; inset: 0; z-index: -1; pointer-events: none;htmlkeepsvar(--bg),bodystays 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.py6/6,test_responsive_polish.py7/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.
test_grid_layer_animation_running— computedbody::before:animationName = bg-grid-drift, timinglinear, iterationsinfinite; matchingCSSAnimationwithplayState === "running".test_glow_layer_animation_running— same forbody::after(bg-glow-breathe);playState === "running".test_animations_advance—currentTimeof both layers advances by ≥200ms over a 500ms wait — the timelines are truly running, not paused (no static frame).test_background_layers_contracts— both pseudo-elements:position: fixed,z-index: -1,pointer-events: none,inset: 0(0px on all four edges);<html>computed backgroundrgb(10, 14, 23)(canvas stays on html),<body>computed backgroundrgba(0, 0, 0, 0)(no occlusion).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_contractspinsposition: fixed,z-index: -1,pointer-events: none,inset: 0on 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 <= clientWidthat 360×740 — live checkscrollWidth=360 clientWidth=360; thefixed; inset: 0layers 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.55pxafter ≈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.