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