fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design
@@ -1,104 +0,0 @@
|
||||
# Phase 21 — Thinking Window: No Scroll Back, Just Live
|
||||
|
||||
**Source:** `TODO.md` L4 — *"Disable scroll in the thinking window.
|
||||
Users don't need to scroll back through thinking, just see it live."*
|
||||
**Story:** `.agent/user_stories/thinking-no-scroll.md` (created by task 02)
|
||||
**Context:** `frontend/assets/styles.css` —
|
||||
`details.thinking .thinking-text` (today: `max-height: 320px;
|
||||
overflow-y: auto;`, i.e. a user-scrollable 320px window, ~line 444);
|
||||
`frontend/assets/app.js` — the phase-17 thinking block (`ensureThinkingBlock`,
|
||||
the streaming `thinking` branch that appends chunks and already pins the
|
||||
stream to the bottom: `textEl.scrollTop = textEl.scrollHeight`, ~line 886),
|
||||
and the phase-14 restore path (stored `thinking` re-renders a
|
||||
**collapsed** block).
|
||||
|
||||
## Objective
|
||||
The live Thinking block is a scratchpad, not a transcript. The user must
|
||||
not be able to scroll back through it — the 320px window always shows the
|
||||
**live tail** of the reasoning stream (the existing per-chunk
|
||||
bottom-pinning stays). Wheel, drag, and keyboard scrolling on
|
||||
`.thinking-text` stop working; the stream itself keeps pinning to the
|
||||
bottom as chunks arrive.
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap A2)
|
||||
1. **Keep the 320px clip** — "just see it live" means the window stays a
|
||||
fixed 320px viewport showing the newest lines; no auto-height growth,
|
||||
no "↓ more" affordance.
|
||||
2. **The answer bubble is untouched** — final answers keep their existing
|
||||
scroll behavior (phase 11 long answers).
|
||||
3. **Restored (collapsed) Thinking blocks are untouched** — the phase-14
|
||||
restore renders them collapsed, where overflow is moot.
|
||||
|
||||
## Design
|
||||
- **CSS (the whole functional change):**
|
||||
`details.thinking .thinking-text` — `overflow-y: auto` →
|
||||
`overflow-y: hidden`; keep `max-height: 320px`.
|
||||
`overflow: hidden` still permits **programmatic** scrolling
|
||||
(`scrollTop`), so the phase-17 pin
|
||||
(`textEl.scrollTop = textEl.scrollHeight` on every thinking chunk)
|
||||
keeps the window glued to the live tail — no JS change needed.
|
||||
Add a CSS comment: *no user scroll back (owner choice 2026-08-24):
|
||||
the window is a live tail only — the JS bottom-pin is the sole
|
||||
scroller*.
|
||||
- **No JS change** — the pin already exists; nothing else touches
|
||||
`.thinking-text` scroll.
|
||||
- **Non-goals:** no change to the answer bubble, the collapsed restore
|
||||
state, the summary/chevron, or the auto-collapse on first delta
|
||||
(phase 17).
|
||||
|
||||
## Dependencies
|
||||
- `17_thinking_display` (complete) — the block, the pin, the restore.
|
||||
- `18_follow_bottom_scroll` (complete) — no overlap (chat-page scroll
|
||||
gate only; the thinking window is a separate inner element).
|
||||
- `11_long_answers` (complete) — the untouched answer-bubble behavior.
|
||||
|
||||
## Tasks
|
||||
1. `01_disable_thinking_scroll.md` — the CSS change + source-level unit
|
||||
pins.
|
||||
2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_thinking_no_scroll.py`
|
||||
(the story gate, isolated), regression suites, story file, final
|
||||
validation, the single atomic commit, phase move to `complete/`.
|
||||
|
||||
## Locked decisions
|
||||
- **A11 untouched** — no CDN, pure CSS. **A16 honored** — one new story
|
||||
E2E suite + adapted regressions. No anchor changed.
|
||||
|
||||
## Testing & Quality
|
||||
- **Unit (source-level, new `tests/unit/test_thinking_no_scroll.py`,
|
||||
repo source-pin pattern):** `styles.css` carries
|
||||
`details.thinking .thinking-text` with `overflow-y: hidden` and
|
||||
`max-height: 320px`; the phase-17 pin line
|
||||
(`textEl.scrollTop = textEl.scrollHeight`) still present in `app.js`
|
||||
(the live-tail mechanism must not be lost).
|
||||
- **Integration:** none (no `app/` changes).
|
||||
- **Coverage:** frontend-only; the >90% `app/` gate is unaffected.
|
||||
- **E2E:** `tests/e2e/test_thinking_no_scroll.py` (task 02), green
|
||||
**in isolation** (prereq `podman compose up -d db`).
|
||||
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] With a long thinking stream, wheel/mouse-drag/keyboard on
|
||||
`.thinking-text` do **not** move it; the visible content is always
|
||||
the live tail (`scrollTop === scrollHeight` after each chunk,
|
||||
within 1px).
|
||||
- [ ] Computed style: `overflow-y: hidden`, `max-height: 320px`.
|
||||
- [ ] A long **answer** bubble still scrolls normally; a restored
|
||||
collapsed Thinking block still renders (phase 17 regression).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app
|
||||
--cov-report=term-missing` ≥ today's number.
|
||||
- [ ] `uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov`
|
||||
green in isolation; regressions green in isolation:
|
||||
`test_thinking_display.py`, `test_long_answers.py`.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] UI Structure Check (AGENTS.md rule 5): no new surface; the block
|
||||
keeps its focus-visible summary, aria contract, and reduced-motion
|
||||
behavior.
|
||||
- [ ] `.agent/user_stories/thinking-no-scroll.md` exists.
|
||||
- [ ] One `--no-gpg-sign` commit (below);
|
||||
`.agent/phases/todo/21_thinking_no_scroll/` moved to
|
||||
`.agent/phases/complete/`.
|
||||
|
||||
## Commit
|
||||
```bash
|
||||
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail"
|
||||
```
|
||||
@@ -1,70 +0,0 @@
|
||||
# Task 02 — E2E story suite, story file, validation, commit
|
||||
|
||||
**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4
|
||||
|
||||
## Objective
|
||||
The story gate: `tests/e2e/test_thinking_no_scroll.py` proves the window
|
||||
can't be user-scrolled but always tracks the live tail, plus regressions,
|
||||
story file, final validation, and the single atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_thinking_no_scroll.py` (new — reuse
|
||||
`test_thinking_display.py`'s mock-LLM streaming scaffolding; the mock
|
||||
must stream a **long** thinking body, in many chunks, so
|
||||
`.thinking-text` overflow exceeds its 320px box). Tests:
|
||||
1. `test_thinking_window_not_user_scrollable` — open the block, wait
|
||||
until `scrollHeight > clientHeight`; focus `.thinking-text`
|
||||
(`el.focus()`), dispatch mouse wheel over it
|
||||
(`page.mouse.wheel(0, -200)` after moving the mouse over the
|
||||
element) and press `Home`/`ArrowUp`: `scrollTop` must not decrease
|
||||
(assert `scrollTop` unchanged within 1px between actions).
|
||||
2. `test_thinking_window_tracks_live_tail` — while chunks stream,
|
||||
after the 2nd-to-last and last chunk:
|
||||
`scrollTop === scrollHeight` (within 1px) — the visible window is
|
||||
the live tail; the **last** chunk's text is within the visible
|
||||
rectangle (its offsetTop + scrollTop geometry check, or
|
||||
`elementFromPoint` at the box's bottom).
|
||||
3. `test_thinking_window_css_contract` — computed style of
|
||||
`.thinking-text`: `overflow-y === "hidden"`,
|
||||
`max-height === "320px"`.
|
||||
4. `test_answer_bubble_still_scrollable` (regression, phase 11) — a
|
||||
long answer (use the long-answer mock from
|
||||
`test_long_answers.py`): the answer bubble is still
|
||||
user-scrollable (scrollTop moves on wheel) and
|
||||
`overflow-y` is not `hidden` there.
|
||||
5. `test_restored_collapsed_thinking_unaffected` (regression,
|
||||
phase 17) — a turn with stored `thinking`, reload: the collapsed
|
||||
Thinking block renders with its text (existing pin from
|
||||
`test_thinking_display.py` — replicate, don't duplicate the file).
|
||||
2. `.agent/user_stories/thinking-no-scroll.md` (new) — story file per
|
||||
the repo format: goal, the bug report verbatim from `TODO.md` L4, the
|
||||
owner-confirmed A2 decisions from `00_phase.md`, E2E mapping table.
|
||||
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
|
||||
`uv run pytest tests/e2e/test_thinking_no_scroll.py -v --no-cov`.
|
||||
4. Regressions, in isolation, one command each:
|
||||
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`
|
||||
- `uv run pytest tests/e2e/test_long_answers.py -v --no-cov`
|
||||
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
|
||||
--cov-report=term-missing` ≥ today's number (>90% gate);
|
||||
`uv run ruff check . && uv run pyright` clean.
|
||||
6. **UI Structure Check** (AGENTS.md rule 5): no new surface; the block
|
||||
keeps its summary chevron, focus-visible ring, aria-live/label
|
||||
contract, and the reduced-motion stillness (styles.css ~line 686).
|
||||
7. Write the phase report (`.agent/reports/21_thinking_no_scroll/`).
|
||||
8. Commit (one atomic commit) and move the phase:
|
||||
```bash
|
||||
git add -A .agent/ frontend/ tests/
|
||||
git commit --no-gpg-sign -m "fix(ui): thinking window no longer scrolls — live 320px view pinned to the stream tail"
|
||||
mv .agent/phases/todo/21_thinking_no_scroll .agent/phases/complete/
|
||||
```
|
||||
|
||||
## Testing & Quality
|
||||
- Story suite green **in isolation**; both regression suites green in
|
||||
isolation; full unit+integration suite green; `app/` coverage at or
|
||||
above today's number (>90%); ruff + pyright clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `test_thinking_no_scroll.py` 5/5 in isolation.
|
||||
- [ ] Regressions (thinking display, long answers) green in isolation.
|
||||
- [ ] Story file + phase report exist.
|
||||
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.
|
||||
@@ -0,0 +1,186 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,24 @@
|
||||
== BEFORE (phase-08 CSS) ==
|
||||
FULL before, 5s apart: 1.436% px>12, mean|d|=1.9793, max=54
|
||||
top-left: mean|d|=3.0659
|
||||
top-right: mean|d|=1.2274
|
||||
bottom-left: mean|d|=0.7014
|
||||
bottom-right: mean|d|=2.9225
|
||||
GRIDONLY before, 5s apart (glow hidden): 1.526% px>12, mean|d|=0.4778, max=60
|
||||
top-left: mean|d|=0.8985
|
||||
top-right: mean|d|=0.8776
|
||||
bottom-left: mean|d|=0.0681
|
||||
bottom-right: mean|d|=0.0671
|
||||
OCCLUSION PROBE: 3127 red-line px (x 132..1276, y 64..254) -> grid layer IS painted (no occlusion); earlier 0-pixel reading was a too-strict absolute-threshold detector (blended red ≈ rgb(78,27,35))
|
||||
|
||||
== AFTER (phase-22 CSS: grid lines 0.6 alpha, mask 140% 110% / 40% / 90%, glow 0.85..1) ==
|
||||
FULL after, 5s apart: 4.159% px>12, mean|d|=2.3007, max=87
|
||||
top-left: mean|d|=2.9225
|
||||
top-right: mean|d|=2.1081
|
||||
bottom-left: mean|d|=1.6897
|
||||
bottom-right: mean|d|=2.4826
|
||||
GRIDONLY after, 5s apart (glow hidden): 6.094% px>12, mean|d|=1.8162, max=74
|
||||
top-left: mean|d|=2.2062
|
||||
top-right: mean|d|=2.1563
|
||||
bottom-left: mean|d|=1.4749
|
||||
bottom-right: mean|d|=1.4275
|
||||
@@ -0,0 +1,13 @@
|
||||
LAYER STATE (computed):
|
||||
before: {'position': 'fixed', 'zIndex': '-1', 'display': 'block', 'pointerEvents': 'none', 'maskImage': 'radial-gradient(120% 90% at 50% 0%, rgb(0, 0, 0) 25%, rgba(0, 0, 0, 0) 78%)', 'backgroundSize': '44px 44px, 44px 44px', 'animation': '60s linear infinite bg-grid-drift'}
|
||||
after: {'position': 'fixed', 'zIndex': '-1', 'display': 'block', 'pointerEvents': 'none', 'animation': '14s ease-in-out infinite alternate bg-glow-breathe'}
|
||||
htmlBg: rgb(10, 14, 23)
|
||||
bodyBg: rgba(0, 0, 0, 0)
|
||||
TIMELINE (t_s, body::before background-position, body::after):
|
||||
t= 0.0s grid-pos='0.855518px 0.855518px, 0.855518px 0.855518px' glow={'opacity': '0.654761', 'transform': 'matrix(1.00068, 0, 0, 1.00068, 0, 0)', 'animationName': 'bg-glow-breathe'}
|
||||
t= 2.8s grid-pos='2.92103px 2.92103px, 2.92103px 2.92103px' glow={'opacity': '0.708889', 'transform': 'matrix(1.00841, 0, 0, 1.00841, 0, 0)', 'animationName': 'bg-glow-breathe'}
|
||||
t= 5.6s grid-pos='4.97421px 4.97421px, 4.97421px 4.97421px' glow={'opacity': '0.815653', 'transform': 'matrix(1.02366, 0, 0, 1.02366, 0, 0)', 'animationName': 'bg-glow-breathe'}
|
||||
t= 8.4s grid-pos='7.02747px 7.02747px, 7.02747px 7.02747px' glow={'opacity': '0.927369', 'transform': 'matrix(1.03962, 0, 0, 1.03962, 0, 0)', 'animationName': 'bg-glow-breathe'}
|
||||
t= 11.2s grid-pos='9.09298px 9.09298px, 9.09298px 9.09298px' glow={'opacity': '0.990928', 'transform': 'matrix(1.0487, 0, 0, 1.0487, 0, 0)', 'animationName': 'bg-glow-breathe'}
|
||||
t= 14.0s grid-pos='11.1462px 11.1462px, 11.1462px 11.1462px' glow={'opacity': '0.994963', 'transform': 'matrix(1.04928, 0, 0, 1.04928, 0, 0)', 'animationName': 'bg-glow-breathe'}
|
||||
t= 16.8s grid-pos='13.1995px 13.1995px, 13.1995px 13.1995px' glow={'opacity': '0.940122', 'transform': 'matrix(1.04145, 0, 0, 1.04145, 0, 0)', 'animationName': 'bg-glow-breathe'}
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 127 KiB |
@@ -0,0 +1,120 @@
|
||||
# Story: Animated Background That Actually Animates
|
||||
|
||||
**Phase:** `22_background_animation` · **E2E:** `tests/e2e/test_background_animation.py`
|
||||
|
||||
## Narrative
|
||||
|
||||
As **the owner**, I reported (2026-08-24) that the Brain of Reese
|
||||
background "just blinks". I want the background the phase-08 design
|
||||
promised — a slow, seamless grid drift plus a gentle glow breathe — to
|
||||
be *perceived* as smooth motion in a real browser, so the app feels
|
||||
alive but calm, not like a broken flicker.
|
||||
|
||||
- **Given** the phase-08 pure-CSS background (a 60s grid drift masked
|
||||
into a small top band + a 14s glow opacity swing)
|
||||
- **When** the page is observed in a real Chromium viewport
|
||||
- **Then** both layers visibly and smoothly animate — the grid drift
|
||||
reads as continuous motion and the glow reads as breathing — with no
|
||||
blink, no static frame, no jank, no new overflow at 360px, and no
|
||||
impact on text contrast or interactivity.
|
||||
|
||||
## Bug report (verbatim, TODO.md L5)
|
||||
|
||||
> Fix background animation not working, just blinking
|
||||
|
||||
## Owner-confirmed (2026-08-24, roadmap A3)
|
||||
|
||||
1. **Intended effect = the phase-08 design comments:** a seamless 60s
|
||||
grid drift (one cell per loop) + a 14s eased glow breathing. The fix
|
||||
serves that design — it is not a redesign.
|
||||
2. **Pure CSS, zero JS** (phase-08 anchor) — no animation JS, no new
|
||||
assets, no `filter: blur`.
|
||||
|
||||
## Root cause (found in task 01 — `.agent/reports/22_background_animation/`)
|
||||
|
||||
Both layers *were* animating and nothing occluded them (occlusion
|
||||
disproven with a red-line probe) — but:
|
||||
|
||||
1. **The grid drift was invisible.** 1px lines at 35% `--line` alpha
|
||||
blend to a ≈10/12/18-level delta over the page bg, and the radial
|
||||
mask faded the layer to ~zero past the top ~25% of the viewport. A
|
||||
0.73px/s drift of that faint texture sits below the perception
|
||||
threshold (measured ≈1.4/765 mean pixel change over 5s in the grid
|
||||
zone; ≈0 in the bottom half — no grid there at all).
|
||||
2. **The glow swing was the only visible motion.** The 0.65↔1.0 opacity
|
||||
swing (35% amplitude, frame-diff concentrated exactly on the two
|
||||
glow centers) was 100% of the perceived animation → it read as a
|
||||
blink/pulse, not breathing.
|
||||
|
||||
## Fix (styles.css — pure CSS, zero JS, no blur, palette untouched)
|
||||
|
||||
| property | phase 08 | phase 22 |
|
||||
|---|---|---|
|
||||
| grid line alpha | 35% `--line` ×2 | **60% `--line` ×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** (one-cell seamless loop) |
|
||||
| glow opacity swing | 0.65↔1 | **0.85↔1** |
|
||||
| glow period/scale | 14s ease-in-out alternate, 1↔1.05 | **unchanged** |
|
||||
|
||||
Both layers keep `position: fixed; inset: 0; z-index: -1;
|
||||
pointer-events: none`; `<html>` keeps the `var(--bg)` canvas and
|
||||
`<body>` stays transparent (the no-occlusion contract); the
|
||||
`prefers-reduced-motion` stilling block is untouched.
|
||||
|
||||
## Acceptance criteria
|
||||
1. In a real Chromium viewport, both background layers run their
|
||||
animations (grid drift + glow breathe) and the animation timelines
|
||||
advance — the background is not a static or paused frame.
|
||||
2. No blink: the glow reads as breathing (0.85↔1), not a pulse
|
||||
(0.65↔1); the grid drift reads as smooth continuous motion.
|
||||
3. Layers stay behind content: `position: fixed`, `z-index: -1`,
|
||||
`pointer-events: none`, full-viewport `inset: 0`; `<html>` keeps
|
||||
the page background and `<body>` stays transparent (no occlusion).
|
||||
4. No new horizontal overflow at 360px (the phase-07 pin).
|
||||
5. Pure CSS, zero JS, no `filter: blur`, no new assets (phase-08
|
||||
anchor); WCAG AA palette untouched (the layers carry no text).
|
||||
6. Regressions green in isolation: `test_dark_tech_theme.py` (pins the
|
||||
live 60s/14s durations + reduced-motion stilling) and
|
||||
`test_responsive_polish.py`.
|
||||
7. 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)`), radial mask fully visible to 40% of
|
||||
the radius and faded out by 90%, 60s linear infinite drift of
|
||||
exactly one cell (0→44px) — seamless loop.
|
||||
- **Glow layer (`body::after`):** indigo `rgb(109 120 242 / 0.14)`
|
||||
56rem circle at 12%/8% + cyan `rgb(34 211 238 / 0.10)` 60rem circle
|
||||
at 88%/92%; 14s ease-in-out infinite alternate, opacity 0.85↔1 +
|
||||
scale 1↔1.05.
|
||||
- **Stacking:** the `<html>` canvas (`var(--bg)` = `#0a0e17`) sits
|
||||
under both `z-index: -1` layers; `<body>` stays transparent and
|
||||
non-stacking, so nothing can paint over the layers.
|
||||
- **Motion:** compositor-friendly `background-position` / `opacity` /
|
||||
`transform` only; no blur filter; `prefers-reduced-motion` stills
|
||||
both layers (phase-08 guard, unchanged).
|
||||
|
||||
## Playwright Mapping Rule
|
||||
**Test Scenario → `tests/e2e/test_background_animation.py`** (the
|
||||
layers are CSS pseudo-elements — asserted via computed style + the Web
|
||||
Animations API; Chromium enumerates pseudo-element CSS animations in
|
||||
`document.getAnimations()`, not `document.body.getAnimations()`):
|
||||
1. `test_grid_layer_animation_running` — computed style of
|
||||
`body::before`: `animationName` = `bg-grid-drift`, timing function
|
||||
`linear`, iteration count `infinite`; a matching entry in the
|
||||
document animation list with `playState === "running"`.
|
||||
2. `test_glow_layer_animation_running` — same for `body::after` with
|
||||
`bg-glow-breathe`; `playState === "running"`.
|
||||
3. `test_animations_advance` — sample `currentTime` of both layers,
|
||||
wait ~500ms, assert both advanced — truly running, not paused
|
||||
(the headless document animation timeline starts ~1s after load, so
|
||||
the sample polls until the timeline is alive first).
|
||||
4. `test_background_layers_contracts` — both pseudo-elements:
|
||||
`position: fixed`, `z-index: -1`, `pointer-events: none`,
|
||||
`inset: 0`; `document.documentElement` computed background is the
|
||||
palette bg (canvas stays on `html`); `document.body` computed
|
||||
background is `rgba(0, 0, 0, 0)` (no occlusion).
|
||||
5. `test_no_horizontal_overflow_with_layers` — 360px viewport:
|
||||
`documentElement.scrollWidth <= clientWidth` (the phase-07 pin,
|
||||
replicated locally).
|
||||