# Phase 25 — Background: No Motion, Only Fading Light **Source:** Owner report (2026-08-25, chat): the background "jitters down and to the right every second and it slowly blinks brighter and darker. 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` (created by task 02) **Context:** `frontend/assets/styles.css` — the background block: `body::before` (44px grid, 1px lines at 60% `--line` alpha, widened radial mask, `animation: bg-grid-drift 60s linear infinite` → `0 0` → `44px 44px` ≈ 0.73px/s down-right) and `body::after` (indigo + cyan radial glows, `animation: bg-glow-breathe 14s ease-in-out infinite alternate` → opacity 0.85↔1 + scale 1↔1.05). Both are `position: fixed; inset: 0; z-index: -1; pointer-events: none`; `` owns the `var(--bg)` canvas, `` stays transparent (no-occlusion contract). Pins to adapt: `tests/unit/test_background_animation.py`, `tests/e2e/test_background_animation.py`, `tests/e2e/test_dark_tech_theme.py` (`test_animated_background` pins 60s/14s; `test_reduced_motion_honored` pins the two body pseudo-layers). ## Objective Stop the background from moving entirely, and replace the uniform whole-layer "blink" with **different bright spots that slowly fade in and out**: the grid becomes a static texture, and three soft glow spots (phase-08 colors/positions, plus a third spot) each run their own slow, **opacity-only** fade cycle at a different period, so the background's brightness fluxuates smoothly and irregularly — no blink, no jitter, no motion. ## Root cause (found from the code, 2026-08-25) 1. **"Jitters down and to the right every second"** = `bg-grid-drift`: 44px/60s (≈0.73px/s) in the diagonal `44px 44px` direction (exactly down-right). A 1px grid line translated sub-pixel-by-sub-pixel is rasterized with per-frame stepping/shimmer — perceived as a once-per- second jitter, not smooth drift. Phase 22 made the drift *visible*; that is precisely why it now reads as jitter. 2. **"Slowly blinks brighter and darker"** = `bg-glow-breathe`: a uniform whole-layer opacity swing 0.85↔1 over 14s (alternate) plus `scale(1)↔scale(1.05)` — the entire background pulses in unison (the scale adds a faint zoom). One synchronized pulse 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 now supersedes that design intent — this is an owner revision of a **design comment**, not of any LOCKED anchor: A1–A17 are untouched, and the phase-08 anchors this phase must still honor are pure CSS / zero JS / no CDN / no new assets / no `filter: blur` (A11 + the block's perf note). ## Owner direction (2026-08-25, verbatim) > "It should be smooth, fluxuating, dimming and brightening, but not > moving. Different bright spots should slowly fade in and out." 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.) ## Design (pure CSS, zero JS — A11 anchor) - `body::before` — grid: **remove** the `animation`; **delete** `@keyframes bg-grid-drift`. Keep the 44px cells, 60% `--line` alpha lines, and widened radial mask (the static texture). - Three glow-spot layers, one soft radial gradient each, **opacity-only** keyframes (`0%,100%` low → `50%` 1, `ease-in-out`, `infinite`), with different durations + negative delays so the cycles are out of phase (periods 26/34/42s → LCM 4641s, the composite pattern effectively never repeats within a viewing session): | layer | spot (gradient) | keyframes | cycle | |---|---|---|---| | `body::after` | indigo `rgb(109 120 242 / 0.14)`, circle 56rem at 12% 8% (phase-08) | `bg-glow-a` | 26s, low opacity 0.25 | | `html::before` | cyan `rgb(34 211 238 / 0.10)`, circle 60rem at 88% 92% (phase-08) | `bg-glow-b` | 34s, delay −12s, low 0.20 | | `html::after` | indigo `rgb(109 120 242 / 0.09)`, circle 52rem at 14% 86% | `bg-glow-c` | 42s, delay −23s, low 0.15 | - `html::before` / `html::after` join `body::after` as background layers: `` is the root stacking context — its `z-index: -1` pseudo-elements paint **above** the `var(--bg)` canvas and **below** the transparent, non-stacking ``'s content, so the no-occlusion contract holds unchanged (verify in the E2E, not just assume). - All four layers keep: `content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;` - `prefers-reduced-motion: reduce` stills **all four** layers (`animation: none`). - No `filter` (phase-08 no-blur perf anchor), no JS, no new assets; the WCAG palette and every text contrast pair are untouched (the layers carry no text). Opacity-only keyframes stay compositor-friendly. ## Dependencies - `08_story_dark_tech_theme` (complete) — the layers, the palette, the pure-CSS / no-blur / no-CDN anchors, and the 60s/14s pins in `tests/e2e/test_dark_tech_theme.py` this phase adapts. - `22_background_animation` (complete) — the current implementation and the unit/E2E pins this phase supersedes. - `07_story_responsive_polish` (complete) — the 360px overflow pin (the layers are `fixed; inset: 0` — they must add no width). ## Tasks 1. `01_still_background_css.md` — the CSS redesign (static grid + three opacity-only glow fades) + unit source pins (new `tests/unit/test_background_no_motion.py`; the phase-22 pins in `tests/unit/test_background_animation.py` adapted). 2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_background_no_motion.py` (the story gate, isolated), the regression suites adapted (`test_background_animation.py`, `test_dark_tech_theme.py`), the story file, the phase report + screenshots, final validation, the single atomic commit, phase move to `complete/`. ## Locked decisions - **A11 untouched** — vanilla HTML/CSS/JS in git, no CDN, zero JS, system fonts; the whole change is CSS. **No `filter`/`blur`** (phase-08 perf anchor). No new assets. **No anchor changed** — the superseded spec is the phase-08 *design intent* (grid drift + whole-layer breathe), not a LOCKED decision; the owner's 2026-08-25 direction is recorded above as the revision. **A16 honored** — one new story E2E suite + adapted regressions. **A17 honored** — one atomic `--no-gpg-sign` commit. ## Testing & Quality - **Unit (source-level, repo source-pin pattern):** - New `tests/unit/test_background_no_motion.py` — pins the full new contract (task 01, step 6). - Adapted `tests/unit/test_background_animation.py` — the phase-22 drift/breathe pins flip to the new contract; the generic layer-plumbing and no-blur/no-JS sections stay (task 01, step 7). - **Integration:** none (no `app/` changes). - **Coverage:** frontend-only; the >90% `app/` gate is unaffected (TOTAL must stay ≥ the pre-change number). - **E2E:** `tests/e2e/test_background_no_motion.py` (task 02), green **in isolation** (prereq `podman compose up -d db`); regressions green in isolation after adaptation: `test_background_animation.py`, `test_dark_tech_theme.py`, `test_responsive_polish.py`. - **Lint/types:** `uv run ruff check . && uv run pyright` clean. ## Completion Criteria - [ ] No movement: the grid is static (`bg-grid-drift` gone, no `animation` on `body::before`) and **no `bg-*` keyframe animates anything but `opacity`** — audited in a real Chromium via `document.styleSheets` (E2E test 3). - [ ] Three distinct bright spots (`body::after`, `html::before`, `html::after`) run distinct slow opacity fades (26s/34s/42s, out of phase); the timelines advance; the layer opacity and a clipped screenshot of the glow region measurably change within a few seconds (E2E tests 2, 4, 5). - [ ] Contracts hold: all four layers `fixed; inset: 0; z-index: -1; pointer-events: none`; `` keeps the `var(--bg)` canvas and `` stays transparent (no occlusion); `prefers-reduced-motion` stills all four; no horizontal overflow at 360px (E2E tests 6–8). - [ ] `.agent/reports/25_background_no_motion/` documents the two root causes with before/after screenshot pairs (`.agent/screenshots/25_background_no_motion/`). - [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL ≥ pre-change number (gate >90%). - [ ] `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov` green in isolation; `test_background_animation.py`, `test_dark_tech_theme.py`, `test_responsive_polish.py` green in isolation after adaptation. - [ ] `uv run ruff check . && uv run pyright` clean. - [ ] UI Structure Check (AGENTS.md rule 5): layers stay behind content, no text/contrast impact, no 360px overflow. - [ ] `.agent/user_stories/background-no-motion.md` exists; the old `background-animation.md` story carries a supersession note. - [ ] One `--no-gpg-sign` commit staging **only this phase's files** (the unrelated dirty `TODO.md` must NOT be staged); `.agent/phases/todo/25_background_no_motion/` moved to `.agent/phases/complete/`. ## Commit ```bash git add -f .agent/phases/complete/25_background_no_motion \ .agent/user_stories/background-no-motion.md \ .agent/user_stories/background-animation.md \ .agent/reports/25_background_no_motion \ .agent/screenshots/25_background_no_motion git add frontend/assets/styles.css tests/unit tests/e2e git commit --no-gpg-sign -m "fix(ui): background no longer moves — static grid, three glow spots fading in and out on their own slow cycles (owner 2026-08-25)" ```