**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix. - Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated. - `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings - `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site) - `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** - Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions. - Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04). - Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
195 lines
8.2 KiB
Python
195 lines
8.2 KiB
Python
"""Unit: the phase-78 static-background contract (source pins).
|
|
|
|
Owner direction (TODO.md L4, recorded per AGENTS.md rule 3): "Remove the
|
|
animated css background, it's too resource intensive" — the three
|
|
opacity-fading glow spots (``body::after`` / ``html::before`` /
|
|
``html::after``), their glow keyframes, and the
|
|
``prefers-reduced-motion`` rule whose only job was stilling those layers
|
|
are deleted from ``styles.css``. The 44px grid texture on
|
|
``body::before`` STAYS — it is static (zero animation cost).
|
|
|
|
Supersedes the phase-25 fading-glow contract (superseded chain
|
|
08 → 25 → 78); the phase-25 unit source-pin suite
|
|
(``tests/unit/test_background_animation.py``) is deleted with its
|
|
premise (three fading glows on named keyframe cycles).
|
|
|
|
Story: n/a (TODO-derived). Browser behavior (no background animation in
|
|
a real viewport, the grid still painted, no occlusion, no overflow) is
|
|
E2E-covered by ``tests/e2e/test_background_no_motion.py`` (task 02).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
STYLES_CSS = (
|
|
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
|
|
)
|
|
|
|
GRID_LAYER = "body::before" # the static 44px grid — STAYS
|
|
# The phase-25 glow layers — all three deleted in phase 78 (they were
|
|
# the ONLY animated part of the background).
|
|
GLOW_LAYERS = ("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 rule
|
|
checks that must not trip on explanatory prose."""
|
|
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
|
|
|
|
|
|
def _find_rule(css: str, selector: str) -> re.Match[str] | None:
|
|
"""The first top-level ``selector { ... }`` rule, or None (the
|
|
deleted glow layers must be ABSENT, so this may not assert)."""
|
|
return re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css)
|
|
|
|
|
|
def _rule_block(css: str, selector: str) -> str:
|
|
"""Body of the first ``selector { ... }`` rule (top-level, no
|
|
nesting)."""
|
|
rule = _find_rule(css, selector)
|
|
assert rule, f"styles.css must define a {selector} rule"
|
|
return rule.group(1)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The animated part is gone — no glow layers, no glow keyframes
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_no_bg_glow_keyframes_remain() -> None:
|
|
"""The three glow @keyframes blocks are deleted — the names must not
|
|
appear anywhere in the file (no declaration, no keyframe block, no
|
|
stale comment). Pinned by the ``bg-`` prefix: no token carrying the
|
|
background keyframe namespace may survive (the prefix is a plain
|
|
literal, so this pin itself carries none of the deleted names)."""
|
|
assert "bg-" not in _css(), (
|
|
"no token in the background keyframe namespace (bg-*) may remain "
|
|
"in styles.css — the glow keyframes and their declarations are deleted"
|
|
)
|
|
assert re.search(r"@keyframes bg-", _css_no_comments()) is None, (
|
|
"no background @keyframes may remain in styles.css"
|
|
)
|
|
|
|
|
|
def test_glow_layers_are_deleted() -> None:
|
|
"""body::after / html::before / html::after no longer exist as CSS
|
|
rules — the layers (their background-images, their animations, their
|
|
fixed/z-index:-1 boxes) are gone from the page entirely."""
|
|
rules = _css_no_comments()
|
|
for sel in GLOW_LAYERS:
|
|
assert _find_rule(rules, sel) is None, (
|
|
f"{sel} must be deleted (the phase-78 static contract)"
|
|
)
|
|
|
|
|
|
def test_no_background_layer_declares_animation() -> None:
|
|
"""None of the four background pseudo-element selectors carries an
|
|
``animation:`` declaration — the three glow selectors are ABSENT,
|
|
and the surviving grid layer is animation-free."""
|
|
rules = _css_no_comments()
|
|
for sel in (GRID_LAYER, *GLOW_LAYERS):
|
|
rule = _find_rule(rules, sel)
|
|
if rule is None:
|
|
continue # deleted layer — nothing to animate
|
|
assert "animation" not in rule.group(0), (
|
|
f"{sel} must not animate (static background contract)"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The static grid STAYS — byte-identical texture, no animation
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_grid_layer_is_static_and_unchanged() -> None:
|
|
"""The owner removed the animated part, not the grid: body::before
|
|
keeps 44px cells, 1px lines at 60% of the grid line color, and the
|
|
widened radial mask (both the -webkit- and standard mask
|
|
properties) — and carries NO animation. Phase 92 (task 02): the
|
|
line color is the 9th identity variable --grid-line at 60% (the
|
|
built-in #4a2626 reproduces the old warm tone exactly — and the
|
|
tab's Grid lines picker now repaints this texture)."""
|
|
block = _rule_block(_css(), GRID_LAYER)
|
|
grid = "color-mix(in srgb, var(--grid-line) 60%, transparent)"
|
|
assert "background-size: 44px 44px" in block
|
|
assert f"linear-gradient(to right, {grid} 1px, transparent 1px)" in block, (
|
|
"grid must keep horizontal 1px lines at 60% --grid-line"
|
|
)
|
|
assert f"linear-gradient(to bottom, {grid} 1px, transparent 1px)" in block, (
|
|
"grid must keep vertical 1px lines at 60% --grid-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
|
|
assert "animation" not in block, "the grid must stay static (no animation)"
|
|
|
|
|
|
def test_grid_layer_plumbing() -> None:
|
|
"""The surviving grid layer stays behind the content and can never
|
|
intercept input: fixed, full-viewport, z-index -1, pointer-events
|
|
none, with pseudo content (UI Structure Check: the fixed; inset: 0
|
|
layer adds no width — no 360px overflow)."""
|
|
block = _rule_block(_css(), GRID_LAYER)
|
|
assert "position: fixed" in block
|
|
assert "inset: 0" in block
|
|
assert "z-index: -1" in block
|
|
assert "pointer-events: none" in block
|
|
assert 'content: ""' in block
|
|
|
|
|
|
def test_html_owns_canvas_and_body_stays_transparent() -> None:
|
|
"""The no-occlusion contract survives the deletion: <html> keeps the
|
|
var(--bg) canvas; <body> stays transparent and non-stacking — or the
|
|
z-index:-1 grid layer 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 grid shows"
|
|
)
|
|
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
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_background_reduced_motion_block_is_deleted() -> None:
|
|
"""The prefers-reduced-motion rule whose only job was stilling the
|
|
background layers (body::before, body::after, html::before,
|
|
html::after → animation: none) is deleted WITH the layers. The
|
|
unrelated reduced-motion blocks (typing dots, spinner, toasts, nav
|
|
slide, …) stay untouched."""
|
|
blocks = re.findall(
|
|
r"@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}", _css()
|
|
)
|
|
assert blocks, "the unrelated reduced-motion blocks must survive"
|
|
for i, block in enumerate(blocks):
|
|
for sel in (GRID_LAYER, *GLOW_LAYERS):
|
|
assert sel not in block, (
|
|
f"reduced-motion block {i} still references background layer {sel}"
|
|
)
|
|
|
|
|
|
def test_no_filter_no_blur() -> None:
|
|
"""The phase-08 performance anchor survives the deletion: no
|
|
``filter`` in the grid rule and no ``blur`` anywhere in styles.css
|
|
(comments stripped)."""
|
|
assert "filter" not in _rule_block(_css(), GRID_LAYER), (
|
|
"the grid layer must not use any filter"
|
|
)
|
|
assert "blur" not in _css_no_comments(), (
|
|
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
|
|
)
|