fix(ui): background no longer moves — static grid, three glow spots fading in and out on their own slow cycles (owner 2026-08-25)

This commit is contained in:
2026-08-25 09:43:19 -04:00
parent 1e6ae360e0
commit 025f57beb5
14 changed files with 1454 additions and 223 deletions
@@ -0,0 +1,128 @@
# Task 01 — Still Background: Static Grid + Three Opacity-Only Glow Fades
**Phase:** `25_background_no_motion` · **Story:** `.agent/user_stories/background-no-motion.md`
## Objective
Redesign the background block of `frontend/assets/styles.css` to the
owner's spec (no movement; different bright spots slowly fading in and
out), and pin the new contract at source level.
## Work
0. **Capture the "before" evidence FIRST** (pre-change): with the DB up
(`podman compose up -d db`), boot the app the same way
`tests/e2e/conftest.py`'s `app_server` fixture does (uvicorn
`app.main:app` on a free port, wait for it to answer), and with a small
throwaway Playwright script screenshot the `/` page at 1280×800 →
`.agent/screenshots/25_background_no_motion/before.png`, then again
~4s later → `before_4s.png` (the pair shows the down-right jitter +
the uniform brightness pulse). Kill the server when done.
1. `frontend/assets/styles.css` — **grid layer `body::before`:** remove
the `animation: bg-grid-drift 60s linear infinite;` declaration and
delete the whole `@keyframes bg-grid-drift { … }` block. Keep the 44px
cells, the 60% `--line` alpha 1px lines, and the widened radial mask —
the grid remains as a **static texture**. Update the block's comment:
drift removed (owner 2026-08-25) — the 0.73px/s sub-pixel drift
rasterizes as a once-per-second down-right jitter; the owner wants no
movement.
2. `frontend/assets/styles.css` — **glow layers.** Replace the
whole-layer breathe with three independent spot layers:
- `body::after` — keep the phase-08 indigo spot exactly:
`background-image: radial-gradient(circle 56rem at 12% 8%,
rgb(109 120 242 / 0.14), transparent 62%);` and
`animation: bg-glow-a 26s ease-in-out infinite;`
- **new `html::before`** — the phase-08 cyan spot:
`background-image: radial-gradient(circle 60rem at 88% 92%,
rgb(34 211 238 / 0.10), transparent 62%);` and
`animation: bg-glow-b 34s ease-in-out -12s infinite;`
- **new `html::after`** — a third indigo spot:
`background-image: radial-gradient(circle 52rem at 14% 86%,
rgb(109 120 242 / 0.09), transparent 62%);` and
`animation: bg-glow-c 42s ease-in-out -23s infinite;`
- All three layers (and the grid layer) must declare:
`content: ""; position: fixed; inset: 0; z-index: -1;
pointer-events: none;` — no `transform`, no `background-position`,
no `filter` on any of them.
- Delete `@keyframes bg-glow-breathe { … }` and add the three
**opacity-only** keyframe blocks (nothing else may appear in any
`bg-*` keyframe):
```css
@keyframes bg-glow-a { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
@keyframes bg-glow-b { 0%, 100% { opacity: 0.20; } 50% { opacity: 1; } }
@keyframes bg-glow-c { 0%, 100% { opacity: 0.15; } 50% { opacity: 1; } }
```
3. `frontend/assets/styles.css` — **reduced motion:** in the existing
`@media (prefers-reduced-motion: reduce)` block that stills the
background (currently `body::before, body::after { animation: none; }`,
right after the spinner block), extend the selector list to all four
layers: `body::before, body::after, html::before, html::after
{ animation: none; }`. Do not touch the typing/spinner/thinking
reduced-motion blocks.
4. `frontend/assets/styles.css` — **section comment:** rewrite the
"Animated background" comment to document the new spec: no movement
(owner 2026-08-25); three independent soft spots, opacity-only fades
at 26/34/42s with negative delays → out of phase, so the total light
fluxuates smoothly and irregularly; `html::before`/`html::after` are
background layers (root stacking context: they paint above the
`var(--bg)` canvas and below the transparent, non-stacking `<body>`'s
content — the no-occlusion contract is unchanged).
5. **New `tests/unit/test_background_no_motion.py`** (repo source-pin
pattern — reuse the helpers from `tests/unit/test_background_animation.py`
(`_css`, `_css_no_comments`, `_rule_block`; note `_rule_block` matches
top-level `selector { … }`, which works for `html::before`/`html::after`
as written in step 2). Pin, at minimum:
- `body::before` carries **no `animation`** declaration;
`@keyframes bg-grid-drift` is absent from the file; the grid keeps
its static texture (`background-size: 44px 44px`, the two 60%-alpha
1px line gradients, the widened mask, both mask properties).
- `body::after` runs `bg-glow-a 26s ease-in-out infinite`;
`html::before` runs `bg-glow-b 34s ease-in-out -12s infinite`;
`html::after` runs `bg-glow-c 42s ease-in-out -23s infinite`;
each glow layer's `background-image` is exactly the single radial
gradient from step 2 (color, radius, position, 62% transparent stop).
- **No movement:** parse every `@keyframes bg-*` rule in the file —
the set of declared property names across all keyframe frames is
exactly `{opacity}` (no `transform`, `scale`, `background-position`,
…); none of the three glow layers declares `transform` or
`background-position`.
- The three glow durations are distinct and each ≥ 20s (slow).
- All four pseudo-layers: `position: fixed`, `inset: 0`,
`z-index: -1`, `pointer-events: none`, `content: ""`.
- Plumbing: `html` keeps `background: var(--bg)`; `body` keeps
`background: transparent` and declares none of `z-index`,
`transform`, `opacity`, `filter`.
- The reduced-motion block stills all four layers (all four selectors
present together with `animation: none`).
- No `filter` in any background layer block; no `blur` anywhere in
the file (comments stripped).
6. **Adapt `tests/unit/test_background_animation.py`** (the phase-22
source pins) to the new contract so the whole unit suite is green:
replace the grid-drift and breathe tests with their new-contract
equivalents (where a check is already covered by the new file, keep the
file self-contained rather than importing from it — the repo pattern is
local pins); keep the generic layer-plumbing tests
(`test_both_layers_are_fixed_zminus1_noninteractive` — extend it to
cover `html::before`/`html::after` — and
`test_html_owns_bg_and_body_stays_transparent`) and the no-blur/no-JS
anchor test; update the module docstring to describe the phase-25
design and point at `.agent/user_stories/background-no-motion.md`.
## Testing & Quality
- `uv run pytest tests/unit -v` green (new + adapted pins).
- `uv run pytest --cov=app --cov-report=term-missing` TOTAL ≥ the
pre-change number (`app/` is untouched — the >90% gate holds).
- `uv run ruff check . && uv run pyright` clean.
- Coverage **>90%** on new/modified code: the functional change is CSS;
the new/modified Python is pytest source pins, exercised in full.
## Completion Criteria
- [ ] `styles.css`: no `bg-grid-drift`, no `bg-glow-breathe`, no
`animation` on `body::before`; exactly three opacity-only
`bg-glow-a/b/c` cycles on `body::after`, `html::before`,
`html::after` (26s/34s/42s, delays 0/−12s/−23s); reduced-motion
stills all four layers; comments document the owner's no-movement
spec.
- [ ] `uv run pytest tests/unit` green with
`tests/unit/test_background_no_motion.py` present and passing.
- [ ] `uv run pytest --cov=app` TOTAL ≥ pre-change; ruff + pyright clean.
- [ ] `.agent/screenshots/25_background_no_motion/before.png` and
`before_4s.png` captured **before** the CSS change.
@@ -0,0 +1,97 @@
# Phase 25 — Background: No Motion, Only Fading Light
**Owner report (2026-08-25, chat, verbatim):** "It should be smooth,
fluxuating, dimming and brightening, but not moving. Different bright
spots should slowly fade in and out." **Story:**
`.agent/user_stories/background-no-motion.md`.
## Root causes (phase-22 measurements as context)
1. **"Jitters down and to the right every second" = the grid drift.**
`bg-grid-drift` translated the 44px grid 44px per 60s (≈0.73px/s)
diagonally down-right — exactly the reported direction. A 1px line
moved sub-pixel by sub-pixel rasterizes with per-frame stepping, not
smooth motion, so it reads as a once-per-second jitter. Phase 22
had made that drift *visible* (60% line alpha + widened mask;
measured 1.82/765 mean pixel change over 5s in the grid zone vs
0.48 pre-phase-22) — that is precisely why it now reads as jitter.
Confirmed in the `before.png`/`before_4s.png` pair below: a
grid-only region far from every glow changes by 2.98/765 mean over
4s (4.7% of its pixels) — the grid itself is moving.
2. **"Slowly blinks brighter and darker" = the whole-layer breathe.**
`bg-glow-breathe` swung the entire glow layer's opacity 0.85↔1 over
14s (alternate) plus `scale(1)↔scale(1.05)` (a faint zoom). One
synchronized pulse of the whole background reads as a blink; the
before pair's uniform change across both glow corners (mean
2.19/765 over the full frame) is that single pulse.
## Design change (styles.css — pure CSS, zero JS, no blur, palette
untouched; task 01)
| property | phase 22 | phase 25 |
|---|---|---|
| grid (`body::before`) | `bg-grid-drift 60s linear infinite` (0→44px, 0.73px/s) | **static** — animation removed, `bg-grid-drift` deleted (44px cells, 60% `--line` 1px lines, widened mask kept) |
| glow | one whole-layer `bg-glow-breathe` (14s, opacity 0.85↔1 + scale 1↔1.05) | **three independent spot layers, opacity-only fades** |
| spot A `body::after` | indigo + cyan on one layer | indigo `rgb(109 120 242 / 0.14)` 56rem @ 12%/8% — `bg-glow-a` **26s** ease-in-out infinite, low 0.25 |
| spot B `html::before` | — | cyan `rgb(34 211 238 / 0.10)` 60rem @ 88%/92% — `bg-glow-b` **34s** ease-in-out **−12s** infinite, low 0.20 |
| spot C `html::after` | — | indigo `rgb(109 120 242 / 0.09)` 52rem @ 14%/86% — `bg-glow-c` **42s** ease-in-out **−23s** infinite, low 0.15 |
| keyframes | `bg-grid-drift` (background-position), `bg-glow-breathe` (opacity + transform) | `bg-glow-a/b/c` — **opacity only** (0%/100% low → 50% 1) |
| reduced motion | stills the two body layers | stills **all four** layers |
`html::before`/`html::after` join as background layers: `<html>` is
the root stacking context, so their `z-index: -1` pseudo-elements
paint above the `var(--bg)` canvas and below the transparent,
non-stacking `<body>`'s content (verified live — E2E test 6). 26/34/42s
with negative delays (LCM 4641s) keep the cycles out of phase, so the
composite pattern effectively never repeats within a viewing session.
No LOCKED anchor changed (A11 pure CSS / zero JS / no CDN / no new
assets; no `filter: blur`).
## Before / after evidence (1280×800 headless Chromium, shots ~4s
apart; `.agent/screenshots/25_background_no_motion/`)
- `before.png` / `before_4s.png` (captured **pre-change**, task 01):
down-right grid jitter + the uniform whole-layer pulse.
- `after.png` / `after_4s.png` (captured post-change, task 02):
brightness changes; the grid is bit-identical.
Region diffs (pure-stdlib PNG decode; "changed" = pixels with
per-channel Δ sum > 12/765):
| region | before pair: changed / mean\|d\| | after pair: changed / mean\|d\| |
|---|---|---|
| full frame | 42,316 (4.1%) / 2.190 | 8,357 (0.8%) / 1.754 |
| grid-only patch (900,30)–(1250,120) | 1,484 (4.7%) / **2.979 — the grid moves** | 0 (0.0%) / **0.000 — bit-identical** |
| spot A center (154,64) patch | 406 (4.1%) / 3.198 | 4,018 (40.2%) / 6.595 — mid-fade |
| spot C clip (0,500)–(500,800) | 6,469 (4.3%) / 1.274 | 0 / 3.247 — gentle fade, no >12/765 steps |
| spot C center (179,688) patch | 63 (0.6%) / 0.789 | 0 / 6.003 — visibly brightening |
| spot B clip (780,500)–(1280,800) | 5,784 (3.9%) / 2.037 | 0 / 1.211 — slow fade at this phase |
Read: after the change, the only region that moves pixel-by-pixel is
the light itself (spot A's 26s cycle passes through its steepest
mid-fade in the 4s window); every region that the grid drift used to
scrub through is now byte-identical — no positional shift anywhere.
## E2E story gate (task 02) — `tests/e2e/test_background_no_motion.py`
8 tests, green in isolation (see "Results"). The deterministic
no-movement proof is test 3: a live Chromium walk of
`document.styleSheets` shows the set of properties declared across
every frame of every `bg-*` @keyframes rule is exactly `{opacity}`.
Regression suites adapted to the new contract:
`tests/e2e/test_background_animation.py` (grid static + `bg-glow-a`
running + the three spot timelines advance + 4-layer contracts + 360px
pin) and `tests/e2e/test_dark_tech_theme.py`
(`test_animated_background` → static grid + 26/34/42s spots;
`test_reduced_motion_honored` → all four layers stilled).
## UI Structure Check (AGENTS.md rule 5)
- **Layers behind content:** E2E test 6 pins `position: fixed`,
`z-index: -1`, `pointer-events: none`, `inset: 0` on all four
pseudo-layers live, plus the html-canvas / body-transparent
no-occlusion pair (`rgb(10, 14, 23)` / `rgba(0, 0, 0, 0)`).
- **No text/contrast impact:** the change touches only background
layers (no palette token, no text on the layers); the phase-08
contrast suite stays green.
- **No 360px overflow:** E2E test 8 — `scrollWidth <= clientWidth` at
360×740 with all four `fixed; inset: 0` layers live.
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

