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
+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)"