diff --git a/.agent/phases/todo/22_background_animation/01_fix_background_animation.md b/.agent/phases/complete/22_background_animation/01_fix_background_animation.md similarity index 100% rename from .agent/phases/todo/22_background_animation/01_fix_background_animation.md rename to .agent/phases/complete/22_background_animation/01_fix_background_animation.md diff --git a/.agent/phases/todo/21_thinking_no_scroll/00_phase.md b/.agent/phases/todo/21_thinking_no_scroll/00_phase.md deleted file mode 100644 index 51a4a47..0000000 --- a/.agent/phases/todo/21_thinking_no_scroll/00_phase.md +++ /dev/null @@ -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" -``` diff --git a/.agent/phases/todo/21_thinking_no_scroll/02_e2e_story_suite_commit.md b/.agent/phases/todo/21_thinking_no_scroll/02_e2e_story_suite_commit.md deleted file mode 100644 index e92d2c7..0000000 --- a/.agent/phases/todo/21_thinking_no_scroll/02_e2e_story_suite_commit.md +++ /dev/null @@ -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/`. diff --git a/.agent/reports/22_background_animation/report.md b/.agent/reports/22_background_animation/report.md new file mode 100644 index 0000000..796c780 --- /dev/null +++ b/.agent/reports/22_background_animation/report.md @@ -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); `` computed background `rgb(10, 14, 23)` (canvas + stays on html), `` 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. diff --git a/.agent/screenshots/22_background_animation/after_frame1.png b/.agent/screenshots/22_background_animation/after_frame1.png new file mode 100644 index 0000000..b8c20b0 Binary files /dev/null and b/.agent/screenshots/22_background_animation/after_frame1.png differ diff --git a/.agent/screenshots/22_background_animation/after_frame2.png b/.agent/screenshots/22_background_animation/after_frame2.png new file mode 100644 index 0000000..dac6cb2 Binary files /dev/null and b/.agent/screenshots/22_background_animation/after_frame2.png differ diff --git a/.agent/screenshots/22_background_animation/after_gridonly_frame1.png b/.agent/screenshots/22_background_animation/after_gridonly_frame1.png new file mode 100644 index 0000000..2c5abb8 Binary files /dev/null and b/.agent/screenshots/22_background_animation/after_gridonly_frame1.png differ diff --git a/.agent/screenshots/22_background_animation/after_gridonly_frame2.png b/.agent/screenshots/22_background_animation/after_gridonly_frame2.png new file mode 100644 index 0000000..f93011b Binary files /dev/null and b/.agent/screenshots/22_background_animation/after_gridonly_frame2.png differ diff --git a/.agent/screenshots/22_background_animation/before_frame1.png b/.agent/screenshots/22_background_animation/before_frame1.png new file mode 100644 index 0000000..5d82f1c Binary files /dev/null and b/.agent/screenshots/22_background_animation/before_frame1.png differ diff --git a/.agent/screenshots/22_background_animation/before_frame2.png b/.agent/screenshots/22_background_animation/before_frame2.png new file mode 100644 index 0000000..6a3de3d Binary files /dev/null and b/.agent/screenshots/22_background_animation/before_frame2.png differ diff --git a/.agent/screenshots/22_background_animation/before_glow_only.png b/.agent/screenshots/22_background_animation/before_glow_only.png new file mode 100644 index 0000000..934666a Binary files /dev/null and b/.agent/screenshots/22_background_animation/before_glow_only.png differ diff --git a/.agent/screenshots/22_background_animation/before_grid_only.png b/.agent/screenshots/22_background_animation/before_grid_only.png new file mode 100644 index 0000000..386dc10 Binary files /dev/null and b/.agent/screenshots/22_background_animation/before_grid_only.png differ diff --git a/.agent/screenshots/22_background_animation/before_gridonly_frame1.png b/.agent/screenshots/22_background_animation/before_gridonly_frame1.png new file mode 100644 index 0000000..fe73f5d Binary files /dev/null and b/.agent/screenshots/22_background_animation/before_gridonly_frame1.png differ diff --git a/.agent/screenshots/22_background_animation/before_gridonly_frame2.png b/.agent/screenshots/22_background_animation/before_gridonly_frame2.png new file mode 100644 index 0000000..a8e4e7f Binary files /dev/null and b/.agent/screenshots/22_background_animation/before_gridonly_frame2.png differ diff --git a/.agent/screenshots/22_background_animation/before_occlusion_probe.png b/.agent/screenshots/22_background_animation/before_occlusion_probe.png new file mode 100644 index 0000000..7b20a16 Binary files /dev/null and b/.agent/screenshots/22_background_animation/before_occlusion_probe.png differ diff --git a/.agent/screenshots/22_background_animation/diagnosis_diff.txt b/.agent/screenshots/22_background_animation/diagnosis_diff.txt new file mode 100644 index 0000000..02aecf2 --- /dev/null +++ b/.agent/screenshots/22_background_animation/diagnosis_diff.txt @@ -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 \ No newline at end of file diff --git a/.agent/screenshots/22_background_animation/diagnosis_log.txt b/.agent/screenshots/22_background_animation/diagnosis_log.txt new file mode 100644 index 0000000..f3c35a3 --- /dev/null +++ b/.agent/screenshots/22_background_animation/diagnosis_log.txt @@ -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'} \ No newline at end of file diff --git a/.agent/screenshots/22_background_animation/final_360px.png b/.agent/screenshots/22_background_animation/final_360px.png new file mode 100644 index 0000000..4a4a335 Binary files /dev/null and b/.agent/screenshots/22_background_animation/final_360px.png differ diff --git a/.agent/screenshots/22_background_animation/final_frame1.png b/.agent/screenshots/22_background_animation/final_frame1.png new file mode 100644 index 0000000..8690413 Binary files /dev/null and b/.agent/screenshots/22_background_animation/final_frame1.png differ diff --git a/.agent/screenshots/22_background_animation/final_frame2.png b/.agent/screenshots/22_background_animation/final_frame2.png new file mode 100644 index 0000000..e21bb15 Binary files /dev/null and b/.agent/screenshots/22_background_animation/final_frame2.png differ diff --git a/.agent/user_stories/background-animation.md b/.agent/user_stories/background-animation.md new file mode 100644 index 0000000..6edce92 --- /dev/null +++ b/.agent/user_stories/background-animation.md @@ -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`; `` keeps the `var(--bg)` canvas and +`` 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`; `` keeps + the page background and `` 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 `` canvas (`var(--bg)` = `#0a0e17`) sits + under both `z-index: -1` layers; `` 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). diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index a6ae545..54aad33 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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); } } diff --git a/tests/e2e/test_background_animation.py b/tests/e2e/test_background_animation.py new file mode 100644 index 0000000..246a2fa --- /dev/null +++ b/tests/e2e/test_background_animation.py @@ -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 ```` + (``rgb(10, 14, 23)`` = ``var(--bg)``) and ```` 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 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 , 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 — 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() diff --git a/tests/unit/test_background_animation.py b/tests/unit/test_background_animation.py new file mode 100644 index 0000000..d982b68 --- /dev/null +++ b/tests/unit/test_background_animation.py @@ -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 + ; 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)" + )