Files
brain-of-reese/.agent/user_stories/background-no-motion.md
T

182 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).