From 824914ca3d113ee600dc123dc25ff92f09179b6c Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 24 Aug 2026 15:23:49 -0400 Subject: [PATCH] =?UTF-8?q?chore(agent):=20add=20phases=2020-23=20from=20T?= =?UTF-8?q?ODO.md=20=E2=80=94=20four=20owner-confirmed=20bug=20fixes;=20cl?= =?UTF-8?q?ear=20TODO.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../todo/20_sources_midstream_bug/00_phase.md | 122 ++++++++++++++++++ .../01_persist_inflight_turn.md | 83 ++++++++++++ .../02_e2e_story_suite_commit.md | 76 +++++++++++ .../todo/21_thinking_no_scroll/00_phase.md | 104 +++++++++++++++ .../01_disable_thinking_scroll.md | 57 ++++++++ .../02_e2e_story_suite_commit.md | 70 ++++++++++ .../todo/22_background_animation/00_phase.md | 114 ++++++++++++++++ .../01_fix_background_animation.md | 60 +++++++++ .../02_e2e_story_suite_commit.md | 68 ++++++++++ .../todo/23_containerfile_build/00_phase.md | 120 +++++++++++++++++ .../01_fix_containerfile_build.md | 91 +++++++++++++ .../02_integration_test_commit.md | 70 ++++++++++ TODO.md | 5 + 13 files changed, 1040 insertions(+) create mode 100644 .agent/phases/todo/20_sources_midstream_bug/00_phase.md create mode 100644 .agent/phases/todo/20_sources_midstream_bug/01_persist_inflight_turn.md create mode 100644 .agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md create mode 100644 .agent/phases/todo/21_thinking_no_scroll/00_phase.md create mode 100644 .agent/phases/todo/21_thinking_no_scroll/01_disable_thinking_scroll.md create mode 100644 .agent/phases/todo/21_thinking_no_scroll/02_e2e_story_suite_commit.md create mode 100644 .agent/phases/todo/22_background_animation/00_phase.md create mode 100644 .agent/phases/todo/22_background_animation/01_fix_background_animation.md create mode 100644 .agent/phases/todo/22_background_animation/02_e2e_story_suite_commit.md create mode 100644 .agent/phases/todo/23_containerfile_build/00_phase.md create mode 100644 .agent/phases/todo/23_containerfile_build/01_fix_containerfile_build.md create mode 100644 .agent/phases/todo/23_containerfile_build/02_integration_test_commit.md create mode 100644 TODO.md diff --git a/.agent/phases/todo/20_sources_midstream_bug/00_phase.md b/.agent/phases/todo/20_sources_midstream_bug/00_phase.md new file mode 100644 index 0000000..06287da --- /dev/null +++ b/.agent/phases/todo/20_sources_midstream_bug/00_phase.md @@ -0,0 +1,122 @@ +# Phase 20 — Sources Mid-Stream: an in-flight answer is not lost on navigation + +**Source:** `TODO.md` L3 — *"Clicking "sources" while chat is generating +clears chat and result will never show up"* +**Story:** `.agent/user_stories/sources-midstream.md` (created by task 02) +**Context:** `frontend/assets/app.js` — the phase-14 persistence block +(`STORAGE_KEY = "bor.chat.v1"`, `conversation`, `saveConversation`, +`rememberBrainTurn`), the turn state machine (`UI_STATE.thinking` / +`.streaming`), the streaming accumulators (`acc` / `thinkingAcc` / +`sawThinking`), and the phase-14 restore path; `frontend/index.html` +(`#nav-sources` link); `frontend/assets/header.js` (`clearChatStorage` — +the deliberate New-Chat clear, NOT this bug). + +## Objective +When the user leaves the chat page (the Sources nav link, the document +viewer, any link) while a turn is still in flight, the answer generated so +far must not vanish. Today the brain message is persisted only on `done`, +so navigating away aborts the stream and the partial answer is lost — the +user returns to their own question with no result, ever. After this phase, +returning to the chat shows the question **and** the partial answer that +had already streamed (rendered like any brain message, thinking block +restored if any). + +## Owner-confirmed (2026-08-24, roadmap A1) +1. **A partial answer is persisted as a plain brain message** — no + "(partial)" marker, no sources/suggestions (the turn is dead; the user + can re-ask for the full answer). +2. Navigation **before the first answer token** (pure thinking) persists + nothing brain-side: the question is restored, no empty/partial bubble. +3. The deliberate **New Chat** `clearChatStorage()` (sources/viewer pages) + is untouched — that clear is by design (phase 14/19). +4. No server-side resume (A10 stays stateless) and no + "leave page?" confirmation dialog. + +## Design +- **`app.js` — one new `pagehide` handler** (`window.addEventListener( + "pagehide", …)` — fires on navigate-away and bfcache store): + - Guard: only when a turn is in flight (current `uiState` is + `UI_STATE.thinking` or `UI_STATE.streaming`) **and** `acc` is + non-empty. + - Action: `rememberBrainTurn(acc, { thinking: thinkingAcc || + undefined })` — reuse the existing save-point helper, so the partial + text is stored raw (the restore path re-renders through the + escape-first markdown renderer; the phase-17 `thinking` field + restores the collapsed Thinking block). + - **Idempotency guard:** a turn-local `persistedOnLeave` flag so a + second `pagehide` (or bfcache store+restore churn) never appends the + same partial message twice. The `done` save point is unaffected + (navigation means the stream is dead; if the user returns via + bfcache the turn is already aborted by the unloading page). +- **Restore path:** unchanged — a stored partial message is a well-formed + brain message and renders exactly like a completed one (minus + sources/deflection, which it simply doesn't carry). +- **Non-goals:** no resume of the SSE stream, no API changes, no changes + to the New Chat buttons, sign-out, or the document-viewer back link. + +## Dependencies +- `14_chat_persistence` (complete) — `bor.chat.v1` shape, save points, + restore, and the `rememberBrainTurn` helper this phase reuses. +- `17_thinking_display` (complete) — the `thinking` field on persisted + brain messages and the `thinkingAcc` accumulator. +- `19_shared_header` (complete) — the `#nav-sources` link (admin-only) + the bug report clicks. +- `18_follow_bottom_scroll` (complete) — no overlap (scroll gating only). + +## Tasks +1. `01_persist_inflight_turn.md` — the `pagehide` partial-persistence + handler in `app.js` + source-level unit pins. +2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_sources_midstream_bug.py` + (the story gate, isolated), regression suites, story file, final + validation, the single atomic commit, phase move to `complete/`. + +## Locked decisions +- **A10 untouched** — API stays stateless; no resume. **A11 untouched** — + vanilla JS, no CDN. **A16 honored** — one new story E2E suite + + adapted regressions. No anchor changed. + +## Testing & Quality +- **Unit (source-level, new `tests/unit/test_sources_midstream.py`, + following the repo's source-pin pattern):** `app.js` registers a + `pagehide` listener; the guard references the in-flight `uiState` and a + non-empty `acc`; the partial path calls `rememberBrainTurn` with + `thinking: thinkingAcc || undefined`; a turn-local idempotency flag + exists; `STORAGE_KEY`/save-point comments updated to list the new + save point. +- **Integration:** none (no `app/` changes) — the `uv run pytest + --cov=app` number must stay at today's. +- **Coverage:** frontend-only; the >90% `app/` gate is unaffected, + re-run to prove it. +- **E2E:** `tests/e2e/test_sources_midstream_bug.py` (task 02), green + **in isolation** (prereq `podman compose up -d db`). +- **Lint/types:** `uv run ruff check . && uv run pyright` clean. + +## Completion Criteria +- [ ] Admin, mid-stream, clicks **Sources** → returns to `/`: the + question **and** the already-streamed partial answer are both + rendered; no error banner; `bor.chat.v1` holds the partial brain + message. +- [ ] Navigate away before the first token → back: question restored, + no empty/partial brain bubble. +- [ ] A completed turn is persisted exactly as before (sources, + deflection, suggestions intact). +- [ ] New Chat from the sources page still clears the conversation. +- [ ] `uv run pytest` green; `uv run pytest --cov=app + --cov-report=term-missing` ≥ today's number. +- [ ] `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov` + green in isolation; regressions green in isolation (one command + each): `test_chat_persistence.py`, `test_thinking_display.py`, + `test_shared_header.py`. +- [ ] `uv run ruff check . && uv run pyright` clean. +- [ ] UI Structure Check (AGENTS.md rule 5): no new UI surface — the + restored partial renders through the existing bubble/thinking + contract. +- [ ] `.agent/user_stories/sources-midstream.md` exists. +- [ ] One `--no-gpg-sign` commit (below); + `.agent/phases/todo/20_sources_midstream_bug/` moved to + `.agent/phases/complete/`. + +## Commit +```bash +git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return" +``` diff --git a/.agent/phases/todo/20_sources_midstream_bug/01_persist_inflight_turn.md b/.agent/phases/todo/20_sources_midstream_bug/01_persist_inflight_turn.md new file mode 100644 index 0000000..0ef26bc --- /dev/null +++ b/.agent/phases/todo/20_sources_midstream_bug/01_persist_inflight_turn.md @@ -0,0 +1,83 @@ +# Task 01 — app.js: persist the partial answer on navigate-away (pagehide) + +**Phase:** `20_sources_midstream_bug` · **Source:** `TODO.md` L3 — +*"Clicking "sources" while chat is generating clears chat and result will +never show up"* + +## Objective +`frontend/assets/app.js` gains exactly one new save point: on `pagehide` +(navigate-away / bfcache), if a turn is in flight and answer text has +already streamed, the partial answer is persisted via the existing +`rememberBrainTurn` helper — so returning to the chat restores the +question **and** what had been generated. + +## Work +1. `frontend/assets/app.js` + - Next to the other turn-local state (around the `acc` / + `thinkingAcc` / `sawThinking` / `aborted` declarations, ~line 920+), + declare a turn-local flag: + ```js + let persistedOnLeave = false; // pagehide partial-persist at most once + ``` + and reset it to `false` at the top of `runTurn` (where `acc`, + `thinkingAcc`, `sawThinking`, `wrap` are initialized), so it is + turn-scoped like the rest. + - Register one handler at module boot (next to the other + `window.addEventListener` calls): + ```js + /* Navigate-away save point (phase 20, owner choice 2026-08-24 A1): + * leaving the chat mid-turn would otherwise drop the in-flight + * answer — the brain message persists only on `done`, and + * navigation aborts the stream. On `pagehide`, if a turn is in + * flight and answer text has streamed, persist the partial raw text + * (reusing the save-point helper, so restore re-renders it exactly + * like a completed answer — no "(partial)" marker, no sources). + * Thinking-only (no answer tokens yet) persists nothing brain-side: + * the question is already saved on send and the user can re-ask. + * `persistedOnLeave` makes this idempotent across pagehide/bfcache + * churn. */ + window.addEventListener("pagehide", () => { + if (persistedOnLeave) return; + if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming) + return; + if (!acc) return; // nothing brain-side to save yet + persistedOnLeave = true; + rememberBrainTurn(acc, { thinking: thinkingAcc || undefined }); + }); + ``` + **ASSUMPTION (owner-confirmed A1):** the partial is a plain brain + message (no marker, no sources); the variables `uiState`, `acc`, + `thinkingAcc` are the turn's existing ones — match the names actually + in scope (the file keeps `let acc = ""` / `let thinkingAcc = ""` + turn-locals inside `runTurn`; if the handler needs them outside that + scope, hoist the turn-locals to module scope *without* changing any + behavior — smallest diff wins). + - Update the phase-14 persistence block comment (~line 631): the + "Save points:" sentence now lists three — the user message on send, + the brain message on `done`, and the **partial** brain message on + navigate-away (`pagehide`, phase 20). +2. `tests/unit/test_sources_midstream.py` (new — follow the repo's + source-level pin pattern used by `tests/unit/test_shared_header.py`): + - `app.js` contains `addEventListener("pagehide"` exactly once. + - The pagehide body is guarded by the in-flight states (`thinking` + and `streaming`) and a non-empty `acc` check. + - The pagehide body calls `rememberBrainTurn(acc,` with + `thinking: thinkingAcc || undefined`. + - `persistedOnLeave` is declared and reset in `runTurn`. + - The persistence block comment lists the `pagehide` save point. + - `clearChatStorage` (header.js) is untouched — still the only + deliberate clear. + +## Testing & Quality +- `uv run pytest tests/unit/test_sources_midstream.py -v` green. +- `uv run ruff check . && uv run pyright` clean. +- Manual smoke (dev server, DEBUGPY optional): send a question against + the real LLM (or any slow stream), click Sources mid-stream, return — + the partial answer is visible. + +## Completion Criteria +- [ ] The `pagehide` handler exists, is turn-scoped and idempotent, and + reuses `rememberBrainTurn` (no duplicated storage code). +- [ ] No other save point changed: send + `done` behave byte-identically + to before (existing persistence unit pins still pass). +- [ ] Unit pins green; lint/types clean. diff --git a/.agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md b/.agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md new file mode 100644 index 0000000..28933f1 --- /dev/null +++ b/.agent/phases/todo/20_sources_midstream_bug/02_e2e_story_suite_commit.md @@ -0,0 +1,76 @@ +# Task 02 — E2E story suite, story file, validation, commit + +**Phase:** `20_sources_midstream_bug` · **Source:** `TODO.md` L3 + +## Objective +The story gate: `tests/e2e/test_sources_midstream_bug.py` proves the bug +is fixed end-to-end (navigate away mid-stream, come back, the partial +answer is there), plus the regression suites, the story file, final +validation, and the single atomic commit. + +## Work +1. `tests/e2e/test_sources_midstream_bug.py` (new — mirror + `test_chat_persistence.py`'s scaffolding: fixture import via + `_import_fixtures`/`_run_in_thread`, mock LLM on a thread, `login` + from `e2e.auth_helpers`). The mock LLM must stream **slowly enough** + that the turn is still in flight when the test navigates (reuse the + streaming pattern from `test_thinking_display.py` / `mock_llm.py`; + tune the per-chunk delay until the navigation lands mid-stream). + Tests (Playwright Mapping Rule — one per numbered scenario): + 1. `test_partial_answer_survives_sources_nav_midstream` — admin + (`login(page, app_url, next="/")`), send a question, wait for the + first streamed chunk to render (expect the first chunk's text in + the answer bubble), **click `#nav-sources`** (the actual nav link — + admin sees it), land on `/sources.html`, then `page.goto("/")`: + expect the question text AND the first-chunk text present, no + `role="alert"` banner. Read `localStorage` `bor.chat.v1`: the + messages contain a brain message whose text starts with the first + chunk. + 2. `test_no_orphan_brain_message_when_navigated_before_first_token` — + mock streams a `thinking` event, then a long pre-token pause; + navigate (direct `page.goto("/sources.html")` is fine here) during + the pause, return to `/`: the question is present, exactly one + user message and **zero** brain messages in both the DOM and + `bor.chat.v1`. + 3. `test_completed_turn_unaffected` — a turn that finishes normally + (`done`), then navigate to sources and back: full answer, sources + chips, and the done-metadata (sources array) intact in storage. + 4. `test_new_chat_still_clears_conversation` — regression: completed + turn → `/sources.html` → click the sources-page New Chat button → + lands on `/` with the empty state and `bor.chat.v1` removed. +2. `.agent/user_stories/sources-midstream.md` (new) — the short story + file matching the repo's story format (goal, the bug report verbatim + from `TODO.md` L3, the owner-confirmed A1 decisions from + `00_phase.md`, the E2E mapping table test-name → scenario). +3. Run the suite **in isolation** (prereq `podman compose up -d db`): + `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov`. +4. Regressions, in isolation, one command each (all must stay green): + - `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov` + - `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` + - `uv run pytest tests/e2e/test_shared_header.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): the restored partial + renders through the existing bubble/thinking contract — no new + surface, no new ids, focus/contrast unchanged. +7. Write the phase report + (`.agent/reports/20_sources_midstream_bug/` — what changed, E2E + results, the manual-smoke note from task 01). +8. Commit (one atomic commit) and move the phase: + ```bash + git add -A .agent/ frontend/ tests/ + git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return" + mv .agent/phases/todo/20_sources_midstream_bug .agent/phases/complete/ + ``` + +## Testing & Quality +- Story suite green **in isolation**; the three 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_sources_midstream_bug.py` 4/4 in isolation. +- [ ] Regressions (persistence, thinking display, shared header) green. +- [ ] Story file + phase report exist. +- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`. diff --git a/.agent/phases/todo/21_thinking_no_scroll/00_phase.md b/.agent/phases/todo/21_thinking_no_scroll/00_phase.md new file mode 100644 index 0000000..51a4a47 --- /dev/null +++ b/.agent/phases/todo/21_thinking_no_scroll/00_phase.md @@ -0,0 +1,104 @@ +# 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/01_disable_thinking_scroll.md b/.agent/phases/todo/21_thinking_no_scroll/01_disable_thinking_scroll.md new file mode 100644 index 0000000..0a7ba6c --- /dev/null +++ b/.agent/phases/todo/21_thinking_no_scroll/01_disable_thinking_scroll.md @@ -0,0 +1,57 @@ +# Task 01 — styles.css: `.thinking-text` overflow hidden (no user scroll) + +**Phase:** `21_thinking_no_scroll` · **Source:** `TODO.md` L4 — +*"Disable scroll in the thinking window. Users don't need to scroll back +through thinking, just see it live."* + +## Objective +One CSS property change makes the Thinking window a live-tail-only view: +`overflow-y: hidden` instead of `auto`, keeping the 320px clip. The +phase-17 JS bottom-pin (which keeps working under `overflow: hidden`) is +the sole scroller. + +## Work +1. `frontend/assets/styles.css` — in the phase-17 thinking block + section (~line 444): + ```css + details.thinking .thinking-text { + padding: 0 0.75rem 0.75rem; + color: var(--ink-soft); /* 6.9:1 on --surface */ + font-size: 0.875rem; + line-height: 1.55; + max-height: 320px; + overflow-y: hidden; /* no scroll back (owner choice 2026-08-24): + the window is a live tail only — the phase-17 + JS bottom-pin (scrollTop = scrollHeight per + chunk) is the sole scroller */ + } + ``` + (Only the `overflow-y` value + comment change; every other declaration + stays byte-identical.) +2. `frontend/assets/app.js` — **no change expected.** Verify the pin is + intact: the streaming `thinking` branch still does + `textEl.scrollTop = textEl.scrollHeight` on every chunk (~line 886). + If (and only if) the pin were missing/broken, fix it — do not remove + or alter any other scrolling behavior. +3. `tests/unit/test_thinking_no_scroll.py` (new — repo source-pin + pattern): + - `styles.css`: the `details.thinking .thinking-text` rule contains + `overflow-y: hidden` and `max-height: 320px` (no `overflow-y: auto` + left in that rule). + - `app.js`: the bottom-pin line + `textEl.scrollTop = textEl.scrollHeight` is still present (the + live-tail mechanism). +4. Manual smoke (dev server): stream a long thinking turn; try to wheel / + drag / Tab+ArrowDown inside the Thinking block — it must not move; + the newest chunk is always the one visible at the bottom. + +## Testing & Quality +- `uv run pytest tests/unit/test_thinking_no_scroll.py -v` green. +- `uv run ruff check . && uv run pyright` clean. + +## Completion Criteria +- [ ] `.thinking-text` is `overflow-y: hidden`, `max-height: 320px`, with + the owner-choice comment. +- [ ] The JS bottom-pin is verified intact (no app.js diff unless the + pin was broken). +- [ ] Unit pins green; lint/types clean; manual smoke passed. 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 new file mode 100644 index 0000000..e92d2c7 --- /dev/null +++ b/.agent/phases/todo/21_thinking_no_scroll/02_e2e_story_suite_commit.md @@ -0,0 +1,70 @@ +# 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/phases/todo/22_background_animation/00_phase.md b/.agent/phases/todo/22_background_animation/00_phase.md new file mode 100644 index 0000000..a9007b9 --- /dev/null +++ b/.agent/phases/todo/22_background_animation/00_phase.md @@ -0,0 +1,114 @@ +# Phase 22 — Animated Background: Make It Actually Animate + +**Source:** `TODO.md` L5 — *"Fix background animation not working, just +blinking"* +**Story:** `.agent/user_stories/background-animation.md` (created by task 02) +**Context:** `frontend/assets/styles.css` — the phase-08 animated +background block (~lines 60–100): `body::before` (44px drifting grid, +1px lines at ~35% `--line` alpha, radial mask +`radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%)`, +`animation: bg-grid-drift 60s linear infinite`) and `body::after` (two +soft radial glows, `animation: bg-glow-breathe 14s ease-in-out infinite +alternate`, opacity 0.65↔1 + scale 1↔1.05). Both layers are +`position: fixed; inset: 0; z-index: -1; pointer-events: none`. `html` +owns the `var(--bg)` canvas and `body` is `background: transparent` +(~lines 42–54) — if any later rule occludes that, the layers vanish. +The **phase-08 design comments are the spec** for what "working" means. + +## Objective +Owner report 2026-08-24: the background "just blinks" — i.e. the motion +the phase-08 design promised (a slow, seamless grid drift + a gentle +glow breathe) is not perceived; at most a flicker/blink is visible. +Diagnose which layer(s) actually fail in a real Chromium viewport, fix +the CSS, and leave a background that visibly and smoothly animates as +designed — no blink, no static frame, no jank. + +## Owner-confirmed (2026-08-24, roadmap A3) +1. **Intended effect = the phase-08 design comments:** seamless 60s grid + drift (one cell per loop) + 14s ease glow breathing. The fix serves + that design, not a redesign. +2. **Pure CSS, zero JS** (phase-08 anchor) — no animation JS, no new + assets, no `filter: blur` (perf note in the block). + +## Design / diagnostic plan +The fix is found, not guessed — work through this checklist in a real +Chromium window (dev server, full page, ~15s of observation): +1. **Per-layer visibility:** toggle each pseudo-element (DevTools + generated-content / a temp outline) and screenshot — is the grid + visible at all? Is only the glow (the "blink" the user perceives) + alive? +2. **Grid layer:** sample `background-position` on `body::before` at two + timestamps — is it actually moving? Is the radial mask fading the + visible region so small that the 44px/60s drift is imperceptible? + (If the drift is real but too faint: raise the grid line alpha and/or + the mask's visible radius — smallest change that reads as "smooth + drift".) +3. **Glow layer:** is the 14s breathe reading as a *blink*? (If the + opacity swing 0.65↔1 is perceived as pulsing: lengthen the period + and/or narrow the opacity delta so it reads as breathing.) +4. **Occlusion check:** confirm nothing later in `styles.css` (or in + `html`/`body` rules) paints an opaque background over the + `z-index: -1` layers — the phase-08 comment at ~line 42 is the + contract. +5. **Apply the fix in `styles.css`** — document the found root cause in + the phase report (screenshot before/after in + `.agent/screenshots/22_background_animation/`). + +## Dependencies +- `08_story_dark_tech_theme` (complete) — owns the layers, the palette, + and the "pure CSS, zero JS" anchor this phase must respect. +- `07_story_responsive_polish` (complete) — no new overflow at 360px + (both layers are `fixed; inset: 0` — keep it that way). + +## Tasks +1. `01_fix_background_animation.md` — diagnosis + the CSS fix + + source-level unit pins. +2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_background_animation.py` + (the story gate, isolated), regression suites, story file, final + validation, the single atomic commit, phase move to `complete/`. + +## Locked decisions +- **Phase-08 anchor honored** — pure CSS, zero JS, no `filter: blur`, + WCAG AA palette untouched (background layers carry no text). + **A11 untouched** — no CDN, no new assets. **A16 honored** — one new + story E2E suite + adapted regressions. No anchor changed. + +## Testing & Quality +- **Unit (source-level, new `tests/unit/test_background_animation.py`, + repo source-pin pattern):** `styles.css` still defines + `@keyframes bg-grid-drift` and `@keyframes bg-glow-breathe`; + `body::before` animates `bg-grid-drift` with `linear infinite`; + `body::after` animates `bg-glow-breathe`; both layers remain + `position: fixed; z-index: -1; pointer-events: none`; `html` keeps + `background: var(--bg)` and `body` keeps `background: transparent` + (the no-occlusion contract). Pin the **final** values the fix lands + on (durations/opacities may move per the design plan). +- **Integration:** none (no `app/` changes). +- **Coverage:** frontend-only; the >90% `app/` gate is unaffected. +- **E2E:** `tests/e2e/test_background_animation.py` (task 02), green + **in isolation** (prereq `podman compose up -d db`). +- **Lint/types:** `uv run ruff check . && uv run pyright` clean. + +## Completion Criteria +- [ ] In a real Chromium viewport, the background visibly and smoothly + animates (grid drift + glow breathe) — screenshot before/after in + the phase report; owner's "just blinking" perception gone. +- [ ] Root cause documented in `.agent/reports/22_background_animation/`. +- [ ] `uv run pytest` green; `uv run pytest --cov=app + --cov-report=term-missing` ≥ today's number. +- [ ] `uv run pytest tests/e2e/test_background_animation.py -v --no-cov` + green in isolation; regressions green in isolation: + `test_dark_tech_theme.py`, `test_responsive_polish.py`. +- [ ] `uv run ruff check . && uv run pyright` clean. +- [ ] UI Structure Check (AGENTS.md rule 5): layers stay behind content + (`z-index: -1`, `pointer-events: none`), no text/contrast impact, + no 360px overflow. +- [ ] `.agent/user_stories/background-animation.md` exists. +- [ ] One `--no-gpg-sign` commit (below); + `.agent/phases/todo/22_background_animation/` moved to + `.agent/phases/complete/`. + +## Commit +```bash +git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design" +``` diff --git a/.agent/phases/todo/22_background_animation/01_fix_background_animation.md b/.agent/phases/todo/22_background_animation/01_fix_background_animation.md new file mode 100644 index 0000000..22b9e41 --- /dev/null +++ b/.agent/phases/todo/22_background_animation/01_fix_background_animation.md @@ -0,0 +1,60 @@ +# Task 01 — Diagnose and fix the animated background (styles.css) + +**Phase:** `22_background_animation` · **Source:** `TODO.md` L5 — +*"Fix background animation not working, just blinking"* + +## Objective +Find why the phase-08 animated background reads as "just blinking" and +fix `styles.css` so the grid drift and the glow breathe are both visibly +and smoothly alive, per the phase-08 design comments (pure CSS, zero JS). + +## Work +1. **Reproduce:** `uv run uvicorn app.main:app --reload` (db up), open `/` + in a visible Chromium window (Playwright or the interactive browser), + observe ≥15s. Note exactly what moves and what doesn't. +2. **Diagnose per the `00_phase.md` checklist** (per-layer visibility + toggles, `background-position` samples on `body::before`, mask + inspection, occlusion check against `html`/`body` rules, glow + opacity-swing perception). Record findings + before-screenshot in + `.agent/reports/22_background_animation/` and + `.agent/screenshots/22_background_animation/`. + **ASSUMPTION (to verify, not assume):** the likely culprits, in + order — (a) the masked grid drift is too faint/slow to perceive, + (b) only the glow opacity swing is visible and it reads as a blink, + (c) a later rule occludes the `z-index: -1` layers. Confirm which + one actually fires before touching CSS; the fix must match the found + cause. +3. **Fix in `frontend/assets/styles.css`** (smallest change that makes + the design read): + - grid: raise line alpha and/or the mask's visible radius and/or the + drift speed as needed for a clearly visible, seamless drift + (drift delta must still equal one 44px cell for a seamless loop — + if the speed changes, keep `background-position` 0→44px and only + move the duration); + - glow: if the breathe reads as a blink, narrow the opacity delta + (e.g. 0.8↔1) and/or lengthen the period — it must read as + breathing, not pulsing; + - keep: both layers `position: fixed; inset: 0; z-index: -1; + pointer-events: none`; no `filter: blur`; no JS; palette/contrast + untouched. +4. **After-screenshot** (same viewport, two frames a few seconds apart + showing motion) into the same screenshots dir. +5. `tests/unit/test_background_animation.py` (new — repo source-pin + pattern): pin the **final** `styles.css` values — both + `@keyframes` present, `body::before` → `bg-grid-drift linear + infinite`, `body::after` → `bg-glow-breathe`, both layers + `fixed`/`z-index: -1`/`pointer-events: none`, `html` keeps + `background: var(--bg)`, `body` keeps `background: transparent`. +6. Manual re-verify: the "just blinking" perception is gone — smooth + drift + gentle breathe, no jank, no static frame. + +## Testing & Quality +- `uv run pytest tests/unit/test_background_animation.py -v` green. +- `uv run ruff check . && uv run pyright` clean. + +## Completion Criteria +- [ ] Root cause documented (with before/after screenshots) in the + phase report dir. +- [ ] Both layers visibly animate as the phase-08 design describes; + pure CSS, zero JS, no blur. +- [ ] Unit pins green against the final values; lint/types clean. diff --git a/.agent/phases/todo/22_background_animation/02_e2e_story_suite_commit.md b/.agent/phases/todo/22_background_animation/02_e2e_story_suite_commit.md new file mode 100644 index 0000000..8124519 --- /dev/null +++ b/.agent/phases/todo/22_background_animation/02_e2e_story_suite_commit.md @@ -0,0 +1,68 @@ +# Task 02 — E2E story suite, story file, validation, commit + +**Phase:** `22_background_animation` · **Source:** `TODO.md` L5 + +## Objective +The story gate: `tests/e2e/test_background_animation.py` proves both +background layers are actually running animations (not just declared), +plus regressions, story file, final validation, and the single atomic +commit. + +## Work +1. `tests/e2e/test_background_animation.py` (new). The layers are CSS + pseudo-elements, so assert via computed style + the Web Animations + API (Chromium reports pseudo-element CSS animations through + `element.getAnimations()`): + 1. `test_grid_layer_animation_running` — + `getComputedStyle(document.body, "::before").animationName` is the + grid-drift keyframe (final name from task 01), timing function + `linear`, iteration count `infinite`; and a matching entry in + `document.body.getAnimations()` with `playState === "running"`. + 2. `test_glow_layer_animation_running` — same for `"::after"` with + the glow-breathe keyframe; `playState === "running"`. + 3. `test_animations_advance` — sample `animation.currentTime` (or + the `getAnimations()` entry's `currentTime`) for both layers, + wait ~500ms (`page.wait_for_timeout`), assert both advanced — + the animations are truly running, not paused. + 4. `test_background_layers_contracts` — both pseudo-elements: + `position: fixed`, `z-index: -1`, `pointer-events: none`; + `document.documentElement` computed `background-color` is the + palette bg (the canvas stays on `html`); `document.body` computed + `background-color` is `rgba(0, 0, 0, 0)` (no occlusion). + 5. `test_no_horizontal_overflow_with_layers` (regression, 360px) — + viewport 360px: `document.documentElement.scrollWidth <= + clientWidth` (the phase-07 pin, replicated locally). +2. `.agent/user_stories/background-animation.md` (new) — story file per + the repo format: goal, the bug report verbatim from `TODO.md` L5, the + owner-confirmed A3 decisions + the found root cause (from task 01's + report), E2E mapping table. +3. Run the suite **in isolation** (prereq `podman compose up -d db`): + `uv run pytest tests/e2e/test_background_animation.py -v --no-cov`. +4. Regressions, in isolation, one command each: + - `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov` + - `uv run pytest tests/e2e/test_responsive_polish.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): layers stay behind + content, no text/contrast impact, no overflow at 360px. +7. Finish the phase report (`.agent/reports/22_background_animation/` — + E2E results + the task-01 screenshots). +8. Commit (one atomic commit) and move the phase: + ```bash + git add -A .agent/ frontend/ tests/ + git commit --no-gpg-sign -m "fix(ui): animated background actually animates — grid drift and glow breathe per the phase-08 design" + mv .agent/phases/todo/22_background_animation .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_background_animation.py` 5/5 in isolation. +- [ ] Regressions (dark tech theme, responsive polish) green in + isolation. +- [ ] Story file + phase report (with screenshots) exist. +- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`. diff --git a/.agent/phases/todo/23_containerfile_build/00_phase.md b/.agent/phases/todo/23_containerfile_build/00_phase.md new file mode 100644 index 0000000..3160eec --- /dev/null +++ b/.agent/phases/todo/23_containerfile_build/00_phase.md @@ -0,0 +1,120 @@ +# Phase 23 — Containerfile: Build the Whole App Image Again + +**Source:** `TODO.md` L6 — *"Fix Containerfile build not working"* +**Story:** `.agent/user_stories/containerfile-build.md` (created by task 02) +**Context:** `Containerfile` (3 stages: node:22-alpine + esbuild +0.25.5 frontend bundle → uv/python deps → slim runtime serving +`/app/static`); `frontend/` (4 pages: `index.html`, `sources.html`, +`document.html`, `login.html`; assets: `styles.css`, `markdown.js` +(classic script), `header.js`/`app.js`/`sources.js`/`document.js`/ +`login.js` (ES modules)); `scripts/entrypoint.sh`. + +## Verified diagnosis (2026-08-24, this conversion — not a guess) +1. **Root cause of the build failure:** phase 19 switched the page + scripts to `import … from "/assets/header.js"` (an absolute URL). + esbuild resolves that as the *filesystem* path `/assets/header.js` + and the stage-1 bundle dies: + `✘ [ERROR] Could not resolve "/assets/header.js"` + (reproduced with esbuild **0.25.5**, the exact pinned version, on a + copy of `frontend/`). +2. **Secondary gap (image would be broken even if it built):** stage 1 + bundles only `app.js` + `sources.js` and copies only `index.html` + + `sources.html`. Missing from the image: `document.html` + + `login.html` (phases 10/16), `document.js` + `login.js`, and + `markdown.js` (classic script loaded by `index.html` + + `document.html`). +3. **Verified fix:** with relative imports (`from "./header.js"`) all + four page scripts bundle cleanly with esbuild 0.25.5. +4. **Latent double-evaluation trap:** all four HTML pages also load + ``; + - `frontend/sources.html` (~line 125), `frontend/document.html` + (~line 80), `frontend/login.html` (~line 67) — same tag. + - Update the surrounding HTML comments that describe the + header-before-page-script load order (e.g. index.html ~lines + 115–119): the order is now guaranteed by the page script's own + `import` (hoisted, evaluated before the page script body calls + `initSharedHeader()`). +4. **`Containerfile` stage 1** — cover the whole app (keep the pinned + `esbuild@0.25.5` and the existing flags): + ```dockerfile + RUN mkdir -p /out/assets \ + && esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \ + && esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \ + && esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \ + && esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \ + && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ + && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ + && cp ./index.html ./sources.html ./document.html ./login.html /out/ + ``` + (`markdown.js` is a classic script — minify only, **no** `--bundle`; + it exposes globals used by the pages.) +5. **Verify locally (no podman):** with esbuild 0.25.5, all four module + bundles + the markdown minify succeed on the real `frontend/` (not a + copy — the copy was only for the diagnosis). +6. **`podman build -f Containerfile .`** → green. +7. **Image smoke test** (results + log excerpt into the report dir): + - throwaway Postgres 17 + pgvector (`podman compose up -d db` and + point the container at it, or a one-off container with the same + env as `compose.yaml`); + - run the built image (migrations run via the entrypoint); + - `GET /`, `/sources.html`, `/document.html`, `/login.html` → 200; + - `GET /assets/app.js` → 200, minified (single-line-ish), and + contains the header code (e.g. the `clearChatStorage` function + body); `GET /assets/markdown.js`, `/styles.css`, the other three + page modules → 200; + - No CDN rule: none of the four served pages contain an `http(s)://` + `src`/`href` asset reference. + - Teardown the throwaway containers when done. +8. **Dev-server regression check** (the tag removal touches dev page + load — confirm boot order still holds): `uv run uvicorn + app.main:app --reload`, load all four pages, check the sign-out + binding exists exactly once (DevTools: no duplicate listener — one + `POST /api/logout` per click) and `initSharedHeader()` ran. (The + isolated E2E regressions run in task 02.) + +## Testing & Quality +- Steps 5–8 above; `uv run ruff check . && uv run pyright` clean + (no Python changes, but keep the gate green). + +## Completion Criteria +- [ ] The recorded build failure is fixed at the root cause (relative + imports) — not masked by an alias/patch. +- [ ] All four direct `header.js` tags removed + comments updated; the + page scripts' `import "./header.js"` is the only header load. +- [ ] Stage 1 produces: 4 HTML pages, 4 bundled modules, minified + `markdown.js`, minified `styles.css`. +- [ ] `podman build` green; image smoke all-200 + No CDN + single + header evaluation; dev-server boot unchanged (step 8). +- [ ] Log/screenshot evidence in `.agent/reports/23_containerfile_build/`. diff --git a/.agent/phases/todo/23_containerfile_build/02_integration_test_commit.md b/.agent/phases/todo/23_containerfile_build/02_integration_test_commit.md new file mode 100644 index 0000000..812a8a8 --- /dev/null +++ b/.agent/phases/todo/23_containerfile_build/02_integration_test_commit.md @@ -0,0 +1,70 @@ +# Task 02 — Integration coverage test, story file, validation, commit + +**Phase:** `23_containerfile_build` · **Source:** `TODO.md` L6 + +## Objective +Pin the stage-1 asset coverage so it can't silently rot again (a new +page/script/asset without a matching Containerfile line fails CI), plus +regressions, story file, final validation, and the single atomic commit. + +## Work +1. `tests/integration/test_containerfile_assets.py` (new — **hermetic**: + parses `Containerfile` + `frontend/` as text, no podman, no network). + Tests: + 1. `test_every_html_page_is_copied_into_stage1` — for each + `frontend/*.html` in the repo, a stage-1 line copies it into + `/out` (regex over the `cp` line; the set must be exactly the + four current pages — a new page added to `frontend/` fails this). + 2. `test_every_local_asset_reference_is_produced` — collect every + local `src=`/`href=` under `assets/` or `/assets/` from the four + HTML files; each basename must be produced by a stage-1 line + (an `esbuild … --outfile=/out/assets/` or a `cp` of it). + (This is what catches a missing `markdown.js`-style gap.) + 3. `test_page_module_scripts_are_bundled` — the set of `type="module"` + page scripts referenced by the HTML (basenames) equals the set of + scripts esbuild bundles in stage 1 (`app.js`, `sources.js`, + `document.js`, `login.js`). + 4. `test_header_module_is_imported_not_directly_loaded` — no HTML + file contains a `