fix(ui): background no longer moves — static grid, three glow spots fading in and out on their own slow cycles (owner 2026-08-25)

This commit is contained in:
2026-08-25 09:43:19 -04:00
parent 1e6ae360e0
commit 025f57beb5
14 changed files with 1454 additions and 223 deletions
+109 -86
View File
@@ -1,43 +1,55 @@
"""Phase 22 E2E (Playwright): the animated background actually animates.
"""Phase 22 E2E (Playwright): the background layers — now a regression
suite for the phase-25 no-motion design.
Story: ``.agent/user_stories/background-animation.md``
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
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.
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 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).
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_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
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 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.
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
``<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
@@ -47,7 +59,9 @@ 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.
document-level list. The two ``html`` pseudo-layers' computed styles
come from ``getComputedStyle(document.documentElement,
"::before"/"::after")``.
"""
from __future__ import annotations
@@ -55,15 +69,15 @@ import time
from playwright.sync_api import Browser, Page
GRID = "bg-grid-drift"
GLOW = "bg-glow-breathe"
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 <html> canvas (phase-08 palette)
# Computed styles of both pseudo-layers + the html/body background
# Computed styles of all four 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);
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return {
anim: cs.animationName,
timing: cs.animationTimingFunction,
@@ -72,17 +86,20 @@ JS_LAYER_REPORT = """() => {
zIndex: cs.zIndex,
pointerEvents: cs.pointerEvents,
edges: [cs.top, cs.right, cs.bottom, cs.left],
image: cs.backgroundImage,
};
};
return {
before: pick("::before"),
after: pick("::after"),
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,
};
}"""
# Both background-layer animations from the Web Animations API
# 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()
@@ -95,23 +112,24 @@ JS_TIMELINE = """(names) => document.getAnimations()
def _timeline(page: Page) -> dict[str, float]:
"""animationName → currentTime (ms) for the two background layers."""
entries = page.evaluate(JS_TIMELINE, [GRID, GLOW])
"""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_timeline_alive(page: Page, timeout_ms: int = 5000) -> None:
"""Poll until both layer timelines report currentTime > 0.
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_animations_advance`` must start once the timeline is alive.
``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) == {GRID, GLOW} and all(times[k] > 0 for k in (GRID, GLOW)):
if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS):
return
page.wait_for_timeout(100)
raise AssertionError(
@@ -124,56 +142,58 @@ def _wait_timeline_alive(page: Page, timeout_ms: int = 5000) -> None:
# --------------------------------------------------------------------------
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``."""
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["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})"
grid = report["grid"]
assert grid["anim"] == "none", (
f"body::before must be static (animationName, got {grid['anim']!r})"
)
assert grid["iterations"] == "infinite", (
f"body::before must loop infinitely (got {grid['iterations']!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"
)
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"
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:
"""AC2: the glow layer runs bg-glow-breathe in a real viewport —
the matching CSSAnimation is reported ``running``."""
"""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["after"]
assert glow["anim"] == GLOW, f"body::after must run {GLOW} (got {glow['anim']!r})"
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 loop infinitely (got {glow['iterations']!r})"
f"body::after must fade forever (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"
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"{GLOW} is {match[0]['playState']!r} — the glow breathe must be running"
f"bg-glow-a is {match[0]['playState']!r} — the spot fade 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."""
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_timeline_alive(page)
_wait_timelines_alive(page)
before = _timeline(page)
page.wait_for_timeout(500)
after = _timeline(page)
for name in (GRID, GLOW):
for name in GLOWS:
delta = after[name] - before[name]
assert delta >= 200, (
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
@@ -182,22 +202,24 @@ def test_animations_advance(page: Page, app_url: str, db_ready: None) -> None:
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."""
"""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 <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"
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"body::{layer} must stay behind content (z-index -1, got {info['zIndex']!r})"
f"{key} 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)"
f"{key} 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})"
f"{key} 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})"
@@ -210,9 +232,10 @@ def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -
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."""
"""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}/")