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:
@@ -0,0 +1,424 @@
|
||||
"""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(10, 14, 23)`` (``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(10, 14, 23)" # var(--bg) — the <html> canvas (phase-08 palette)
|
||||
|
||||
# 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 <html>, <body>
|
||||
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 <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_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()
|
||||
Reference in New Issue
Block a user