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:
@@ -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
|
||||
|
||||
> **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`
|
||||
|
||||
## Narrative
|
||||
|
||||
@@ -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
@@ -57,18 +57,37 @@ body {
|
||||
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
|
||||
with a radial fade (visible across most of the viewport, fading to the
|
||||
corners). The drift delta (44px) equals one cell, so the loop is
|
||||
seamless.
|
||||
Phase 22 fix (owner report 2026-08-24, roadmap A3): the original 35%
|
||||
alpha + 25%-black mask made the 0.73px/s drift invisible in a real
|
||||
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. */
|
||||
/* Static grid texture: 44px cells, 1px lines at 60% --line alpha, masked
|
||||
with a widened radial fade (visible across most of the viewport,
|
||||
fading to the corners). Phase 25 (owner 2026-08-25): the grid drift is
|
||||
REMOVED — the 0.73px/s sub-pixel drift rasterizes as a once-per-second
|
||||
down-right jitter, and the owner wants no movement. The grid stays as
|
||||
a still texture. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
@@ -81,34 +100,50 @@ body::before {
|
||||
background-size: 44px 44px;
|
||||
-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%);
|
||||
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 —
|
||||
14s ease-in-out breathing (opacity + scale). No filter:blur (perf).
|
||||
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. */
|
||||
/* Glow spot A — the phase-08 indigo (top-left): one soft radial spot
|
||||
fading in and out on its own 26s opacity-only cycle. */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%),
|
||||
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;
|
||||
background-image: radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%);
|
||||
animation: bg-glow-a 26s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bg-glow-breathe {
|
||||
from { opacity: 0.85; transform: scale(1); }
|
||||
to { opacity: 1; transform: scale(1.05); }
|
||||
|
||||
/* Glow spot B — the phase-08 cyan (bottom-right): 34s cycle, -12s delay
|
||||
(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 {
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
@@ -797,9 +832,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.spinner { animation-duration: 2s; }
|
||||
}
|
||||
/* 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) {
|
||||
body::before, body::after { animation: none; }
|
||||
body::before, body::after, html::before, html::after { animation: none; }
|
||||
}
|
||||
|
||||
/* ---------- Banners ---------- */
|
||||
|
||||
@@ -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``):
|
||||
|
||||
uv run pytest tests/e2e/test_background_animation.py -v --no-cov
|
||||
|
||||
Owner report (2026-08-24, roadmap A3): the phase-08 background "just
|
||||
blinks". The diagnosis (``.agent/reports/22_background_animation/``)
|
||||
found both layers *were* animating with no occlusion — but the grid
|
||||
drift was imperceptible (35% alpha 1px lines × a small radial mask ×
|
||||
0.73px/s) and only the glow's 0.65↔1 swing was perceived. The fix
|
||||
(styles.css, pure CSS, zero JS): 60% grid line alpha + wider mask and a
|
||||
0.85↔1 glow breathe.
|
||||
Phase 22 proved the phase-08 background (grid drift + whole-layer
|
||||
breathe) actually animated in a real Chromium viewport. The owner then
|
||||
reported (2026-08-25, chat): the background "jitters down and to the
|
||||
right every second and it slowly blinks brighter and darker. It should
|
||||
be smooth, fluxuating, dimming and brightening, but not moving.
|
||||
Different bright spots should slowly fade in and out." — so the
|
||||
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:
|
||||
in a real Chromium viewport both pseudo-element layers run their
|
||||
animations AND the animation timelines actually advance (no static
|
||||
frame, no paused layer, no new occlusion or overflow).
|
||||
This adapted suite now pins the phase-25 contract on the same
|
||||
layers (the full story gate is
|
||||
``tests/e2e/test_background_no_motion.py``): the grid is static, the
|
||||
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):
|
||||
|
||||
1. ``test_grid_layer_animation_running`` — computed style of
|
||||
``body::before``: ``animationName`` is ``bg-grid-drift``, timing
|
||||
function ``linear``, iteration count ``infinite``; plus a matching
|
||||
entry in the document animation list with
|
||||
``playState === "running"``.
|
||||
2. ``test_glow_layer_animation_running`` — same for ``body::after``
|
||||
with the ``bg-glow-breathe`` keyframe; ``playState === "running"``.
|
||||
3. ``test_animations_advance`` — ``currentTime`` of both layers
|
||||
sampled, ~500ms waited, both advanced — the timelines are truly
|
||||
1. ``test_grid_layer_is_static`` — computed style of ``body::before``:
|
||||
``animationName`` is ``"none"`` (the 0.73px/s drift is gone), no
|
||||
``bg-grid-drift`` entry in the document animation list, and the
|
||||
static grid ``backgroundImage`` is still painted.
|
||||
2. ``test_glow_layer_animation_running`` — ``body::after`` runs
|
||||
``bg-glow-a`` (26s, ease-in-out, infinite) with a matching entry in
|
||||
the document animation list, ``playState === "running"``.
|
||||
3. ``test_glow_timelines_advance`` — ``currentTime`` of all three spot
|
||||
timelines sampled, ~500ms waited, all advanced — the fades are truly
|
||||
running, not paused (headless Chromium starts the document
|
||||
animation timeline ~1s after load, so the first sample polls until
|
||||
the timeline is alive).
|
||||
4. ``test_background_layers_contracts`` — both pseudo-elements:
|
||||
``position: fixed``, ``z-index: -1``, ``pointer-events: none``,
|
||||
``inset: 0`` (UI Structure Check: behind content, click-through,
|
||||
full-viewport); the page canvas stays on ``<html>``
|
||||
(``rgb(10, 14, 23)`` = ``var(--bg)``) and ``<body>`` stays
|
||||
transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion contract.
|
||||
the timelines are alive).
|
||||
4. ``test_background_layers_contracts`` — all four pseudo-elements
|
||||
(body ``::before``/``::after`` + the phase-25 ``html
|
||||
::before``/``::after`` spots): ``position: fixed``, ``z-index: -1``,
|
||||
``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind
|
||||
content, click-through, full-viewport); the page canvas stays on
|
||||
``<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
|
||||
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin,
|
||||
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()``
|
||||
(verified on Chromium 151 — the element-level list is empty for
|
||||
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
|
||||
|
||||
@@ -55,15 +69,15 @@ import time
|
||||
|
||||
from playwright.sync_api import Browser, Page
|
||||
|
||||
GRID = "bg-grid-drift"
|
||||
GLOW = "bg-glow-breathe"
|
||||
GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere
|
||||
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)
|
||||
|
||||
# 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).
|
||||
JS_LAYER_REPORT = """() => {
|
||||
const pick = (pseudo) => {
|
||||
const cs = getComputedStyle(document.body, pseudo);
|
||||
const pick = (el, pseudo) => {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
return {
|
||||
anim: cs.animationName,
|
||||
timing: cs.animationTimingFunction,
|
||||
@@ -72,17 +86,20 @@ JS_LAYER_REPORT = """() => {
|
||||
zIndex: cs.zIndex,
|
||||
pointerEvents: cs.pointerEvents,
|
||||
edges: [cs.top, cs.right, cs.bottom, cs.left],
|
||||
image: cs.backgroundImage,
|
||||
};
|
||||
};
|
||||
return {
|
||||
before: pick("::before"),
|
||||
after: pick("::after"),
|
||||
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,
|
||||
};
|
||||
}"""
|
||||
|
||||
# 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
|
||||
# array argument (Playwright serializes the Python list to a JS array).
|
||||
JS_TIMELINE = """(names) => document.getAnimations()
|
||||
@@ -95,23 +112,24 @@ JS_TIMELINE = """(names) => document.getAnimations()
|
||||
|
||||
|
||||
def _timeline(page: Page) -> dict[str, float]:
|
||||
"""animationName → currentTime (ms) for the two background layers."""
|
||||
entries = page.evaluate(JS_TIMELINE, [GRID, GLOW])
|
||||
"""animationName → currentTime (ms) for the three glow-spot layers."""
|
||||
entries = page.evaluate(JS_TIMELINE, list(GLOWS))
|
||||
return {str(a["name"]): float(a["t"]) for a in entries}
|
||||
|
||||
|
||||
def _wait_timeline_alive(page: Page, timeout_ms: int = 5000) -> None:
|
||||
"""Poll until both layer timelines report currentTime > 0.
|
||||
def _wait_timelines_alive(page: Page, timeout_ms: int = 5000) -> None:
|
||||
"""Poll until all three spot timelines report currentTime > 0.
|
||||
|
||||
Headless Chromium starts the document animation timeline shortly
|
||||
after load (observed ≈1.4s after navigation) — until then
|
||||
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
|
||||
while time.monotonic() < deadline:
|
||||
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
|
||||
page.wait_for_timeout(100)
|
||||
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:
|
||||
"""AC1: the grid layer runs bg-grid-drift linear infinite in a real
|
||||
viewport — not just declared in CSS: the matching CSSAnimation is
|
||||
reported ``running``."""
|
||||
def test_grid_layer_is_static(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""Phase-25 AC1: the grid layer is STATIC in a real viewport —
|
||||
``animationName`` is ``"none"``, no ``bg-grid-drift`` animation
|
||||
exists, and the static grid texture is still painted (the owner
|
||||
rejected the grid's motion, not the grid)."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
grid = report["before"]
|
||||
assert grid["anim"] == GRID, f"body::before must run {GRID} (got {grid['anim']!r})"
|
||||
assert grid["timing"] == "linear", (
|
||||
f"body::before must keep linear timing (got {grid['timing']!r})"
|
||||
grid = report["grid"]
|
||||
assert grid["anim"] == "none", (
|
||||
f"body::before must be static (animationName, got {grid['anim']!r})"
|
||||
)
|
||||
assert grid["iterations"] == "infinite", (
|
||||
f"body::before must loop infinitely (got {grid['iterations']!r})"
|
||||
live = page.evaluate(JS_TIMELINE, [GRID_OLD])
|
||||
assert not live, (
|
||||
f"no {GRID_OLD} animation may exist (got {live!r}) — the drift is deleted"
|
||||
)
|
||||
live = page.evaluate(JS_TIMELINE, [GRID, GLOW])
|
||||
match = [a for a in live if a["name"] == GRID]
|
||||
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"
|
||||
assert grid["image"] != "none", (
|
||||
f"the static grid texture must still be painted (image {grid['image']!r})"
|
||||
)
|
||||
|
||||
|
||||
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 —
|
||||
the matching CSSAnimation is reported ``running``."""
|
||||
"""Phase-25 AC2: the indigo spot layer (body::after) runs
|
||||
bg-glow-a — 26s, ease-in-out, infinite — in a real viewport: the
|
||||
matching CSSAnimation is reported ``running``."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
glow = report["after"]
|
||||
assert glow["anim"] == GLOW, f"body::after must run {GLOW} (got {glow['anim']!r})"
|
||||
glow = report["glowA"]
|
||||
assert glow["anim"] == "bg-glow-a", (
|
||||
f"body::after must run bg-glow-a (got {glow['anim']!r})"
|
||||
)
|
||||
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])
|
||||
match = [a for a in live if a["name"] == GLOW]
|
||||
assert match, f"no {GLOW} entry in document.getAnimations() — layer not animating"
|
||||
live = page.evaluate(JS_TIMELINE, list(GLOWS))
|
||||
match = [a for a in live if a["name"] == "bg-glow-a"]
|
||||
assert match, "no bg-glow-a entry in document.getAnimations() — spot not fading"
|
||||
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:
|
||||
"""AC1: both timelines actually advance — the background is a live
|
||||
animation, not a static (or paused) frame. Sample currentTime, wait
|
||||
~500ms, and require real progress on both layers."""
|
||||
def test_glow_timelines_advance(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""Phase-25 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."""
|
||||
page.goto(app_url)
|
||||
_wait_timeline_alive(page)
|
||||
_wait_timelines_alive(page)
|
||||
before = _timeline(page)
|
||||
page.wait_for_timeout(500)
|
||||
after = _timeline(page)
|
||||
for name in (GRID, GLOW):
|
||||
for name in GLOWS:
|
||||
delta = after[name] - before[name]
|
||||
assert delta >= 200, (
|
||||
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:
|
||||
"""AC3/AC5: UI Structure Check — the layers stay behind content
|
||||
(fixed, z-index -1, pointer-events none, full-viewport) and nothing
|
||||
occludes them: the page canvas is on <html>, <body> transparent."""
|
||||
"""Phase-25 AC4: UI Structure Check — ALL FOUR background
|
||||
pseudo-layers (body ::before/::after + the phase-25 html
|
||||
::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)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
for layer in ("before", "after"):
|
||||
info = report[layer]
|
||||
assert info["position"] == "fixed", f"body::{layer} must stay position:fixed"
|
||||
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"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", (
|
||||
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"], (
|
||||
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, (
|
||||
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(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC4: the background layers add no width — the phase-07 overflow
|
||||
pin (documentElement.scrollWidth <= clientWidth) still holds at the
|
||||
360px floor with both fixed; inset: 0 layers live."""
|
||||
"""Phase-25 AC6: the 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}/")
|
||||
|
||||
@@ -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()
|
||||
@@ -13,12 +13,14 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
(same contrast helper as Phase 07).
|
||||
2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw
|
||||
``outerHTML`` contains any emoji code point.
|
||||
3. ``test_animated_background`` — ``body::before``/``::after`` carry
|
||||
background images AND run their animations by default (fixed,
|
||||
pointer-events none).
|
||||
3. ``test_animated_background`` — the background layers (fixed,
|
||||
pointer-events none, carrying images): the grid (``body::before``)
|
||||
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
|
||||
``reduced_motion="reduce"`` → ``animation-name: none`` on both layers
|
||||
(the static grid + glows remain).
|
||||
``reduced_motion="reduce"`` → ``animation-name: none`` on all four
|
||||
layers (the static grid + spot images remain).
|
||||
5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer
|
||||
+ a source chip + the send button recovers (state machine intact under
|
||||
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:
|
||||
"""AC3: the background is subtly animated, pure CSS, zero JS, and can
|
||||
never block or dim content: both body pseudo-layers are fixed,
|
||||
pointer-events:none, carry a background image and run their animation
|
||||
by default (60s grid drift + 14s glow breathing)."""
|
||||
never block or dim content. Phase-25 contract (owner 2026-08-25:
|
||||
"not moving. Different bright spots should slowly fade in and out")
|
||||
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)
|
||||
report = page.evaluate(
|
||||
"""() => {
|
||||
const pick = (pseudo) => {
|
||||
const cs = getComputedStyle(document.body, pseudo);
|
||||
const pick = (el, pseudo) => {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
return {
|
||||
image: cs.backgroundImage,
|
||||
anim: cs.animationName,
|
||||
@@ -274,27 +281,42 @@ def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
|
||||
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"):
|
||||
info = report[layer]
|
||||
assert info["image"] != "none", f"body::{layer} must carry a background image"
|
||||
assert info["anim"] not in ("", "none"), (
|
||||
f"body::{layer} must animate by default (got {info['anim']!r})"
|
||||
for key in ("grid", "glowA", "glowB", "glowC"):
|
||||
info = report[key]
|
||||
assert info["image"] != "none", f"{key} must carry a background image"
|
||||
assert info["position"] == "fixed", f"{key} must be position:fixed"
|
||||
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(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC4: with prefers-reduced-motion: reduce both background layers stop
|
||||
animating (animation-name: none) — the static grid + glows remain."""
|
||||
"""AC4: with prefers-reduced-motion: reduce ALL FOUR background
|
||||
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(
|
||||
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
|
||||
)
|
||||
@@ -303,19 +325,24 @@ def test_reduced_motion_honored(
|
||||
rpage.goto(app_url)
|
||||
report = rpage.evaluate(
|
||||
"""() => {
|
||||
const pick = (pseudo) => {
|
||||
const cs = getComputedStyle(document.body, pseudo);
|
||||
const pick = (el, pseudo) => {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
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"):
|
||||
assert report[layer]["anim"] == "none", (
|
||||
f"body::{layer} must not animate under reduced motion"
|
||||
for key in ("grid", "glowA", "glowB", "glowC"):
|
||||
assert report[key]["anim"] == "none", (
|
||||
f"{key} must not animate under reduced motion (got {report[key]['anim']!r})"
|
||||
)
|
||||
assert report[layer]["image"] != "none", (
|
||||
f"body::{layer}: the static background must remain visible"
|
||||
assert report[key]["image"] != "none", (
|
||||
f"{key}: the static background image must remain visible"
|
||||
)
|
||||
finally:
|
||||
context.close()
|
||||
|
||||
@@ -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
|
||||
blinks". The diagnosis (`.agent/reports/22_background_animation/`) found
|
||||
both layers *were* animating with no occlusion — the grid simply wasn't
|
||||
perceptible: 1px lines at 35% `--line` alpha (≈10-18/255 over the page
|
||||
bg), masked to the top ~25% of the viewport, drifting 0.73px/s. Only the
|
||||
glow's 0.65↔1 opacity swing was visible, and it read as a blink.
|
||||
Phase 22 (owner report 2026-08-24) made the phase-08 background
|
||||
perceptible: 60% grid-line alpha, a widened mask, a 60s one-cell grid
|
||||
drift, and a 14s whole-layer glow breathe. The owner then reported
|
||||
(2026-08-25, chat): the background "jitters down and to the right every
|
||||
second and it slowly blinks brighter and darker. It should be smooth,
|
||||
fluxuating, dimming and brightening, but not moving. Different bright
|
||||
spots should slowly fade in and out." — the phase-22 design intent
|
||||
(grid drift + whole-layer breathe) is superseded.
|
||||
|
||||
The fix (styles.css, pure CSS, zero JS, no `filter: blur`):
|
||||
- grid lines 35% → 60% `--line` alpha;
|
||||
- mask widened: `120% 90% … black 25%, transparent 78%` →
|
||||
`140% 110% … black 40%, transparent 90%` (grid now readable across
|
||||
most of the viewport, fading to the corners);
|
||||
- glow breathe narrowed 0.65↔1 → 0.85↔1 (breathing, not pulsing).
|
||||
The new design (styles.css, pure CSS, zero JS, no `filter` — A11):
|
||||
- grid (body::before): a STATIC texture — the drift animation and its
|
||||
keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a
|
||||
once-per-second down-right jitter);
|
||||
- three independent soft glow spots — body::after (phase-08 indigo,
|
||||
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
|
||||
60s (one-cell seamless drift) and 14s — changing them would also break
|
||||
the phase-08 story gate (`tests/e2e/test_dark_tech_theme.py` pins the
|
||||
live durations). This file pins the FINAL values so a silent regression
|
||||
(weaker alpha, shrunken mask, wider opacity swing, re-occluded layer) is
|
||||
caught without a browser. Browser behavior (visible motion, no jank) is
|
||||
E2E-covered by tests/e2e/test_background_animation.py (task 02).
|
||||
This file keeps the generic layer-plumbing pins (the no-occlusion
|
||||
contract, fixed / z-index -1 / pointer-events none — now across all
|
||||
four layers) and the phase-08 no-blur/no-JS anchor. The full new
|
||||
contract (no animation on the grid, opacity-only keyframes, the three
|
||||
spot gradients, reduced motion across all four layers) is pinned in
|
||||
tests/unit/test_background_no_motion.py; browser behavior is E2E-covered
|
||||
by tests/e2e/test_background_no_motion.py (task 02).
|
||||
|
||||
Story: .agent/user_stories/background-no-motion.md (supersedes
|
||||
.agent/user_stories/background-animation.md).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -31,6 +41,8 @@ STYLES_CSS = (
|
||||
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
|
||||
)
|
||||
|
||||
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
@@ -59,17 +71,39 @@ def _glow_rule(css: str) -> str:
|
||||
return _rule_block(css, "body::after")
|
||||
|
||||
|
||||
def _bg_keyframes(css: str) -> dict[str, str]:
|
||||
"""Name → body for every @keyframes bg-* rule (balanced braces —
|
||||
works for the one-line blocks and a multi-line reformat alike)."""
|
||||
out: dict[str, str] = {}
|
||||
for m in re.finditer(r"@keyframes (bg-[A-Za-z0-9-]+)\s*\{", css):
|
||||
start, depth, i = m.end(), 1, m.end()
|
||||
while i < len(css) and depth:
|
||||
if css[i] == "{":
|
||||
depth += 1
|
||||
elif css[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
out[m.group(1)] = css[start:i - 1]
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Layer plumbing — the no-occlusion contract (phase 08) must survive
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
|
||||
"""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
|
||||
none (UI Structure Check: layers behind content, no 360px overflow)."""
|
||||
for name, block in (("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css()))):
|
||||
none (phase 25: html::before / html::after join body::before /
|
||||
body::after as background layers — UI Structure Check: layers behind
|
||||
content, no 360px overflow, since they are fixed; inset: 0)."""
|
||||
for name, block in (
|
||||
("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css())),
|
||||
("html::before", _rule_block(_css(), "html::before")),
|
||||
("html::after", _rule_block(_css(), "html::after")),
|
||||
):
|
||||
assert "position: fixed" in block, f"{name} must stay position:fixed"
|
||||
assert "inset: 0" in block, f"{name} must stay full-viewport (inset: 0)"
|
||||
assert "z-index: -1" in block, f"{name} must stay z-index:-1"
|
||||
@@ -80,7 +114,9 @@ def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
|
||||
def test_html_owns_bg_and_body_stays_transparent() -> None:
|
||||
"""The no-occlusion contract: the visible page background lives on
|
||||
<html>; <body> must remain transparent and non-stacking, or the
|
||||
z-index:-1 layers 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")
|
||||
assert "background: var(--bg)" in html_block, (
|
||||
"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:
|
||||
"""body::before runs bg-grid-drift 60s linear infinite — the
|
||||
owner-confirmed 60s one-cell loop (seamless, delta == 44px)."""
|
||||
def test_grid_is_static_no_drift() -> None:
|
||||
"""body::before must carry NO animation — the phase-22 60s one-cell
|
||||
drift (0.73px/s down-right) rasterized as a once-per-second jitter;
|
||||
the owner wants no movement (2026-08-25). Its keyframes are deleted
|
||||
too."""
|
||||
block = _grid_rule(_css())
|
||||
assert "animation: bg-grid-drift 60s linear infinite" in block
|
||||
|
||||
|
||||
def test_grid_keyframes_move_exactly_one_cell() -> None:
|
||||
"""The drift delta must equal one 44px cell (0 0 → 44px 44px) for a
|
||||
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 "animation" not in block, (
|
||||
"body::before must not animate (the no-movement contract)"
|
||||
)
|
||||
assert "bg-grid-drift" not in _css(), (
|
||||
"@keyframes bg-grid-drift must be deleted"
|
||||
)
|
||||
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:
|
||||
"""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
|
||||
docstring)."""
|
||||
(the static texture keeps the values that made the grid readable —
|
||||
see tests/unit/test_background_no_motion.py for the phase-25 story)."""
|
||||
block = _grid_rule(_css())
|
||||
assert "background-size: 44px 44px" in block
|
||||
line = "linear-gradient(to right, rgb(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:
|
||||
"""body::after runs bg-glow-breathe 14s ease-in-out infinite alternate
|
||||
— the owner-confirmed 14s breathing period (untouched)."""
|
||||
block = _glow_rule(_css())
|
||||
assert "animation: bg-glow-breathe 14s ease-in-out infinite alternate" in block
|
||||
|
||||
|
||||
def test_glow_keyframes_narrowed_opacity_swing() -> None:
|
||||
"""The opacity swing is narrowed to 0.85↔1 (phase-08's 0.65↔1 was the
|
||||
only visible motion and read as a blink). The gentle scale (1↔1.05)
|
||||
stays."""
|
||||
keyframes = re.search(
|
||||
r"@keyframes bg-glow-breathe\s*\{([\s\S]*?)\n\}", _css()
|
||||
def test_three_spots_run_own_slow_opacity_fades() -> None:
|
||||
"""The whole-layer breathe is replaced by three independent
|
||||
opacity-only fades on distinct slow periods with negative delays (out
|
||||
of phase): body::after 26s, html::before 34s -12s, html::after 42s
|
||||
-23s. The old breathe keyframes are deleted."""
|
||||
assert "animation: bg-glow-a 26s ease-in-out infinite" in _glow_rule(_css())
|
||||
assert (
|
||||
"animation: bg-glow-b 34s ease-in-out -12s infinite"
|
||||
in _rule_block(_css(), "html::before")
|
||||
)
|
||||
assert (
|
||||
"animation: bg-glow-c 42s ease-in-out -23s infinite"
|
||||
in _rule_block(_css(), "html::after")
|
||||
)
|
||||
assert "bg-glow-breathe" not in _css(), (
|
||||
"@keyframes bg-glow-breathe must be deleted"
|
||||
)
|
||||
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:
|
||||
"""Phase-22 is a perception fix, not a redesign: the two glow
|
||||
gradients (indigo top-left, cyan bottom-right) keep phase-08's colors
|
||||
and radii."""
|
||||
block = _glow_rule(_css())
|
||||
def test_glow_keyframes_are_opacity_only() -> None:
|
||||
"""The no-movement contract: every bg-* keyframe block animates ONLY
|
||||
opacity (no transform/scale, no background-position)."""
|
||||
keyframes = _bg_keyframes(_css())
|
||||
assert set(keyframes) == {"bg-glow-a", "bg-glow-b", "bg-glow-c"}, (
|
||||
"exactly three bg-glow-* keyframe blocks must exist"
|
||||
)
|
||||
for name, body in keyframes.items():
|
||||
props = set(re.findall(r"([A-Za-z-]+)\s*:", body))
|
||||
assert props == {"opacity"}, (
|
||||
f"{name} must animate only opacity, found {sorted(props)}"
|
||||
)
|
||||
|
||||
|
||||
def test_glow_spots_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 (
|
||||
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), "
|
||||
"transparent 62%)" in block
|
||||
"transparent 62%)" in _glow_rule(_css())
|
||||
)
|
||||
assert (
|
||||
"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:
|
||||
"""The phase-08 performance anchor: no `filter: blur` (or any filter)
|
||||
on either layer, and the animation is CSS-only (both layers carry an
|
||||
`animation:` shorthand; nothing in styles.css references a script)."""
|
||||
for name, block in (("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css()))):
|
||||
"""The phase-08 performance anchor: no `filter` (or any filter) on
|
||||
any layer, and the motion is CSS-only — the three glow layers carry
|
||||
an `animation:` shorthand (the grid is deliberately still in phase
|
||||
25), and nothing in styles.css references a script."""
|
||||
for name, block in (
|
||||
("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css())),
|
||||
("html::before", _rule_block(_css(), "html::before")),
|
||||
("html::after", _rule_block(_css(), "html::after")),
|
||||
):
|
||||
assert "filter" not in block, f"{name} must not use any filter"
|
||||
for name, block in (
|
||||
("body::after", _glow_rule(_css())),
|
||||
("html::before", _rule_block(_css(), "html::before")),
|
||||
("html::after", _rule_block(_css(), "html::after")),
|
||||
):
|
||||
assert "animation:" in block, f"{name} must be CSS-animated"
|
||||
assert "blur" not in _css_no_comments(), (
|
||||
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
Reference in New Issue
Block a user