# 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.