.py -v --no-cov`): `test_git_sources_admin.py` (new), `test_nav_consistency.py` (updated), `test_sync_button.py`, `test_shared_header.py`, `test_header_consistency.py`, `test_tuning_nav_link.py`, `test_smoke.py`.
+5. Full gate: `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`.
+6. **UI Structure Check** (AGENTS.md rule 5) on the new page (full-width table, labels, contrast, focus-visible, aria-live on the list updates) + no CDN (rule 6).
+7. **Commit** (A17): stage this phase's files (`app/**`, `alembic/**`, `frontend/**`, `tests/**`, `README.md`, `.env.example`), message `feat(sources): admin page to add and remove git sources (TODO.md L4)`, always `--no-gpg-sign`. Move `.agent/phases/todo/35_git_sources_admin/` to `.agent/phases/complete/`.
+
+## Testing & Quality
+- E2E: `tests/e2e/test_git_sources_admin.py` green **in isolation** (A16: one story, one file).
+- Unit/integration: from tasks 01–03 — all green under `uv run pytest`.
+- Coverage: **>90%** on `app/` (new API module + resolver fully covered).
+
+## Completion Criteria
+- [ ] `uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov` green in isolation.
+- [ ] `test_nav_consistency.py` updated for the fourth nav link and green; every suite in the task 06 regression list green in isolation.
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
+- [ ] README + `.env.example` document the fallback semantics.
+- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
diff --git a/.agent/phases/todo/36_summary_in_viewer/00_phase.md b/.agent/phases/todo/36_summary_in_viewer/00_phase.md
new file mode 100644
index 0000000..e08e446
--- /dev/null
+++ b/.agent/phases/todo/36_summary_in_viewer/00_phase.md
@@ -0,0 +1,40 @@
+# Phase 36 — Document Summary Shown Together With the Original
+
+**Source:** `TODO.md` L5 — "When I click on a document with a summary I should be able to see the summary and the original document together."
+**Story:** `.agent/user_stories/summary-in-viewer.md`
+**Context:** Phase 30 stores a lite-model summary on `documents.summary` for every non-markdown document (plus an indexed `is_summary` chunk) — but the viewer never shows it: `GET /api/documents/content` omits the field and the shared renderer `renderDocument` (`frontend/assets/document.js`, used by BOTH the full-page viewer `document.html` and the chat/sources modal, phase 26) only renders the raw content. Markdown documents carry no summary (phase 30) and must render exactly as before.
+
+## Objective
+When a document **has** a summary, show it and the original content together — a labeled Summary panel above the content, on both viewer surfaces at once (shared renderer); documents without a summary are unchanged.
+
+## Dependencies
+- `30_document_summaries` (complete) — the `documents.summary` column (migration 0004), the summarizer, the `summary_kb` E2E fixture + the deterministic mock `SUMMARY_MODE` digest.
+- `10_story_document_viewer` + `26_document_modal_viewer` (complete) — the viewer page, the modal, and the shared `renderDocument(doc, {titleEl, metaEl, contentEl})` core both surfaces render through.
+- `16_admin_auth` (complete) — the soft rule this phase must not touch: the content endpoint stays public + stateless (catalog gated, viewer public).
+
+## Tasks
+1. `01_content_api_summary_field.md` — `DocContent.summary` + the endpoint returns it; integration tests.
+2. `02_viewer_summary_panel.md` — the shared renderer draws the `.doc-summary` panel (both surfaces) + theme-matched styles.
+3. `03_e2e_and_regression.md` — the story E2E suite `test_summary_in_viewer.py`; regressions; full gate; commit.
+
+## Testing & Quality
+- Unit/integration: the content endpoint returns the summary for a summarized non-markdown doc and `null` for a markdown doc; anonymous access unchanged.
+- Coverage: **>90%** on `app/` (the touched endpoint stays covered).
+- E2E (mandatory, A16): `tests/e2e/test_summary_in_viewer.py` — the story gate, run in isolation.
+
+## Completion Criteria
+- [ ] `GET /api/documents/content` returns `summary` (string or null); no auth/shape change beyond the added nullable field; the endpoint is still public.
+- [ ] A summarized document shows the labeled Summary panel **above** the original content in the full-page viewer AND the modal; the original content (including content the summary digest doesn't contain) is fully visible.
+- [ ] A markdown document (no summary) renders exactly as before on both surfaces — no empty panel.
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov` green in isolation; regressions (task 03 list) green.
+- [ ] `uv run ruff check . && uv run pyright` clean.
+- [ ] UI Structure Check (AGENTS.md rule 5): the panel is a labeled section, contrast ≥4.5:1, no CDN (rule 6).
+- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
+
+## Locked decisions
+- **A7 / A15 untouched** — retrieval, context assembly, and the SSE contract are unchanged; this is a display + API-field phase.
+- **Phase 16 soft rule untouched** — `GET /api/documents/content` stays public + stateless (anyone who can open a document sees its summary; the catalog stays admin-gated).
+- **Phase 30 untouched** — summaries are still generated at import, still markdown-excluded, still fail-soft (NULL possible); this phase only surfaces the existing field.
+- **Shared-renderer principle (phase 26)** — the panel is drawn in `renderDocument`, so the page and the modal can never drift.
+- **A11 untouched** — vanilla HTML/CSS/JS, no CDN, no new packages; summary text rendered with `textContent` (XSS contract unchanged).
+- **A16 / A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
diff --git a/.agent/phases/todo/36_summary_in_viewer/01_content_api_summary_field.md b/.agent/phases/todo/36_summary_in_viewer/01_content_api_summary_field.md
new file mode 100644
index 0000000..0c0289a
--- /dev/null
+++ b/.agent/phases/todo/36_summary_in_viewer/01_content_api_summary_field.md
@@ -0,0 +1,32 @@
+# Task 01 — The content endpoint returns the summary
+
+**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
+**Story:** `.agent/user_stories/summary-in-viewer.md`
+
+## Objective
+Surface the existing `documents.summary` field (phase 30) on the viewer's data contract: `DocContent` gains a nullable `summary` and `GET /api/documents/content` returns it — endpoint stays public, stateless, and otherwise byte-identical.
+
+## Work
+1. `app/schemas.py` — `DocContent` gains:
+ ```python
+ #: Lite-model summary (phase 30) — non-markdown A9 docs only; None for
+ #: markdown documents, pre-phase-30 rows, and the fail-soft path where
+ #: summary generation failed but the document was still indexed.
+ summary: str | None = None
+ ```
+ (Place it after `format` / before `content`, with the docstring mirroring `Document.summary`'s.)
+2. `app/api/docs.py` — `get_document_content` returns `summary=doc.summary` in the `DocContent(...)` construction. Nothing else changes (no auth, no query change — `Document` is already selected in full).
+3. Integration tests — extend the existing `/api/documents/content` test module (find it in `tests/integration/` — the phase-10 content-endpoint tests):
+ - A non-markdown document row seeded with `summary="…"` → response JSON carries `summary` verbatim.
+ - A markdown document row with `summary=None` → `"summary": null`.
+ - Anonymous (no admin cookie) still gets 200 (the phase-16 soft rule — public viewer) for both.
+ - The existing assertions (404 on unknown pair, content/format fields) stay green unmodified.
+
+## Testing & Quality
+- Integration: the cases above; `uv run pytest` green overall.
+- Coverage: **>90%** on `app/` — the touched endpoint stays covered (the new field is exercised by the new assertions).
+
+## Completion Criteria
+- [ ] `GET /api/documents/content?source=…&path=…` returns the summary for a summarized doc and `null` for a markdown doc; anonymous access unchanged (200).
+- [ ] No other field, status code, or auth behavior of the endpoint changed.
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
diff --git a/.agent/phases/todo/36_summary_in_viewer/02_viewer_summary_panel.md b/.agent/phases/todo/36_summary_in_viewer/02_viewer_summary_panel.md
new file mode 100644
index 0000000..2eb6943
--- /dev/null
+++ b/.agent/phases/todo/36_summary_in_viewer/02_viewer_summary_panel.md
@@ -0,0 +1,44 @@
+# Task 02 — The shared renderer draws the Summary panel (both surfaces)
+
+**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
+**Story:** `.agent/user_stories/summary-in-viewer.md`
+
+## Objective
+`renderDocument` — the single rendering core both the full-page viewer (`document.html`) and the chat/sources modal (`document-modal.js`) go through (phase 26) — draws a labeled Summary panel above the original content whenever `doc.summary` is non-empty; `null`/empty renders nothing, so markdown documents and fail-soft rows are byte-for-byte unchanged.
+
+## Work
+1. `frontend/assets/document.js` — in `renderDocument(doc, { titleEl, metaEl, contentEl })`, **after** the meta row is populated and **before** the content is built, append the summary section to `contentEl` (which is then filled with the usual `.doc-md` / `` content after it):
+ ```js
+ if (doc.summary && doc.summary.trim() !== "") {
+ const section = document.createElement("section");
+ section.className = "doc-summary";
+ section.setAttribute("aria-label", "Summary");
+ const title = document.createElement("h2");
+ title.className = "doc-summary-title";
+ title.textContent = "Summary";
+ const body = document.createElement("p");
+ body.className = "doc-summary-text";
+ body.textContent = doc.summary; // text node — XSS contract unchanged
+ section.append(title, body);
+ contentEl.appendChild(section);
+ }
+ ```
+ - The meta badge row (`#doc-meta` / `#doc-modal-meta`) is untouched; the panel sits between meta and content on **both** surfaces because both call this one function.
+ - Update the file-header comment (the renderer now also owns the optional summary panel) and the `renderDocument` doc comment.
+2. `frontend/assets/styles.css` — `.doc-summary` (dark tech theme, phase-08 palette):
+ - A clearly-distinct "summary, not content" look: surface `#121a2e` with a 3px brand left border (`#6d78f2`) or a brand-soft (`#232b52`) header strip — pick one and keep it simple; `border-radius` matching the existing content cards; padding ~1rem; `margin-bottom` separating it from the content.
+ - `.doc-summary-title` — small-caps/label treatment: `#a5b4fc` (brand-ink on the brand-soft chip, ≈6.9:1) or `#a5b4fc` on surface (verify ≥4.5:1 — if short, use the chip).
+ - `.doc-summary-text` — `var(--ink)` (`#e8ebf4`) on the surface (≈14.5:1); wraps inside the same width the content uses (the ≤46rem centered column for md docs, the raw-content width otherwise — the panel is a child of `contentEl`, so it inherits the column; verify for the `` case where the content is wider).
+ - No animation (nothing for `prefers-reduced-motion` to still); the section is static content — no focusability needed (it carries `aria-label` + heading).
+3. Do NOT touch `document-modal.js` (it calls `renderDocument` — the panel comes for free), `markdown.js`, or the page scripts.
+
+## Testing & Quality
+- Frontend-only — no Python change; the no-CDN integration test is unaffected.
+- Coverage: `app/` gate unaffected.
+- Manual smoke (dev server with an imported non-md doc, e.g. a yaml from the fixture KB via the importer + mock, or a hand-seeded `documents.summary` row): modal from the Sources table AND the full page both show panel + content; a markdown doc shows no panel.
+
+## Completion Criteria
+- [ ] A document with a summary shows the labeled panel above the original content in **both** the modal and the full-page viewer; the original content is fully visible (nothing hidden/collapsed).
+- [ ] A document without a summary (`null` or empty/whitespace) renders exactly as before on both surfaces — no panel, no empty box.
+- [ ] The summary text is written with `textContent` (XSS contract); the meta row is unchanged.
+- [ ] `uv run pytest` green (no-CDN test); `uv run ruff check . && uv run pyright` clean.
diff --git a/.agent/phases/todo/36_summary_in_viewer/03_e2e_and_regression.md b/.agent/phases/todo/36_summary_in_viewer/03_e2e_and_regression.md
new file mode 100644
index 0000000..9d51975
--- /dev/null
+++ b/.agent/phases/todo/36_summary_in_viewer/03_e2e_and_regression.md
@@ -0,0 +1,31 @@
+# Task 03 — Story E2E + regression pass + commit
+
+**Phase:** `36_summary_in_viewer` · **Source:** `TODO.md:5 — "When I click on a document with a summary I should be able to see the summary and the original document together."`
+**Story:** `.agent/user_stories/summary-in-viewer.md`
+
+## Objective
+Prove the story end-to-end with its dedicated Playwright suite — summary panel + original content visible together on both surfaces, no panel for markdown docs — then the full gate + one commit.
+
+## Work
+1. `tests/e2e/test_summary_in_viewer.py` (NEW — the story gate, run in isolation). Reuse `tests/e2e/test_document_summaries.py`'s machinery as closely as possible (its fixture KB + import helpers are the reference):
+ - Fixtures: the standard E2E app + DB + the deterministic mock LLM (`tests/e2e/mock_llm.py` — its `SUMMARY_MODE` answer is a byte-stable 24-token digest); import `tests/fixtures/summary_kb/` so `quadlet/qwen-llamacpp.yaml` gets its stored summary + `notes/qwen-llamacpp-notes.md` stays summary-less (the existing tail sentinel `RESE-SUMMARY-SENTINEL-7f3a` sits on the yaml's LAST line — **outside** the 24-token digest, so it is a marker for "the original, not the summary").
+ - **Full page:** open `/document.html?source=summary_kb&path=quadlet%2Fqwen-llamacpp.yaml` → `.doc-summary` visible with the deterministic digest text AND the original content visible with the sentinel (`RESE-SUMMARY-SENTINEL-7f3a` present in the rendered content) — summary and original **together**.
+ - **Modal:** from the Sources table (admin session via `tests/e2e/auth_helpers.py`), click the yaml's row → the modal shows the same panel + content (sentinel present, digest present); then "Full page" still lands on the dedicated page with the panel (the two surfaces agree).
+ - **No-summary control:** the markdown doc (`notes/qwen-llamacpp-notes.md`) → no `.doc-summary` element on the full page and in the modal; the content renders as before.
+ - **API shape (cheap, via the page context's `fetch` or `context.request`):** `GET /api/documents/content` for the yaml carries `summary` (string), for the md doc `null`; anonymous fetch → 200 (soft rule unchanged).
+ - The E2E must not depend on a real LLM (the mock's digest is deterministic — the same pattern `test_document_summaries.py` relies on).
+2. **Regression pass — each in isolation** (`uv run pytest tests/e2e/.py -v --no-cov`): `test_summary_in_viewer.py` (new), `test_document_viewer.py`, `test_document_summaries.py`, `test_document_back_navigation.py`, `test_chat_rag.py` (the source-chip modal path), `test_smoke.py`.
+3. Full gate: `uv run pytest`, `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`.
+4. **UI Structure Check** (AGENTS.md rule 5): the panel is a labeled section (`aria-label` + heading), theme contrast ≥4.5:1, it does not break the centered 46rem chat-column-width content layout, no CDN (rule 6 — the no-CDN integration test covers it).
+5. **Commit** (A17): stage this phase's files (`app/schemas.py`, `app/api/docs.py`, `frontend/assets/document.js`, `frontend/assets/styles.css`, `tests/**`), message `feat(viewer): show document summary together with the original (TODO.md L5)`, always `--no-gpg-sign`. Move `.agent/phases/todo/36_summary_in_viewer/` to `.agent/phases/complete/`.
+
+## Testing & Quality
+- E2E: `tests/e2e/test_summary_in_viewer.py` green **in isolation** (A16: one story, one file).
+- Unit/integration: from task 01 — all green under `uv run pytest`.
+- Coverage: **>90%** on `app/` (the endpoint change is covered).
+
+## Completion Criteria
+- [ ] `uv run pytest tests/e2e/test_summary_in_viewer.py -v --no-cov` green in isolation.
+- [ ] Every suite in the task 03 regression list green in isolation.
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
+- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
diff --git a/.agent/user_stories/git-sources-admin.md b/.agent/user_stories/git-sources-admin.md
new file mode 100644
index 0000000..58ae4d9
--- /dev/null
+++ b/.agent/user_stories/git-sources-admin.md
@@ -0,0 +1,53 @@
+# Story: Admin Page to Add / Remove Git Sources
+
+**Phase:** `35_git_sources_admin` · **E2E:** `tests/e2e/test_git_sources_admin.py`
+
+## Narrative
+
+As **the admin (owner)**, today I manage the git repos that feed the
+knowledge base by editing `BOR_GIT_SOURCES` in `.env` and restarting. I
+want a page only I can access where I can **add and remove git
+sources** — no env file, no restart — and have the Sync button
+(phase 32) and `import_docs` (phase 28) use that stored list.
+
+- **Given** I am signed in as admin
+- **When** I open the Git sources page and add a repo URL (or remove
+ one)
+- **Then** the change is stored in Postgres (`git_sources` table) and
+ the next sync / import uses the stored list — the
+ `BOR_GIT_SOURCES` env var remains only a fallback while the table is
+ empty, and phase 32's fail-loud "no git sources configured" is
+ preserved when both are empty.
+
+## Acceptance criteria
+1. Migration 0006: `git_sources` (id UUID PK, url TEXT UNIQUE NOT
+ NULL, added_at TIMESTAMPTZ) — reversible, integration-tested
+ up/down (existing migration-test pattern).
+2. Admin-only API (phase 16 pattern — 403 `admin only` anonymous):
+ `GET /api/git-sources` (DB rows in added order; while the table is
+ empty, the env list with `from_env: true`), `POST /api/git-sources`
+ {url} (trimmed, 1–500 chars, `https?://` / `ssh://` / `git@`
+ shape, 409 on duplicate with a detail that never echoes the URL),
+ `DELETE /api/git-sources/{id}` (204 / 404).
+3. `effective_git_sources()` shared by `app/api/sync.py::_run_sync`
+ and `scripts/import_docs.py`: DB rows win, env fallback only while
+ the table is empty, fail-loud unchanged when both are empty; the
+ sync log line records the list origin (`origin=db|env`).
+4. `/git-sources.html` — soft-gated like the Sources page (anonymous:
+ sign-in gate) with the standard header (phase 34) and an
+ admin-only "Git sources" nav link on all five pages (phase 29
+ pattern): sources list (mono URL + added date + per-row Remove),
+ an add form (labeled input + Add, inline error, never-stale button
+ per §7.4), an env-fallback note while the table is empty, and a
+ hint that the KB itself updates via the Sync button.
+5. `.env.example` + README: `BOR_GIT_SOURCES` documented as the
+ empty-table fallback; the admin page is the primary management UI.
+6. Unit + integration green, `app/` coverage >90%, story E2E green in
+ isolation, ruff + pyright clean, one `--no-gpg-sign` commit.
+
+## Playwright Mapping Rule
+`tests/e2e/test_git_sources_admin.py` — one story, one file, run in
+isolation: anonymous gets the sign-in gate (and 403s on the API); the
+admin adds/removes sources on the page (the list updates live, invalid
+input gets an inline error) and reaches the page via the "Git
+sources" nav link on every page.
diff --git a/.agent/user_stories/nav-consistency.md b/.agent/user_stories/nav-consistency.md
new file mode 100644
index 0000000..32bd014
--- /dev/null
+++ b/.agent/user_stories/nav-consistency.md
@@ -0,0 +1,57 @@
+# Story: One Navbar on Every Page
+
+**Phase:** `34_consistent_navbar` · **E2E:** `tests/e2e/test_nav_consistency.py`
+
+## Narrative
+
+As **any user (admin or anonymous)**, I move between the Chat, Sources,
+Document viewer, Global Tuning, and Sign in pages. Today the header bar
+is only similar between pages: the document viewer replaces it with a
+back + title bar (every nav button vanishes), the login page misses the
+Tuning link, and the functional controls are page-scoped — the Tuning
+steering toggle lives on chat only, the Sync sources button on Sources
+only — so buttons pop in and out as I switch tabs. I want ONE identical
+bar on every page.
+
+- **Given** any of the app's five pages
+- **When** I look at the header
+- **Then** I see the same controls in the same order — brand, nav
+ [Chat, Sources, Tuning], Tuning toggle, Sync sources (admin), New
+ chat, and exactly one of Sign in / Sign out — with none missing
+ because of which page I happen to be on.
+
+## Acceptance criteria
+1. `frontend/assets/header.js` is the single owner of the functional
+ header controls: the steering toggle + panel logic (moved from
+ `app.js`, exported `refreshSteering()`), the Sync button state
+ machine (moved from `sources.js`, dispatching `bor:sync-status`),
+ one New chat binding (chat page: `bor:new-chat` event; other pages:
+ clear + navigate), and the sign-in `?next=` rewrite to the current
+ page. No control logic remains in the page scripts.
+2. All five pages (`index.html`, `sources.html`, `document.html`,
+ `tuning.html`, `login.html`) ship the identical header markup; the
+ `#steering-panel` section exists on every page (chat: after
+ `#kb-banner` as today; other pages: first child of ``).
+3. The document viewer keeps its back link + title in a second
+ titlebar row below the standard bar (phase 13 back-target behavior
+ unchanged); the pinned 64px / 58px height contract applies to the
+ standard row on every page.
+4. The locked A10 UI revision is preserved: admin-only controls
+ (Sources / Tuning nav links, Sync button) ship hidden and are
+ revealed only for a signed-in admin — for the admin every button is
+ active on every page; anonymous visitors get the reduced bar,
+ identically on every page (owner confirmation 2026-08-26).
+5. The chat page's per-bubble Tune button + inline form (phases 15/27)
+ and the Sources page's sync result line + error banner (phase 32)
+ keep working exactly as before.
+6. `tests/e2e/test_nav_consistency.py` green in isolation;
+ `test_header_consistency.py` / `test_shared_header.py` updated to the
+ new viewer contract; regressions green; `app/` coverage >90%
+ (frontend-only phase — unchanged); ruff + pyright clean; one
+ `--no-gpg-sign` commit.
+
+## Playwright Mapping Rule
+`tests/e2e/test_nav_consistency.py` — one story, one file, run in
+isolation. It asserts, per role (admin, anonymous), that the visible
+header control inventory is identical on all five pages, and that the
+viewer's back link + titlebar row survive the change.
diff --git a/.agent/user_stories/summary-in-viewer.md b/.agent/user_stories/summary-in-viewer.md
new file mode 100644
index 0000000..4c76d2f
--- /dev/null
+++ b/.agent/user_stories/summary-in-viewer.md
@@ -0,0 +1,45 @@
+# Story: Summary + Original Document Together in the Viewer
+
+**Phase:** `36_summary_in_viewer` · **E2E:** `tests/e2e/test_summary_in_viewer.py`
+
+## Narrative
+
+As **any user**, phase 30 gives every non-markdown document a
+lite-model summary (stored on `documents.summary`, indexed as an
+`is_summary` chunk, used for retrieval). When I open such a document —
+from the Sources table, a chat source chip, or a direct URL — I only
+see the raw original. I want to see **the summary and the original
+document together**.
+
+- **Given** a document that has a summary (phase 30: non-markdown A9
+ documents)
+- **When** I open it in the document viewer (the modal or the full
+ page)
+- **Then** a labeled Summary panel sits above the original content —
+ both visible at once. Documents without a summary (markdown docs,
+ pre-phase-30 rows, fail-soft rows) render exactly as before.
+
+## Acceptance criteria
+1. `DocContent` gains `summary: str | None`; `GET
+ /api/documents/content` returns `documents.summary`. The endpoint
+ stays public + stateless — the phase 16 soft rule (catalog gated,
+ viewer public) is unchanged.
+2. The shared renderer `renderDocument` (`assets/document.js`)
+ renders the summary panel for **both** surfaces at once (the
+ full-page viewer + the chat/sources modal); null/empty summary →
+ no panel; the summary text is written with `textContent` (XSS
+ contract unchanged).
+3. `.doc-summary` styling matches the dark tech theme (phase 08
+ palette, contrast ≥4.5:1) and reads as a summary, not as document
+ content.
+4. Integration tests: a summarized non-markdown doc returns its
+ summary; a markdown doc returns null; anonymous access unchanged.
+5. Story E2E green in isolation (the `summary_kb` fixture: the
+ deterministic digest panel + the full original with its tail
+ sentinel visible together; the markdown control doc → no panel);
+ regressions green; `app/` coverage >90%; ruff + pyright clean; one
+ `--no-gpg-sign` commit.
+
+## Playwright Mapping Rule
+`tests/e2e/test_summary_in_viewer.py` — one story, one file, run in
+isolation.