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

This commit is contained in:
2026-08-25 09:43:19 -04:00
parent 1e6ae360e0
commit 025f57beb5
14 changed files with 1454 additions and 223 deletions
@@ -0,0 +1,97 @@
# Phase 25 — Background: No Motion, Only Fading Light
**Owner report (2026-08-25, chat, verbatim):** "It should be smooth,
fluxuating, dimming and brightening, but not moving. Different bright
spots should slowly fade in and out." **Story:**
`.agent/user_stories/background-no-motion.md`.
## Root causes (phase-22 measurements as context)
1. **"Jitters down and to the right every second" = the grid drift.**
`bg-grid-drift` translated the 44px grid 44px per 60s (≈0.73px/s)
diagonally down-right — exactly the reported direction. A 1px line
moved sub-pixel by sub-pixel rasterizes with per-frame stepping, not
smooth motion, so it reads as a once-per-second jitter. Phase 22
had made that drift *visible* (60% line alpha + widened mask;
measured 1.82/765 mean pixel change over 5s in the grid zone vs
0.48 pre-phase-22) — that is precisely why it now reads as jitter.
Confirmed in the `before.png`/`before_4s.png` pair below: a
grid-only region far from every glow changes by 2.98/765 mean over
4s (4.7% of its pixels) — the grid itself is moving.
2. **"Slowly blinks brighter and darker" = the whole-layer breathe.**
`bg-glow-breathe` swung the entire glow layer's opacity 0.85↔1 over
14s (alternate) plus `scale(1)↔scale(1.05)` (a faint zoom). One
synchronized pulse of the whole background reads as a blink; the
before pair's uniform change across both glow corners (mean
2.19/765 over the full frame) is that single pulse.
## Design change (styles.css — pure CSS, zero JS, no blur, palette
untouched; task 01)
| property | phase 22 | phase 25 |
|---|---|---|
| grid (`body::before`) | `bg-grid-drift 60s linear infinite` (0→44px, 0.73px/s) | **static** — animation removed, `bg-grid-drift` deleted (44px cells, 60% `--line` 1px lines, widened mask kept) |
| glow | one whole-layer `bg-glow-breathe` (14s, opacity 0.85↔1 + scale 1↔1.05) | **three independent spot layers, opacity-only fades** |
| spot A `body::after` | indigo + cyan on one layer | indigo `rgb(109 120 242 / 0.14)` 56rem @ 12%/8% — `bg-glow-a` **26s** ease-in-out infinite, low 0.25 |
| spot B `html::before` | — | cyan `rgb(34 211 238 / 0.10)` 60rem @ 88%/92% — `bg-glow-b` **34s** ease-in-out **−12s** infinite, low 0.20 |
| spot C `html::after` | — | indigo `rgb(109 120 242 / 0.09)` 52rem @ 14%/86% — `bg-glow-c` **42s** ease-in-out **−23s** infinite, low 0.15 |
| keyframes | `bg-grid-drift` (background-position), `bg-glow-breathe` (opacity + transform) | `bg-glow-a/b/c` — **opacity only** (0%/100% low → 50% 1) |
| reduced motion | stills the two body layers | stills **all four** layers |
`html::before`/`html::after` join as background layers: `<html>` is
the root stacking context, so their `z-index: -1` pseudo-elements
paint above the `var(--bg)` canvas and below the transparent,
non-stacking `<body>`'s content (verified live — E2E test 6). 26/34/42s
with negative delays (LCM 4641s) keep the cycles out of phase, so the
composite pattern effectively never repeats within a viewing session.
No LOCKED anchor changed (A11 pure CSS / zero JS / no CDN / no new
assets; no `filter: blur`).
## Before / after evidence (1280×800 headless Chromium, shots ~4s
apart; `.agent/screenshots/25_background_no_motion/`)
- `before.png` / `before_4s.png` (captured **pre-change**, task 01):
down-right grid jitter + the uniform whole-layer pulse.
- `after.png` / `after_4s.png` (captured post-change, task 02):
brightness changes; the grid is bit-identical.
Region diffs (pure-stdlib PNG decode; "changed" = pixels with
per-channel Δ sum > 12/765):
| region | before pair: changed / mean\|d\| | after pair: changed / mean\|d\| |
|---|---|---|
| full frame | 42,316 (4.1%) / 2.190 | 8,357 (0.8%) / 1.754 |
| grid-only patch (900,30)–(1250,120) | 1,484 (4.7%) / **2.979 — the grid moves** | 0 (0.0%) / **0.000 — bit-identical** |
| spot A center (154,64) patch | 406 (4.1%) / 3.198 | 4,018 (40.2%) / 6.595 — mid-fade |
| spot C clip (0,500)–(500,800) | 6,469 (4.3%) / 1.274 | 0 / 3.247 — gentle fade, no >12/765 steps |
| spot C center (179,688) patch | 63 (0.6%) / 0.789 | 0 / 6.003 — visibly brightening |
| spot B clip (780,500)–(1280,800) | 5,784 (3.9%) / 2.037 | 0 / 1.211 — slow fade at this phase |
Read: after the change, the only region that moves pixel-by-pixel is
the light itself (spot A's 26s cycle passes through its steepest
mid-fade in the 4s window); every region that the grid drift used to
scrub through is now byte-identical — no positional shift anywhere.
## E2E story gate (task 02) — `tests/e2e/test_background_no_motion.py`
8 tests, green in isolation (see "Results"). The deterministic
no-movement proof is test 3: a live Chromium walk of
`document.styleSheets` shows the set of properties declared across
every frame of every `bg-*` @keyframes rule is exactly `{opacity}`.
Regression suites adapted to the new contract:
`tests/e2e/test_background_animation.py` (grid static + `bg-glow-a`
running + the three spot timelines advance + 4-layer contracts + 360px
pin) and `tests/e2e/test_dark_tech_theme.py`
(`test_animated_background` → static grid + 26/34/42s spots;
`test_reduced_motion_honored` → all four layers stilled).
## UI Structure Check (AGENTS.md rule 5)
- **Layers behind content:** E2E test 6 pins `position: fixed`,
`z-index: -1`, `pointer-events: none`, `inset: 0` on all four
pseudo-layers live, plus the html-canvas / body-transparent
no-occlusion pair (`rgb(10, 14, 23)` / `rgba(0, 0, 0, 0)`).
- **No text/contrast impact:** the change touches only background
layers (no palette token, no text on the layers); the phase-08
contrast suite stays green.
- **No 360px overflow:** E2E test 8 — `scrollWidth <= clientWidth` at
360×740 with all four `fixed; inset: 0` layers live.