@@ -1,5 +1,13 @@
# Story: Animated Background That Actually Animates # Story: Animated Background That Actually Animates
> **SUPERSEDED (owner direction 2026-08-25):** the phase-22 motion
> design (grid drift + whole-layer breathe) is superseded by the
> owner's "no movement, only fading light" direction — see
> `.agent/user_stories/background-no-motion.md` (phase
> `25_background_no_motion`). The phase-22 history below is preserved
> as-is; the old E2E suite now pins the phase-25 contract as a
> regression.
**Phase:** `22_background_animation` · **E2E:** `tests/e2e/test_background_animation.py` **Phase:** `22_background_animation` · **E2E:** `tests/e2e/test_background_animation.py`
## Narrative ## Narrative
+181
View File
@@ -0,0 +1,181 @@
# Story: A Background That No Longer Moves — Only Fading Light
**Phase:** `25_background_no_motion` · **E2E:**
`tests/e2e/test_background_no_motion.py`
**Supersedes:** `.agent/user_stories/background-animation.md`
(phase-22 motion design — history preserved there)
## Narrative
As **the owner**, I reported (2026-08-25, chat) that the phase-22
background "jitters down and to the right every second and it slowly
blinks brighter and darker". I want the background to be smooth,
fluxuating, dimming and brightening — but **not moving** — with
**different bright spots** that slowly fade in and out.
- **Given** the phase-22 animated background (a 60s one-cell grid drift
at 0.73px/s diagonally down-right + a 14s whole-layer glow breathe of
opacity 0.85↔1 with scale 1↔1.05)
- **When** the page is observed in a real Chromium viewport
- **Then** nothing moves — the grid is a static texture and no
background keyframe animates anything but `opacity` — while three
independent bright spots each fade in and out on their own slow,
out-of-phase cycles (26s/34s/42s), so the total light fluxuates
smoothly and irregularly: no jitter, no blink, no static frame, no
new overflow at 360px, and no impact on text contrast or
interactivity.
## Owner report (verbatim, 2026-08-25, chat)
> It should be smooth, fluxuating, dimming and brightening, but not
> moving. Different bright spots should slowly fade in and out.
## Owner direction (2026-08-25)
1. **No movement** — no grid drift, no `scale`/`transform`, no
`background-position` animation, anywhere in the background.
2. **Fluxuating brightness** — overall page brightness varies smoothly
and irregularly (not one synchronized pulse).
3. **Different bright spots** — multiple glow spots, each fading in
and out on its own slow cycle.
4. **The static grid stays** — the owner rejected the grid's *motion*,
not the grid; it remains as a still texture. (If the owner later
wants the grid gone, that is a follow-up, not this phase.)
## Root cause (found from the code + phase-22 measurements)
1. **"Jitters down and to the right every second" = the grid drift.**
`bg-grid-drift` moved the 44px grid 44px per 60s (≈0.73px/s)
diagonally down-right — exactly the reported direction. A 1px grid
line translated sub-pixel by sub-pixel is rasterized with per-frame
stepping/shimmer, not smooth motion: it reads as a once-per-second
jitter. Phase 22 had made that drift *visible* (60% line alpha,
wider mask — measured 1.82/765 mean pixel change over 5s in the
grid zone); that is precisely why it now reads as jitter.
2. **"Slowly blinks brighter and darker" = the whole-layer breathe.**
`bg-glow-breathe` swung the ENTIRE glow layer's opacity 0.85↔1 over
14s (alternate) plus `scale(1)↔scale(1.05)` (a faint zoom). One
synchronized pulse of the whole background reads as a blink; the
owner wants independent spots instead.
Phase 22 served the phase-08 design intent (grid drift + whole-layer
breathe). The owner's 2026-08-25 direction supersedes that **design
intent** — no LOCKED anchor changed (A11 stays pure CSS / zero JS / no
CDN / no new assets; the no-`filter: blur` perf anchor is honored).
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
| property | phase 22 | phase 25 |
|---|---|---|
| grid (`body::before`) | `bg-grid-drift 60s linear infinite` (0→44px) | **static** — no animation, `bg-grid-drift` deleted (44px cells, 60% `--line` 1px lines, widened mask kept) |
| glow layer count | one whole-layer breathe | **three independent spot layers** |
| spot A (`body::after`) | indigo + cyan spots, 14s opacity 0.85↔1 + scale 1↔1.05 | **indigo `rgb(109 120 242 / 0.14)` 56rem at 12%/8%**, `bg-glow-a` **26s** ease-in-out infinite, low opacity **0.25** |
| spot B (`html::before`) | — (cyan shared body::after) | **cyan `rgb(34 211 238 / 0.10)` 60rem at 88%/92%**, `bg-glow-b` **34s** ease-in-out **−12s** infinite, low **0.20** |
| spot C (`html::after`) | — | **indigo `rgb(109 120 242 / 0.09)` 52rem at 14%/86%**, `bg-glow-c` **42s** ease-in-out **−23s** infinite, low **0.15** |
| keyframes | `bg-grid-drift` (background-position), `bg-glow-breathe` (opacity + transform) | **`bg-glow-a/b/c` — opacity only** (0%/100% low → 50% 1) |
| reduced motion | stills `body::before/::after` | stills **all four** layers |
All four layers keep `content: ""; position: fixed; inset: 0;
z-index: -1; pointer-events: none`. `<html>` keeps the `var(--bg)`
canvas and `<body>` stays transparent (the no-occlusion contract):
`html` is the root stacking context, so its `z-index: -1`
pseudo-elements paint above the canvas and below the transparent,
non-stacking `<body>`'s content. The 26/34/42s periods with negative
delays (LCM 4641s) keep the cycles out of phase — the composite
pattern effectively never repeats within a viewing session.
## Acceptance criteria
1. **No movement:** the grid is static (`body::before` computed
`animationName: none`; no `bg-grid-drift` in
`document.getAnimations()`; grid texture still painted) and —
audited in real Chromium via `document.styleSheets` — **no `bg-*`
keyframe animates anything but `opacity`** (the deterministic
no-movement proof).
2. **Three distinct bright spots** (`body::after`, `html::before`,
`html::after`) run distinct slow opacity fades (26s/34s/42s,
ease-in-out, infinite, pairwise distinct, out of phase); all three
timelines advance; the layer's computed opacity AND a clipped
screenshot of the bottom-left glow region measurably change within
a few seconds (a real fade, not a frozen frame).
3. **Contracts hold:** all four layers `position: fixed`, `z-index:
-1`, `pointer-events: none`, full-viewport `inset: 0`; `<html>`
keeps the `var(--bg)` canvas (`rgb(10, 14, 23)`) and `<body>` stays
transparent (`rgba(0, 0, 0, 0)`) — no occlusion.
4. **Reduced motion** stills all four layers
(`animationName: none`), the static grid + spot images remain.
5. No new horizontal overflow at 360px (the phase-07 pin).
6. Pure CSS, zero JS, no `filter: blur`, no new assets (A11 + phase-08
perf anchor); WCAG AA palette untouched (the layers carry no text).
7. Regressions green in isolation:
`tests/e2e/test_background_animation.py` (adapted to the phase-25
contract), `tests/e2e/test_dark_tech_theme.py` (grid static +
26/34/42s spots; reduced motion across all four layers),
`tests/e2e/test_responsive_polish.py`.
8. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean.
## UI Visualization & Structure
- **Grid layer (`body::before`):** 44px cells, 1px lines at 60% of
`--line` (`rgb(38 48 74 / 0.6)`), widened radial mask
(`140% 110% at 50% 0%, black 40%, transparent 90%`) — a STATIC
texture, no animation.
- **Glow spot A (`body::after`):** indigo `rgb(109 120 242 / 0.14)`
56rem circle at 12%/8% (phase-08 position/color); opacity-only fade
0.25↔1 over 26s ease-in-out.
- **Glow spot B (`html::before`):** cyan `rgb(34 211 238 / 0.10)` 60rem
circle at 88%/92% (phase-08 position/color); fade 0.20↔1 over 34s,
−12s delay.
- **Glow spot C (`html::after`):** indigo `rgb(109 120 242 / 0.09)`
52rem circle at 14%/86%; fade 0.15↔1 over 42s, −23s delay.
- **Stacking / no occlusion:** the `<html>` canvas
(`var(--bg)` = `#0a0e17`) sits under all four `z-index: -1` layers;
`<body>` stays transparent and non-stacking, so nothing can paint
over the layers — verified live, not assumed.
- **Motion:** opacity-only keyframes (compositor-friendly); no
`transform`, no `background-position`, no `filter` anywhere in the
background; `prefers-reduced-motion: reduce` stills all four layers
(the static background remains visible).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_background_no_motion.py`** (the
layers are CSS pseudo-elements — asserted via computed style + the Web
Animations API + a live `document.styleSheets` audit; Chromium
enumerates pseudo-element CSS animations in `document.getAnimations()`,
not `document.body.getAnimations()`, and the `html` pseudo-layers'
computed styles come from
`getComputedStyle(document.documentElement, "::before"/"::after")`):
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` — walk
`document.styleSheets`; for every `CSSRule.KEYFRAMES_RULE` whose
name starts with `bg-`, collect the declared property names of
every keyframe frame; the set across all frames is exactly
`{"opacity"}` — 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 all three, 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.
6. `test_background_layers_contracts` — all four pseudo-layers:
`position: fixed`, `z-index: -1`, `pointer-events: none`,
top/right/bottom/left all `0px`; `documentElement` computed
background is `rgb(10, 14, 23)` (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`.
8. `test_no_horizontal_overflow_with_layers` — 360px viewport:
`documentElement.scrollWidth <= clientWidth` (the phase-07 pin).
+66 -30
View File
@@ -57,18 +57,37 @@ body {
min-height: 100dvh; min-height: 100dvh;
} }
/* ---------- Animated background (pure CSS, zero JS — phase 08) ---------- */ /* ---------- Animated background (pure CSS, zero JS — phase 08; reworked
phase 25: no movement, only fading light) ----------
Owner direction (2026-08-25, verbatim): "It should be smooth,
fluxuating, dimming and brightening, but not moving. Different bright
spots should slowly fade in and out."
- NO movement anywhere in the background: no grid drift, no
transform/scale, no background-position animation. The 44px/60s grid
drift (0.73px/s, diagonally down-right) rasterizes sub-pixel by
sub-pixel and reads as a once-per-second jitter; the 14s whole-layer
opacity+scale pulse reads as a uniform blink. Both are gone.
- Three independent soft glow spots, each fading in and out on its own
SLOW opacity-only cycle — 26s / 34s / 42s, ease-in-out, with negative
delays (-12s, -23s) so the cycles run out of phase (LCM 4641s: the
composite pattern effectively never repeats within a viewing
session). The total light fluxuates smoothly and irregularly.
- html::before / html::after join body::before / body::after as
background layers: <html> is the root stacking context, so their
z-index:-1 pseudo-elements paint ABOVE the var(--bg) canvas and
BELOW the transparent, non-stacking <body>'s content — the
no-occlusion contract (html owns the canvas, body stays
transparent) is unchanged.
- No filter (phase-08 no-blur perf anchor), no JS, no new assets;
opacity-only keyframes stay compositor-friendly.
- prefers-reduced-motion: reduce stills all four layers. */
/* Fine drifting grid: 44px cells, 1px lines at 60% --line alpha, masked /* Static grid texture: 44px cells, 1px lines at 60% --line alpha, masked
with a radial fade (visible across most of the viewport, fading to the with a widened radial fade (visible across most of the viewport,
corners). The drift delta (44px) equals one cell, so the loop is fading to the corners). Phase 25 (owner 2026-08-25): the grid drift is
seamless. REMOVED — the 0.73px/s sub-pixel drift rasterizes as a once-per-second
Phase 22 fix (owner report 2026-08-24, roadmap A3): the original 35% down-right jitter, and the owner wants no movement. The grid stays as
alpha + 25%-black mask made the 0.73px/s drift invisible in a real a still texture. */
viewport (measured: ~1.4/765 mean pixel change over 5s in the grid
zone) — only the glow swing was perceived, so the background read as
"just blinking". Higher line alpha + wider visible mask radius make the
same 60s one-cell drift clearly readable as smooth motion. */
body::before { body::before {
content: ""; content: "";
position: fixed; position: fixed;
@@ -81,34 +100,50 @@ body::before {
background-size: 44px 44px; background-size: 44px 44px;
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%); -webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%); mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
animation: bg-grid-drift 60s linear infinite;
}
@keyframes bg-grid-drift {
from { background-position: 0 0, 0 0; }
to { background-position: 44px 44px, 44px 44px; }
} }
/* Two large, soft radial glows: indigo top-left, cyan bottom-right — /* Glow spot A — the phase-08 indigo (top-left): one soft radial spot
14s ease-in-out breathing (opacity + scale). No filter:blur (perf). fading in and out on its own 26s opacity-only cycle. */
Phase 22 fix (owner report 2026-08-24, roadmap A3): the 0.65↔1 opacity
swing was the ONLY visible motion on the page, so it read as a blink.
Narrowed to 0.85↔1 — a gentle breathe, not a pulse. */
body::after { body::after {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background-image: background-image: radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%);
radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%), animation: bg-glow-a 26s ease-in-out infinite;
radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%);
animation: bg-glow-breathe 14s ease-in-out infinite alternate;
} }
@keyframes bg-glow-breathe {
from { opacity: 0.85; transform: scale(1); } /* Glow spot B — the phase-08 cyan (bottom-right): 34s cycle, -12s delay
to { opacity: 1; transform: scale(1.05); } (out of phase with spot A). */
html::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%);
animation: bg-glow-b 34s ease-in-out -12s infinite;
} }
/* Glow spot C — a third indigo (bottom-left): 42s cycle, -23s delay
(out of phase with spots A and B). */
html::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), transparent 62%);
animation: bg-glow-c 42s ease-in-out -23s infinite;
}
/* Opacity-only fades — nothing but opacity may appear in any bg-*
keyframe (the no-movement contract, phase 25). */
@keyframes bg-glow-a { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
@keyframes bg-glow-b { 0%, 100% { opacity: 0.20; } 50% { opacity: 1; } }
@keyframes bg-glow-c { 0%, 100% { opacity: 0.15; } 50% { opacity: 1; } }
.container { .container {
width: 100%; width: 100%;
max-width: 72rem; max-width: 72rem;
@@ -797,9 +832,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.spinner { animation-duration: 2s; } .spinner { animation-duration: 2s; }
} }
/* The background layers are the only other motion on the page: under /* The background layers are the only other motion on the page: under
reduced motion they go static (grid + glows remain, just still). */ reduced motion they go static (grid + glows remain, just still) — all
four layers (phase 25: the html::before / html::after spots join). */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
body::before, body::after { animation: none; } body::before, body::after, html::before, html::after { animation: none; }
} }
/* ---------- Banners ---------- */ /* ---------- Banners ---------- */
+109 -86
View File
@@ -1,43 +1,55 @@
"""Phase 22 E2E (Playwright): the animated background actually animates. """Phase 22 E2E (Playwright): the background layers — now a regression
suite for the phase-25 no-motion design.
Story: ``.agent/user_stories/background-animation.md`` Story: ``.agent/user_stories/background-no-motion.md`` (the phase-25
owner direction supersedes this suite's original pins; the phase-22
history lives in ``.agent/user_stories/background-animation.md``)
Run in isolation (DB must be up: ``podman compose up -d db``): Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_background_animation.py -v --no-cov uv run pytest tests/e2e/test_background_animation.py -v --no-cov
Owner report (2026-08-24, roadmap A3): the phase-08 background "just Phase 22 proved the phase-08 background (grid drift + whole-layer
blinks". The diagnosis (``.agent/reports/22_background_animation/``) breathe) actually animated in a real Chromium viewport. The owner then
found both layers *were* animating with no occlusion — but the grid reported (2026-08-25, chat): the background "jitters down and to the
drift was imperceptible (35% alpha 1px lines × a small radial mask × right every second and it slowly blinks brighter and darker. It should
0.73px/s) and only the glow's 0.65↔1 swing was perceived. The fix be smooth, fluxuating, dimming and brightening, but not moving.
(styles.css, pure CSS, zero JS): 60% grid line alpha + wider mask and a Different bright spots should slowly fade in and out." — so the
0.85↔1 glow breathe. phase-25 redesign (``frontend/assets/styles.css``) removed the
movement entirely: the grid (``body::before``) is a STATIC texture
(``bg-grid-drift`` deleted), and three independent soft glow spots run
their own slow opacity-only fades — ``body::after`` runs ``bg-glow-a``
(26s), ``html::before`` runs ``bg-glow-b`` (34s, −12s delay),
``html::after`` runs ``bg-glow-c`` (42s, −23s delay).
This suite proves the *behavior* the unit source pins only describe: This adapted suite now pins the phase-25 contract on the same
in a real Chromium viewport both pseudo-element layers run their layers (the full story gate is
animations AND the animation timelines actually advance (no static ``tests/e2e/test_background_no_motion.py``): the grid is static, the
frame, no paused layer, no new occlusion or overflow). indigo spot runs ``bg-glow-a`` and the three spot timelines advance,
all four pseudo-layers keep the fixed/z-index −1/pointer-events
none/no-occlusion contract, and there is no 360px overflow.
Test → story mapping (Playwright Mapping Rule): Test → story mapping (Playwright Mapping Rule):
1. ``test_grid_layer_animation_running`` — computed style of 1. ``test_grid_layer_is_static`` — computed style of ``body::before``:
``body::before``: ``animationName`` is ``bg-grid-drift``, timing ``animationName`` is ``"none"`` (the 0.73px/s drift is gone), no
function ``linear``, iteration count ``infinite``; plus a matching ``bg-grid-drift`` entry in the document animation list, and the
entry in the document animation list with static grid ``backgroundImage`` is still painted.
``playState === "running"``. 2. ``test_glow_layer_animation_running`` — ``body::after`` runs
2. ``test_glow_layer_animation_running`` — same for ``body::after`` ``bg-glow-a`` (26s, ease-in-out, infinite) with a matching entry in
with the ``bg-glow-breathe`` keyframe; ``playState === "running"``. the document animation list, ``playState === "running"``.
3. ``test_animations_advance`` — ``currentTime`` of both layers 3. ``test_glow_timelines_advance`` — ``currentTime`` of all three spot
sampled, ~500ms waited, both advanced — the timelines are truly timelines sampled, ~500ms waited, all advanced — the fades are truly
running, not paused (headless Chromium starts the document running, not paused (headless Chromium starts the document
animation timeline ~1s after load, so the first sample polls until animation timeline ~1s after load, so the first sample polls until
the timeline is alive). the timelines are alive).
4. ``test_background_layers_contracts`` — both pseudo-elements: 4. ``test_background_layers_contracts`` — all four pseudo-elements
``position: fixed``, ``z-index: -1``, ``pointer-events: none``, (body ``::before``/``::after`` + the phase-25 ``html
``inset: 0`` (UI Structure Check: behind content, click-through, ::before``/``::after`` spots): ``position: fixed``, ``z-index: -1``,
full-viewport); the page canvas stays on ``<html>`` ``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind
(``rgb(10, 14, 23)`` = ``var(--bg)``) and ``<body>`` stays content, click-through, full-viewport); the page canvas stays on
transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion contract. ``<html>`` (``rgb(10, 14, 23)`` = ``var(--bg)``) and ``<body>``
stays transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion
contract.
5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport 5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin, ``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin,
replicated locally — the ``fixed; inset: 0`` layers must add no replicated locally — the ``fixed; inset: 0`` layers must add no
@@ -47,7 +59,9 @@ Chromium note: pseudo-element CSS animations are enumerated by
``document.getAnimations()``, NOT by ``document.body.getAnimations()`` ``document.getAnimations()``, NOT by ``document.body.getAnimations()``
(verified on Chromium 151 — the element-level list is empty for (verified on Chromium 151 — the element-level list is empty for
pseudo-layers), so tests 1–3 match on ``animationName`` in the pseudo-layers), so tests 1–3 match on ``animationName`` in the
document-level list. document-level list. The two ``html`` pseudo-layers' computed styles
come from ``getComputedStyle(document.documentElement,
"::before"/"::after")``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -55,15 +69,15 @@ import time
from playwright.sync_api import Browser, Page from playwright.sync_api import Browser, Page
GRID = "bg-grid-drift" GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere
GLOW = "bg-glow-breathe" GLOWS = ("bg-glow-a", "bg-glow-b", "bg-glow-c") # the three phase-25 spot fades
PAGE_BG = "rgb(10, 14, 23)" # var(--bg) — the <html> canvas (phase-08 palette) PAGE_BG = "rgb(10, 14, 23)" # var(--bg) — the <html> canvas (phase-08 palette)
# Computed styles of both pseudo-layers + the html/body background # Computed styles of all four pseudo-layers + the html/body background
# contract (single evaluate — one round-trip per test). # contract (single evaluate — one round-trip per test).
JS_LAYER_REPORT = """() => { JS_LAYER_REPORT = """() => {
const pick = (pseudo) => { const pick = (el, pseudo) => {
const cs = getComputedStyle(document.body, pseudo); const cs = getComputedStyle(el, pseudo);
return { return {
anim: cs.animationName, anim: cs.animationName,
timing: cs.animationTimingFunction, timing: cs.animationTimingFunction,
@@ -72,17 +86,20 @@ JS_LAYER_REPORT = """() => {
zIndex: cs.zIndex, zIndex: cs.zIndex,
pointerEvents: cs.pointerEvents, pointerEvents: cs.pointerEvents,
edges: [cs.top, cs.right, cs.bottom, cs.left], edges: [cs.top, cs.right, cs.bottom, cs.left],
image: cs.backgroundImage,
}; };
}; };
return { return {
before: pick("::before"), grid: pick(document.body, "::before"),
after: pick("::after"), glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
htmlBg: getComputedStyle(document.documentElement).backgroundColor, htmlBg: getComputedStyle(document.documentElement).backgroundColor,
bodyBg: getComputedStyle(document.body).backgroundColor, bodyBg: getComputedStyle(document.body).backgroundColor,
}; };
}""" }"""
# Both background-layer animations from the Web Animations API # The background-layer animations from the Web Animations API
# ({name, playState, currentTime}); the keyframe names are passed as one # ({name, playState, currentTime}); the keyframe names are passed as one
# array argument (Playwright serializes the Python list to a JS array). # array argument (Playwright serializes the Python list to a JS array).
JS_TIMELINE = """(names) => document.getAnimations() JS_TIMELINE = """(names) => document.getAnimations()
@@ -95,23 +112,24 @@ JS_TIMELINE = """(names) => document.getAnimations()
def _timeline(page: Page) -> dict[str, float]: def _timeline(page: Page) -> dict[str, float]:
"""animationName → currentTime (ms) for the two background layers.""" """animationName → currentTime (ms) for the three glow-spot layers."""
entries = page.evaluate(JS_TIMELINE, [GRID, GLOW]) entries = page.evaluate(JS_TIMELINE, list(GLOWS))
return {str(a["name"]): float(a["t"]) for a in entries} return {str(a["name"]): float(a["t"]) for a in entries}
def _wait_timeline_alive(page: Page, timeout_ms: int = 5000) -> None: def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None:
"""Poll until both layer timelines report currentTime > 0. """Poll until all three spot timelines report currentTime > 0.
Headless Chromium starts the document animation timeline shortly Headless Chromium starts the document animation timeline shortly
after load (observed ≈1.4s after navigation) — until then after load (observed ≈1.4s after navigation) — until then
currentTime is 0, so the "did it advance?" sample in currentTime is 0, so the "did it advance?" sample in
``test_animations_advance`` must start once the timeline is alive. ``test_glow_timelines_advance`` must start once the timeline is
alive.
""" """
deadline = time.monotonic() + timeout_ms / 1000 deadline = time.monotonic() + timeout_ms / 1000
while time.monotonic() < deadline: while time.monotonic() < deadline:
times = _timeline(page) times = _timeline(page)
if set(times) == {GRID, GLOW} and all(times[k] > 0 for k in (GRID, GLOW)): if set(times) == set(GLOWS) and all(times[k] > 0 for k in GLOWS):
return return
page.wait_for_timeout(100) page.wait_for_timeout(100)
raise AssertionError( raise AssertionError(
@@ -124,56 +142,58 @@ def _wait_timeline_alive(page: Page, timeout_ms: int = 5000) -> None:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def test_grid_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None: def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None:
"""AC1: the grid layer runs bg-grid-drift linear infinite in a real """Phase-25 AC1: the grid layer is STATIC in a real viewport —
viewport — not just declared in CSS: the matching CSSAnimation is ``animationName`` is ``"none"``, no ``bg-grid-drift`` animation
reported ``running``.""" exists, and the static grid texture is still painted (the owner
rejected the grid's motion, not the grid)."""
page.goto(app_url) page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT) report = page.evaluate(JS_LAYER_REPORT)
grid = report["before"] grid = report["grid"]
assert grid["anim"] == GRID, f"body::before must run {GRID} (got {grid['anim']!r})" assert grid["anim"] == "none", (
assert grid["timing"] == "linear", ( f"body::before must be static (animationName, got {grid['anim']!r})"
f"body::before must keep linear timing (got {grid['timing']!r})"
) )
assert grid["iterations"] == "infinite", ( live = page.evaluate(JS_TIMELINE, [GRID_OLD])
f"body::before must loop infinitely (got {grid['iterations']!r})" assert not live, (
f"no {GRID_OLD} animation may exist (got {live!r}) — the drift is deleted"
) )
live = page.evaluate(JS_TIMELINE, [GRID, GLOW]) assert grid["image"] != "none", (
match = [a for a in live if a["name"] == GRID] f"the static grid texture must still be painted (image {grid['image']!r})"
assert match, f"no {GRID} entry in document.getAnimations() — layer not animating"
assert match[0]["playState"] == "running", (
f"{GRID} is {match[0]['playState']!r} — the grid drift must be running"
) )
def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None: def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None:
"""AC2: the glow layer runs bg-glow-breathe in a real viewport — """Phase-25 AC2: the indigo spot layer (body::after) runs
the matching CSSAnimation is reported ``running``.""" bg-glow-a — 26s, ease-in-out, infinite — in a real viewport: the
matching CSSAnimation is reported ``running``."""
page.goto(app_url) page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT) report = page.evaluate(JS_LAYER_REPORT)
glow = report["after"] glow = report["glowA"]
assert glow["anim"] == GLOW, f"body::after must run {GLOW} (got {glow['anim']!r})" assert glow["anim"] == "bg-glow-a", (
f"body::after must run bg-glow-a (got {glow['anim']!r})"
)
assert glow["iterations"] == "infinite", ( assert glow["iterations"] == "infinite", (
f"body::after must loop infinitely (got {glow['iterations']!r})" f"body::after must fade forever (got {glow['iterations']!r})"
) )
live = page.evaluate(JS_TIMELINE, [GRID, GLOW]) live = page.evaluate(JS_TIMELINE, list(GLOWS))
match = [a for a in live if a["name"] == GLOW] match = [a for a in live if a["name"] == "bg-glow-a"]
assert match, f"no {GLOW} entry in document.getAnimations() — layer not animating" assert match, "no bg-glow-a entry in document.getAnimations() — spot not fading"
assert match[0]["playState"] == "running", ( assert match[0]["playState"] == "running", (
f"{GLOW} is {match[0]['playState']!r} — the glow breathe must be running" f"bg-glow-a is {match[0]['playState']!r} — the spot fade must be running"
) )
def test_animations_advance(page: Page, app_url: str, db_ready: None) -> None: def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None:
"""AC1: both timelines actually advance — the background is a live """Phase-25 AC2: all three spot timelines actually advance — the
animation, not a static (or paused) frame. Sample currentTime, wait background is a live animation, not a static (or paused) frame.
~500ms, and require real progress on both layers.""" Sample currentTime, wait ~500ms, and require real progress on all
three layers."""
page.goto(app_url) page.goto(app_url)
_wait_timeline_alive(page) _wait_timelines_alive(page)
before = _timeline(page) before = _timeline(page)
page.wait_for_timeout(500) page.wait_for_timeout(500)
after = _timeline(page) after = _timeline(page)
for name in (GRID, GLOW): for name in GLOWS:
delta = after[name] - before[name] delta = after[name] - before[name]
assert delta >= 200, ( assert delta >= 200, (
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) " f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
@@ -182,22 +202,24 @@ def test_animations_advance(page: Page, app_url: str, db_ready: None) -> None:
def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None: def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None:
"""AC3/AC5: UI Structure Check — the layers stay behind content """Phase-25 AC4: UI Structure Check — ALL FOUR background
(fixed, z-index -1, pointer-events none, full-viewport) and nothing pseudo-layers (body ::before/::after + the phase-25 html
occludes them: the page canvas is on <html>, <body> transparent.""" ::before/::after spots) stay behind content (fixed, z-index -1,
pointer-events none, full-viewport) and nothing occludes them: the
page canvas is on <html>, <body> transparent."""
page.goto(app_url) page.goto(app_url)
report = page.evaluate(JS_LAYER_REPORT) report = page.evaluate(JS_LAYER_REPORT)
for layer in ("before", "after"): for key in ("grid", "glowA", "glowB", "glowC"):
info = report[layer] info = report[key]
assert info["position"] == "fixed", f"body::{layer} must stay position:fixed" assert info["position"] == "fixed", f"{key} must stay position:fixed"
assert info["zIndex"] == "-1", ( assert info["zIndex"] == "-1", (
f"body::{layer} must stay behind content (z-index -1, got {info['zIndex']!r})" f"{key} must stay behind content (z-index -1, got {info['zIndex']!r})"
) )
assert info["pointerEvents"] == "none", ( assert info["pointerEvents"] == "none", (
f"body::{layer} must stay click-through (pointer-events none)" f"{key} must stay click-through (pointer-events none)"
) )
assert info["edges"] == ["0px", "0px", "0px", "0px"], ( assert info["edges"] == ["0px", "0px", "0px", "0px"], (
f"body::{layer} must stay full-viewport (inset: 0, got {info['edges']!r})" f"{key} must stay full-viewport (inset: 0, got {info['edges']!r})"
) )
assert report["htmlBg"] == PAGE_BG, ( assert report["htmlBg"] == PAGE_BG, (
f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})" f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})"
@@ -210,9 +232,10 @@ def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -
def test_no_horizontal_overflow_with_layers( def test_no_horizontal_overflow_with_layers(
browser: Browser, app_url: str, db_ready: None browser: Browser, app_url: str, db_ready: None
) -> None: ) -> None:
"""AC4: the background layers add no width — the phase-07 overflow """Phase-25 AC6: the background layers add no width — the phase-07
pin (documentElement.scrollWidth <= clientWidth) still holds at the overflow pin (documentElement.scrollWidth <= clientWidth) still
360px floor with both fixed; inset: 0 layers live.""" holds at the 360px floor with all four fixed; inset: 0 layers
live."""
phone = browser.new_page(viewport={"width": 360, "height": 740}) phone = browser.new_page(viewport={"width": 360, "height": 740})
try: try:
phone.goto(f"{app_url}/") phone.goto(f"{app_url}/")
+424
View File
@@ -0,0 +1,424 @@
"""Phase 25 E2E (Playwright): the background no longer moves — it only fades.
Story: ``.agent/user_stories/background-no-motion.md`` (supersedes
``background-animation.md``)
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 two root causes (phase-22 measurements in
``.agent/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.
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(10, 14, 23)`` (``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.
"""
from __future__ import annotations
import time
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(10, 14, 23)" # var(--bg) — the <html> canvas (phase-08 palette)
# Computed styles of all four 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,
edges: [cs.top, cs.right, cs.bottom, cs.left],
image: cs.backgroundImage,
};
};
return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
htmlBg: getComputedStyle(document.documentElement).backgroundColor,
bodyBg: getComputedStyle(document.body).backgroundColor,
};
}"""
# 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();
for (const sheet of document.styleSheets) {
let rules;
try {
rules = sheet.cssRules;
} catch (e) {
continue; // cross-origin sheet — not expected (no-CDN rule)
}
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);
}
}
}
}
}
return { names, props: [...props] };
}"""
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)."""
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"),
)
durations: list[float] = []
for key, name, expected in recipe:
info = report[key]
assert info["anim"] == name, (
f"{key} must run {name} (got {info['anim']!r})"
)
assert info["duration"] == expected, (
f"{key} must run a {expected} cycle (got {info['duration']!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"
)
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})"
)
def test_reduced_motion_stills_all_layers(
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."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
try:
rpage = context.new_page()
rpage.goto(app_url)
report = rpage.evaluate(JS_LAYER_REPORT)
for key in ("grid", "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}: the static background image must remain visible"
)
finally:
context.close()
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."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
scroll, client = phone.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll <= client, (
f"horizontal overflow at 360px with the background layers: "
f"{scroll} > {client}"
)
finally:
phone.close()
+58 -31
View File
@@ -13,12 +13,14 @@ Test → story mapping (Playwright Mapping Rule):
(same contrast helper as Phase 07). (same contrast helper as Phase 07).
2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw 2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw
``outerHTML`` contains any emoji code point. ``outerHTML`` contains any emoji code point.
3. ``test_animated_background`` — ``body::before``/``::after`` carry 3. ``test_animated_background`` — the background layers (fixed,
background images AND run their animations by default (fixed, pointer-events none, carrying images): the grid (``body::before``)
pointer-events none). is STATIC (phase 25, owner 2026-08-25: no movement) and the three
glow spots run their opacity-only fades ``bg-glow-a/b/c`` at
26s/34s/42s (``body::after`` + the two ``html`` pseudo-layers).
4. ``test_reduced_motion_honored`` — a context with 4. ``test_reduced_motion_honored`` — a context with
``reduced_motion="reduce"`` → ``animation-name: none`` on both layers ``reduced_motion="reduce"`` → ``animation-name: none`` on all four
(the static grid + glows remain). layers (the static grid + spot images remain).
5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer 5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer
+ a source chip + the send button recovers (state machine intact under + a source chip + the send button recovers (state machine intact under
the new skin). the new skin).
@@ -258,14 +260,19 @@ def test_no_emoji_in_chrome(page: Page, app_url: str, db_ready: None) -> None:
def test_animated_background(page: Page, app_url: str, db_ready: None) -> None: def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is subtly animated, pure CSS, zero JS, and can """AC3: the background is subtly animated, pure CSS, zero JS, and can
never block or dim content: both body pseudo-layers are fixed, never block or dim content. Phase-25 contract (owner 2026-08-25:
pointer-events:none, carry a background image and run their animation "not moving. Different bright spots should slowly fade in and out")
by default (60s grid drift + 14s glow breathing).""" supersedes the phase-08 recipe: the grid (``body::before``) is a
STATIC texture (``animationName: none``, image still painted) and
the three glow spots each run their own opacity-only fade —
``body::after`` → ``bg-glow-a`` 26s, ``html::before`` →
``bg-glow-b`` 34s, ``html::after`` → ``bg-glow-c`` 42s — all fixed,
pointer-events:none, carrying an image."""
page.goto(app_url) page.goto(app_url)
report = page.evaluate( report = page.evaluate(
"""() => { """() => {
const pick = (pseudo) => { const pick = (el, pseudo) => {
const cs = getComputedStyle(document.body, pseudo); const cs = getComputedStyle(el, pseudo);
return { return {
image: cs.backgroundImage, image: cs.backgroundImage,
anim: cs.animationName, anim: cs.animationName,
@@ -274,27 +281,42 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
pointerEvents: cs.pointerEvents, pointerEvents: cs.pointerEvents,
}; };
}; };
return { before: pick("::before"), after: pick("::after") }; return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
};
}""" }"""
) )
for layer in ("before", "after"): for key in ("grid", "glowA", "glowB", "glowC"):
info = report[layer] info = report[key]
assert info["image"] != "none", f"body::{layer} must carry a background image" assert info["image"] != "none", f"{key} must carry a background image"
assert info["anim"] not in ("", "none"), ( assert info["position"] == "fixed", f"{key} must be position:fixed"
f"body::{layer} must animate by default (got {info['anim']!r})" assert info["pointerEvents"] == "none", f"{key} must not intercept input"
# The phase-25 recipe: a static grid + three out-of-phase spot fades.
assert report["grid"]["anim"] == "none", (
f"the grid must be static (got {report['grid']['anim']!r})"
)
for key, name, seconds in (
("glowA", "bg-glow-a", 26.0),
("glowB", "bg-glow-b", 34.0),
("glowC", "bg-glow-c", 42.0),
):
info = report[key]
assert info["anim"] == name, f"{key} must run {name} (got {info['anim']!r})"
assert _seconds(info["duration"]) == pytest.approx(seconds), (
f"{key} must run its {seconds:.0f}s fade (got {info['duration']!r})"
) )
assert info["position"] == "fixed", f"body::{layer} must be position:fixed"
assert info["pointerEvents"] == "none", f"body::{layer} must not intercept input"
# The recipe: 60s seamless grid drift, 14s breathing glows.
assert _seconds(report["before"]["duration"]) == pytest.approx(60.0)
assert _seconds(report["after"]["duration"]) == pytest.approx(14.0)
def test_reduced_motion_honored( def test_reduced_motion_honored(
browser: Browser, app_url: str, db_ready: None browser: Browser, app_url: str, db_ready: None
) -> None: ) -> None:
"""AC4: with prefers-reduced-motion: reduce both background layers stop """AC4: with prefers-reduced-motion: reduce ALL FOUR background
animating (animation-name: none) — the static grid + glows remain.""" layers stop animating (animation-name: none) — the static grid +
spot images remain (phase 25 stills the two html pseudo-layers as
well as the body pair)."""
context = browser.new_context( context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800} reduced_motion="reduce", viewport={"width": 1280, "height": 800}
) )
@@ -303,19 +325,24 @@ def test_reduced_motion_honored(
rpage.goto(app_url) rpage.goto(app_url)
report = rpage.evaluate( report = rpage.evaluate(
"""() => { """() => {
const pick = (pseudo) => { const pick = (el, pseudo) => {
const cs = getComputedStyle(document.body, pseudo); const cs = getComputedStyle(el, pseudo);
return { anim: cs.animationName, image: cs.backgroundImage }; return { anim: cs.animationName, image: cs.backgroundImage };
}; };
return { before: pick("::before"), after: pick("::after") }; return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
};
}""" }"""
) )
for layer in ("before", "after"): for key in ("grid", "glowA", "glowB", "glowC"):
assert report[layer]["anim"] == "none", ( assert report[key]["anim"] == "none", (
f"body::{layer} must not animate under reduced motion" f"{key} must not animate under reduced motion (got {report[key]['anim']!r})"
) )
assert report[layer]["image"] != "none", ( assert report[key]["image"] != "none", (
f"body::{layer}: the static background must remain visible" f"{key}: the static background image must remain visible"
) )
finally: finally:
context.close() context.close()
+131 -76
View File
@@ -1,26 +1,36 @@
"""Unit: the phase-22 animated-background contract (source pins). """Unit: the phase-25 still-background contract — layer plumbing and the
phase-08 anchors (source pins).
The owner report (2026-08-24, roadmap A3): the phase-08 background "just Phase 22 (owner report 2026-08-24) made the phase-08 background
blinks". The diagnosis (`.agent/reports/22_background_animation/`) found perceptible: 60% grid-line alpha, a widened mask, a 60s one-cell grid
both layers *were* animating with no occlusion — the grid simply wasn't drift, and a 14s whole-layer glow breathe. The owner then reported
perceptible: 1px lines at 35% `--line` alpha (≈10-18/255 over the page (2026-08-25, chat): the background "jitters down and to the right every
bg), masked to the top ~25% of the viewport, drifting 0.73px/s. Only the second and it slowly blinks brighter and darker. It should be smooth,
glow's 0.65↔1 opacity swing was visible, and it read as a blink. 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 fix (styles.css, pure CSS, zero JS, no `filter: blur`): The new design (styles.css, pure CSS, zero JS, no `filter` — A11):
- grid lines 35% → 60% `--line` alpha; - grid (body::before): a STATIC texture — the drift animation and its
- mask widened: `120% 90% … black 25%, transparent 78%` → keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a
`140% 110% … black 40%, transparent 90%` (grid now readable across once-per-second down-right jitter);
most of the viewport, fading to the corners); - three independent soft glow spots — body::after (phase-08 indigo,
- glow breathe narrowed 0.65↔1 → 0.85↔1 (breathing, not pulsing). 26s), html::before (phase-08 cyan, 34s, -12s delay), html::after
(third indigo, 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.
Durations are the owner-confirmed phase-08 design and stay pinned at This file keeps the generic layer-plumbing pins (the no-occlusion
60s (one-cell seamless drift) and 14s — changing them would also break contract, fixed / z-index -1 / pointer-events none — now across all
the phase-08 story gate (`tests/e2e/test_dark_tech_theme.py` pins the four layers) and the phase-08 no-blur/no-JS anchor. The full new
live durations). This file pins the FINAL values so a silent regression contract (no animation on the grid, opacity-only keyframes, the three
(weaker alpha, shrunken mask, wider opacity swing, re-occluded layer) is spot gradients, reduced motion across all four layers) is pinned in
caught without a browser. Browser behavior (visible motion, no jank) is tests/unit/test_background_no_motion.py; browser behavior is E2E-covered
E2E-covered by tests/e2e/test_background_animation.py (task 02). 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 from __future__ import annotations
@@ -31,6 +41,8 @@ STYLES_CSS = (
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css" Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
) )
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
def _css() -> str: def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8") return STYLES_CSS.read_text(encoding="utf-8")
@@ -59,17 +71,39 @@ def _glow_rule(css: str) -> str:
return _rule_block(css, "body::after") 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 # Layer plumbing — the no-occlusion contract (phase 08) must survive
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def test_both_layers_are_fixed_zminus1_noninteractive() -> None: def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
"""Both background layers stay behind the content and can never """All four background layers stay behind the content and can never
intercept input: fixed, full-viewport, z-index -1, pointer-events intercept input: fixed, full-viewport, z-index -1, pointer-events
none (UI Structure Check: layers behind content, no 360px overflow).""" none (phase 25: html::before / html::after join body::before /
for name, block in (("body::before", _grid_rule(_css())), body::after as background layers — UI Structure Check: layers behind
("body::after", _glow_rule(_css()))): 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 "position: fixed" in block, f"{name} must stay position:fixed"
assert "inset: 0" in block, f"{name} must stay full-viewport (inset: 0)" 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 "z-index: -1" in block, f"{name} must stay z-index:-1"
@@ -80,7 +114,9 @@ def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
def test_html_owns_bg_and_body_stays_transparent() -> None: def test_html_owns_bg_and_body_stays_transparent() -> None:
"""The no-occlusion contract: the visible page background lives on """The no-occlusion contract: the visible page background lives on
<html>; <body> must remain transparent and non-stacking, or the <html>; <body> must remain transparent and non-stacking, or the
z-index:-1 layers are painted over (the phase-08 recipe).""" 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") html_block = _rule_block(_css(), "html")
assert "background: var(--bg)" in html_block, ( assert "background: var(--bg)" in html_block, (
"html must keep background: var(--bg) (the page canvas)" "html must keep background: var(--bg) (the page canvas)"
@@ -98,33 +134,28 @@ def test_html_owns_bg_and_body_stays_transparent() -> None:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Grid layer — the phase-22 final values # Grid layer — phase 25: a static texture (the drift is gone)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def test_grid_animates_seamless_one_cell_drift() -> None: def test_grid_is_static_no_drift() -> None:
"""body::before runs bg-grid-drift 60s linear infinite — the """body::before must carry NO animation — the phase-22 60s one-cell
owner-confirmed 60s one-cell loop (seamless, delta == 44px).""" 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()) block = _grid_rule(_css())
assert "animation: bg-grid-drift 60s linear infinite" in block assert "animation" not in block, (
"body::before must not animate (the no-movement contract)"
)
def test_grid_keyframes_move_exactly_one_cell() -> None: assert "bg-grid-drift" not in _css(), (
"""The drift delta must equal one 44px cell (0 0 → 44px 44px) for a "@keyframes bg-grid-drift must be deleted"
seamless loop — if the speed ever changes, only the duration may move."""
keyframes = re.search(
r"@keyframes bg-grid-drift\s*\{([\s\S]*?)\n\}", _css()
) )
assert keyframes, "styles.css must define @keyframes bg-grid-drift"
body = keyframes.group(1)
assert "background-position: 0 0, 0 0" in body
assert "background-position: 44px 44px, 44px 44px" in body
def test_grid_cells_and_line_contrast() -> None: def test_grid_cells_and_line_contrast() -> None:
"""44px cells with 1px lines at the phase-22 fixed 60% --line alpha """44px cells with 1px lines at the phase-22 fixed 60% --line alpha
(phase-08's 35% measured imperceptible at 0.73px/s — see module (the static texture keeps the values that made the grid readable —
docstring).""" see tests/unit/test_background_no_motion.py for the phase-25 story)."""
block = _grid_rule(_css()) block = _grid_rule(_css())
assert "background-size: 44px 44px" in block assert "background-size: 44px 44px" in block
line = "linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)" line = "linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)"
@@ -148,45 +179,59 @@ def test_grid_mask_widened_and_prefixed() -> None:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Glow layer — the phase-22 final values # Glow layers — phase 25: three spots, each on its own slow opacity fade
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def test_glow_animates_breathe_not_blink() -> None: def test_three_spots_run_own_slow_opacity_fades() -> None:
"""body::after runs bg-glow-breathe 14s ease-in-out infinite alternate """The whole-layer breathe is replaced by three independent
— the owner-confirmed 14s breathing period (untouched).""" opacity-only fades on distinct slow periods with negative delays (out
block = _glow_rule(_css()) of phase): body::after 26s, html::before 34s -12s, html::after 42s
assert "animation: bg-glow-breathe 14s ease-in-out infinite alternate" in block -23s. The old breathe keyframes are deleted."""
assert "animation: bg-glow-a 26s ease-in-out infinite" in _glow_rule(_css())
assert (
def test_glow_keyframes_narrowed_opacity_swing() -> None: "animation: bg-glow-b 34s ease-in-out -12s infinite"
"""The opacity swing is narrowed to 0.85↔1 (phase-08's 0.65↔1 was the in _rule_block(_css(), "html::before")
only visible motion and read as a blink). The gentle scale (1↔1.05) )
stays.""" assert (
keyframes = re.search( "animation: bg-glow-c 42s ease-in-out -23s infinite"
r"@keyframes bg-glow-breathe\s*\{([\s\S]*?)\n\}", _css() in _rule_block(_css(), "html::after")
)
assert "bg-glow-breathe" not in _css(), (
"@keyframes bg-glow-breathe must be deleted"
) )
assert keyframes, "styles.css must define @keyframes bg-glow-breathe"
body = keyframes.group(1)
assert re.search(r"opacity:\s*0\.85", body), "glow low must be 0.85"
assert re.search(r"opacity:\s*1;?", body), "glow high must be 1"
assert "0.65" not in body, "the blinking 0.65 low must not return"
assert re.search(r"scale\(1\)", body)
assert re.search(r"scale\(1\.05\)", body)
def test_glow_colors_and_radii_untouched() -> None: def test_glow_keyframes_are_opacity_only() -> None:
"""Phase-22 is a perception fix, not a redesign: the two glow """The no-movement contract: every bg-* keyframe block animates ONLY
gradients (indigo top-left, cyan bottom-right) keep phase-08's colors opacity (no transform/scale, no background-position)."""
and radii.""" keyframes = _bg_keyframes(_css())
block = _glow_rule(_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_keep_phase08_colors_and_add_a_third() -> None:
"""The phase-08 spots keep their colors, radii and positions — the
indigo top-left stays on body::after, the cyan bottom-right moves to
html::before — and a third soft indigo spot (52rem at 14% 86%)
joins on html::after. All spots fade to transparent at 62%."""
assert ( assert (
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), " "radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), "
"transparent 62%)" in block "transparent 62%)" in _glow_rule(_css())
) )
assert ( assert (
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), " "radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), "
"transparent 62%)" in block "transparent 62%)" in _rule_block(_css(), "html::before")
)
assert (
"radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), "
"transparent 62%)" in _rule_block(_css(), "html::after")
) )
@@ -196,12 +241,22 @@ def test_glow_colors_and_radii_untouched() -> None:
def test_no_blur_no_js_in_background_layers() -> None: def test_no_blur_no_js_in_background_layers() -> None:
"""The phase-08 performance anchor: no `filter: blur` (or any filter) """The phase-08 performance anchor: no `filter` (or any filter) on
on either layer, and the animation is CSS-only (both layers carry an any layer, and the motion is CSS-only — the three glow layers carry
`animation:` shorthand; nothing in styles.css references a script).""" an `animation:` shorthand (the grid is deliberately still in phase
for name, block in (("body::before", _grid_rule(_css())), 25), and nothing in styles.css references a script."""
("body::after", _glow_rule(_css()))): 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" 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 "animation:" in block, f"{name} must be CSS-animated"
assert "blur" not in _css_no_comments(), ( assert "blur" not in _css_no_comments(), (
"no filter: blur anywhere in styles.css (phase-08 perf anchor)" "no filter: blur anywhere in styles.css (phase-08 perf anchor)"
+252
View File
@@ -0,0 +1,252 @@
"""Unit: the phase-25 still-background contract (source pins).
Owner report (2026-08-25, chat): the animated 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 diagnosis (`.agent/reports/25_background_no_motion/`) found both
root causes in the phase-22 design:
- "jitters down and to the right" = the grid's 44px/60s drift
(0.73px/s, diagonally down-right) — a 1px grid line translated
sub-pixel by sub-pixel rasterizes with per-frame stepping;
- "slowly blinks" = the whole-layer 14s opacity 0.85↔1 +
scale(1)↔scale(1.05) pulse — one synchronized pulse reads as a blink.
The fix (styles.css, pure CSS, zero JS, no `filter` — A11): the grid is
a STATIC texture (no animation, no bg-grid-drift keyframes), and three
independent soft glow spots (body::after, html::before, html::after)
each run their own SLOW opacity-only fade (26/34/42s, ease-in-out,
negative delays → out of phase; LCM 4641s → the composite pattern
effectively never repeats within a viewing session), so the total light
fluxuates smoothly and irregularly — no blink, no jitter, no movement.
Story: .agent/user_stories/background-no-motion.md. Browser behavior
(no motion, visible fades, no occlusion, no overflow) is E2E-covered by
tests/e2e/test_background_no_motion.py (task 02).
"""
from __future__ import annotations
import re
from tests.unit.test_background_animation import _css, _css_no_comments, _rule_block
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
# (layer, keyframes name, duration shorthand, single-spot gradient)
GLOW_SPOTS = (
("body::after", "bg-glow-a", "animation: bg-glow-a 26s ease-in-out infinite",
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%)"),
("html::before", "bg-glow-b", "animation: bg-glow-b 34s ease-in-out -12s infinite",
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%)"),
("html::after", "bg-glow-c", "animation: bg-glow-c 42s ease-in-out -23s infinite",
"radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), transparent 62%)"),
)
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
# --------------------------------------------------------------------------
# No movement — the grid is a static texture, and no bg-* keyframe may
# animate anything but opacity
# --------------------------------------------------------------------------
def test_grid_has_no_animation() -> None:
"""body::before must carry NO animation declaration — the phase-22
0.73px/s drift rasterized as a once-per-second down-right jitter; the
owner wants no movement (2026-08-25)."""
block = _rule_block(_css(), "body::before")
assert "animation" not in block, (
"body::before must not animate (the no-movement contract)"
)
def test_drift_and_breathe_keyframes_are_deleted() -> None:
"""@keyframes bg-grid-drift and @keyframes bg-glow-breathe are gone —
the names must not appear anywhere in the file (no declaration, no
keyframe block, no stale comment)."""
css = _css()
assert "bg-grid-drift" not in css, "bg-grid-drift must be deleted"
assert "bg-glow-breathe" not in css, "bg-glow-breathe must be deleted"
def test_grid_keeps_its_static_texture() -> None:
"""The owner rejected the grid's MOTION, not the grid: 44px cells,
1px lines at 60% --line alpha, and the widened radial mask (both the
-webkit- and standard mask properties) stay."""
block = _rule_block(_css(), "body::before")
assert "background-size: 44px 44px" in block
assert (
"linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)" in block
), "grid must keep horizontal 1px lines at 60% --line"
assert (
"linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px)" in block
), "grid must keep vertical 1px lines at 60% --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
def test_exactly_three_bg_glow_keyframes_exist() -> None:
"""Exactly three bg-* keyframe blocks: bg-glow-a/b/c (the old
bg-grid-drift and bg-glow-breathe are deleted)."""
assert set(_bg_keyframes(_css())) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}
def test_bg_keyframes_animate_opacity_only() -> None:
"""The no-movement contract: across ALL frames of ALL bg-* keyframes
the set of declared properties is exactly {opacity} — no transform,
scale, background-position, nothing else may appear."""
props: set[str] = set()
for _name, body in _bg_keyframes(_css()).items():
props |= set(re.findall(r"([A-Za-z-]+)\s*:", body))
assert props == {"opacity"}, (
f"bg-* keyframes must animate only opacity, found {sorted(props)}"
)
def test_glow_layers_declare_no_transform_or_position_animation() -> None:
"""The three glow layers themselves must not declare transform or
background-position either (the no-movement contract applies to the
layers, not just their keyframes)."""
for sel, _name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert "transform" not in block, f"{sel} must not declare transform"
assert "background-position" not in block, (
f"{sel} must not declare background-position"
)
# --------------------------------------------------------------------------
# Three distinct bright spots, each on its own slow opacity-only fade
# --------------------------------------------------------------------------
def test_each_spot_runs_its_own_slow_opacity_fade() -> None:
"""body::after (phase-08 indigo, 26s), html::before (phase-08 cyan,
34s, -12s delay), html::after (third indigo, 42s, -23s delay) — each
glow layer's background-image is EXACTLY the single radial gradient
from the spec (color, radius, position, 62% transparent stop)."""
for sel, _name, animation, gradient in GLOW_SPOTS:
block = _rule_block(_css(), sel)
assert animation in block, f"{sel} must run {animation}"
assert f"background-image: {gradient};" in block, (
f"{sel} must carry exactly one radial gradient: {gradient}"
)
def test_glow_durations_are_distinct_and_slow() -> None:
"""The three cycles are out of phase (distinct durations) and each is
slow (>= 20s); LCM(26, 34, 42) = 4641s, so the composite pattern
effectively never repeats within a viewing session."""
durations: list[float] = []
for sel, name, _anim, _grad in GLOW_SPOTS:
block = _rule_block(_css(), sel)
m = re.search(rf"animation: {name}\s+([\d.]+)s", block)
assert m, f"{sel} must run its {name} fade"
durations.append(float(m.group(1)))
assert len(set(durations)) == len(durations), (
"the three spot cycles must be out of phase (distinct durations)"
)
assert all(d >= 20 for d in durations), (
f"each spot fade must be slow (>= 20s), got {durations}"
)
def test_glow_keyframes_low_and_high_opacities() -> None:
"""Each cycle: 0%/100% at its own low opacity (0.25 / 0.20 / 0.15),
50% at 1 — smooth fade in and out, never a hard cut."""
keyframes = _bg_keyframes(_css())
lows = {"bg-glow-a": 0.25, "bg-glow-b": 0.20, "bg-glow-c": 0.15}
for name, low in lows.items():
body = keyframes[name]
m = re.search(r"0%,\s*100%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == low, (
f"{name} must start/end at opacity {low}"
)
m = re.search(r"50%\s*\{\s*opacity:\s*([\d.]+)\s*;\s*\}", body)
assert m and float(m.group(1)) == 1.0, (f"{name} must peak at opacity 1")
# --------------------------------------------------------------------------
# Layer plumbing — the no-occlusion contract across all four layers
# --------------------------------------------------------------------------
def test_all_four_layers_are_fixed_zminus1_noninteractive() -> None:
"""All four background pseudo-layers stay behind the content and can
never intercept input: fixed, full-viewport, z-index -1,
pointer-events none, with pseudo content (UI Structure Check: layers
behind content, no 360px overflow — the layers are fixed; inset: 0)."""
for sel in ALL_LAYERS:
block = _rule_block(_css(), sel)
assert "position: fixed" in block, f"{sel} must stay position:fixed"
assert "inset: 0" in block, f"{sel} must stay full-viewport (inset: 0)"
assert "z-index: -1" in block, f"{sel} must stay z-index:-1"
assert "pointer-events: none" in block, f"{sel} must stay click-through"
assert 'content: ""' in block, f"{sel} must keep its pseudo content"
def test_html_owns_canvas_and_body_stays_transparent() -> None:
"""The no-occlusion contract: <html> keeps the var(--bg) canvas;
<body> stays transparent and non-stacking — or the z-index:-1 layers
(including the new html::before / html::after spots) 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 layers show"
)
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 (no filter, no blur, zero JS)
# --------------------------------------------------------------------------
def test_reduced_motion_stills_all_four_layers() -> None:
"""prefers-reduced-motion: reduce must still ALL FOUR layers together
(body::before, body::after, html::before, html::after) with
animation: none — the typing/spinner/thinking blocks are untouched."""
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}", _css()
)
assert any(
all(sel in b for sel in ALL_LAYERS) and "animation: none" in b
for b in blocks
), "a reduced-motion block must still all four background layers"
def test_no_filter_in_any_layer_and_no_blur_anywhere() -> None:
"""The phase-08 performance anchor: no `filter` in any background
layer block, and no `blur` anywhere in styles.css (comments
stripped)."""
for sel in ALL_LAYERS:
assert "filter" not in _rule_block(_css(), sel), (
f"{sel} must not use any filter"
)
assert "blur" not in _css_no_comments(), (
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
)