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

10 KiB
Raw Blame History

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