fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design

This commit is contained in:
2026-08-24 22:16:41 -04:00
parent 22a6121411
commit 0adc9b5801
24 changed files with 797 additions and 183 deletions
+227
View File
@@ -0,0 +1,227 @@
"""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 ``<html>``
(``rgb(10, 14, 23)`` = ``var(--bg)``) and ``<body>`` 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 <html> 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 <html>, <body> 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 <html> — 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()