Files
brain-of-reese/tests/unit/test_background_animation.py
T
ducoterra dbf2af26c6 refactor(agents): migrate .agent/ planning tree to .agents/
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.
2026-09-05 10:57:07 -04:00

272 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)"
)