"""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: .agent/user_stories/background-no-motion.md (supersedes .agent/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 ; 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)" )