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 `