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
@@ -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
+181
View File
@@ -0,0 +1,181 @@
# Story: A Background That No Longer Moves — Only Fading Light
**Phase:** `25_background_no_motion` · **E2E:**
`tests/e2e/test_background_no_motion.py`
**Supersedes:** `.agent/user_stories/background-animation.md`
(phase-22 motion design — history preserved there)
## Narrative
As **the owner**, I reported (2026-08-25, chat) that the phase-22
background "jitters down and to the right every second and it slowly
blinks brighter and darker". I want the background to be smooth,
fluxuating, dimming and brightening — but **not moving** — with
**different bright spots** that slowly fade in and out.
- **Given** the phase-22 animated background (a 60s one-cell grid drift
at 0.73px/s diagonally down-right + a 14s whole-layer glow breathe of
opacity 0.85↔1 with scale 1↔1.05)
- **When** the page is observed in a real Chromium viewport
- **Then** nothing moves — the grid is a static texture and no
background keyframe animates anything but `opacity` — while three
independent bright spots each fade in and out on their own slow,
out-of-phase cycles (26s/34s/42s), so the total light fluxuates
smoothly and irregularly: no jitter, no blink, no static frame, no
new overflow at 360px, and no impact on text contrast or
interactivity.
## Owner report (verbatim, 2026-08-25, chat)
> It should be smooth, fluxuating, dimming and brightening, but not
> moving. Different bright spots should slowly fade in and out.
## Owner direction (2026-08-25)
1. **No movement** — no grid drift, no `scale`/`transform`, no
`background-position` animation, anywhere in the background.
2. **Fluxuating brightness** — overall page brightness varies smoothly
and irregularly (not one synchronized pulse).
3. **Different bright spots** — multiple glow spots, each fading in
and out on its own slow cycle.
4. **The static grid stays** — the owner rejected the grid's *motion*,
not the grid; it remains as a still texture. (If the owner later
wants the grid gone, that is a follow-up, not this phase.)
## Root cause (found from the code + phase-22 measurements)
1. **"Jitters down and to the right every second" = the grid drift.**
`bg-grid-drift` moved the 44px grid 44px per 60s (≈0.73px/s)
diagonally down-right — exactly the reported direction. A 1px grid
line translated sub-pixel by sub-pixel is rasterized with per-frame
stepping/shimmer, not smooth motion: it reads as a once-per-second
jitter. Phase 22 had made that drift *visible* (60% line alpha,
wider mask — measured 1.82/765 mean pixel change over 5s in the
grid zone); that is precisely why it now reads as jitter.
2. **"Slowly blinks brighter and darker" = the whole-layer breathe.**
`bg-glow-breathe` swung the ENTIRE glow layer's opacity 0.85↔1 over
14s (alternate) plus `scale(1)↔scale(1.05)` (a faint zoom). One
synchronized pulse of the whole background reads as a blink; the
owner wants independent spots instead.
Phase 22 served the phase-08 design intent (grid drift + whole-layer
breathe). The owner's 2026-08-25 direction supersedes that **design
intent** — no LOCKED anchor changed (A11 stays pure CSS / zero JS / no
CDN / no new assets; the no-`filter: blur` perf anchor is honored).
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
| property | phase 22 | phase 25 |
|---|---|---|
| grid (`body::before`) | `bg-grid-drift 60s linear infinite` (0→44px) | **static** — no animation, `bg-grid-drift` deleted (44px cells, 60% `--line` 1px lines, widened mask kept) |
| glow layer count | one whole-layer breathe | **three independent spot layers** |
| spot A (`body::after`) | indigo + cyan spots, 14s opacity 0.85↔1 + scale 1↔1.05 | **indigo `rgb(109 120 242 / 0.14)` 56rem at 12%/8%**, `bg-glow-a` **26s** ease-in-out infinite, low opacity **0.25** |
| spot B (`html::before`) | — (cyan shared body::after) | **cyan `rgb(34 211 238 / 0.10)` 60rem at 88%/92%**, `bg-glow-b` **34s** ease-in-out **−12s** infinite, low **0.20** |
| spot C (`html::after`) | — | **indigo `rgb(109 120 242 / 0.09)` 52rem at 14%/86%**, `bg-glow-c` **42s** ease-in-out **−23s** infinite, low **0.15** |
| keyframes | `bg-grid-drift` (background-position), `bg-glow-breathe` (opacity + transform) | **`bg-glow-a/b/c` — opacity only** (0%/100% low → 50% 1) |
| reduced motion | stills `body::before/::after` | stills **all four** layers |
All four layers keep `content: ""; position: fixed; inset: 0;
z-index: -1; pointer-events: none`. `<html>` keeps the `var(--bg)`
canvas and `<body>` stays transparent (the no-occlusion contract):
`html` is the root stacking context, so its `z-index: -1`
pseudo-elements paint above the canvas and below the transparent,
non-stacking `<body>`'s content. The 26/34/42s periods with negative
delays (LCM 4641s) keep the cycles out of phase — the composite
pattern effectively never repeats within a viewing session.
## Acceptance criteria
1. **No movement:** the grid is static (`body::before` computed
`animationName: none`; no `bg-grid-drift` in
`document.getAnimations()`; grid texture still painted) and —
audited in real Chromium via `document.styleSheets` — **no `bg-*`
keyframe animates anything but `opacity`** (the deterministic
no-movement proof).
2. **Three distinct bright spots** (`body::after`, `html::before`,
`html::after`) run distinct slow opacity fades (26s/34s/42s,
ease-in-out, infinite, pairwise distinct, out of phase); all three
timelines advance; the layer's computed opacity AND a clipped
screenshot of the bottom-left glow region measurably change within
a few seconds (a real fade, not a frozen frame).
3. **Contracts hold:** all four layers `position: fixed`, `z-index:
-1`, `pointer-events: none`, full-viewport `inset: 0`; `<html>`
keeps the `var(--bg)` canvas (`rgb(10, 14, 23)`) and `<body>` stays
transparent (`rgba(0, 0, 0, 0)`) — no occlusion.
4. **Reduced motion** stills all four layers
(`animationName: none`), the static grid + spot images remain.
5. No new horizontal overflow at 360px (the phase-07 pin).
6. Pure CSS, zero JS, no `filter: blur`, no new assets (A11 + phase-08
perf anchor); WCAG AA palette untouched (the layers carry no text).
7. Regressions green in isolation:
`tests/e2e/test_background_animation.py` (adapted to the phase-25
contract), `tests/e2e/test_dark_tech_theme.py` (grid static +
26/34/42s spots; reduced motion across all four layers),
`tests/e2e/test_responsive_polish.py`.
8. Unit + integration green, `app/` coverage >90%, story E2E green in
isolation, ruff + pyright clean.
## UI Visualization & Structure
- **Grid layer (`body::before`):** 44px cells, 1px lines at 60% of
`--line` (`rgb(38 48 74 / 0.6)`), widened radial mask
(`140% 110% at 50% 0%, black 40%, transparent 90%`) — a STATIC
texture, no animation.
- **Glow spot A (`body::after`):** indigo `rgb(109 120 242 / 0.14)`
56rem circle at 12%/8% (phase-08 position/color); opacity-only fade
0.25↔1 over 26s ease-in-out.
- **Glow spot B (`html::before`):** cyan `rgb(34 211 238 / 0.10)` 60rem
circle at 88%/92% (phase-08 position/color); fade 0.20↔1 over 34s,
−12s delay.
- **Glow spot C (`html::after`):** indigo `rgb(109 120 242 / 0.09)`
52rem circle at 14%/86%; fade 0.15↔1 over 42s, −23s delay.
- **Stacking / no occlusion:** the `<html>` canvas
(`var(--bg)` = `#0a0e17`) sits under all four `z-index: -1` layers;
`<body>` stays transparent and non-stacking, so nothing can paint
over the layers — verified live, not assumed.
- **Motion:** opacity-only keyframes (compositor-friendly); no
`transform`, no `background-position`, no `filter` anywhere in the
background; `prefers-reduced-motion: reduce` stills all four layers
(the static background remains visible).
## Playwright Mapping Rule
**Test Scenario → `tests/e2e/test_background_no_motion.py`** (the
layers are CSS pseudo-elements — asserted via computed style + the Web
Animations API + a live `document.styleSheets` audit; Chromium
enumerates pseudo-element CSS animations in `document.getAnimations()`,
not `document.body.getAnimations()`, and the `html` pseudo-layers'
computed styles come from
`getComputedStyle(document.documentElement, "::before"/"::after")`):
1. `test_grid_layer_is_static` — computed `animationName` of
`body::before` is `"none"`; no `bg-grid-drift` entry in
`document.getAnimations()`; the grid `backgroundImage` is still
present (the static texture survives).
2. `test_three_glow_layers_run_distinct_fades` — `body::after` →
`bg-glow-a` (26s), `documentElement::before` → `bg-glow-b` (34s),
`documentElement::after` → `bg-glow-c` (42s); each `ease-in-out` +
`infinite`, with a matching `playState === "running"` entry in the
document animation list; the three durations are pairwise distinct.
3. `test_no_motion_properties_in_background_keyframes` — walk
`document.styleSheets`; for every `CSSRule.KEYFRAMES_RULE` whose
name starts with `bg-`, collect the declared property names of
every keyframe frame; the set across all frames is exactly
`{"opacity"}` — the deterministic no-movement proof.
4. `test_glow_timelines_advance` — poll until all three timelines
report `currentTime > 0` (headless Chromium starts the document
timeline ~1s after load), sample all three, wait ~500ms, each
advanced ≥ 200ms.
5. `test_background_light_actually_changes` — (a) the computed opacity
of `body::after` changes by ≥ 0.05 within ~8s (a real fade, not a
frozen frame); (b) two clipped screenshots ~4s apart of the
bottom-left glow region (the `html::after` spot at 14%/86%) differ
in bytes — the light visibly changes while nothing moves.
6. `test_background_layers_contracts` — all four pseudo-layers:
`position: fixed`, `z-index: -1`, `pointer-events: none`,
top/right/bottom/left all `0px`; `documentElement` computed
background is `rgb(10, 14, 23)` (canvas stays on `html`);
`document.body` computed background is `rgba(0, 0, 0, 0)` (no
occlusion).
7. `test_reduced_motion_stills_all_layers` —
`reduced_motion="reduce"` context: all four pseudo-layers report
computed `animationName` `"none"` and still carry a
`backgroundImage`.
8. `test_no_horizontal_overflow_with_layers` — 360px viewport:
`documentElement.scrollWidth <= clientWidth` (the phase-07 pin).