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:
@@ -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}/")
|
||||
|
||||
Reference in New Issue
Block a user