Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""Unit: the phase-25 still-background contract — layer plumbing and the
|
||
phase-08 anchors (source pins).
|
||
|
||
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 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 (26s), html::before
|
||
(34s, -12s delay), html::after (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. The 2026-08-28 rebrand recolored the
|
||
phase-08 indigo/cyan spots to the warm dark-red theme palette
|
||
(rose / orange / red) and the grid lines to the warm line tone —
|
||
structure (sizes, positions, alphas, periods) unchanged.
|
||
|
||
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: .agents/user_stories/background-no-motion.md (supersedes
|
||
.agents/user_stories/background-animation.md).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
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")
|
||
|
||
|
||
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")
|
||
|
||
|
||
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:
|
||
"""All four background layers stay behind the content and can never
|
||
intercept input: fixed, full-viewport, z-index -1, pointer-events
|
||
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"
|
||
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 (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)"
|
||
)
|
||
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 — phase 25: a static texture (the drift is gone)
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
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" 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"
|
||
)
|
||
|
||
|
||
def test_grid_cells_and_line_contrast() -> None:
|
||
"""44px cells with 1px lines at the phase-22 fixed 60% line alpha,
|
||
in the rebrand warm line tone (2026-08-28; the phase-22 indigo
|
||
value rgb(38 48 74 / 0.6) was recolored with the dark-red theme)
|
||
— 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(74 38 38 / 0.6) 1px, transparent 1px)"
|
||
assert line in block, "grid must keep horizontal 1px lines at 60% line alpha"
|
||
assert (
|
||
"linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)"
|
||
in block
|
||
), "grid must keep vertical 1px lines at 60% line alpha"
|
||
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 layers — phase 25: three spots, each on its own slow opacity fade
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
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"
|
||
)
|
||
|
||
|
||
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_use_the_rebrand_warm_palette() -> None:
|
||
"""The three spots keep their radii and positions from the phase-25
|
||
layout (rose top-left on body::after, orange bottom-right on
|
||
html::before, red bottom-left 52rem at 14% 86% on html::after) but
|
||
wear the 2026-08-28 rebrand palette (warm dark-red theme; the
|
||
phase-08 indigo/cyan values are gone). All spots fade to
|
||
transparent at 62%."""
|
||
assert (
|
||
"radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), "
|
||
"transparent 62%)" in _glow_rule(_css())
|
||
)
|
||
assert (
|
||
"radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), "
|
||
"transparent 62%)" in _rule_block(_css(), "html::before")
|
||
)
|
||
assert (
|
||
"radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), "
|
||
"transparent 62%)" in _rule_block(_css(), "html::after")
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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` (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)"
|
||
)
|