feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

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:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+151 -317
View File
@@ -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}/")