feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
This commit is contained in:
@@ -1,119 +1,77 @@
|
||||
"""Phase 25 E2E (Playwright): the background no longer moves — it only fades.
|
||||
"""Phase 78 E2E (Playwright): the background is fully static.
|
||||
|
||||
Story: ``.agents/user_stories/background-no-motion.md`` (supersedes
|
||||
``background-animation.md``)
|
||||
Source: ``TODO.md`` L4 — owner direction: "Remove the animated css
|
||||
background, it's too resource intensive" (supersedes the phase-25
|
||||
fading-glow contract of the ``background-no-motion`` story; the
|
||||
superseded chain is 08 → 25 → 78).
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov
|
||||
|
||||
Owner report (2026-08-25, chat): the phase-22 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 three opacity-fading glow spots (``body::after`` / ``html::before``
|
||||
/ ``html::after``), their glow keyframes, and the
|
||||
``prefers-reduced-motion`` rule that stilled them are deleted from
|
||||
``styles.css`` — the infinite CSS animations ran continuously on every
|
||||
page, in every tab. The 44px grid texture (``body::before``) STAYS: it
|
||||
is static (zero animation cost). All other UI animations (typing dots,
|
||||
spinner, toasts, nav slide, …) are untouched.
|
||||
|
||||
The two root causes (phase-22 measurements in
|
||||
``.agents/reports/22_background_animation/``):
|
||||
- the "jitter" was the grid's 44px/60s drift (0.73px/s, diagonally
|
||||
down-right) — a 1px line translated sub-pixel by sub-pixel
|
||||
rasterizes with per-frame stepping, not smooth motion;
|
||||
- the "blink" was the whole-layer 14s opacity 0.85↔1 + scale(1)↔
|
||||
scale(1.05) pulse — one synchronized pulse reads as blinking.
|
||||
|
||||
The fix (styles.css, pure CSS, zero JS — A11): the grid (``body::before``)
|
||||
is a STATIC texture (no animation, ``bg-grid-drift`` deleted), and three
|
||||
independent soft glow spots each run their own SLOW opacity-only fade —
|
||||
``body::after`` (phase-08 indigo) runs ``bg-glow-a`` 26s,
|
||||
``html::before`` (phase-08 cyan) runs ``bg-glow-b`` 34s with −12s delay,
|
||||
``html::after`` (a third indigo) runs ``bg-glow-c`` 42s with −23s delay.
|
||||
The out-of-phase 26/34/42s cycles (LCM 4641s) make the total light
|
||||
fluxuate smoothly and irregularly — no blink, no jitter, no movement.
|
||||
|
||||
This suite proves the *behavior* the unit source pins only describe, in
|
||||
a real Chromium viewport: the grid is static, the three spots run
|
||||
distinct opacity-only fades whose timelines advance and whose light
|
||||
measurably changes, no ``bg-*`` keyframe animates anything but
|
||||
``opacity`` (the deterministic no-movement proof), all four layers keep
|
||||
the fixed/z-index −1/pointer-events-none/no-occlusion contract,
|
||||
reduced motion stills all four, and there is no 360px overflow.
|
||||
This suite proves the *behavior* the unit source pins
|
||||
(``tests/unit/test_background_no_motion.py``) only describe, in a real
|
||||
Chromium viewport: the three glow pseudo-elements report no
|
||||
background-image, ``animation-name: none``, and no box at all (the
|
||||
rules are gone), no ``bg-*`` keyframes or running ``bg-*`` animations
|
||||
exist anywhere, the grid is still painted and static, the
|
||||
no-occlusion canvas contract survives (``<html>`` owns ``var(--bg)``),
|
||||
reduced motion changes nothing (already static), and there is no
|
||||
360px overflow.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
|
||||
1. ``test_grid_layer_is_static`` — computed ``animationName`` of
|
||||
``body::before`` is ``"none"``; no ``bg-grid-drift`` entry in
|
||||
``document.getAnimations()``; the grid ``backgroundImage`` is still
|
||||
present (the static texture survives).
|
||||
2. ``test_three_glow_layers_run_distinct_fades`` — ``body::after`` →
|
||||
``bg-glow-a`` (26s), ``documentElement::before`` → ``bg-glow-b``
|
||||
(34s), ``documentElement::after`` → ``bg-glow-c`` (42s); each
|
||||
``ease-in-out`` + ``infinite`` with a matching
|
||||
``playState === "running"`` entry in the document animation list;
|
||||
the three durations are pairwise distinct.
|
||||
3. ``test_no_motion_properties_in_background_keyframes`` — walks
|
||||
``document.styleSheets``; across every frame of every ``bg-*``
|
||||
``KEYFRAMES_RULE`` the set of declared property names is exactly
|
||||
``{"opacity"}`` — no ``transform``/``background-position`` anywhere
|
||||
(the deterministic no-movement proof).
|
||||
4. ``test_glow_timelines_advance`` — poll until all three timelines
|
||||
report ``currentTime > 0`` (headless Chromium starts the document
|
||||
timeline ~1s after load), sample, wait ~500ms, each advanced
|
||||
≥ 200ms.
|
||||
5. ``test_background_light_actually_changes`` — (a) the computed
|
||||
``opacity`` of ``body::after`` changes by ≥ 0.05 within ~8s (a real
|
||||
fade, not a frozen frame); (b) two clipped screenshots ~4s apart of
|
||||
the bottom-left glow region (the ``html::after`` spot at 14%/86%)
|
||||
differ in bytes — the light visibly changes while nothing moves
|
||||
(a fresh ``/`` page has no other animation, so the diff is the
|
||||
background's).
|
||||
6. ``test_background_layers_contracts`` — all four pseudo-layers:
|
||||
``position: fixed``, ``z-index: -1``, ``pointer-events: none``,
|
||||
top/right/bottom/left all ``0px``; the ``documentElement`` computed
|
||||
background is ``rgb(15, 10, 10)`` (``var(--bg)`` — the canvas stays
|
||||
on ``html``); ``document.body`` computed background is
|
||||
``rgba(0, 0, 0, 0)`` (no occlusion).
|
||||
7. ``test_reduced_motion_stills_all_layers`` —
|
||||
``reduced_motion="reduce"`` context: all four pseudo-layers report
|
||||
computed ``animationName`` ``"none"`` and still carry a
|
||||
``backgroundImage`` (the static background remains visible).
|
||||
8. ``test_no_horizontal_overflow_with_layers`` — 360px viewport:
|
||||
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin).
|
||||
|
||||
Chromium notes (verified on Chromium 151, kept from the phase-22 suite):
|
||||
pseudo-element CSS animations are enumerated by
|
||||
``document.getAnimations()``, NOT by
|
||||
``document.body.getAnimations()`` (the element-level list is empty for
|
||||
pseudo-layers), so the running/advancing checks match on
|
||||
``animationName`` in the document-level list. And the computed styles
|
||||
of the two new ``html`` pseudo-layers come from
|
||||
``getComputedStyle(document.documentElement, "::before")`` /
|
||||
``("::after")`` — ``document.body`` only carries the two
|
||||
``body`` pseudo-layers.
|
||||
1. ``test_background_layers_computed_styles`` — computed styles:
|
||||
``body::before`` keeps the grid (``backgroundImage`` non-empty,
|
||||
``animationName: none``); ``body::after`` / ``html::before`` /
|
||||
``html::after`` report ``backgroundImage: none`` +
|
||||
``animationName: none`` + ``position: static`` + ``content: none``
|
||||
(the pseudo-elements have no box — the rules are deleted, not just
|
||||
stilled); the page canvas stays on ``<html>`` (``rgb(15, 10, 10)`` =
|
||||
``var(--bg)``) and ``<body>`` stays transparent (no occlusion).
|
||||
2. ``test_no_background_keyframes_or_animations`` — deterministic
|
||||
static proof: no ``@keyframes`` rule with a ``bg-`` name in any
|
||||
live stylesheet, and no running ``bg-*`` entry in
|
||||
``document.getAnimations()`` (pseudo-element CSS animations are
|
||||
enumerated by the document-level list, not the element-level one —
|
||||
verified on Chromium 151).
|
||||
3. ``test_grid_layer_is_fixed_behind_content`` — the surviving grid
|
||||
layer keeps the UI-structure contract: ``position: fixed``,
|
||||
``z-index: -1``, ``pointer-events: none``, ``inset: 0`` (behind
|
||||
content, click-through, full-viewport).
|
||||
4. ``test_reduced_motion_background_static`` — a
|
||||
``reduced_motion="reduce"`` context: the grid is still painted and
|
||||
static, the deleted glow layers still report no image/animation —
|
||||
nothing is resurrected under reduced motion.
|
||||
5. ``test_no_horizontal_overflow_with_layers`` — 360px viewport:
|
||||
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin —
|
||||
the background adds no width).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from playwright.sync_api import Browser, Page
|
||||
|
||||
from playwright.sync_api import Browser, FloatRect, Page
|
||||
|
||||
GLOW_A = "bg-glow-a" # body::after — phase-08 indigo spot, 26s
|
||||
GLOW_B = "bg-glow-b" # html::before — phase-08 cyan spot, 34s, -12s delay
|
||||
GLOW_C = "bg-glow-c" # html::after — third indigo spot, 42s, -23s delay
|
||||
GLOWS = (GLOW_A, GLOW_B, GLOW_C) # the three spot keyframe names
|
||||
EASE_IN_OUT = "ease-in-out" # computed animationTimingFunction for ease-in-out
|
||||
PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
|
||||
|
||||
# Computed styles of all four pseudo-layers + the html/body background
|
||||
# contract (single evaluate — one round-trip per test).
|
||||
# Computed styles of the surviving grid layer + the three deleted glow
|
||||
# pseudo-layers + the html/body background contract (single evaluate —
|
||||
# one round-trip per test).
|
||||
JS_LAYER_REPORT = """() => {
|
||||
const pick = (el, pseudo) => {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
return {
|
||||
anim: cs.animationName,
|
||||
duration: cs.animationDuration,
|
||||
timing: cs.animationTimingFunction,
|
||||
iterations: cs.animationIterationCount,
|
||||
position: cs.position,
|
||||
zIndex: cs.zIndex,
|
||||
pointerEvents: cs.pointerEvents,
|
||||
content: cs.content,
|
||||
edges: [cs.top, cs.right, cs.bottom, cs.left],
|
||||
image: cs.backgroundImage,
|
||||
};
|
||||
@@ -128,24 +86,12 @@ JS_LAYER_REPORT = """() => {
|
||||
};
|
||||
}"""
|
||||
|
||||
# The three background-layer animations from the Web Animations API
|
||||
# ({name, playState, currentTime}); the keyframe names are passed as one
|
||||
# array argument (Playwright serializes the Python list to a JS array).
|
||||
JS_TIMELINE = """(names) => document.getAnimations()
|
||||
.filter((a) => names.includes(a.animationName))
|
||||
.map((a) => ({
|
||||
name: a.animationName,
|
||||
playState: a.playState,
|
||||
t: a.currentTime,
|
||||
}))"""
|
||||
|
||||
# Deterministic no-movement audit: walk every same-origin stylesheet and
|
||||
# collect, for each @keyframes bg-* rule, the property names declared in
|
||||
# every keyframe frame. Returns {names: [...], props: [...]} — props must
|
||||
# be exactly ["opacity"].
|
||||
JS_KEYFRAME_PROPS = """() => {
|
||||
const names = [];
|
||||
const props = new Set();
|
||||
# Deterministic static audit: walk every same-origin stylesheet and
|
||||
# collect the names of @keyframes rules starting with "bg-" (must be
|
||||
# empty), plus the animationNames running in document.getAnimations()
|
||||
# that start with "bg-" (must be empty).
|
||||
JS_BG_ANIMATION_AUDIT = """() => {
|
||||
const keyframeNames = [];
|
||||
for (const sheet of document.styleSheets) {
|
||||
let rules;
|
||||
try {
|
||||
@@ -155,236 +101,114 @@ JS_KEYFRAME_PROPS = """() => {
|
||||
}
|
||||
for (const rule of rules) {
|
||||
if (rule.type === CSSRule.KEYFRAMES_RULE && rule.name.startsWith("bg-")) {
|
||||
names.push(rule.name);
|
||||
for (const frame of rule.cssRules) {
|
||||
for (const p of frame.style) {
|
||||
props.add(p);
|
||||
}
|
||||
}
|
||||
keyframeNames.push(rule.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { names, props: [...props] };
|
||||
const running = document.getAnimations()
|
||||
.map((a) => a.animationName)
|
||||
.filter((n) => n && n.startsWith("bg-"));
|
||||
return { keyframeNames, running };
|
||||
}"""
|
||||
|
||||
|
||||
def _seconds(value: str) -> float:
|
||||
"""Chromium reports animation durations as "26s" — parse as seconds."""
|
||||
return float(str(value).replace("s", ""))
|
||||
|
||||
|
||||
def _timeline(page: Page) -> dict[str, float]:
|
||||
"""animationName → currentTime (ms) for the three glow layers."""
|
||||
entries = page.evaluate(JS_TIMELINE, list(GLOWS))
|
||||
return {str(a["name"]): float(a["t"]) for a in entries}
|
||||
|
||||
|
||||
def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None:
|
||||
"""Poll until all three glow timelines report currentTime > 0.
|
||||
|
||||
Headless Chromium starts the document animation timeline shortly
|
||||
after load (observed ≈1s after navigation) — until then currentTime
|
||||
is 0, so the "did it advance?" sample in
|
||||
``test_glow_timelines_advance`` must start once the timeline is
|
||||
alive.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_ms / 1000
|
||||
while time.monotonic() < deadline:
|
||||
times = _timeline(page)
|
||||
if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS):
|
||||
return
|
||||
page.wait_for_timeout(100)
|
||||
raise AssertionError(
|
||||
f"glow animation timelines never started (saw {_timeline(page)!r})"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tests (story → test mapping, see module docstring)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC1: no movement — the grid layer (body::before) runs NO animation
|
||||
in a real viewport (the phase-22 0.73px/s drift read as a
|
||||
once-per-second down-right jitter), and its static texture is still
|
||||
painted (the grid stays — the owner rejected its motion, not the
|
||||
grid)."""
|
||||
def test_background_layers_computed_styles(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC1: the animated background no longer exists in a real viewport —
|
||||
the three glow pseudo-elements report no background-image and
|
||||
``animation-name: none``, and they have no box at all
|
||||
(``position: static`` / ``content: none`` — the CSS rules are
|
||||
deleted, not merely stilled). The static grid (``body::before``)
|
||||
stays: its texture is still painted and it carries no animation.
|
||||
The no-occlusion contract survives: the canvas is on ``<html>``,
|
||||
``<body>`` transparent."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
|
||||
grid = report["grid"]
|
||||
assert grid["anim"] == "none", (
|
||||
f"body::before must be static (animationName, got {grid['anim']!r})"
|
||||
)
|
||||
live = page.evaluate(JS_TIMELINE, ["bg-grid-drift"])
|
||||
assert not live, (
|
||||
f"no bg-grid-drift animation may exist (got {live!r}) — the drift is deleted"
|
||||
)
|
||||
assert grid["image"] != "none", (
|
||||
f"the static grid texture must still be painted (image {grid['image']!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_three_glow_layers_run_distinct_fades(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC2: three different bright spots, each on its own slow fade —
|
||||
body::after runs bg-glow-a (26s), the two html pseudo-layers run
|
||||
bg-glow-b (34s) and bg-glow-c (42s); every one is ease-in-out,
|
||||
infinite, reported ``running`` in the document animation list, and
|
||||
the three durations are pairwise distinct (out of phase)."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
recipe = (
|
||||
("glowA", GLOW_A, "26s"),
|
||||
("glowB", GLOW_B, "34s"),
|
||||
("glowC", GLOW_C, "42s"),
|
||||
assert grid["anim"] == "none", (
|
||||
f"body::before must stay static (animationName, got {grid['anim']!r})"
|
||||
)
|
||||
durations: list[float] = []
|
||||
for key, name, expected in recipe:
|
||||
|
||||
for key in ("glowA", "glowB", "glowC"):
|
||||
info = report[key]
|
||||
assert info["anim"] == name, (
|
||||
f"{key} must run {name} (got {info['anim']!r})"
|
||||
assert info["image"] == "none", (
|
||||
f"{key}: the deleted glow layer must carry no background-image "
|
||||
f"(got {info['image']!r})"
|
||||
)
|
||||
assert info["duration"] == expected, (
|
||||
f"{key} must run a {expected} cycle (got {info['duration']!r})"
|
||||
assert info["anim"] == "none", (
|
||||
f"{key}: the deleted glow layer must not animate "
|
||||
f"(got {info['anim']!r})"
|
||||
)
|
||||
assert info["timing"] == EASE_IN_OUT, (
|
||||
f"{key} must ease in-out (got {info['timing']!r})"
|
||||
)
|
||||
assert info["iterations"] == "infinite", (
|
||||
f"{key} must fade forever (got {info['iterations']!r})"
|
||||
)
|
||||
durations.append(_seconds(info["duration"]))
|
||||
assert len(set(durations)) == 3, (
|
||||
f"the three spot cycles must be out of phase (got {durations})"
|
||||
)
|
||||
live = page.evaluate(JS_TIMELINE, list(GLOWS))
|
||||
for name in GLOWS:
|
||||
match = [a for a in live if a["name"] == name]
|
||||
assert match, f"no {name} entry in document.getAnimations() — spot not fading"
|
||||
assert match[0]["playState"] == "running", (
|
||||
f"{name} is {match[0]['playState']!r} — the spot fade must be running"
|
||||
assert info["content"] == "none" and info["position"] == "static", (
|
||||
f"{key}: the glow pseudo-element must have no box "
|
||||
f"(content {info['content']!r}, position {info['position']!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_no_motion_properties_in_background_keyframes(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC1: the deterministic no-movement proof — walk the live stylesheets
|
||||
in Chromium: across every frame of every ``bg-*`` @keyframes rule the
|
||||
set of declared properties is exactly {opacity}. No transform, no
|
||||
background-position, nothing that can move a pixel."""
|
||||
page.goto(app_url)
|
||||
audit = page.evaluate(JS_KEYFRAME_PROPS)
|
||||
names = set(str(n) for n in audit["names"])
|
||||
assert names == {GLOW_A, GLOW_B, GLOW_C}, (
|
||||
f"expected exactly the three spot keyframes {sorted({GLOW_A, GLOW_B, GLOW_C})}, "
|
||||
f"found {sorted(names)}"
|
||||
)
|
||||
props = {str(p) for p in audit["props"]}
|
||||
assert props == {"opacity"}, (
|
||||
f"bg-* keyframes may only animate opacity (found {sorted(props)}) — "
|
||||
"the background must not move"
|
||||
)
|
||||
|
||||
|
||||
def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC2: all three spot timelines actually advance — the background is
|
||||
a live animation, not a static (or paused) frame. Sample
|
||||
currentTime, wait ~500ms, and require real progress on all three
|
||||
layers (the document animation timeline in headless Chromium starts
|
||||
~1s after load, so poll until it is alive first)."""
|
||||
page.goto(app_url)
|
||||
_wait_timelines_alive(page)
|
||||
before = _timeline(page)
|
||||
page.wait_for_timeout(500)
|
||||
after = _timeline(page)
|
||||
for name in GLOWS:
|
||||
delta = after[name] - before[name]
|
||||
assert delta >= 200, (
|
||||
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
|
||||
"— paused or static?"
|
||||
)
|
||||
|
||||
|
||||
def test_background_light_actually_changes(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC2/AC3: the light fluxuates — (a) the computed opacity of
|
||||
body::after measurably changes within a few seconds (a real fade,
|
||||
not a frozen frame), and (b) a clipped screenshot of the bottom-left
|
||||
glow region (the html::after spot at 14%/86% of the 1280×800
|
||||
viewport) differs in bytes ~4s later — the light visibly changes
|
||||
while nothing moves (a fresh / page runs no other animation, so the
|
||||
pixel diff is the background's)."""
|
||||
page.goto(app_url)
|
||||
|
||||
# (a) computed opacity of the indigo spot fades by >= 0.05 within ~8s.
|
||||
def _spot_opacity() -> float:
|
||||
return float(
|
||||
page.evaluate("() => getComputedStyle(document.body, '::after').opacity")
|
||||
)
|
||||
|
||||
t0 = _spot_opacity()
|
||||
deadline = time.monotonic() + 8.0
|
||||
delta = 0.0
|
||||
while time.monotonic() < deadline:
|
||||
delta = abs(_spot_opacity() - t0)
|
||||
if delta >= 0.05:
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
assert delta >= 0.05, (
|
||||
f"body::after opacity did not fade (Δ={delta:.3f} < 0.05 over 8s) — "
|
||||
"frozen frame?"
|
||||
)
|
||||
|
||||
# (b) the rendered glow region changes over ~4s (bottom-left spot at
|
||||
# 14%/86% ≈ (179px, 688px) in the 1280×800 viewport).
|
||||
clip: FloatRect = {"x": 0, "y": 500, "width": 500, "height": 300}
|
||||
shot_1 = page.screenshot(clip=clip)
|
||||
page.wait_for_timeout(4000)
|
||||
shot_2 = page.screenshot(clip=clip)
|
||||
assert shot_1 != shot_2, (
|
||||
"the clipped bottom-left glow region is byte-identical 4s apart — "
|
||||
"the light must visibly change even though nothing moves"
|
||||
)
|
||||
|
||||
|
||||
def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC4: UI Structure Check — all four background pseudo-layers (body
|
||||
::before/::after + the two new html ::before/::after spots) stay
|
||||
behind content (fixed, z-index -1, pointer-events none, full-viewport)
|
||||
and nothing occludes them: the page canvas stays on <html>, <body>
|
||||
stays transparent."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
for key in ("grid", "glowA", "glowB", "glowC"):
|
||||
info = report[key]
|
||||
assert info["position"] == "fixed", f"{key} must stay position:fixed"
|
||||
assert info["zIndex"] == "-1", (
|
||||
f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})"
|
||||
)
|
||||
assert info["pointerEvents"] == "none", (
|
||||
f"{key} must stay click-through (pointer-events none)"
|
||||
)
|
||||
assert info["edges"] == ["0px", "0px", "0px", "0px"], (
|
||||
f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})"
|
||||
)
|
||||
assert report["htmlBg"] == PAGE_BG, (
|
||||
f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})"
|
||||
)
|
||||
assert report["bodyBg"] == "rgba(0, 0, 0, 0)", (
|
||||
f"body must stay transparent so the layers show (got {report['bodyBg']!r})"
|
||||
f"body must stay transparent so the grid shows (got {report['bodyBg']!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_reduced_motion_stills_all_layers(
|
||||
def test_no_background_keyframes_or_animations(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC1: the deterministic static proof — no ``@keyframes`` rule in
|
||||
the background keyframe namespace (the ``bg-`` prefix) exists in any
|
||||
live stylesheet, and no background-namespaced animation is running
|
||||
in ``document.getAnimations()`` (the owner removed the infinite CSS
|
||||
animations entirely — they ran on every page, in every tab)."""
|
||||
page.goto(app_url)
|
||||
audit = page.evaluate(JS_BG_ANIMATION_AUDIT)
|
||||
assert not audit["keyframeNames"], (
|
||||
f"no bg-* @keyframes may remain (found {audit['keyframeNames']!r})"
|
||||
)
|
||||
assert not audit["running"], (
|
||||
f"no bg-* animation may be running (found {audit['running']!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_grid_layer_is_fixed_behind_content(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC2: UI Structure Check — the surviving grid layer stays behind
|
||||
the content and can never intercept input: ``position: fixed``,
|
||||
``z-index: -1``, ``pointer-events: none``, full-viewport
|
||||
(``inset: 0``)."""
|
||||
page.goto(app_url)
|
||||
grid = page.evaluate(JS_LAYER_REPORT)["grid"]
|
||||
assert grid["position"] == "fixed", "body::before must stay position:fixed"
|
||||
assert grid["zIndex"] == "-1", (
|
||||
f"the grid must stay behind content (z-index -1, got {grid['zIndex']!r})"
|
||||
)
|
||||
assert grid["pointerEvents"] == "none", (
|
||||
"the grid must stay click-through (pointer-events none)"
|
||||
)
|
||||
assert grid["edges"] == ["0px", "0px", "0px", "0px"], (
|
||||
f"the grid must stay full-viewport (inset: 0, got {grid['edges']!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_reduced_motion_background_static(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC5: with prefers-reduced-motion: reduce ALL FOUR pseudo-layers
|
||||
stop animating (animation-name: none) — the static grid + spot
|
||||
images remain visible."""
|
||||
"""AC3: with ``prefers-reduced-motion: reduce`` the background is
|
||||
already fully static — the grid is still painted and reports
|
||||
``animation-name: none``, and the deleted glow layers still report
|
||||
no image/animation (nothing is resurrected; the reduced-motion
|
||||
blocks that survive in styles.css cover UI animations only)."""
|
||||
context = browser.new_context(
|
||||
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
|
||||
)
|
||||
@@ -392,13 +216,23 @@ def test_reduced_motion_stills_all_layers(
|
||||
rpage = context.new_page()
|
||||
rpage.goto(app_url)
|
||||
report = rpage.evaluate(JS_LAYER_REPORT)
|
||||
for key in ("grid", "glowA", "glowB", "glowC"):
|
||||
grid = report["grid"]
|
||||
assert grid["image"] != "none", (
|
||||
f"the static grid must remain visible under reduced motion "
|
||||
f"(image {grid['image']!r})"
|
||||
)
|
||||
assert grid["anim"] == "none", (
|
||||
f"the grid must stay static under reduced motion (got {grid['anim']!r})"
|
||||
)
|
||||
for key in ("glowA", "glowB", "glowC"):
|
||||
info = report[key]
|
||||
assert info["anim"] == "none", (
|
||||
f"{key} must not animate under reduced motion (got {info['anim']!r})"
|
||||
assert info["image"] == "none", (
|
||||
f"{key}: no glow background may exist under reduced motion "
|
||||
f"(got {info['image']!r})"
|
||||
)
|
||||
assert info["image"] != "none", (
|
||||
f"{key}: the static background image must remain visible"
|
||||
assert info["anim"] == "none", (
|
||||
f"{key}: no glow animation may exist under reduced motion "
|
||||
f"(got {info['anim']!r})"
|
||||
)
|
||||
finally:
|
||||
context.close()
|
||||
@@ -407,9 +241,9 @@ def test_reduced_motion_stills_all_layers(
|
||||
def test_no_horizontal_overflow_with_layers(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC6: the four background layers add no width — the phase-07
|
||||
overflow pin (documentElement.scrollWidth <= clientWidth) still holds
|
||||
at the 360px floor with all four fixed; inset: 0 layers live."""
|
||||
"""AC4: the background adds no width — the phase-07 overflow pin
|
||||
(``documentElement.scrollWidth <= clientWidth``) holds at the 360px
|
||||
floor with the single fixed; inset: 0 grid layer."""
|
||||
phone = browser.new_page(viewport={"width": 360, "height": 740})
|
||||
try:
|
||||
phone.goto(f"{app_url}/")
|
||||
|
||||
Reference in New Issue
Block a user