feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down

This commit is contained in:
2026-08-29 17:27:04 -04:00
parent 6bf7f456d4
commit 1a60ecbd8b
186 changed files with 1738 additions and 7796 deletions
@@ -1,55 +0,0 @@
# Phase 21 — Thinking Window: No Scroll Back, Just Live
**Status:** Complete
**Date:** 2026-08-25
## Objective
Disable user scrolling in the Thinking window so it always shows the live tail
of the reasoning stream in a fixed 320px clip.
## What was implemented
### Task 01 — CSS change + unit pins (completed by prior agent)
- `frontend/assets/styles.css`: `details.thinking .thinking-text` changed from
`overflow-y: auto` to `overflow-y: hidden` (with owner-choice comment).
- `tests/unit/test_thinking_no_scroll.py`: source-level pins for CSS value,
comment, and JS bottom-pin integrity.
### Task 02 — E2E story suite, story file, validation, commit (completed this run)
- `tests/e2e/test_thinking_no_scroll.py` (5 tests):
1. `test_thinking_window_not_user_scrollable` — wheel/drag/keyboard on the
frozen live tail do not move the window.
2. `test_thinking_window_tracks_live_tail` — per-chunk pin keeps the window
glued to the tail; last chunk's text is inside the visible rectangle.
3. `test_thinking_window_css_contract` — computed `overflow-y` is `hidden`,
`max-height` is `320px`, clip is real.
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — answer
bubble overflow is untouched, page scrolls normally.
5. `test_restored_collapsed_thinking_unaffected` (regression, phase 17) —
stored thinking restores collapsed with full text.
- `.agent/user_stories/thinking-no-scroll.md` — story file with bug report,
narrative, acceptance criteria, owner-confirmed decisions, and Playwright
mapping.
## Test / lint / coverage results
| Check | Command | Result |
|-------|---------|--------|
| E2E story suite | `uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov` | **5/5 passed** |
| Regression: thinking display | `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` | **5/5 passed** |
| Regression: long answers | `uv run pytest tests/e2e/test_long_answers.py -v --no-cov` | **2/2 passed** |
| Full suite | `uv run pytest --no-cov` | **319 passed** |
| Coverage | `uv run pytest --cov=app --cov-report=term-missing` | **99%** (gate: >90%) |
| Lint | `uv run ruff check .` | **All checks passed** |
| Types | `uv run pyright` | **0 errors** |
## Notable decisions
- No JS changes: the phase-17 bottom-pin (`textEl.scrollTop = textEl.scrollHeight`)
already works under `overflow-y: hidden`, so the CSS-only change is sufficient.
- The E2E tests use the mock's `think out loud then hesitate` trigger (phase 20)
to create a 4s frozen window where user scroll would be observable if regressed.
- The long thinking body (~2,700 chars, lengthened in phase 21) overflows the
320px window by ~2x, making the live-tail clip contract E2E-observable.
## Deviations
None. All changes follow LOCKED DECISIONS A11 (no CDN, pure CSS), A16 (one E2E
suite per story), and the owner-confirmed roadmap A2 decisions.
@@ -1,186 +0,0 @@
# Phase 22 / Task 01 — Background animation diagnosis + fix
**Owner report (2026-08-24):** "background animation not working, just
blinking". **Method:** live dev server (`uv run uvicorn app.main:app`,
db up), headless Chromium 1280×800 (Playwright), ≥15s observation per
session, computed-style sampling + pixel-diffed screenshots. All
artifacts in `.agent/screenshots/22_background_animation/`.
## Diagnosis (per the 00_phase.md checklist)
**1. Per-layer visibility.** Both pseudo-layers exist, computed
`position: fixed`, `z-index: -1`, `pointer-events: none`, both animating.
Per-layer screenshots (`before_glow_only.png`, `before_grid_only.png`):
the glow is clearly visible in its two corners; the grid is drawn **only
in a narrow band below the header** (top ~25% of the viewport) and is
barely distinguishable from the page bg.
**2. Grid layer — moving but imperceptible.** `background-position` on
`body::before` sampled over 16.8s: `0.86px → 13.2px` (≈0.79px/s — the
spec'd 44px/60s, seamless one-cell drift). The drift is *real*.
Isolating it (glow hidden, two frames 5s apart,
`before_gridonly_frame1/2.png`):
| metric | value |
|---|---|
| pixels changed (>12/765) | 1.5% |
| mean pixel change | **0.48/765** (≈0.2/255 per channel) |
| bottom-half quadrants | **≈0.07** (no grid there at all — masked out) |
Why: 1px lines at **35% of `--line`** blend to ≈rgb(20,26,41) on
rgb(10,14,23) — a ≈10/12/18-level delta — and the radial mask
(`120% 90% at 50% 0%, black 25%, transparent 78%`) fades the layer to
~zero past ~560px at center (probe: lines gone by y≈254 at the edges).
A 0.73px/s drift of that faint texture sits below the perception
threshold → the grid contributes no visible motion.
**3. Glow layer — the only visible motion = the "blink".** Full-background
frame diff (5s apart, `before_frame1/2.png`): 1.4% of pixels changed,
concentrated exactly on the two glow centers — top-left mean 3.07,
bottom-right 2.92 (glow positions 12%/8% and 88%/92%), top-right 1.23,
bottom-left 0.70. The 0.65↔1.0 opacity swing (35% amplitude, + scale)
is the entire perceived animation → reads as a blink/pulse, not
breathing.
**4. Occlusion check — ruled out.** Forced the grid lines to bright red:
3127 red-line pixels render (`before_occlusion_probe.png`, x 132–1276,
y 64–254 — the mask shape). Computed: `html` bg `rgb(10,14,23)`
(= `var(--bg)`), `body` bg `rgba(0,0,0,0)` (transparent), no later rule
paints over the layers. Assumption (c) **does not fire**.
### Root cause
**Assumptions (a)+(b) confirmed, (c) disproven.** The grid drift works
but is invisible (faint 35%-alpha lines × small masked region × 0.73px/s),
so the only perceived motion is the glow's large opacity swing — which
is exactly what "just blinking" is.
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
| property | before (phase 08) | after (phase 22) |
|---|---|---|
| grid line alpha | `rgb(38 48 74 / 0.35)` ×2 | **`rgb(38 48 74 / 0.6)`** ×2 |
| grid mask | `120% 90% at 50% 0%, black 25%, transparent 78%` | **`140% 110% at 50% 0%, black 40%, transparent 90%`** |
| grid drift | 60s, 0→44px | **unchanged** (60s, 0→44px — one-cell seamless) |
| glow opacity | 0.65 ↔ 1 | **0.85 ↔ 1** |
| glow period/scale | 14s ease-in-out alternate, 1↔1.05 | **unchanged** |
- Grid: +71% line contrast and a wider fully-visible mask radius make
the same 60s one-cell drift readable as smooth motion; the grid now
extends across most of the viewport, fading to the corners.
- Glow: amplitude cut 35%→15% — breathing, not pulsing.
- Durations deliberately untouched: the owner-confirmed intended effect
is the phase-08 design (60s drift + 14s breathe), and
`tests/e2e/test_dark_tech_theme.py::test_animated_background` pins the
live 60s/14s durations. Both layers keep `fixed; inset: 0; z-index:
-1; pointer-events: none`; `html` keeps `var(--bg)`, `body` stays
transparent; reduced-motion stilling block untouched.
## After (same viewport, same instrumentation)
| metric | before | after |
|---|---|---|
| grid-only 5s diff, mean \|d\| | 0.48 | **1.82** (3.8×) |
| grid-only, pixels changed | 1.5% | **6.1%** |
| grid-only bottom-half | ≈0.07 (absent) | **1.43–1.47** (present + moving) |
| full 5s diff, pixels changed | 1.4% | **4.2%** |
| full quadrant means (TL/TR/BL/BR) | 3.07 / 1.23 / 0.70 / 2.92 | 2.92 / 2.11 / 1.69 / 2.48 |
Motion is now distributed across the whole viewport (drift everywhere +
gentler corner breathing) instead of concentrated in the two glow
corners; after frames (`after_frame1/2.png`, 5s apart) confirm no static
frame. Visual check of `after_frame1.png`: grid clearly visible, still
subtle — no text/contrast impact, no jank (compositor-friendly
`background-position`/`opacity`/`transform` only), no 360px overflow
(layers remain `fixed; inset: 0`; `test_responsive_polish.py` 7/7 green).
## Screenshots
`before_frame1/2.png` (5s pair), `before_glow_only.png`,
`before_grid_only.png`, `before_occlusion_probe.png`,
`before_gridonly_frame1/2.png` (isolated grid pair), `after_frame1/2.png`
(5s pair), `after_gridonly_frame1/2.png`, `diagnosis_log.txt` (timeline
samples), `diagnosis_diff.txt` (all diff numbers).
## Tests (task 01)
- New: `tests/unit/test_background_animation.py` (10 source pins on the
final values + the no-occlusion + no-blur/zero-JS anchors) — green.
- `uv run pytest --cov=app` — 329 passed, app coverage 99%.
- `uv run ruff check . && uv run pyright` — clean.
- Regressions (isolated): `test_dark_tech_theme.py` 6/6,
`test_responsive_polish.py` 7/7.
---
# Task 02 — E2E story suite, story file, validation, commit
## Story gate: `tests/e2e/test_background_animation.py` (5 tests)
The layers are CSS pseudo-elements, so the suite asserts via computed
style + the Web Animations API. **Implementation note (deviation from
the task file, documented per the plan's "found, not guessed" rule):**
the task assumed Chromium reports pseudo-element CSS animations through
`element.getAnimations()` — verified false on Chromium 151
(`document.body.getAnimations()` returns `[]` for pseudo-layers); the
animations ARE enumerated by `document.getAnimations()`, with
`animationName` / `playState` / `currentTime` intact. The suite uses the
document-level list and matches on `animationName`. A second finding:
headless Chromium starts the document animation timeline ~1.4s after
navigation (currentTime stays 0 until then), so `test_animations_advance`
polls until both timelines are alive before sampling.
1. `test_grid_layer_animation_running` — computed `body::before`:
`animationName = bg-grid-drift`, timing `linear`, iterations
`infinite`; matching `CSSAnimation` with `playState === "running"`.
2. `test_glow_layer_animation_running` — same for `body::after`
(`bg-glow-breathe`); `playState === "running"`.
3. `test_animations_advance` — `currentTime` of both layers advances by
≥200ms over a 500ms wait — the timelines are truly running, not
paused (no static frame).
4. `test_background_layers_contracts` — both pseudo-elements: `position:
fixed`, `z-index: -1`, `pointer-events: none`, `inset: 0` (0px on all
four edges); `<html>` computed background `rgb(10, 14, 23)` (canvas
stays on html), `<body>` computed background `rgba(0, 0, 0, 0)`
(no occlusion).
5. `test_no_horizontal_overflow_with_layers` — 360px viewport:
`documentElement.scrollWidth <= clientWidth` (phase-07 pin,
replicated locally).
## Results (all commands, final state)
| command | result |
|---|---|
| `uv run pytest tests/e2e/test_background_animation.py -v --no-cov` | **5 passed** (run 4× — stable) |
| `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov` | **6 passed** (isolated) |
| `uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov` | **7 passed** (isolated) |
| `uv run pytest` | **329 passed** |
| `uv run pytest --cov=app --cov-report=term-missing` | 329 passed, **app coverage 99%** (≥90% gate; unchanged from task 01) |
| `uv run ruff check . && uv run pyright` | **clean** (0 errors) |
Prerequisite honored: `podman compose up -d db` — `brain-of-reese_db_1`
Up (healthy) throughout.
## UI Structure Check (AGENTS.md rule 5)
- **Layers behind content:** E2E `test_background_layers_contracts`
pins `position: fixed`, `z-index: -1`, `pointer-events: none`,
`inset: 0` on both pseudo-layers in the live viewport (plus the
html-canvas/body-transparent no-occlusion pair).
- **No text/contrast impact:** the fix touches only line alpha, mask
radius, and glow opacity — no palette token, no text on the layers,
all WCAG AA pairs unchanged (phase-08 suite green).
- **No 360px overflow:** E2E pin `scrollWidth <= clientWidth` at
360×740 — live check `scrollWidth=360 clientWidth=360`; the
`fixed; inset: 0` layers add no width (phase-07 suite green).
## Final screenshots (fresh server, headless Chromium 151)
- `final_frame1.png` / `final_frame2.png` — 1280×800, 3s apart: grid
drift visible across the viewport (live sample at frame 2:
`background-position 2.55px` after ≈3.5s ≈ 0.73px/s — the spec'd
one-cell/60s drift), glow corners breathing gently.
- `final_360px.png` — 360×740, no horizontal overflow.
- Task-01 before/after evidence (root-cause + fix delta) in the same
directory (`before_*`, `after_*`, `diagnosis_log.txt`,
`diagnosis_diff.txt`).
## Story file
`.agent/user_stories/background-animation.md` — goal, bug report
verbatim (TODO.md L5), owner-confirmed A3 decisions, found root cause,
fix table, acceptance criteria, UI structure, Playwright mapping table.
@@ -1,120 +0,0 @@
# Phase 23 report — Containerfile: build the whole app image again
**Date:** 2026-08-24 · **Source:** `TODO.md` L6 ("Fix Containerfile build
not working") · **Story:** `.agent/user_stories/containerfile-build.md`
## Root cause (verified, not guessed — logs in this directory)
1. **Build failure:** phase 19's absolute `import … from
"/assets/header.js"` is resolved by esbuild as the *filesystem* path
`/assets/header.js` → `✘ [ERROR] Could not resolve "/assets/header.js"`,
reproduced with the exact pinned esbuild **0.25.5**
(`repro_esbuild_0.25.5.log`, `repro_podman_build_before_fix.log`).
2. **Under-shipped image:** stage 1 bundled only `app.js` +
`sources.js` and copied only `index.html` + `sources.html` —
`document.html` / `login.html` / `document.js` / `login.js` /
`markdown.js` were all missing from the image.
3. **Double-evaluation trap:** the four direct `<script
src="/assets/header.js">` tags would evaluate the header module
twice in the image (bundled copy + raw module) → duplicate sign-out
listener, double init.
4. **Second blocker (task 01):** a local `npm install` of esbuild left
the binary off the next `RUN`'s PATH (`esbuild: not found`) → the
stage installs it globally.
## Fix
| piece | after |
|---|---|
| page-script header import | `from "./header.js"` (relative — owner-confirmed over an esbuild alias) |
| direct header.js `<script>` tags | **removed from all four pages** (the hoisted import covers evaluation order) |
| esbuild install | `npm install --no-audit --no-fund -g esbuild@0.25.5` (pinned, on PATH) |
| stage-1 bundles | `app.js`, `sources.js`, `document.js`, `login.js` |
| stage-1 minifies | `styles.css` + `markdown.js` (no `--bundle` — classic script) |
| stage-1 copies | all four HTML pages into `/out` |
## Build gate — `podman build` (task 01, `podman_build_after_fix.log`)
```
[1/3] STEP 3/5: RUN npm install --no-audit --no-fund -g esbuild@0.25.5
[1/3] STEP 5/5: RUN mkdir -p /out/assets && esbuild ./assets/app.js --bundle … && … && cp ./index.html ./sources.html ./document.html ./login.html /out/
../out/assets/app.js 14.3kb ⚡ Done in 2ms
../out/assets/sources.js 2.3kb ⚡ Done in 2ms
../out/assets/document.js 2.4kb ⚡ Done in 2ms
../out/assets/login.js 1.5kb ⚡ Done in 2ms
[3/3] COMMIT brain-of-reese/app:phase23test
Successfully tagged localhost/brain-of-reese/app:phase23test
39f5563a1b101b274d81d86aa06f86a4d6d1bba453e8dba40d6f48f987537019
BUILD_EXIT=0
```
## Image smoke (task 01, `image_smoke.log`)
Throwaway Postgres 17 + pgvector (`bor-smoke-db`, isolated network),
production env, teardown done:
- app boots: migrations applied, uvicorn up, `/api/health` →
`{"status":"ok","db":"up",…}`
- pages: `GET /`, `/sources.html`, `/document.html`, `/login.html` → **200**
- assets: `app.js`, `sources.js`, `document.js`, `login.js`,
`markdown.js`, `styles.css` → **200**
- single-evaluation: raw `/assets/header.js` → **404** (expected — the
header code ships inside each page bundle); all four bundles are
minified and contain the header markers (`sign-out-btn`,
`/api/logout`)
- No CDN rule: `cdn_violations=0` across all four served pages
- `direct_header_script_tags=0`
- → **`SMOKE_PASS`**
## Task 02 — hermetic coverage pin + validation
`tests/integration/test_containerfile_assets.py` (6 tests, no podman /
no network / no DB — parses `Containerfile` + `frontend/` as text):
1. `test_every_html_page_is_copied_into_stage1` — cp set == pages on disk
2. `test_every_local_asset_reference_is_produced` — every `assets/`
src/href has a stage-1 producer
3. `test_page_module_scripts_are_bundled` — HTML module set == esbuild
`--bundle` input set
4. `test_header_module_is_imported_not_directly_loaded` — no direct
header.js tag anywhere; every page script imports `./header.js`
relatively
5. `test_markdown_js_is_a_produced_classic_script` — minified without
`--bundle`; no top-level import/export in the source
6. `test_esbuild_stays_pinned` — concrete `esbuild@X.Y.Z`, no floating
spec
Mutation-checked while authoring: removing `login.html` from the cp
line (→ test 1 fails), `esbuild@latest` (→ test 6 fails), deleting the
`markdown.js` line (→ tests 2+5 fail), deleting the `document.js`
bundle line (→ test 3 fails), re-adding a direct header.js tag (→
tests 3+4 fail). All pins fire.
### Test / lint / coverage results (exact commands)
| command | result |
|---|---|
| `uv run pytest tests/integration/test_containerfile_assets.py -v --no-cov` | 6 passed |
| `uv run pytest tests/e2e/test_smoke.py -v --no-cov` (isolated) | **3 passed** (`e2e_smoke.log`) |
| `uv run pytest tests/e2e/test_shared_header.py -v --no-cov` (isolated) | **6 passed** (`e2e_shared_header.log`) |
| `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov` (isolated) | **4 passed** (`e2e_chat_persistence.log`) |
| `uv run pytest` | **335 passed** (329 → 335: the six new pins) |
| `uv run pytest --cov=app --cov-report=term-missing` | **335 passed; TOTAL 1109 stmts, 14 missed, 99%** (≥ today's 99%; >90% gate) |
| `uv run ruff check . && uv run pyright` | **All checks passed! / 0 errors, 0 warnings** |
Dev-server behavior unchanged: the tag removal is covered by the three
isolated E2E regressions above (browser-level header init, sign-out
binding exactly once, chat persistence across navigation).
## Decisions
- Relative imports over an esbuild alias (owner-confirmed A4-1).
- Direct header.js tags removed over shipping a raw header.js
(owner-confirmed A4-2 — single module evaluation).
- No Playwright suite for this phase (build/infrastructure): the gate
is the hermetic integration pin + the real `podman build` + image
smoke recorded above + the dev-server E2E regressions (A16 note in
the phase plan).
- No LOCKED anchor changed (A11 No-CDN and A16 honored).
- Build-evidence image `localhost/brain-of-reese/app:phase23test` kept
locally.
@@ -1,97 +0,0 @@
# 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)
1. **"Jitters down and to the right every second" = the grid drift.**
`bg-grid-drift` translated 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 the `before.png`/`before_4s.png` pair 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.
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
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: 0` on 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 <= clientWidth` at
360×740 with all four `fixed; inset: 0` layers live.