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:
@@ -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)"
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
Reference in New Issue
Block a user