Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
425 lines
18 KiB
Python
425 lines
18 KiB
Python
"""Phase 25 E2E (Playwright): the background no longer moves — it only fades.
|
||
|
||
Story: ``.agents/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
|
||
``.agents/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(15, 10, 10)`` (``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(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
|
||
|
||
# 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()
|