fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design
This commit is contained in:
@@ -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()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Unit: the phase-22 animated-background contract (source pins).
|
||||
|
||||
The 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 — the grid simply wasn't
|
||||
perceptible: 1px lines at 35% `--line` alpha (≈10-18/255 over the page
|
||||
bg), masked to the top ~25% of the viewport, drifting 0.73px/s. Only the
|
||||
glow's 0.65↔1 opacity swing was visible, and it read as a blink.
|
||||
|
||||
The fix (styles.css, pure CSS, zero JS, no `filter: blur`):
|
||||
- grid lines 35% → 60% `--line` alpha;
|
||||
- mask widened: `120% 90% … black 25%, transparent 78%` →
|
||||
`140% 110% … black 40%, transparent 90%` (grid now readable across
|
||||
most of the viewport, fading to the corners);
|
||||
- glow breathe narrowed 0.65↔1 → 0.85↔1 (breathing, not pulsing).
|
||||
|
||||
Durations are the owner-confirmed phase-08 design and stay pinned at
|
||||
60s (one-cell seamless drift) and 14s — changing them would also break
|
||||
the phase-08 story gate (`tests/e2e/test_dark_tech_theme.py` pins the
|
||||
live durations). This file pins the FINAL values so a silent regression
|
||||
(weaker alpha, shrunken mask, wider opacity swing, re-occluded layer) is
|
||||
caught without a browser. Browser behavior (visible motion, no jank) is
|
||||
E2E-covered by tests/e2e/test_background_animation.py (task 02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
STYLES_CSS = (
|
||||
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
|
||||
)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css_no_comments() -> str:
|
||||
"""styles.css with /* … */ comments stripped — for functional anchor
|
||||
checks (filter/blur) that must not trip on explanatory comments."""
|
||||
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
|
||||
|
||||
|
||||
def _rule_block(css: str, selector: str) -> str:
|
||||
"""Body of the first `selector { ... }` rule (top-level, no nesting)."""
|
||||
rule = re.search(
|
||||
r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css
|
||||
)
|
||||
assert rule, f"styles.css must define a {selector} rule"
|
||||
return rule.group(1)
|
||||
|
||||
|
||||
def _grid_rule(css: str) -> str:
|
||||
return _rule_block(css, "body::before")
|
||||
|
||||
|
||||
def _glow_rule(css: str) -> str:
|
||||
return _rule_block(css, "body::after")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Layer plumbing — the no-occlusion contract (phase 08) must survive
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
|
||||
"""Both background layers stay behind the content and can never
|
||||
intercept input: fixed, full-viewport, z-index -1, pointer-events
|
||||
none (UI Structure Check: layers behind content, no 360px overflow)."""
|
||||
for name, block in (("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css()))):
|
||||
assert "position: fixed" in block, f"{name} must stay position:fixed"
|
||||
assert "inset: 0" in block, f"{name} must stay full-viewport (inset: 0)"
|
||||
assert "z-index: -1" in block, f"{name} must stay z-index:-1"
|
||||
assert "pointer-events: none" in block, f"{name} must stay click-through"
|
||||
assert "content: \"\"" in block, f"{name} must keep its pseudo content"
|
||||
|
||||
|
||||
def test_html_owns_bg_and_body_stays_transparent() -> None:
|
||||
"""The no-occlusion contract: the visible page background lives on
|
||||
<html>; <body> must remain transparent and non-stacking, or the
|
||||
z-index:-1 layers are painted over (the phase-08 recipe)."""
|
||||
html_block = _rule_block(_css(), "html")
|
||||
assert "background: var(--bg)" in html_block, (
|
||||
"html must keep background: var(--bg) (the page canvas)"
|
||||
)
|
||||
body_block = _rule_block(_css(), "body")
|
||||
assert "background: transparent" in body_block, (
|
||||
"body must keep background: transparent so the layers show"
|
||||
)
|
||||
# body must not gain a z-index/transform/opacity that would turn it
|
||||
# into a stacking context trapping the negative-z-index layers.
|
||||
for prop in ("z-index", "transform", "opacity", "filter"):
|
||||
assert prop + ":" not in body_block, (
|
||||
f"body must not create a stacking context (found {prop})"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Grid layer — the phase-22 final values
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grid_animates_seamless_one_cell_drift() -> None:
|
||||
"""body::before runs bg-grid-drift 60s linear infinite — the
|
||||
owner-confirmed 60s one-cell loop (seamless, delta == 44px)."""
|
||||
block = _grid_rule(_css())
|
||||
assert "animation: bg-grid-drift 60s linear infinite" in block
|
||||
|
||||
|
||||
def test_grid_keyframes_move_exactly_one_cell() -> None:
|
||||
"""The drift delta must equal one 44px cell (0 0 → 44px 44px) for a
|
||||
seamless loop — if the speed ever changes, only the duration may move."""
|
||||
keyframes = re.search(
|
||||
r"@keyframes bg-grid-drift\s*\{([\s\S]*?)\n\}", _css()
|
||||
)
|
||||
assert keyframes, "styles.css must define @keyframes bg-grid-drift"
|
||||
body = keyframes.group(1)
|
||||
assert "background-position: 0 0, 0 0" in body
|
||||
assert "background-position: 44px 44px, 44px 44px" in body
|
||||
|
||||
|
||||
def test_grid_cells_and_line_contrast() -> None:
|
||||
"""44px cells with 1px lines at the phase-22 fixed 60% --line alpha
|
||||
(phase-08's 35% measured imperceptible at 0.73px/s — see module
|
||||
docstring)."""
|
||||
block = _grid_rule(_css())
|
||||
assert "background-size: 44px 44px" in block
|
||||
line = "linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)"
|
||||
assert line in block, "grid must keep horizontal 1px lines at 60% --line"
|
||||
assert (
|
||||
"linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px)"
|
||||
in block
|
||||
), "grid must keep vertical 1px lines at 60% --line"
|
||||
assert "0.35" not in block, "the too-faint 35% line alpha must not return"
|
||||
|
||||
|
||||
def test_grid_mask_widened_and_prefixed() -> None:
|
||||
"""The phase-22 mask: 140%×110% ellipse, fully visible to 40% of the
|
||||
radius, faded out by 90% — the grid must read across most of the
|
||||
viewport (phase-08's 120%×90%/25%/78% masked it to the top ~25%).
|
||||
The -webkit- and standard mask-image must stay in lockstep."""
|
||||
block = _grid_rule(_css())
|
||||
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
|
||||
assert f"-webkit-mask-image: {mask};" in block
|
||||
assert f"mask-image: {mask};" in block
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Glow layer — the phase-22 final values
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_glow_animates_breathe_not_blink() -> None:
|
||||
"""body::after runs bg-glow-breathe 14s ease-in-out infinite alternate
|
||||
— the owner-confirmed 14s breathing period (untouched)."""
|
||||
block = _glow_rule(_css())
|
||||
assert "animation: bg-glow-breathe 14s ease-in-out infinite alternate" in block
|
||||
|
||||
|
||||
def test_glow_keyframes_narrowed_opacity_swing() -> None:
|
||||
"""The opacity swing is narrowed to 0.85↔1 (phase-08's 0.65↔1 was the
|
||||
only visible motion and read as a blink). The gentle scale (1↔1.05)
|
||||
stays."""
|
||||
keyframes = re.search(
|
||||
r"@keyframes bg-glow-breathe\s*\{([\s\S]*?)\n\}", _css()
|
||||
)
|
||||
assert keyframes, "styles.css must define @keyframes bg-glow-breathe"
|
||||
body = keyframes.group(1)
|
||||
assert re.search(r"opacity:\s*0\.85", body), "glow low must be 0.85"
|
||||
assert re.search(r"opacity:\s*1;?", body), "glow high must be 1"
|
||||
assert "0.65" not in body, "the blinking 0.65 low must not return"
|
||||
assert re.search(r"scale\(1\)", body)
|
||||
assert re.search(r"scale\(1\.05\)", body)
|
||||
|
||||
|
||||
def test_glow_colors_and_radii_untouched() -> None:
|
||||
"""Phase-22 is a perception fix, not a redesign: the two glow
|
||||
gradients (indigo top-left, cyan bottom-right) keep phase-08's colors
|
||||
and radii."""
|
||||
block = _glow_rule(_css())
|
||||
assert (
|
||||
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), "
|
||||
"transparent 62%)" in block
|
||||
)
|
||||
assert (
|
||||
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), "
|
||||
"transparent 62%)" in block
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Phase-08 anchor: pure CSS, zero JS, no blur
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_blur_no_js_in_background_layers() -> None:
|
||||
"""The phase-08 performance anchor: no `filter: blur` (or any filter)
|
||||
on either layer, and the animation is CSS-only (both layers carry an
|
||||
`animation:` shorthand; nothing in styles.css references a script)."""
|
||||
for name, block in (("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css()))):
|
||||
assert "filter" not in block, f"{name} must not use any filter"
|
||||
assert "animation:" in block, f"{name} must be CSS-animated"
|
||||
assert "blur" not in _css_no_comments(), (
|
||||
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
|
||||
)
|
||||
Reference in New Issue
Block a user