5.7 KiB
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)
- "Jitters down and to the right every second" = the grid drift.
bg-grid-drifttranslated 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 thebefore.png/before_4s.pngpair 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. - "Slowly blinks brighter and darker" = the whole-layer breathe.
bg-glow-breatheswung the entire glow layer's opacity 0.85↔1 over 14s (alternate) plusscale(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: 0on 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 <= clientWidthat 360×740 with all fourfixed; inset: 0layers live.