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).
|
||||
@@ -59,9 +59,16 @@ body {
|
||||
|
||||
/* ---------- Animated background (pure CSS, zero JS — phase 08) ---------- */
|
||||
|
||||
/* Fine drifting grid: 44px cells, 1px lines at ~35% --line alpha, masked
|
||||
with a radial fade (visible center-top, fading to the edges). The drift
|
||||
delta (44px) equals one cell, so the loop is seamless. */
|
||||
/* Fine drifting grid: 44px cells, 1px lines at 60% --line alpha, masked
|
||||
with a radial fade (visible across most of the viewport, fading to the
|
||||
corners). The drift delta (44px) equals one cell, so the loop is
|
||||
seamless.
|
||||
Phase 22 fix (owner report 2026-08-24, roadmap A3): the original 35%
|
||||
alpha + 25%-black mask made the 0.73px/s drift invisible in a real
|
||||
viewport (measured: ~1.4/765 mean pixel change over 5s in the grid
|
||||
zone) — only the glow swing was perceived, so the background read as
|
||||
"just blinking". Higher line alpha + wider visible mask radius make the
|
||||
same 60s one-cell drift clearly readable as smooth motion. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
@@ -69,11 +76,11 @@ body::before {
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, rgb(38 48 74 / 0.35) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgb(38 48 74 / 0.35) 1px, transparent 1px);
|
||||
linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
-webkit-mask-image: radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%);
|
||||
mask-image: radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%);
|
||||
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||
animation: bg-grid-drift 60s linear infinite;
|
||||
}
|
||||
@keyframes bg-grid-drift {
|
||||
@@ -82,7 +89,10 @@ body::before {
|
||||
}
|
||||
|
||||
/* Two large, soft radial glows: indigo top-left, cyan bottom-right —
|
||||
14s ease-in-out breathing (opacity + scale). No filter:blur (perf). */
|
||||
14s ease-in-out breathing (opacity + scale). No filter:blur (perf).
|
||||
Phase 22 fix (owner report 2026-08-24, roadmap A3): the 0.65↔1 opacity
|
||||
swing was the ONLY visible motion on the page, so it read as a blink.
|
||||
Narrowed to 0.85↔1 — a gentle breathe, not a pulse. */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
@@ -95,7 +105,7 @@ body::after {
|
||||
animation: bg-glow-breathe 14s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes bg-glow-breathe {
|
||||
from { opacity: 0.65; transform: scale(1); }
|
||||
from { opacity: 0.85; transform: scale(1); }
|
||||
to { opacity: 1; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Phase 22 E2E (Playwright): the animated background actually animates.
|
||||
|
||||
Story: ``.agent/user_stories/background-animation.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_background_animation.py -v --no-cov
|
||||
|
||||
Owner report (2026-08-24, roadmap A3): the phase-08 background "just
|
||||
blinks". The diagnosis (``.agent/reports/22_background_animation/``)
|
||||
found both layers *were* animating with no occlusion — but the grid
|
||||
drift was imperceptible (35% alpha 1px lines × a small radial mask ×
|
||||
0.73px/s) and only the glow's 0.65↔1 swing was perceived. The fix
|
||||
(styles.css, pure CSS, zero JS): 60% grid line alpha + wider mask and a
|
||||
0.85↔1 glow breathe.
|
||||
|
||||
This suite proves the *behavior* the unit source pins only describe:
|
||||
in a real Chromium viewport both pseudo-element layers run their
|
||||
animations AND the animation timelines actually advance (no static
|
||||
frame, no paused layer, no new occlusion or overflow).
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
|
||||
1. ``test_grid_layer_animation_running`` — computed style of
|
||||
``body::before``: ``animationName`` is ``bg-grid-drift``, timing
|
||||
function ``linear``, iteration count ``infinite``; plus a matching
|
||||
entry in the document animation list with
|
||||
``playState === "running"``.
|
||||
2. ``test_glow_layer_animation_running`` — same for ``body::after``
|
||||
with the ``bg-glow-breathe`` keyframe; ``playState === "running"``.
|
||||
3. ``test_animations_advance`` — ``currentTime`` of both layers
|
||||
sampled, ~500ms waited, both advanced — the timelines are truly
|
||||
running, not paused (headless Chromium starts the document
|
||||
animation timeline ~1s after load, so the first sample polls until
|
||||
the timeline is alive).
|
||||
4. ``test_background_layers_contracts`` — both pseudo-elements:
|
||||
``position: fixed``, ``z-index: -1``, ``pointer-events: none``,
|
||||
``inset: 0`` (UI Structure Check: behind content, click-through,
|
||||
full-viewport); the page canvas stays on ``<html>``
|
||||
(``rgb(10, 14, 23)`` = ``var(--bg)``) and ``<body>`` stays
|
||||
transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion contract.
|
||||
5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport
|
||||
``documentElement.scrollWidth <= clientWidth`` (the phase-07 pin,
|
||||
replicated locally — the ``fixed; inset: 0`` layers must add no
|
||||
width).
|
||||
|
||||
Chromium note: pseudo-element CSS animations are enumerated by
|
||||
``document.getAnimations()``, NOT by ``document.body.getAnimations()``
|
||||
(verified on Chromium 151 — the element-level list is empty for
|
||||
pseudo-layers), so tests 1–3 match on ``animationName`` in the
|
||||
document-level list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from playwright.sync_api import Browser, Page
|
||||
|
||||
GRID = "bg-grid-drift"
|
||||
GLOW = "bg-glow-breathe"
|
||||
PAGE_BG = "rgb(10, 14, 23)" # var(--bg) — the <html> canvas (phase-08 palette)
|
||||
|
||||
# Computed styles of both pseudo-layers + the html/body background
|
||||
# contract (single evaluate — one round-trip per test).
|
||||
JS_LAYER_REPORT = """() => {
|
||||
const pick = (pseudo) => {
|
||||
const cs = getComputedStyle(document.body, pseudo);
|
||||
return {
|
||||
anim: cs.animationName,
|
||||
timing: cs.animationTimingFunction,
|
||||
iterations: cs.animationIterationCount,
|
||||
position: cs.position,
|
||||
zIndex: cs.zIndex,
|
||||
pointerEvents: cs.pointerEvents,
|
||||
edges: [cs.top, cs.right, cs.bottom, cs.left],
|
||||
};
|
||||
};
|
||||
return {
|
||||
before: pick("::before"),
|
||||
after: pick("::after"),
|
||||
htmlBg: getComputedStyle(document.documentElement).backgroundColor,
|
||||
bodyBg: getComputedStyle(document.body).backgroundColor,
|
||||
};
|
||||
}"""
|
||||
|
||||
# Both background-layer animations from the Web Animations API
|
||||
# ({name, playState, currentTime}); the keyframe names are passed as one
|
||||
# array argument (Playwright serializes the Python list to a JS array).
|
||||
JS_TIMELINE = """(names) => document.getAnimations()
|
||||
.filter((a) => names.includes(a.animationName))
|
||||
.map((a) => ({
|
||||
name: a.animationName,
|
||||
playState: a.playState,
|
||||
t: a.currentTime,
|
||||
}))"""
|
||||
|
||||
|
||||
def _timeline(page: Page) -> dict[str, float]:
|
||||
"""animationName → currentTime (ms) for the two background layers."""
|
||||
entries = page.evaluate(JS_TIMELINE, [GRID, GLOW])
|
||||
return {str(a["name"]): float(a["t"]) for a in entries}
|
||||
|
||||
|
||||
def _wait_timeline_alive(page: Page, timeout_ms: int = 5000) -> None:
|
||||
"""Poll until both layer timelines report currentTime > 0.
|
||||
|
||||
Headless Chromium starts the document animation timeline shortly
|
||||
after load (observed ≈1.4s after navigation) — until then
|
||||
currentTime is 0, so the "did it advance?" sample in
|
||||
``test_animations_advance`` must start once the timeline is alive.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_ms / 1000
|
||||
while time.monotonic() < deadline:
|
||||
times = _timeline(page)
|
||||
if set(times) == {GRID, GLOW} and all(times[k] > 0 for k in (GRID, GLOW)):
|
||||
return
|
||||
page.wait_for_timeout(100)
|
||||
raise AssertionError(
|
||||
f"background animation timeline never started (saw {_timeline(page)!r})"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tests (story → test mapping, see module docstring)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grid_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC1: the grid layer runs bg-grid-drift linear infinite in a real
|
||||
viewport — not just declared in CSS: the matching CSSAnimation is
|
||||
reported ``running``."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
grid = report["before"]
|
||||
assert grid["anim"] == GRID, f"body::before must run {GRID} (got {grid['anim']!r})"
|
||||
assert grid["timing"] == "linear", (
|
||||
f"body::before must keep linear timing (got {grid['timing']!r})"
|
||||
)
|
||||
assert grid["iterations"] == "infinite", (
|
||||
f"body::before must loop infinitely (got {grid['iterations']!r})"
|
||||
)
|
||||
live = page.evaluate(JS_TIMELINE, [GRID, GLOW])
|
||||
match = [a for a in live if a["name"] == GRID]
|
||||
assert match, f"no {GRID} entry in document.getAnimations() — layer not animating"
|
||||
assert match[0]["playState"] == "running", (
|
||||
f"{GRID} is {match[0]['playState']!r} — the grid drift must be running"
|
||||
)
|
||||
|
||||
|
||||
def test_glow_layer_animation_running(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC2: the glow layer runs bg-glow-breathe in a real viewport —
|
||||
the matching CSSAnimation is reported ``running``."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
glow = report["after"]
|
||||
assert glow["anim"] == GLOW, f"body::after must run {GLOW} (got {glow['anim']!r})"
|
||||
assert glow["iterations"] == "infinite", (
|
||||
f"body::after must loop infinitely (got {glow['iterations']!r})"
|
||||
)
|
||||
live = page.evaluate(JS_TIMELINE, [GRID, GLOW])
|
||||
match = [a for a in live if a["name"] == GLOW]
|
||||
assert match, f"no {GLOW} entry in document.getAnimations() — layer not animating"
|
||||
assert match[0]["playState"] == "running", (
|
||||
f"{GLOW} is {match[0]['playState']!r} — the glow breathe must be running"
|
||||
)
|
||||
|
||||
|
||||
def test_animations_advance(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC1: both timelines actually advance — the background is a live
|
||||
animation, not a static (or paused) frame. Sample currentTime, wait
|
||||
~500ms, and require real progress on both layers."""
|
||||
page.goto(app_url)
|
||||
_wait_timeline_alive(page)
|
||||
before = _timeline(page)
|
||||
page.wait_for_timeout(500)
|
||||
after = _timeline(page)
|
||||
for name in (GRID, GLOW):
|
||||
delta = after[name] - before[name]
|
||||
assert delta >= 200, (
|
||||
f"{name} timeline did not advance (Δ={delta:.0f}ms < 200ms over 500ms) "
|
||||
"— paused or static?"
|
||||
)
|
||||
|
||||
|
||||
def test_background_layers_contracts(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""AC3/AC5: UI Structure Check — the layers stay behind content
|
||||
(fixed, z-index -1, pointer-events none, full-viewport) and nothing
|
||||
occludes them: the page canvas is on <html>, <body> transparent."""
|
||||
page.goto(app_url)
|
||||
report = page.evaluate(JS_LAYER_REPORT)
|
||||
for layer in ("before", "after"):
|
||||
info = report[layer]
|
||||
assert info["position"] == "fixed", f"body::{layer} must stay position:fixed"
|
||||
assert info["zIndex"] == "-1", (
|
||||
f"body::{layer} must stay behind content (z-index -1, got {info['zIndex']!r})"
|
||||
)
|
||||
assert info["pointerEvents"] == "none", (
|
||||
f"body::{layer} must stay click-through (pointer-events none)"
|
||||
)
|
||||
assert info["edges"] == ["0px", "0px", "0px", "0px"], (
|
||||
f"body::{layer} must stay full-viewport (inset: 0, got {info['edges']!r})"
|
||||
)
|
||||
assert report["htmlBg"] == PAGE_BG, (
|
||||
f"the page canvas must stay on <html> — var(--bg) (got {report['htmlBg']!r})"
|
||||
)
|
||||
assert report["bodyBg"] == "rgba(0, 0, 0, 0)", (
|
||||
f"body must stay transparent so the layers show (got {report['bodyBg']!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_no_horizontal_overflow_with_layers(
|
||||
browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
"""AC4: the background layers add no width — the phase-07 overflow
|
||||
pin (documentElement.scrollWidth <= clientWidth) still holds at the
|
||||
360px floor with both fixed; inset: 0 layers live."""
|
||||
phone = browser.new_page(viewport={"width": 360, "height": 740})
|
||||
try:
|
||||
phone.goto(f"{app_url}/")
|
||||
scroll, client = phone.evaluate(
|
||||
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
|
||||
)
|
||||
assert scroll <= client, (
|
||||
f"horizontal overflow at 360px with the background layers: "
|
||||
f"{scroll} > {client}"
|
||||
)
|
||||
finally:
|
||||
phone.close()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Unit: the phase-22 animated-background contract (source pins).
|
||||
|
||||
The owner report (2026-08-24, roadmap A3): the phase-08 background "just
|
||||
blinks". The diagnosis (`.agent/reports/22_background_animation/`) found
|
||||
both layers *were* animating with no occlusion — the grid simply wasn't
|
||||
perceptible: 1px lines at 35% `--line` alpha (≈10-18/255 over the page
|
||||
bg), masked to the top ~25% of the viewport, drifting 0.73px/s. Only the
|
||||
glow's 0.65↔1 opacity swing was visible, and it read as a blink.
|
||||
|
||||
The fix (styles.css, pure CSS, zero JS, no `filter: blur`):
|
||||
- grid lines 35% → 60% `--line` alpha;
|
||||
- mask widened: `120% 90% … black 25%, transparent 78%` →
|
||||
`140% 110% … black 40%, transparent 90%` (grid now readable across
|
||||
most of the viewport, fading to the corners);
|
||||
- glow breathe narrowed 0.65↔1 → 0.85↔1 (breathing, not pulsing).
|
||||
|
||||
Durations are the owner-confirmed phase-08 design and stay pinned at
|
||||
60s (one-cell seamless drift) and 14s — changing them would also break
|
||||
the phase-08 story gate (`tests/e2e/test_dark_tech_theme.py` pins the
|
||||
live durations). This file pins the FINAL values so a silent regression
|
||||
(weaker alpha, shrunken mask, wider opacity swing, re-occluded layer) is
|
||||
caught without a browser. Browser behavior (visible motion, no jank) is
|
||||
E2E-covered by tests/e2e/test_background_animation.py (task 02).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
STYLES_CSS = (
|
||||
Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
|
||||
)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css_no_comments() -> str:
|
||||
"""styles.css with /* … */ comments stripped — for functional anchor
|
||||
checks (filter/blur) that must not trip on explanatory comments."""
|
||||
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
|
||||
|
||||
|
||||
def _rule_block(css: str, selector: str) -> str:
|
||||
"""Body of the first `selector { ... }` rule (top-level, no nesting)."""
|
||||
rule = re.search(
|
||||
r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css
|
||||
)
|
||||
assert rule, f"styles.css must define a {selector} rule"
|
||||
return rule.group(1)
|
||||
|
||||
|
||||
def _grid_rule(css: str) -> str:
|
||||
return _rule_block(css, "body::before")
|
||||
|
||||
|
||||
def _glow_rule(css: str) -> str:
|
||||
return _rule_block(css, "body::after")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Layer plumbing — the no-occlusion contract (phase 08) must survive
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_both_layers_are_fixed_zminus1_noninteractive() -> None:
|
||||
"""Both background layers stay behind the content and can never
|
||||
intercept input: fixed, full-viewport, z-index -1, pointer-events
|
||||
none (UI Structure Check: layers behind content, no 360px overflow)."""
|
||||
for name, block in (("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css()))):
|
||||
assert "position: fixed" in block, f"{name} must stay position:fixed"
|
||||
assert "inset: 0" in block, f"{name} must stay full-viewport (inset: 0)"
|
||||
assert "z-index: -1" in block, f"{name} must stay z-index:-1"
|
||||
assert "pointer-events: none" in block, f"{name} must stay click-through"
|
||||
assert "content: \"\"" in block, f"{name} must keep its pseudo content"
|
||||
|
||||
|
||||
def test_html_owns_bg_and_body_stays_transparent() -> None:
|
||||
"""The no-occlusion contract: the visible page background lives on
|
||||
<html>; <body> must remain transparent and non-stacking, or the
|
||||
z-index:-1 layers are painted over (the phase-08 recipe)."""
|
||||
html_block = _rule_block(_css(), "html")
|
||||
assert "background: var(--bg)" in html_block, (
|
||||
"html must keep background: var(--bg) (the page canvas)"
|
||||
)
|
||||
body_block = _rule_block(_css(), "body")
|
||||
assert "background: transparent" in body_block, (
|
||||
"body must keep background: transparent so the layers show"
|
||||
)
|
||||
# body must not gain a z-index/transform/opacity that would turn it
|
||||
# into a stacking context trapping the negative-z-index layers.
|
||||
for prop in ("z-index", "transform", "opacity", "filter"):
|
||||
assert prop + ":" not in body_block, (
|
||||
f"body must not create a stacking context (found {prop})"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Grid layer — the phase-22 final values
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grid_animates_seamless_one_cell_drift() -> None:
|
||||
"""body::before runs bg-grid-drift 60s linear infinite — the
|
||||
owner-confirmed 60s one-cell loop (seamless, delta == 44px)."""
|
||||
block = _grid_rule(_css())
|
||||
assert "animation: bg-grid-drift 60s linear infinite" in block
|
||||
|
||||
|
||||
def test_grid_keyframes_move_exactly_one_cell() -> None:
|
||||
"""The drift delta must equal one 44px cell (0 0 → 44px 44px) for a
|
||||
seamless loop — if the speed ever changes, only the duration may move."""
|
||||
keyframes = re.search(
|
||||
r"@keyframes bg-grid-drift\s*\{([\s\S]*?)\n\}", _css()
|
||||
)
|
||||
assert keyframes, "styles.css must define @keyframes bg-grid-drift"
|
||||
body = keyframes.group(1)
|
||||
assert "background-position: 0 0, 0 0" in body
|
||||
assert "background-position: 44px 44px, 44px 44px" in body
|
||||
|
||||
|
||||
def test_grid_cells_and_line_contrast() -> None:
|
||||
"""44px cells with 1px lines at the phase-22 fixed 60% --line alpha
|
||||
(phase-08's 35% measured imperceptible at 0.73px/s — see module
|
||||
docstring)."""
|
||||
block = _grid_rule(_css())
|
||||
assert "background-size: 44px 44px" in block
|
||||
line = "linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)"
|
||||
assert line in block, "grid must keep horizontal 1px lines at 60% --line"
|
||||
assert (
|
||||
"linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px)"
|
||||
in block
|
||||
), "grid must keep vertical 1px lines at 60% --line"
|
||||
assert "0.35" not in block, "the too-faint 35% line alpha must not return"
|
||||
|
||||
|
||||
def test_grid_mask_widened_and_prefixed() -> None:
|
||||
"""The phase-22 mask: 140%×110% ellipse, fully visible to 40% of the
|
||||
radius, faded out by 90% — the grid must read across most of the
|
||||
viewport (phase-08's 120%×90%/25%/78% masked it to the top ~25%).
|
||||
The -webkit- and standard mask-image must stay in lockstep."""
|
||||
block = _grid_rule(_css())
|
||||
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
|
||||
assert f"-webkit-mask-image: {mask};" in block
|
||||
assert f"mask-image: {mask};" in block
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Glow layer — the phase-22 final values
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_glow_animates_breathe_not_blink() -> None:
|
||||
"""body::after runs bg-glow-breathe 14s ease-in-out infinite alternate
|
||||
— the owner-confirmed 14s breathing period (untouched)."""
|
||||
block = _glow_rule(_css())
|
||||
assert "animation: bg-glow-breathe 14s ease-in-out infinite alternate" in block
|
||||
|
||||
|
||||
def test_glow_keyframes_narrowed_opacity_swing() -> None:
|
||||
"""The opacity swing is narrowed to 0.85↔1 (phase-08's 0.65↔1 was the
|
||||
only visible motion and read as a blink). The gentle scale (1↔1.05)
|
||||
stays."""
|
||||
keyframes = re.search(
|
||||
r"@keyframes bg-glow-breathe\s*\{([\s\S]*?)\n\}", _css()
|
||||
)
|
||||
assert keyframes, "styles.css must define @keyframes bg-glow-breathe"
|
||||
body = keyframes.group(1)
|
||||
assert re.search(r"opacity:\s*0\.85", body), "glow low must be 0.85"
|
||||
assert re.search(r"opacity:\s*1;?", body), "glow high must be 1"
|
||||
assert "0.65" not in body, "the blinking 0.65 low must not return"
|
||||
assert re.search(r"scale\(1\)", body)
|
||||
assert re.search(r"scale\(1\.05\)", body)
|
||||
|
||||
|
||||
def test_glow_colors_and_radii_untouched() -> None:
|
||||
"""Phase-22 is a perception fix, not a redesign: the two glow
|
||||
gradients (indigo top-left, cyan bottom-right) keep phase-08's colors
|
||||
and radii."""
|
||||
block = _glow_rule(_css())
|
||||
assert (
|
||||
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), "
|
||||
"transparent 62%)" in block
|
||||
)
|
||||
assert (
|
||||
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), "
|
||||
"transparent 62%)" in block
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Phase-08 anchor: pure CSS, zero JS, no blur
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_blur_no_js_in_background_layers() -> None:
|
||||
"""The phase-08 performance anchor: no `filter: blur` (or any filter)
|
||||
on either layer, and the animation is CSS-only (both layers carry an
|
||||
`animation:` shorthand; nothing in styles.css references a script)."""
|
||||
for name, block in (("body::before", _grid_rule(_css())),
|
||||
("body::after", _glow_rule(_css()))):
|
||||
assert "filter" not in block, f"{name} must not use any filter"
|
||||
assert "animation:" in block, f"{name} must be CSS-animated"
|
||||
assert "blur" not in _css_no_comments(), (
|
||||
"no filter: blur anywhere in styles.css (phase-08 perf anchor)"
|
||||
)
|
||||