"""Phase 22 E2E (Playwright): the animated background actually animates. Story: ``.agent/user_stories/background-animation.md`` Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_background_animation.py -v --no-cov Owner report (2026-08-24, roadmap A3): the phase-08 background "just blinks". The diagnosis (``.agent/reports/22_background_animation/``) found both layers *were* animating with no occlusion — but the grid drift was imperceptible (35% alpha 1px lines × a small radial mask × 0.73px/s) and only the glow's 0.65↔1 swing was perceived. The fix (styles.css, pure CSS, zero JS): 60% grid line alpha + wider mask and a 0.85↔1 glow breathe. This suite proves the *behavior* the unit source pins only describe: in a real Chromium viewport both pseudo-element layers run their animations AND the animation timelines actually advance (no static frame, no paused layer, no new occlusion or overflow). Test → story mapping (Playwright Mapping Rule): 1. ``test_grid_layer_animation_running`` — computed style of ``body::before``: ``animationName`` is ``bg-grid-drift``, timing function ``linear``, iteration count ``infinite``; plus a matching entry in the document animation list with ``playState === "running"``. 2. ``test_glow_layer_animation_running`` — same for ``body::after`` with the ``bg-glow-breathe`` keyframe; ``playState === "running"``. 3. ``test_animations_advance`` — ``currentTime`` of both layers sampled, ~500ms waited, both advanced — the timelines are truly running, not paused (headless Chromium starts the document animation timeline ~1s after load, so the first sample polls until the timeline is alive). 4. ``test_background_layers_contracts`` — both pseudo-elements: ``position: fixed``, ``z-index: -1``, ``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind content, click-through, full-viewport); the page canvas stays on ```` (``rgb(10, 14, 23)`` = ``var(--bg)``) and ```` stays transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion contract. 5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport ``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin, replicated locally — the ``fixed; inset: 0`` layers must add no width). Chromium note: pseudo-element CSS animations are enumerated by ``document.getAnimations()``, NOT by ``document.body.getAnimations()`` (verified on Chromium 151 — the element-level list is empty for pseudo-layers), so tests 1–3 match on ``animationName`` in the document-level list. """ from __future__ import annotations import time from playwright.sync_api import Browser, Page GRID = "bg-grid-drift" GLOW = "bg-glow-breathe" PAGE_BG = "rgb(10, 14, 23)" # var(--bg) — the canvas (phase-08 palette) # Computed styles of both pseudo-layers + the html/body background # contract (single evaluate — one round-trip per test). JS_LAYER_REPORT = """() => { const pick = (pseudo) => { const cs = getComputedStyle(document.body, pseudo); return { anim: cs.animationName, timing: cs.animationTimingFunction, iterations: cs.animationIterationCount, position: cs.position, zIndex: cs.zIndex, pointerEvents: cs.pointerEvents, edges: [cs.top, cs.right, cs.bottom, cs.left], }; }; return { before: pick("::before"), after: pick("::after"), htmlBg: getComputedStyle(document.documentElement).backgroundColor, bodyBg: getComputedStyle(document.body).backgroundColor, }; }""" # Both background-layer animations from the Web Animations API # ({name, playState, currentTime}); the keyframe names are passed as one # array argument (Playwright serializes the Python list to a JS array). JS_TIMELINE = """(names) => document.getAnimations() .filter((a) => names.includes(a.animationName)) .map((a) => ({ name: a.animationName, playState: a.playState, t: a.currentTime, }))""" def _timeline(page: Page) -> dict[str, float]: """animationName → currentTime (ms) for the two background layers.""" entries = page.evaluate(JS_TIMELINE, [GRID, GLOW]) return {str(a["name"]): float(a["t"]) for a in entries} def _wait_timeline_alive(page: Page, timeout_ms: int = 5000) -> None: """Poll until both layer timelines report currentTime > 0. Headless Chromium starts the document animation timeline shortly after load (observed ≈1.4s after navigation) — until then currentTime is 0, so the "did it advance?" sample in ``test_animations_advance`` must start once the timeline is alive. """ deadline = time.monotonic() + timeout_ms / 1000 while time.monotonic() < deadline: times = _timeline(page) if set(times) == {GRID, GLOW} and all(times[k] > 0 for k in (GRID, GLOW)): return page.wait_for_timeout(100) raise AssertionError( f"background animation timeline never started (saw {_timeline(page)!r})" ) # -------------------------------------------------------------------------- # Tests (story → test mapping, see module docstring) # -------------------------------------------------------------------------- def test_grid_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None: """AC1: the grid layer runs bg-grid-drift linear infinite in a real viewport — not just declared in CSS: the matching CSSAnimation is reported ``running``.""" page.goto(app_url) report = page.evaluate(JS_LAYER_REPORT) grid = report["before"] assert grid["anim"] == GRID, f"body::before must run {GRID} (got {grid['anim']!r})" assert grid["timing"] == "linear", ( f"body::before must keep linear timing (got {grid['timing']!r})" ) assert grid["iterations"] == "infinite", ( f"body::before must loop infinitely (got {grid['iterations']!r})" ) live = page.evaluate(JS_TIMELINE, [GRID, GLOW]) match = [a for a in live if a["name"] == GRID] assert match, f"no {GRID} entry in document.getAnimations() — layer not animating" assert match[0]["playState"] == "running", ( f"{GRID} is {match[0]['playState']!r} — the grid drift must be running" ) def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None: """AC2: the glow layer runs bg-glow-breathe in a real viewport — the matching CSSAnimation is reported ``running``.""" page.goto(app_url) report = page.evaluate(JS_LAYER_REPORT) glow = report["after"] assert glow["anim"] == GLOW, f"body::after must run {GLOW} (got {glow['anim']!r})" assert glow["iterations"] == "infinite", ( f"body::after must loop infinitely (got {glow['iterations']!r})" ) live = page.evaluate(JS_TIMELINE, [GRID, GLOW]) match = [a for a in live if a["name"] == GLOW] assert match, f"no {GLOW} entry in document.getAnimations() — layer not animating" assert match[0]["playState"] == "running", ( f"{GLOW} is {match[0]['playState']!r} — the glow breathe must be running" ) def test_animations_advance(page: Page, app_url: str, db_ready: None) -> None: """AC1: both timelines actually advance — the background is a live animation, not a static (or paused) frame. Sample currentTime, wait ~500ms, and require real progress on both layers.""" page.goto(app_url) _wait_timeline_alive(page) before = _timeline(page) page.wait_for_timeout(500) after = _timeline(page) for name in (GRID, GLOW): delta = after[name] - before[name] assert delta >= 200, ( f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) " "— paused or static?" ) def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None: """AC3/AC5: UI Structure Check — the layers stay behind content (fixed, z-index -1, pointer-events none, full-viewport) and nothing occludes them: the page canvas is on , transparent.""" page.goto(app_url) report = page.evaluate(JS_LAYER_REPORT) for layer in ("before", "after"): info = report[layer] assert info["position"] == "fixed", f"body::{layer} must stay position:fixed" assert info["zIndex"] == "-1", ( f"body::{layer} must stay behind content (z-index -1, got {info['zIndex']!r})" ) assert info["pointerEvents"] == "none", ( f"body::{layer} must stay click-through (pointer-events none)" ) assert info["edges"] == ["0px", "0px", "0px", "0px"], ( f"body::{layer} must stay full-viewport (inset: 0, got {info['edges']!r})" ) assert report["htmlBg"] == PAGE_BG, ( f"the page canvas must stay on — var(--bg) (got {report['htmlBg']!r})" ) assert report["bodyBg"] == "rgba(0, 0, 0, 0)", ( f"body must stay transparent so the layers show (got {report['bodyBg']!r})" ) def test_no_horizontal_overflow_with_layers( browser: Browser, app_url: str, db_ready: None ) -> None: """AC4: the background layers add no width — the phase-07 overflow pin (documentElement.scrollWidth <= clientWidth) still holds at the 360px floor with both fixed; inset: 0 layers live.""" phone = browser.new_page(viewport={"width": 360, "height": 740}) try: phone.goto(f"{app_url}/") scroll, client = phone.evaluate( "() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]" ) assert scroll <= client, ( f"horizontal overflow at 360px with the background layers: " f"{scroll} > {client}" ) finally: phone.close()