"""Phase 25 E2E (Playwright): the background no longer moves — it only fades. Story: ``.agent/user_stories/background-no-motion.md`` (supersedes ``background-animation.md``) Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov Owner report (2026-08-25, chat): the phase-22 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." The two root causes (phase-22 measurements in ``.agent/reports/22_background_animation/``): - the "jitter" was the grid's 44px/60s drift (0.73px/s, diagonally down-right) — a 1px line translated sub-pixel by sub-pixel rasterizes with per-frame stepping, not smooth motion; - the "blink" was the whole-layer 14s opacity 0.85↔1 + scale(1)↔ scale(1.05) pulse — one synchronized pulse reads as blinking. The fix (styles.css, pure CSS, zero JS — A11): the grid (``body::before``) is a STATIC texture (no animation, ``bg-grid-drift`` deleted), and three independent soft glow spots each run their own SLOW opacity-only fade — ``body::after`` (phase-08 indigo) runs ``bg-glow-a`` 26s, ``html::before`` (phase-08 cyan) runs ``bg-glow-b`` 34s with −12s delay, ``html::after`` (a third indigo) runs ``bg-glow-c`` 42s with −23s delay. The out-of-phase 26/34/42s cycles (LCM 4641s) make the total light fluxuate smoothly and irregularly — no blink, no jitter, no movement. This suite proves the *behavior* the unit source pins only describe, in a real Chromium viewport: the grid is static, the three spots run distinct opacity-only fades whose timelines advance and whose light measurably changes, no ``bg-*`` keyframe animates anything but ``opacity`` (the deterministic no-movement proof), all four layers keep the fixed/z-index −1/pointer-events-none/no-occlusion contract, reduced motion stills all four, and there is no 360px overflow. Test → story mapping (Playwright Mapping Rule): 1. ``test_grid_layer_is_static`` — computed ``animationName`` of ``body::before`` is ``"none"``; no ``bg-grid-drift`` entry in ``document.getAnimations()``; the grid ``backgroundImage`` is still present (the static texture survives). 2. ``test_three_glow_layers_run_distinct_fades`` — ``body::after`` → ``bg-glow-a`` (26s), ``documentElement::before`` → ``bg-glow-b`` (34s), ``documentElement::after`` → ``bg-glow-c`` (42s); each ``ease-in-out`` + ``infinite`` with a matching ``playState === "running"`` entry in the document animation list; the three durations are pairwise distinct. 3. ``test_no_motion_properties_in_background_keyframes`` — walks ``document.styleSheets``; across every frame of every ``bg-*`` ``KEYFRAMES_RULE`` the set of declared property names is exactly ``{"opacity"}`` — no ``transform``/``background-position`` anywhere (the deterministic no-movement proof). 4. ``test_glow_timelines_advance`` — poll until all three timelines report ``currentTime > 0`` (headless Chromium starts the document timeline ~1s after load), sample, wait ~500ms, each advanced ≥ 200ms. 5. ``test_background_light_actually_changes`` — (a) the computed ``opacity`` of ``body::after`` changes by ≥ 0.05 within ~8s (a real fade, not a frozen frame); (b) two clipped screenshots ~4s apart of the bottom-left glow region (the ``html::after`` spot at 14%/86%) differ in bytes — the light visibly changes while nothing moves (a fresh ``/`` page has no other animation, so the diff is the background's). 6. ``test_background_layers_contracts`` — all four pseudo-layers: ``position: fixed``, ``z-index: -1``, ``pointer-events: none``, top/right/bottom/left all ``0px``; the ``documentElement`` computed background is ``rgb(15, 10, 10)`` (``var(--bg)`` — the canvas stays on ``html``); ``document.body`` computed background is ``rgba(0, 0, 0, 0)`` (no occlusion). 7. ``test_reduced_motion_stills_all_layers`` — ``reduced_motion="reduce"`` context: all four pseudo-layers report computed ``animationName`` ``"none"`` and still carry a ``backgroundImage`` (the static background remains visible). 8. ``test_no_horizontal_overflow_with_layers`` — 360px viewport: ``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin). Chromium notes (verified on Chromium 151, kept from the phase-22 suite): pseudo-element CSS animations are enumerated by ``document.getAnimations()``, NOT by ``document.body.getAnimations()`` (the element-level list is empty for pseudo-layers), so the running/advancing checks match on ``animationName`` in the document-level list. And the computed styles of the two new ``html`` pseudo-layers come from ``getComputedStyle(document.documentElement, "::before")`` / ``("::after")`` — ``document.body`` only carries the two ``body`` pseudo-layers. """ from __future__ import annotations import time from playwright.sync_api import Browser, FloatRect, Page GLOW_A = "bg-glow-a" # body::after — phase-08 indigo spot, 26s GLOW_B = "bg-glow-b" # html::before — phase-08 cyan spot, 34s, -12s delay GLOW_C = "bg-glow-c" # html::after — third indigo spot, 42s, -23s delay GLOWS = (GLOW_A, GLOW_B, GLOW_C) # the three spot keyframe names EASE_IN_OUT = "ease-in-out" # computed animationTimingFunction for ease-in-out PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the canvas (dark-red rebrand #0f0a0a) # 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, duration: cs.animationDuration, 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 three 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, }))""" # Deterministic no-movement audit: walk every same-origin stylesheet and # collect, for each @keyframes bg-* rule, the property names declared in # every keyframe frame. Returns {names: [...], props: [...]} — props must # be exactly ["opacity"]. JS_KEYFRAME_PROPS = """() => { const names = []; const props = new Set(); for (const sheet of document.styleSheets) { let rules; try { rules = sheet.cssRules; } catch (e) { continue; // cross-origin sheet — not expected (no-CDN rule) } for (const rule of rules) { if (rule.type === CSSRule.KEYFRAMES_RULE && rule.name.startsWith("bg-")) { names.push(rule.name); for (const frame of rule.cssRules) { for (const p of frame.style) { props.add(p); } } } } } return { names, props: [...props] }; }""" def _seconds(value: str) -> float: """Chromium reports animation durations as "26s" — parse as seconds.""" return float(str(value).replace("s", "")) def _timeline(page: Page) -> dict[str, float]: """animationName → currentTime (ms) for the three glow 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 glow timelines report currentTime > 0. Headless Chromium starts the document animation timeline shortly after load (observed ≈1s 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"glow animation timelines 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: """AC1: no movement — the grid layer (body::before) runs NO animation in a real viewport (the phase-22 0.73px/s drift read as a once-per-second down-right jitter), and its static texture is still painted (the grid stays — the owner rejected its 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, ["bg-grid-drift"]) assert not live, ( f"no bg-grid-drift 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_three_glow_layers_run_distinct_fades( page: Page, app_url: str, db_ready: None ) -> None: """AC2: three different bright spots, each on its own slow fade — body::after runs bg-glow-a (26s), the two html pseudo-layers run bg-glow-b (34s) and bg-glow-c (42s); every one is ease-in-out, infinite, reported ``running`` in the document animation list, and the three durations are pairwise distinct (out of phase).""" page.goto(app_url) report = page.evaluate(JS_LAYER_REPORT) recipe = ( ("glowA", GLOW_A, "26s"), ("glowB", GLOW_B, "34s"), ("glowC", GLOW_C, "42s"), ) durations: list[float] = [] for key, name, expected in recipe: info = report[key] assert info["anim"] == name, ( f"{key} must run {name} (got {info['anim']!r})" ) assert info["duration"] == expected, ( f"{key} must run a {expected} cycle (got {info['duration']!r})" ) assert info["timing"] == EASE_IN_OUT, ( f"{key} must ease in-out (got {info['timing']!r})" ) assert info["iterations"] == "infinite", ( f"{key} must fade forever (got {info['iterations']!r})" ) durations.append(_seconds(info["duration"])) assert len(set(durations)) == 3, ( f"the three spot cycles must be out of phase (got {durations})" ) live = page.evaluate(JS_TIMELINE, list(GLOWS)) for name in GLOWS: match = [a for a in live if a["name"] == name] assert match, f"no {name} entry in document.getAnimations() — spot not fading" assert match[0]["playState"] == "running", ( f"{name} is {match[0]['playState']!r} — the spot fade must be running" ) def test_no_motion_properties_in_background_keyframes( page: Page, app_url: str, db_ready: None ) -> None: """AC1: the deterministic no-movement proof — walk the live stylesheets in Chromium: across every frame of every ``bg-*`` @keyframes rule the set of declared properties is exactly {opacity}. No transform, no background-position, nothing that can move a pixel.""" page.goto(app_url) audit = page.evaluate(JS_KEYFRAME_PROPS) names = set(str(n) for n in audit["names"]) assert names == {GLOW_A, GLOW_B, GLOW_C}, ( f"expected exactly the three spot keyframes {sorted({GLOW_A, GLOW_B, GLOW_C})}, " f"found {sorted(names)}" ) props = {str(p) for p in audit["props"]} assert props == {"opacity"}, ( f"bg-* keyframes may only animate opacity (found {sorted(props)}) — " "the background must not move" ) def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None: """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 (the document animation timeline in headless Chromium starts ~1s after load, so poll until it is alive first).""" 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_light_actually_changes( page: Page, app_url: str, db_ready: None ) -> None: """AC2/AC3: the light fluxuates — (a) the computed opacity of body::after measurably changes within a few seconds (a real fade, not a frozen frame), and (b) a clipped screenshot of the bottom-left glow region (the html::after spot at 14%/86% of the 1280×800 viewport) differs in bytes ~4s later — the light visibly changes while nothing moves (a fresh / page runs no other animation, so the pixel diff is the background's).""" page.goto(app_url) # (a) computed opacity of the indigo spot fades by >= 0.05 within ~8s. def _spot_opacity() -> float: return float( page.evaluate("() => getComputedStyle(document.body, '::after').opacity") ) t0 = _spot_opacity() deadline = time.monotonic() + 8.0 delta = 0.0 while time.monotonic() < deadline: delta = abs(_spot_opacity() - t0) if delta >= 0.05: break page.wait_for_timeout(100) assert delta >= 0.05, ( f"body::after opacity did not fade (Δ={delta:.3f} < 0.05 over 8s) — " "frozen frame?" ) # (b) the rendered glow region changes over ~4s (bottom-left spot at # 14%/86% ≈ (179px, 688px) in the 1280×800 viewport). clip: FloatRect = {"x": 0, "y": 500, "width": 500, "height": 300} shot_1 = page.screenshot(clip=clip) page.wait_for_timeout(4000) shot_2 = page.screenshot(clip=clip) assert shot_1 != shot_2, ( "the clipped bottom-left glow region is byte-identical 4s apart — " "the light must visibly change even though nothing moves" ) def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None: """AC4: UI Structure Check — all four background pseudo-layers (body ::before/::after + the two new html ::before/::after spots) stay behind content (fixed, z-index -1, pointer-events none, full-viewport) and nothing occludes them: the page canvas stays on , stays 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_reduced_motion_stills_all_layers( browser: Browser, app_url: str, db_ready: None ) -> None: """AC5: with prefers-reduced-motion: reduce ALL FOUR pseudo-layers stop animating (animation-name: none) — the static grid + spot images remain visible.""" context = browser.new_context( reduced_motion="reduce", viewport={"width": 1280, "height": 800} ) try: rpage = context.new_page() rpage.goto(app_url) report = rpage.evaluate(JS_LAYER_REPORT) for key in ("grid", "glowA", "glowB", "glowC"): info = report[key] assert info["anim"] == "none", ( f"{key} must not animate under reduced motion (got {info['anim']!r})" ) assert info["image"] != "none", ( f"{key}: the static background image must remain visible" ) finally: context.close() def test_no_horizontal_overflow_with_layers( browser: Browser, app_url: str, db_ready: None ) -> None: """AC6: the four 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()