diff --git a/.agent/phases/complete/25_background_no_motion/01_still_background_css.md b/.agent/phases/complete/25_background_no_motion/01_still_background_css.md new file mode 100644 index 0000000..92723c0 --- /dev/null +++ b/.agent/phases/complete/25_background_no_motion/01_still_background_css.md @@ -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 ``'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. diff --git a/.agent/reports/25_background_no_motion/report.md b/.agent/reports/25_background_no_motion/report.md new file mode 100644 index 0000000..6ed56e0 --- /dev/null +++ b/.agent/reports/25_background_no_motion/report.md @@ -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: `` is +the root stacking context, so their `z-index: -1` pseudo-elements +paint above the `var(--bg)` canvas and below the transparent, +non-stacking ``'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. diff --git a/.agent/screenshots/25_background_no_motion/after.png b/.agent/screenshots/25_background_no_motion/after.png new file mode 100644 index 0000000..85ecffb Binary files /dev/null and b/.agent/screenshots/25_background_no_motion/after.png differ diff --git a/.agent/screenshots/25_background_no_motion/after_4s.png b/.agent/screenshots/25_background_no_motion/after_4s.png new file mode 100644 index 0000000..8b29ab4 Binary files /dev/null and b/.agent/screenshots/25_background_no_motion/after_4s.png differ diff --git a/.agent/screenshots/25_background_no_motion/before.png b/.agent/screenshots/25_background_no_motion/before.png new file mode 100644 index 0000000..0661d87 Binary files /dev/null and b/.agent/screenshots/25_background_no_motion/before.png differ diff --git a/.agent/screenshots/25_background_no_motion/before_4s.png b/.agent/screenshots/25_background_no_motion/before_4s.png new file mode 100644 index 0000000..9a036cf Binary files /dev/null and b/.agent/screenshots/25_background_no_motion/before_4s.png differ diff --git a/.agent/user_stories/background-animation.md b/.agent/user_stories/background-animation.md index 6edce92..fade684 100644 --- a/.agent/user_stories/background-animation.md +++ b/.agent/user_stories/background-animation.md @@ -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 diff --git a/.agent/user_stories/background-no-motion.md b/.agent/user_stories/background-no-motion.md new file mode 100644 index 0000000..29c43a1 --- /dev/null +++ b/.agent/user_stories/background-no-motion.md @@ -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`. `` keeps the `var(--bg)` +canvas and `` 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 ``'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`; `` + keeps the `var(--bg)` canvas (`rgb(10, 14, 23)`) and `` 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 `` canvas + (`var(--bg)` = `#0a0e17`) sits under all four `z-index: -1` layers; + `` 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). diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 54aad33..96bad66 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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: is the root stacking context, so their + z-index:-1 pseudo-elements paint ABOVE the var(--bg) canvas and + BELOW the transparent, non-stacking '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 ---------- */ diff --git a/tests/e2e/test_background_animation.py b/tests/e2e/test_background_animation.py index 246a2fa..a4be127 100644 --- a/tests/e2e/test_background_animation.py +++ b/tests/e2e/test_background_animation.py @@ -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 ```` - (``rgb(10, 14, 23)`` = ``var(--bg)``) and ```` 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 + ```` (``rgb(10, 14, 23)`` = ``var(--bg)``) and ```` + 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 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 , 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 , 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 — 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}/") diff --git a/tests/e2e/test_background_no_motion.py b/tests/e2e/test_background_no_motion.py new file mode 100644 index 0000000..22fce0c --- /dev/null +++ b/tests/e2e/test_background_no_motion.py @@ -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 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 , + 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 — 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() diff --git a/tests/e2e/test_dark_tech_theme.py b/tests/e2e/test_dark_tech_theme.py index cb91180..7302e72 100644 --- a/tests/e2e/test_dark_tech_theme.py +++ b/tests/e2e/test_dark_tech_theme.py @@ -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() diff --git a/tests/unit/test_background_animation.py b/tests/unit/test_background_animation.py index d982b68..6b8b76a 100644 --- a/tests/unit/test_background_animation.py +++ b/tests/unit/test_background_animation.py @@ -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 ; 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)" diff --git a/tests/unit/test_background_no_motion.py b/tests/unit/test_background_no_motion.py new file mode 100644 index 0000000..7ff5ef3 --- /dev/null +++ b/tests/unit/test_background_no_motion.py @@ -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: keeps the var(--bg) canvas; + 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)" + )