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}/")
+424
View File
@@ -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()
+58 -31
View File
@@ -13,12 +13,14 @@ Test → story mapping (Playwright Mapping Rule):
(same contrast helper as Phase 07).
2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw
``outerHTML`` contains any emoji code point.
3. ``test_animated_background`` — ``body::before``/``::after`` carry
background images AND run their animations by default (fixed,
pointer-events none).
3. ``test_animated_background`` — the background layers (fixed,
pointer-events none, carrying images): the grid (``body::before``)
is STATIC (phase 25, owner 2026-08-25: no movement) and the three
glow spots run their opacity-only fades ``bg-glow-a/b/c`` at
26s/34s/42s (``body::after`` + the two ``html`` pseudo-layers).
4. ``test_reduced_motion_honored`` — a context with
``reduced_motion="reduce"`` → ``animation-name: none`` on both layers
(the static grid + glows remain).
``reduced_motion="reduce"`` → ``animation-name: none`` on all four
layers (the static grid + spot images remain).
5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer
+ a source chip + the send button recovers (state machine intact under
the new skin).
@@ -258,14 +260,19 @@ def test_no_emoji_in_chrome(page: Page, app_url: str, db_ready: None) -> None:
def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is subtly animated, pure CSS, zero JS, and can
never block or dim content: both body pseudo-layers are fixed,
pointer-events:none, carry a background image and run their animation
by default (60s grid drift + 14s glow breathing)."""
never block or dim content. Phase-25 contract (owner 2026-08-25:
"not moving. Different bright spots should slowly fade in and out")
supersedes the phase-08 recipe: the grid (``body::before``) is a
STATIC texture (``animationName: none``, image still painted) and
the three glow spots each run their own opacity-only fade —
``body::after`` → ``bg-glow-a`` 26s, ``html::before`` →
``bg-glow-b`` 34s, ``html::after`` → ``bg-glow-c`` 42s — all fixed,
pointer-events:none, carrying an image."""
page.goto(app_url)
report = page.evaluate(
"""() => {
const pick = (pseudo) => {
const cs = getComputedStyle(document.body, pseudo);
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return {
image: cs.backgroundImage,
anim: cs.animationName,
@@ -274,27 +281,42 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
pointerEvents: cs.pointerEvents,
};
};
return { before: pick("::before"), after: pick("::after") };
return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
};
}"""
)
for layer in ("before", "after"):
info = report[layer]
assert info["image"] != "none", f"body::{layer} must carry a background image"
assert info["anim"] not in ("", "none"), (
f"body::{layer} must animate by default (got {info['anim']!r})"
for key in ("grid", "glowA", "glowB", "glowC"):
info = report[key]
assert info["image"] != "none", f"{key} must carry a background image"
assert info["position"] == "fixed", f"{key} must be position:fixed"
assert info["pointerEvents"] == "none", f"{key} must not intercept input"
# The phase-25 recipe: a static grid + three out-of-phase spot fades.
assert report["grid"]["anim"] == "none", (
f"the grid must be static (got {report['grid']['anim']!r})"
)
for key, name, seconds in (
("glowA", "bg-glow-a", 26.0),
("glowB", "bg-glow-b", 34.0),
("glowC", "bg-glow-c", 42.0),
):
info = report[key]
assert info["anim"] == name, f"{key} must run {name} (got {info['anim']!r})"
assert _seconds(info["duration"]) == pytest.approx(seconds), (
f"{key} must run its {seconds:.0f}s fade (got {info['duration']!r})"
)
assert info["position"] == "fixed", f"body::{layer} must be position:fixed"
assert info["pointerEvents"] == "none", f"body::{layer} must not intercept input"
# The recipe: 60s seamless grid drift, 14s breathing glows.
assert _seconds(report["before"]["duration"]) == pytest.approx(60.0)
assert _seconds(report["after"]["duration"]) == pytest.approx(14.0)
def test_reduced_motion_honored(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: with prefers-reduced-motion: reduce both background layers stop
animating (animation-name: none) — the static grid + glows remain."""
"""AC4: with prefers-reduced-motion: reduce ALL FOUR background
layers stop animating (animation-name: none) — the static grid +
spot images remain (phase 25 stills the two html pseudo-layers as
well as the body pair)."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
@@ -303,19 +325,24 @@ def test_reduced_motion_honored(
rpage.goto(app_url)
report = rpage.evaluate(
"""() => {
const pick = (pseudo) => {
const cs = getComputedStyle(document.body, pseudo);
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return { anim: cs.animationName, image: cs.backgroundImage };
};
return { before: pick("::before"), after: pick("::after") };
return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
};
}"""
)
for layer in ("before", "after"):
assert report[layer]["anim"] == "none", (
f"body::{layer} must not animate under reduced motion"
for key in ("grid", "glowA", "glowB", "glowC"):
assert report[key]["anim"] == "none", (
f"{key} must not animate under reduced motion (got {report[key]['anim']!r})"
)
assert report[layer]["image"] != "none", (
f"body::{layer}: the static background must remain visible"
assert report[key]["image"] != "none", (
f"{key}: the static background image must remain visible"
)
finally:
context.close()
+131 -76
View File
@@ -1,26 +1,36 @@
"""Unit: the phase-22 animated-background contract (source pins).
"""Unit: the phase-25 still-background contract — layer plumbing and the
phase-08 anchors (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.
Phase 22 (owner report 2026-08-24) made the phase-08 background
perceptible: 60% grid-line alpha, a widened mask, a 60s one-cell grid
drift, and a 14s whole-layer glow breathe. 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." — the phase-22 design intent
(grid drift + whole-layer breathe) is superseded.
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).
The new design (styles.css, pure CSS, zero JS, no `filter` — A11):
- grid (body::before): a STATIC texture — the drift animation and its
keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a
once-per-second down-right jitter);
- three independent soft glow spots — body::after (phase-08 indigo,
26s), html::before (phase-08 cyan, 34s, -12s delay), html::after
(third indigo, 42s, -23s delay) — each on its own SLOW opacity-only
fade (the whole-layer breathe keyframes are deleted), so the total
light fluxuates smoothly and irregularly; LCM(26, 34, 42) = 4641s,
so the composite pattern never repeats within a viewing session.
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).
This file keeps the generic layer-plumbing pins (the no-occlusion
contract, fixed / z-index -1 / pointer-events none — now across all
four layers) and the phase-08 no-blur/no-JS anchor. The full new
contract (no animation on the grid, opacity-only keyframes, the three
spot gradients, reduced motion across all four layers) is pinned in
tests/unit/test_background_no_motion.py; browser behavior is E2E-covered
by tests/e2e/test_background_no_motion.py (task 02).
Story: .agent/user_stories/background-no-motion.md (supersedes
.agent/user_stories/background-animation.md).
"""
from __future__ import annotations
@@ -31,6 +41,8 @@ STYLES_CSS = (
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
)
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
@@ -59,17 +71,39 @@ def _glow_rule(css: str) -> str:
return _rule_block(css, "body::after")
def _bg_keyframes(css: str) -> dict[str, str]:
"""Name → body for every @keyframes bg-* rule (balanced braces —
works for the one-line blocks and a multi-line reformat alike)."""
out: dict[str, str] = {}
for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css):
start, depth, i = m.end(), 1, m.end()
while i < len(css) and depth:
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
i += 1
out[m.group(1)] = css[start:i - 1]
return out
# --------------------------------------------------------------------------
# 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
"""All four 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()))):
none (phase 25: html::before / html::after join body::before /
body::after as background layers — UI Structure Check: layers behind
content, no 360px overflow, since they are fixed; inset: 0)."""
for name, block in (
("body::before", _grid_rule(_css())),
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
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"
@@ -80,7 +114,9 @@ def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
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)."""
z-index:-1 layers (including the phase-25 html pseudo-layers, which
paint above the canvas and below body's content as the root stacking
context) 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)"
@@ -98,33 +134,28 @@ def test_html_owns_bg_and_body_stays_transparent() -> None:
# --------------------------------------------------------------------------
# Grid layer — the phase-22 final values
# Grid layer — phase 25: a static texture (the drift is gone)
# --------------------------------------------------------------------------
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)."""
def test_grid_is_static_no_drift() -> None:
"""body::before must carry NO animation — the phase-22 60s one-cell
drift (0.73px/s down-right) rasterized as a once-per-second jitter;
the owner wants no movement (2026-08-25). Its keyframes are deleted
too."""
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 "animation" not in block, (
"body::before must not animate (the no-movement contract)"
)
assert "bg-grid-drift" not in _css(), (
"@keyframes bg-grid-drift must be deleted"
)
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)."""
(the static texture keeps the values that made the grid readable —
see tests/unit/test_background_no_motion.py for the phase-25 story)."""
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)"
@@ -148,45 +179,59 @@ def test_grid_mask_widened_and_prefixed() -> None:
# --------------------------------------------------------------------------
# Glow layer — the phase-22 final values
# Glow layers — phase 25: three spots, each on its own slow opacity fade
# --------------------------------------------------------------------------
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()
def test_three_spots_run_own_slow_opacity_fades() -> None:
"""The whole-layer breathe is replaced by three independent
opacity-only fades on distinct slow periods with negative delays (out
of phase): body::after 26s, html::before 34s -12s, html::after 42s
-23s. The old breathe keyframes are deleted."""
assert "animation: bg-glow-a 26s ease-in-out infinite" in _glow_rule(_css())
assert (
"animation: bg-glow-b 34s ease-in-out -12s infinite"
in _rule_block(_css(), "html::before")
)
assert (
"animation: bg-glow-c 42s ease-in-out -23s infinite"
in _rule_block(_css(), "html::after")
)
assert "bg-glow-breathe" not in _css(), (
"@keyframes bg-glow-breathe must be deleted"
)
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())
def test_glow_keyframes_are_opacity_only() -> None:
"""The no-movement contract: every bg-* keyframe block animates ONLY
opacity (no transform/scale, no background-position)."""
keyframes = _bg_keyframes(_css())
assert set(keyframes) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}, (
"exactly three bg-glow-* keyframe blocks must exist"
)
for name, body in keyframes.items():
props = set(re.findall(r"([A-Za-z-]+)\s*:", body))
assert props == {"opacity"}, (
f"{name} must animate only opacity, found {sorted(props)}"
)
def test_glow_spots_keep_phase08_colors_and_add_a_third() -> None:
"""The phase-08 spots keep their colors, radii and positions — the
indigo top-left stays on body::after, the cyan bottom-right moves to
html::before — and a third soft indigo spot (52rem at 14% 86%)
joins on html::after. All spots fade to transparent at 62%."""
assert (
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), "
"transparent 62%)" in block
"transparent 62%)" in _glow_rule(_css())
)
assert (
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), "
"transparent 62%)" in block
"transparent 62%)" in _rule_block(_css(), "html::before")
)
assert (
"radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), "
"transparent 62%)" in _rule_block(_css(), "html::after")
)
@@ -196,12 +241,22 @@ def test_glow_colors_and_radii_untouched() -> None:
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()))):
"""The phase-08 performance anchor: no `filter` (or any filter) on
any layer, and the motion is CSS-only — the three glow layers carry
an `animation:` shorthand (the grid is deliberately still in phase
25), and nothing in styles.css references a script."""
for name, block in (
("body::before", _grid_rule(_css())),
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
assert "filter" not in block, f"{name} must not use any filter"
for name, block in (
("body::after", _glow_rule(_css())),
("html::before", _rule_block(_css(), "html::before")),
("html::after", _rule_block(_css(), "html::after")),
):
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)"
+252
View File
@@ -0,0 +1,252 @@
"""Unit: the phase-25 still-background contract (source pins).
Owner report (2026-08-25, chat): the animated 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 diagnosis (`.agent/reports/25_background_no_motion/`) found both
root causes in the phase-22 design:
- "jitters down and to the right" = the grid's 44px/60s drift
(0.73px/s, diagonally down-right) — a 1px grid line translated
sub-pixel by sub-pixel rasterizes with per-frame stepping;
- "slowly blinks" = the whole-layer 14s opacity 0.85↔1 +
scale(1)↔scale(1.05) pulse — one synchronized pulse reads as a blink.
The fix (styles.css, pure CSS, zero JS, no `filter` — A11): the grid is
a STATIC texture (no animation, no bg-grid-drift keyframes), and three
independent soft glow spots (body::after, html::before, html::after)
each run their own SLOW opacity-only fade (26/34/42s, ease-in-out,
negative delays → out of phase; LCM 4641s → the composite pattern
effectively never repeats within a viewing session), so the total light
fluxuates smoothly and irregularly — no blink, no jitter, no movement.
Story: .agent/user_stories/background-no-motion.md. Browser behavior
(no motion, visible fades, no occlusion, no overflow) is E2E-covered by
tests/e2e/test_background_no_motion.py (task 02).
"""
from __future__ import annotations
import re
from tests.unit.test_background_animation import _css, _css_no_comments, _rule_block
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
# (layer, keyframes name, duration shorthand, single-spot gradient)
GLOW_SPOTS = (
("body::after", "bg-glow-a", "animation: bg-glow-a 26s ease-in-out infinite",
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%)"),
("html::before", "bg-glow-b", "animation: bg-glow-b 34s ease-in-out -12s infinite",
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%)"),
("html::after", "bg-glow-c", "animation: bg-glow-c 42s ease-in-out -23s infinite",
"radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), transparent 62%)"),
)
def _bg_keyframes(css: str) -> dict[str, str]:
"""Name → body for every @keyframes bg-* rule (balanced braces —
works for the one-line blocks and a multi-line reformat alike)."""
out: dict[str, str] = {}
for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css):
start, depth, i = m.end(), 1, m.end()
while i < len(css) and depth:
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
i += 1
out[m.group(1)] = css[start:i - 1]
return out
# --------------------------------------------------------------------------
# No movement — the grid is a static texture, and no bg-* keyframe may
# animate anything but opacity
# --------------------------------------------------------------------------
def test_grid_has_no_animation() -> None:
"""body::before must carry NO animation declaration — the phase-22
0.73px/s drift rasterized as a once-per-second down-right jitter; the
owner wants no movement (2026-08-25)."""
block = _rule_block(_css(), "body::before")
assert "animation" not in block, (
"body::before must not animate (the no-movement contract)"
)
def test_drift_and_breathe_keyframes_are_deleted() -> None:
"""@keyframes bg-grid-drift and @keyframes bg-glow-breathe are gone —
the names must not appear anywhere in the file (no declaration, no
keyframe block, no stale comment)."""
css = _css()
assert "bg-grid-drift" not in css, "bg-grid-drift must be deleted"
assert "bg-glow-breathe" not in css, "bg-glow-breathe must be deleted"
def test_grid_keeps_its_static_texture() -> None:
"""The owner rejected the grid's MOTION, not the grid: 44px cells,
1px lines at 60% --line alpha, and the widened radial mask (both the
-webkit- and standard mask properties) stay."""
block = _rule_block(_css(), "body::before")
assert "background-size: 44px 44px" in block
assert (
"linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)" 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"
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
def test_exactly_three_bg_glow_keyframes_exist() -> None:
"""Exactly three bg-* keyframe blocks: bg-glow-a/b/c (the old
bg-grid-drift and bg-glow-breathe are deleted)."""
assert set(_bg_keyframes(_css())) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}
def test_bg_keyframes_animate_opacity_only() -> None:
"""The no-movement contract: across ALL frames of ALL bg-* keyframes
the set of declared properties is exactly {opacity} — no transform,
scale, background-position, nothing else may appear."""
props: set[str] = set()
for _name, body in _bg_keyframes(_css()).items():
props |= set(re.findall(r"([A-Za-z-]+)\s*:", body))
assert props == {"opacity"}, (
f"bg-* keyframes must animate only opacity, found {sorted(props)}"
)
def test_glow_layers_declare_no_transform_or_position_animation() -> None:
"""The three glow layers themselves must not declare transform or
background-position either (the no-movement contract applies to the
layers, not just their keyframes)."""
for sel, _name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert "transform" not in block, f"{sel} must not declare transform"
assert "background-position" not in block, (
f"{sel} must not declare background-position"
)
# --------------------------------------------------------------------------
# Three distinct bright spots, each on its own slow opacity-only fade
# --------------------------------------------------------------------------
def test_each_spot_runs_its_own_slow_opacity_fade() -> None:
"""body::after (phase-08 indigo, 26s), html::before (phase-08 cyan,
34s, -12s delay), html::after (third indigo, 42s, -23s delay) — each
glow layer's background-image is EXACTLY the single radial gradient
from the spec (color, radius, position, 62% transparent stop)."""
for sel, _name, animation, gradient in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert animation in block, f"{sel} must run {animation}"
assert f"background-image: {gradient};" in block, (
f"{sel} must carry exactly one radial gradient: {gradient}"
)
def test_glow_durations_are_distinct_and_slow() -> None:
"""The three cycles are out of phase (distinct durations) and each is
slow (>= 20s); LCM(26, 34, 42) = 4641s, so the composite pattern
effectively never repeats within a viewing session."""
durations: list[float] = []
for sel, name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
m = re.search(rf"animation: {name}\s+([\d.]+)s", block)
assert m, f"{sel} must run its {name} fade"
durations.append(float(m.group(1)))
assert len(set(durations)) == len(durations), (
"the three spot cycles must be out of phase (distinct durations)"
)
assert all(d >= 20 for d in durations), (
f"each spot fade must be slow (>= 20s), got {durations}"
)
def test_glow_keyframes_low_and_high_opacities() -> None:
"""Each cycle: 0%/100% at its own low opacity (0.25 / 0.20 / 0.15),
50% at 1 — smooth fade in and out, never a hard cut."""
keyframes = _bg_keyframes(_css())
lows = {"bg-glow-a": 0.25, "bg-glow-b": 0.20, "bg-glow-c": 0.15}
for name, low in lows.items():
body = keyframes[name]
m = re.search(r"0%,\s*100%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == low, (
f"{name} must start/end at opacity {low}"
)
m = re.search(r"50%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == 1.0, (f"{name} must peak at opacity 1")
# --------------------------------------------------------------------------
# Layer plumbing — the no-occlusion contract across all four layers
# --------------------------------------------------------------------------
def test_all_four_layers_are_fixed_zminus1_noninteractive() -> None:
"""All four background pseudo-layers stay behind the content and can
never intercept input: fixed, full-viewport, z-index -1,
pointer-events none, with pseudo content (UI Structure Check: layers
behind content, no 360px overflow — the layers are fixed; inset: 0)."""
for sel in ALL_LAYERS:
block = _rule_block(_css(), sel)
assert "position: fixed" in block, f"{sel} must stay position:fixed"
assert "inset: 0" in block, f"{sel} must stay full-viewport (inset: 0)"
assert "z-index: -1" in block, f"{sel} must stay z-index:-1"
assert "pointer-events: none" in block, f"{sel} must stay click-through"
assert 'content: ""' in block, f"{sel} must keep its pseudo content"
def test_html_owns_canvas_and_body_stays_transparent() -> None:
"""The no-occlusion contract: <html> keeps the var(--bg) canvas;
<body> stays transparent and non-stacking — or the z-index:-1 layers
(including the new html::before / html::after spots) would be painted
over."""
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"
)
for prop in ("z-index", "transform", "opacity", "filter"):
assert prop + ":" not in body_block, (
f"body must not create a stacking context (found {prop})"
)
# --------------------------------------------------------------------------
# Reduced motion + phase-08 anchors (no filter, no blur, zero JS)
# --------------------------------------------------------------------------
def test_reduced_motion_stills_all_four_layers() -> None:
"""prefers-reduced-motion: reduce must still ALL FOUR layers together
(body::before, body::after, html::before, html::after) with
animation: none — the typing/spinner/thinking blocks are untouched."""
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}", _css()
)
assert any(
all(sel in b for sel in ALL_LAYERS) and "animation: none" in b
for b in blocks
), "a reduced-motion block must still all four background layers"
def test_no_filter_in_any_layer_and_no_blur_anywhere() -> None:
"""The phase-08 performance anchor: no `filter` in any background
layer block, and no `blur` anywhere in styles.css (comments
stripped)."""
for sel in ALL_LAYERS:
assert "filter" not in _rule_block(_css(), sel), (
f"{sel} must not use any filter"
)
assert "blur" not in _css_no_comments(), (
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
)