"""Phase 22 E2E (Playwright): the background layers — now a regression suite for the phase-25 no-motion design. Story: ``.agent/user_stories/background-no-motion.md`` (the phase-25 owner direction supersedes this suite's original pins; the phase-22 history lives in ``.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 Phase 22 proved the phase-08 background (grid drift + whole-layer breathe) actually animated in a real Chromium viewport. The owner then reported (2026-08-25, chat): the background "jitters down and to the right every second and it slowly blinks brighter and darker. It should be smooth, fluxuating, dimming and brightening, but not moving. Different bright spots should slowly fade in and out." — so the phase-25 redesign (``frontend/assets/styles.css``) removed the movement entirely: the grid (``body::before``) is a STATIC texture (``bg-grid-drift`` deleted), and three independent soft glow spots run their own slow opacity-only fades — ``body::after`` runs ``bg-glow-a`` (26s), ``html::before`` runs ``bg-glow-b`` (34s, −12s delay), ``html::after`` runs ``bg-glow-c`` (42s, −23s delay). This adapted suite now pins the phase-25 contract on the same layers (the full story gate is ``tests/e2e/test_background_no_motion.py``): the grid is static, the indigo spot runs ``bg-glow-a`` and the three spot timelines advance, all four pseudo-layers keep the fixed/z-index −1/pointer-events none/no-occlusion contract, and there is no 360px overflow. Test → story mapping (Playwright Mapping Rule): 1. ``test_grid_layer_is_static`` — computed style of ``body::before``: ``animationName`` is ``"none"`` (the 0.73px/s drift is gone), no ``bg-grid-drift`` entry in the document animation list, and the static grid ``backgroundImage`` is still painted. 2. ``test_glow_layer_animation_running`` — ``body::after`` runs ``bg-glow-a`` (26s, ease-in-out, infinite) with a matching entry in the document animation list, ``playState === "running"``. 3. ``test_glow_timelines_advance`` — ``currentTime`` of all three spot timelines sampled, ~500ms waited, all advanced — the fades are truly running, not paused (headless Chromium starts the document animation timeline ~1s after load, so the first sample polls until the timelines are alive). 4. ``test_background_layers_contracts`` — all four pseudo-elements (body ``::before``/``::after`` + the phase-25 ``html ::before``/``::after`` spots): ``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. The two ``html`` pseudo-layers' computed styles come from ``getComputedStyle(document.documentElement, "::before"/"::after")``. """ from __future__ import annotations import time from playwright.sync_api import Browser, Page GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere GLOWS = ("bg-glow-a", "bg-glow-b", "bg-glow-c") # the three phase-25 spot fades PAGE_BG = "rgb(10, 14, 23)" # var(--bg) — the canvas (phase-08 palette) # Computed styles of all four pseudo-layers + the html/body background # contract (single evaluate — one round-trip per test). JS_LAYER_REPORT = """() => { const pick = (el, pseudo) => { const cs = getComputedStyle(el, 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], image: cs.backgroundImage, }; }; return { grid: pick(document.body, "::before"), glowA: pick(document.body, "::after"), glowB: pick(document.documentElement, "::before"), glowC: pick(document.documentElement, "::after"), htmlBg: getComputedStyle(document.documentElement).backgroundColor, bodyBg: getComputedStyle(document.body).backgroundColor, }; }""" # The 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 three glow-spot layers.""" entries = page.evaluate(JS_TIMELINE, list(GLOWS)) return {str(a["name"]): float(a["t"]) for a in entries} def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None: """Poll until all three spot 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_glow_timelines_advance`` must start once the timeline is alive. """ deadline = time.monotonic() + timeout_ms / 1000 while time.monotonic() < deadline: times = _timeline(page) if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS): 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_is_static(page: Page, app_url: str, db_ready: None) -> None: """Phase-25 AC1: the grid layer is STATIC in a real viewport — ``animationName`` is ``"none"``, no ``bg-grid-drift`` animation exists, and the static grid texture is still painted (the owner rejected the grid's motion, not the grid).""" page.goto(app_url) report = page.evaluate(JS_LAYER_REPORT) grid = report["grid"] assert grid["anim"] == "none", ( f"body::before must be static (animationName, got {grid['anim']!r})" ) live = page.evaluate(JS_TIMELINE, [GRID_OLD]) assert not live, ( f"no {GRID_OLD} animation may exist (got {live!r}) — the drift is deleted" ) assert grid["image"] != "none", ( f"the static grid texture must still be painted (image {grid['image']!r})" ) def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None: """Phase-25 AC2: the indigo spot layer (body::after) runs bg-glow-a — 26s, ease-in-out, infinite — in a real viewport: the matching CSSAnimation is reported ``running``.""" page.goto(app_url) report = page.evaluate(JS_LAYER_REPORT) glow = report["glowA"] assert glow["anim"] == "bg-glow-a", ( f"body::after must run bg-glow-a (got {glow['anim']!r})" ) assert glow["iterations"] == "infinite", ( f"body::after must fade forever (got {glow['iterations']!r})" ) live = page.evaluate(JS_TIMELINE, list(GLOWS)) match = [a for a in live if a["name"] == "bg-glow-a"] assert match, "no bg-glow-a entry in document.getAnimations() — spot not fading" assert match[0]["playState"] == "running", ( f"bg-glow-a is {match[0]['playState']!r} — the spot fade must be running" ) def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None: """Phase-25 AC2: all three spot timelines actually advance — the background is a live animation, not a static (or paused) frame. Sample currentTime, wait ~500ms, and require real progress on all three layers.""" page.goto(app_url) _wait_timelines_alive(page) before = _timeline(page) page.wait_for_timeout(500) after = _timeline(page) for name in GLOWS: 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: """Phase-25 AC4: UI Structure Check — ALL FOUR background pseudo-layers (body ::before/::after + the phase-25 html ::before/::after spots) 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 key in ("grid", "glowA", "glowB", "glowC"): info = report[key] assert info["position"] == "fixed", f"{key} must stay position:fixed" assert info["zIndex"] == "-1", ( f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})" ) assert info["pointerEvents"] == "none", ( f"{key} must stay click-through (pointer-events none)" ) assert info["edges"] == ["0px", "0px", "0px", "0px"], ( f"{key} 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: """Phase-25 AC6: the background layers add no width — the phase-07 overflow pin (documentElement.scrollWidth <= clientWidth) still holds at the 360px floor with all four 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()