Files
brain-of-reese/.agent/reports/22_background_animation/report.md
T

9.4 KiB
Raw Blame History

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.