diff --git a/.agents/phases/todo/146_search_tool_scope/00_phase.md b/.agents/phases/todo/146_search_tool_scope/00_phase.md
new file mode 100644
index 0000000..526fdb7
--- /dev/null
+++ b/.agents/phases/todo/146_search_tool_scope/00_phase.md
@@ -0,0 +1,34 @@
+# Phase 146 — the search tool line shows WHERE the LLM is searching: a scoped grep carries its document
+
+**Source:** `TODO.md` L3 — "The "search" tool in the UI doesn't show where the LLM is searching. This results in, what looks like, multiple search tool calls with the same value, like the LLM is repeating itself."
+**Story:** n/a (TODO-derived phase, Protocol B append).
+**Context:** `app/rag/agent.py` — the `grep` tool definition (~L395–441: required `pattern` + optional `path` — "Rarely needed — only for re-searching one document you already know: that document's combined `source/path` identity"); `app/api/chat.py` — the `ToolCallPiece` → SSE `tool` frame builder (~L909–947: `argument = piece.arguments.get("pattern" if piece.name == "grep" else "path")` — a scoped grep's `path` argument is DROPPED today, so every search line renders the same pattern); `app/schemas.py` — `ChatToolEvent` (L182: `{name, argument, offset?, limit?}` — the additive-omission serializer precedent is `DocContent._serialize` dropping `image_url` when None); `frontend/assets/app.js` — `appendToolLine` (~L1122: the grep branch renders `🔎 Searching for pattern` — no scope, byte-identical for bare and scoped calls), the live tool-frame handler (~L3178–3230: `ev.offset`/`ev.limit` additive wire fields + the `toolRecord` stamp — the exact pattern this phase extends with `ev.path`), the restore path (~L2064–2086: re-renders saved `{name, argument, offset?, limit?}` records through the same helper); `tests/e2e/mock_llm.py` — the scripted-turn idiom (the phase-70/72 `grep` scripted branches — the house way of driving a model tool call deterministically).
+**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):** the fix is the missing SCOPE: the `tool` frame additively carries the grep's optional `path` (the re-searched document's combined `source/path`), and the UI renders it — `🔎 Searching for in ` — so a whole-KB search and a re-search of one document are visibly different (the "repeating itself" confusion is gone). Bare greps render byte-identical to pre-phase. The scope also rides the status label ("… is searching for X in Y") and persists additively on the saved turn's tool record (`bor.chat.v1` JSONB — old records restore byte-identically, no migration).
+
+## Objective
+A grep that re-searches a specific document shows THAT document in the chat's tool line and status label, so repeated patterns with different scopes read as distinct searches; every bare-grep rendering (live, restored, persisted) is byte-identical to pre-phase.
+
+## Dependencies
+- `145_git_source_links` (todo) — pipeline order only; no shared code (this phase touches the tool-frame wire + the chat tool-line renderer; 145 touches read results + the doc viewer meta row).
+
+## Design (shared by all tasks — the executor reads this, not the chat)
+- **The wire (task 01):** `ChatToolEvent` gains `path: str | None = None` — ADDITIVE-OMISSION: a `model_serializer(mode="wrap")` drops the key when None (the `DocContent` `image_url` precedent), so every frame that is not a scoped grep serializes byte-identical to pre-phase. The frame builder sets it ONLY for `piece.name == "grep"` when `piece.arguments.get("path")` is a string (a non-string `path` is a model error the backend already refuses — the guard just yields None → omitted). Legacy tool names never appear on the live wire (the server only emits current names), so no legacy handling.
+- **The UI (task 02):** `appendToolLine` gains a fifth `scope` parameter (default null). The grep branch appends, ONLY when `scope` is a non-empty string, a sibling `` AFTER the pattern ``: text node ` in ` + a second `` (the scope is data, never markup; NO new emoji — the frontend emoji guard strips precisely the existing template literals, which stay untouched — the phase-139 read-range-suffix span-append precedent). The live handler reads `ev.path` (string guard), persists it on the `toolRecord` ONLY when non-null (additive JSONB key — a bare-grep record keeps the exact pre-phase shape), and extends the grep status label with ` in ${scope}`. The restore path reads the saved `t.path` through the same helper — a record saved before this phase (no key) renders byte-identically.
+- **The E2E (task 02):** `mock_llm.py` gains ONE scripted question branch: a scoped `grep` (`pattern` + `path`) followed by the terminal answer — the house scripted-turn idiom; the bare-grep scripted turns from phases 70/72 stay as the byte-identity pins.
+
+## Tasks
+1. `01_tool_frame_scope.md` — the additive `path` field on `ChatToolEvent` + the frame builder + the serialization matrix.
+2. `02_ui_scope_render_e2e.md` — the tool line + status label + persisted record + restore path + the dedicated E2E.
+
+## Testing & Quality
+- Unit: the frame-serialization matrix (scoped grep / bare grep / read / ls / null-argument — non-scoped frames byte-identical pins).
+- E2E: one dedicated Playwright file, run in isolation, mock-only.
+- Coverage: **>90%** on new/modified code (`app/`).
+
+## Completion Criteria
+- [ ] a scoped grep streams `{"type": "tool", "name": "grep", "argument": , "path": }`; a bare grep's frame is byte-identical to pre-phase (`path` key ABSENT, not null); read/ls frames unchanged
+- [ ] the chat tool line for a scoped grep reads `🔎 Searching for in ` (two `` spans, no new emoji); the bare-grep line is byte-identical (the phase-70/87/117 pins pass unchanged)
+- [ ] the status label says "… is searching for in " for a scoped grep only
+- [ ] a saved scoped-grep turn persists `path` on the tool record and re-renders the scope on restore; pre-phase saved records restore byte-identically
+- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
+- [ ] no behavior change in completed phases (the read/ls tool lines, the disclosure count, the saved-turn JSON shape for non-scoped records, the done frame)
diff --git a/.agents/phases/todo/146_search_tool_scope/01_tool_frame_scope.md b/.agents/phases/todo/146_search_tool_scope/01_tool_frame_scope.md
new file mode 100644
index 0000000..75fe5fe
--- /dev/null
+++ b/.agents/phases/todo/146_search_tool_scope/01_tool_frame_scope.md
@@ -0,0 +1,27 @@
+# Task 01 — the `tool` frame carries the grep's scope: additive `path` on `ChatToolEvent`
+
+**Phase:** `146_search_tool_scope` · **Source:** `TODO.md:3` — "The "search" tool in the UI doesn't show where the LLM is searching. This results in, what looks like, multiple search tool calls with the same value, like the LLM is repeating itself."
+**Story:** n/a
+
+## Objective
+The SSE `tool` frame for a SCOPED grep (a re-search of one document — `grep(pattern, path)`) carries the document's combined `source/path` identity as an additive `path` field, so the UI can show WHERE the search runs. Every frame that is not a scoped grep serializes byte-identical to pre-phase.
+
+## Work
+1. `app/schemas.py` — `ChatToolEvent` (L182): add `path: str | None = None` with a `@model_serializer(mode="wrap")` that pops `path` when it is None (the `DocContent._serialize` `image_url`-omission precedent — the key is ABSENT from the wire, never `null`). Update the class docstring: the A15 extension line gains the phase-146 note (a scoped grep's optional `path` — the re-searched document's combined `source/path` — is the ONLY case the field is present).
+2. `app/api/chat.py` — the `ToolCallPiece` branch (~L915–947, where `argument` is extracted): compute, for `piece.name == "grep"` ONLY, `scope = piece.arguments.get("path")` and coerce with the same string guard as `argument` (`scope if isinstance(scope, str) else None` — a non-string `path` is a model error the agent already refuses; the guard just yields None → omitted). Pass `path=scope` to the `ChatToolEvent(...)` constructor. No other event type, tool name, or field is touched — read/ls frames and every non-scoped grep keep the exact pre-phase wire bytes (the wrap serializer does the omission).
+3. `tests/unit/` — the `ChatToolEvent` serialization home (the existing SSE-frame unit file — `tests/unit/test_chat*.py`; if no frame serializer test exists yet, create one following the house layout): the matrix —
+ - scoped grep: `name="grep"`, `argument="pattern"`, `path="srcs/doc.md"` → the dumped dict has `type`, `name`, `argument`, AND `path` (exact values);
+ - bare grep: `path=None` → the dumped dict has NO `path` key (assert `"path" not in data` — not `null`);
+ - `read` (with and without offset/limit) and `ls` (with and without argument) → NO `path` key, byte-identical to pre-phase (assert the full dumped dict equality against the literal pre-phase shape);
+ - grep with a NON-string `path` (the model-error shape, e.g. a list) → the builder yields `path=None` → omitted (this case is pinned at the builder level in the step-4 test).
+4. `tests/unit/` — the frame-builder test (the chat-endpoint unit home that already drives `ToolCallPiece` handling, if one exists — otherwise a focused unit test constructing the piece and invoking the same extraction): grep piece with `arguments={"pattern": "p", "path": "s/d.md"}` → event carries both; grep piece with `arguments={"pattern": "p"}` → no scope; grep piece with `arguments={"pattern": "p", "path": 42}` → scope None (omitted); read/ls pieces → scope never read from their arguments.
+
+## Testing & Quality
+- Unit: the two matrices above; every pre-existing frame pin passes UNCHANGED (byte-identity of non-scoped frames is the point).
+- Coverage: **>90%** on this task's new/modified code (`app/`).
+
+## Completion Criteria
+- [ ] a scoped grep's `tool` frame carries `path` = the model's `path` string; a bare grep's frame has no `path` key at all
+- [ ] read/ls/null-argument frames dump to the exact pre-phase dict (equality pins)
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
+- [ ] no other SSE event type or agent-tool surface is touched
diff --git a/.agents/phases/todo/146_search_tool_scope/02_ui_scope_render_e2e.md b/.agents/phases/todo/146_search_tool_scope/02_ui_scope_render_e2e.md
new file mode 100644
index 0000000..65a2be6
--- /dev/null
+++ b/.agents/phases/todo/146_search_tool_scope/02_ui_scope_render_e2e.md
@@ -0,0 +1,29 @@
+# Task 02 — the tool line + status label show the search scope, live and restored; the dedicated E2E
+
+**Phase:** `146_search_tool_scope` · **Source:** `TODO.md:3` — "The "search" tool in the UI doesn't show where the LLM is searching. This results in, what looks like, multiple search tool calls with the same value, like the LLM is repeating itself."
+**Story:** n/a
+
+## Objective
+A scoped grep renders `🔎 Searching for in ` in the tool line and "… is searching for in " in the status label — live, persisted on the saved turn (additively), and re-rendered on restore. Every bare-grep rendering is byte-identical to pre-phase.
+
+## Work
+1. `frontend/assets/app.js` — `appendToolLine` (~L1122): add a fifth parameter `scope = null`. In the grep branch (the `(name === "grep" || name === "search_documents") && argument` arm), AFTER the pattern `` child, append ONLY when `typeof scope === "string" && scope` — the phase-139 read-range-suffix pattern: a `` (class `grep-scope`) containing a text node ` in ` and a second `` whose `textContent = scope` (the scope is data, never markup). The existing `🔎 Searching for ` template literal and the pattern `` are untouched (the frontend emoji guard strips precisely those glyphs in this file — NO new emoji is introduced). A null/empty scope → no span → the line is byte-identical to pre-phase.
+2. `frontend/assets/app.js` — the live tool-frame handler (~L3178): read `const framePath = typeof ev.path === "string" && ev.path ? ev.path : null;` (the `ev.offset`/`ev.limit` guard style); add it to the `toolRecord` ONLY when non-null (`if (framePath !== null) toolRecord.path = framePath;` — the additive `bor.chat.v1` JSONB convention: a bare-grep record keeps the exact pre-phase `{name, argument}` shape, old records restore byte-identically, no migration); pass `framePath` as `appendToolLine`'s fifth argument; extend the grep status label — `(name === "grep" || name === "search_documents") && argument` → `` `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}` `` (bare grep → byte-identical label).
+3. `frontend/assets/app.js` — the restore path (~L2064–2086): inside the `m.tools` loop, read the saved scope with the same guard (`const tPath = typeof t.path === "string" && t.path ? t.path : null;`) and pass it to `appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)` — a record saved before this phase (no `path` key) renders byte-identically (the phase-95 truncation-note precedent: it keys on the `` argument, not the scope).
+4. `tests/e2e/mock_llm.py` — one new scripted-question branch (the house scripted-turn idiom, modeled on the phase-70/72 `grep` branches): a sentinel question → the model streams ONLY a `tool_calls` delta calling `grep` with `{"pattern": , "path": "/"}` (id `call_0`) → the follow-up round (tool result present) streams the terminal answer. The fixture source/doc pair must exist in the session app's indexed KB (the same fixture the `grep` scripted branches already use — reuse its source/path so the agent's real grep finds matches; the exact path string the UI renders is the model's, so use the fixture's real combined identity). The EXISTING bare-grep scripted turns are untouched (they are the byte-identity pins).
+5. `tests/e2e/test_search_tool_scope.py` — the dedicated E2E (one Playwright file, run in isolation, mock-only — the house module-app pattern if the scripted mock lives in the session app, else the session app directly):
+ - **scoped line:** ask the scripted question → after the tool frame, the `.tool-calls` list holds one `.tool-call` whose text is `🔎 Searching for in ` — assert the TWO `` spans (pattern, then scope) and the ` in ` text node between them (textContent structure, not a regex over the whole line — the pin style of the phase-139 range-suffix assertions);
+ - **status label:** while the turn is live (before the delta frames), the typing indicator/status carries `… is searching for in `;
+ - **bare-grep byte-identity:** an existing bare-grep scripted question renders exactly the pre-phase line `🔎 Searching for ` (ONE `` span, no `.grep-scope` span) and the pre-phase status label;
+ - **restore:** save the scoped turn (the save affordance), reload the chat, the restored turn's tool line re-renders the scope through the same structure (folded disclosure — the phase-117 shape is unchanged).
+
+## Testing & Quality
+- E2E: the assertions above; every pre-existing tool-line pin (phase-70 labels, phase-87 clock, phase-117 disclosure, phase-139 range suffix) passes UNCHANGED — the byte-identity of bare greps is the point.
+- Coverage: n/a (frontend task — the E2E is the gate; `app/` coverage stays >90% from task 01).
+
+## Completion Criteria
+- [ ] a scoped grep's tool line shows the scope in a second `` span after the pattern; the status label carries ` in `; both live and on restore
+- [ ] a bare grep renders byte-identical to pre-phase in every surface (live line, status, saved record shape, restored line)
+- [ ] the saved scoped record carries `path` additively; pre-phase saved records (no key) restore byte-identically
+- [ ] `uv run pytest` green (all pre-existing suites); `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
+- [ ] no new emoji anywhere in `app.js` (the frontend emoji guard stays green)
diff --git a/.agents/phases/todo/147_sync_changed_only/00_phase.md b/.agents/phases/todo/147_sync_changed_only/00_phase.md
new file mode 100644
index 0000000..98b8c64
--- /dev/null
+++ b/.agents/phases/todo/147_sync_changed_only/00_phase.md
@@ -0,0 +1,42 @@
+# Phase 147 — sync only what changed: the source file's byte hash is checked BEFORE extraction and vision
+
+**Source:** `TODO.md` L4 — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
+**Story:** n/a (TODO-derived phase, Protocol B append).
+**Context:** `app/rag/importer.py` — `import_sources` (L560: the per-source walk; the `progress`/`on_chunk_progress` hooks fire per file before indexing — unchanged by this phase), `_index_file` (L879: the single upsert entry — dispatches image → `_index_image_file` (L2642), `.pdf` → `_index_pdf_file` (L1181), text fallback), and the three per-branch unchanged blocks that today run only AFTER the expensive work: text (L~928: digest over the read text — cheap, the read itself is the cost), image (L~2660: digest over `read_bytes()` — the vision description runs only on a digest MISS, already fine), PDF (L~1303: digest over the FINAL constructed content — reached only after `extract_pdf_text` (L162 of `app/rag/pdf.py`) ALWAYS runs and, for garbage layers, `_fallback_page_screenshots` (every page rendered + vision-described) or, for sparse layers with `pdf_pictorial` on, `_pictorial_pdf_pass` (every candidate page rendered + vision-described) — so an UNCHANGED pictorial PDF re-extracts and re-burns vision on EVERY sync; a non-deterministic vision model additionally flips the final-content digest and forces a full re-chunk/re-embed/re-summary each run — the owner's "synced every time even if they haven't changed"). `Document.content_hash` (app/models.py L112) is the FINAL-CONTENT digest (exposed via `app/rag/retriever.py` L203/426/555/611 — the dedupe/caching identity) and stays untouched; the new `file_hash` is the RAW SOURCE-FILE-BYTES digest, compared first, used for change detection only. `alembic/versions/` — the latest migration is `0024_git_source_branch.py`. The unchanged-block semantics that must survive the fast path (all three branches, identical shape): `summary.unchanged += 1` + log; the phase-118 (A2) summary-NULL backfill; the phase-106 (D1) `created_at_manual` early-return; the phase-106 (D4) date refresh (`normalize_doc_date` of the raw_date — mtime fallback — may go OLDER, counts `summary.dates_updated`, commits).
+**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):** change detection = sha256 of the RAW FILE BYTES (the owner's "hash the document contents"), computed ONCE at the top of `_index_file` — before the image/text/PDF dispatch — and compared against a new `documents.file_hash` column (migration 0025, nullable). A match takes a shared unchanged fast path: NO file read (beyond the streamed hash), NO PDF extraction, NO vision call, NO embed, NO summary call — only the preserved bookkeeping (summary-NULL backfill from the STORED `doc.content`, manual-date early-return, date refresh). Pre-phase rows (`file_hash` NULL) fall through to the branches' existing logic exactly as today and get their hash STAMPED on that first post-upgrade sync (one full pass, then fast forever). `content_hash` and everything built on it (retriever exposure, chunk identity) are untouched.
+
+## Objective
+An unchanged source file costs one streamed sha256 and a row lookup per sync — zero extraction, zero vision, zero embedding, zero summary LLM — across all three document classes (text, image, pictorial PDF), while every preserved unchanged-path behavior (summary backfill, manual-date lock, date refresh, counts) is byte-for-byte maintained.
+
+## Dependencies
+- `146_search_tool_scope` (todo) — pipeline order only; no shared code (this phase touches the importer + one migration; 146 touches the tool-frame wire + the chat renderer).
+
+## Design (shared by all tasks — the executor reads this, not the chat)
+- **The column (task 01):** `alembic/versions/0025_documents_file_hash.py` — `documents.file_hash` `String(64)`, **nullable, no index** (it is compared in Python after the unique `(source, path)` lookup — unlike `content_hash`, it is never a query surface and never exposed to the retriever); downgrade drops it. `Document.file_hash: Mapped[str | None]` + docstring (the raw source-file-bytes sha256 — the sync change signal; DISTINCT from `content_hash` — the final indexed content's digest, which for a pictorial PDF includes vision text and is the retriever's identity).
+- **The fast path (task 02):**
+ - `_file_hash(path: Path) -> str` — sha256 over the raw bytes, streamed in 1 MiB chunks (a multi-hundred-MB PDF never loads fully; the `hashlib` incremental pattern).
+ - `_index_file` top (before the image/PDF/text dispatch): `file_digest = _file_hash(full_path)`; the row lookup (the `(source, path)` scalar select the branches already do — moved up); `if doc is not None and doc.file_hash == file_digest:` → `_unchanged_doc(...)` → **return**. The `raw_date` mtime fallback (the D2 convention: read before the unchanged early-return — the branches already do this; the fast path needs it for the date refresh) is computed in `_index_file` before the check when `raw_date is None`.
+ - `_unchanged_doc(session, doc, *, source, rel, llm, summary, raw_date)` — the ONE shared implementation of the three near-identical unchanged blocks: `summary.unchanged += 1` + the `import: unchanged` log line (unchanged wording); the phase-118 (A2) backfill when `doc.summary is None` — `content=doc.content` (EXACTLY what the bytes produced — for text the stored content IS the file text; for an image the stored description; for a PDF the stored final content — so the backfill never re-reads/re-extracts/re-describes); the D1 `created_at_manual` early-return; the D4 date refresh (`target = normalize_doc_date(raw_date)`; on change: commit + `summary.dates_updated` + the `import: date-refreshed` log line — unchanged wording). The three branches' existing unchanged blocks are REPLACED by the shared helper (the per-branch callsite passes the same arguments; behavior is identical, code is one site).
+ - **Hash stamping:** every branch writes `doc.file_hash = file_digest` on create AND update (all three — text, image, PDF — `file_digest` passed down from `_index_file`). The fast path makes the stamp redundant for the common case, but a bytes-changed/content-same edge (e.g. a PDF metadata edit with identical extracted text) lands in a branch whose content digest still matches — the branch's unchanged flow (now the shared helper) ALSO stamps `doc.file_hash = file_digest` and commits when it is dirty (the date-refresh commit may already have run — stamp BEFORE that block's final decision: set the attribute, and commit once at the end of the helper if dirty) so the NEXT sync takes the fast path. Pre-phase rows (`file_hash` NULL) take exactly this path on the first post-upgrade sync (full pipeline or the shared helper — whichever the branch's content digest decides) and are stamped either way.
+ - The `progress`/`on_chunk_progress` hooks are untouched (the `progress` call fires per file before `_index_file` as today — an unchanged file was already "the current file" pre-phase; the fast path just makes it cheap). `summary.files` counts the walked file either way. Pruning, `limit`, the per-source post-index passes (`_enrich_pending_images`, `_backfill_image_contexts` — row-driven) are untouched.
+- **The E2E (task 03):** the `file://` fixture-repo module-app idiom (the `test_sync_button.py` recipe — real clone, mock LLM): run 1 (N added) → capture every document row's `indexed_at`/`content_hash` → run 2 → the settle line reads `0 added · 0 updated · N unchanged` and every row's `indexed_at`/`content_hash` is unchanged (the re-index would bump `indexed_at`) → commit a new file to the fixture repo → run 3 → only the new file is `added`.
+
+## Tasks
+1. `01_file_hash_column.md` — migration 0025 + the model field.
+2. `02_unchanged_fast_path.md` — the streamed hash, the top-of-`_index_file` check, the shared unchanged helper, the stamping, the branch rewiring, the unit matrix.
+3. `03_e2e_unchanged_second_run.md` — the dedicated E2E: the idempotent second sync is provably free of re-indexing.
+
+## Testing & Quality
+- Unit: the fast-path matrix (text / image / garbage-PDF / pictorial-PDF unchanged → zero LLM calls; changed → full pipeline + stamp; NULL hash → pre-phase behavior + stamp; D1/D4 pins on the fast path; the streamed hash on a >1 MiB file).
+- Integration: the migration upgrade/downgrade round-trip (the house migration-test idiom).
+- E2E: one dedicated Playwright file, run in isolation, mock-only (real git clone).
+- Coverage: **>90%** on new/modified code (`app/`).
+
+## Completion Criteria
+- [ ] `documents.file_hash` exists (nullable String(64), no index) via migration 0025; the upgrade/downgrade round-trips on the test DB
+- [ ] an unchanged text file, image, garbage-layer PDF, and pictorial PDF each cost ZERO llm calls (embed + chat) and zero PDF extraction/vision on the second sync — `summary.unchanged` counts them; `indexed_at`/`content_hash` untouched
+- [ ] a changed file (any byte) re-indexes fully and stamps the new hash; a bytes-changed/content-same file stamps the hash without a content change
+- [ ] the preserved unchanged semantics are pinned: summary-NULL backfill, `created_at_manual` early-return, the date refresh (may go older, counts `dates_updated`, commits), the log-line wordings
+- [ ] `content_hash` and the retriever's exposure of it are byte-identical to pre-phase
+- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_sync_changed_only.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
+- [ ] no behavior change in completed phases (prune semantics, `limit`/`progress` contracts, the per-source post-index passes, the sync status/result wire)
diff --git a/.agents/phases/todo/147_sync_changed_only/01_file_hash_column.md b/.agents/phases/todo/147_sync_changed_only/01_file_hash_column.md
new file mode 100644
index 0000000..df5a4d8
--- /dev/null
+++ b/.agents/phases/todo/147_sync_changed_only/01_file_hash_column.md
@@ -0,0 +1,24 @@
+# Task 01 — migration 0025: `documents.file_hash` (the raw source-file-bytes digest)
+
+**Phase:** `147_sync_changed_only` · **Source:** `TODO.md:4` — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
+**Story:** n/a
+
+## Objective
+The `documents` table gains `file_hash` — the sha256 of the RAW SOURCE FILE's bytes — the column the task-02 fast path compares before any extraction or vision work. Existing rows stay NULL (= "unknown" — one full pipeline pass stamps them on the next sync; no backfill is possible in a migration — the migration cannot see the source files on disk).
+
+## Work
+1. `alembic/versions/0025_documents_file_hash.py` — `revision = "0025"`, `down_revision = "0024"`:
+ - `upgrade`: `op.add_column("documents", sa.Column("file_hash", sa.String(length=64), nullable=True))` — **no index** (the fast path compares it in Python after the unique `(source, path)` lookup; unlike `content_hash` — indexed because it is a query/exposure surface — `file_hash` is internal bookkeeping) and no server default (NULL is the meaningful pre-phase state).
+ - `downgrade`: `op.drop_column("documents", "file_hash")`.
+2. `app/models.py` — `Document` (L102): add, directly below `content_hash` (L112), `file_hash: Mapped[str | None] = mapped_column(String(64), default=None)` with a docstring in the house style: the sha256 of the RAW SOURCE FILE's bytes at index time (phase 147) — the sync's change signal, compared at the top of the importer's `_index_file` BEFORE the image/text/PDF dispatch (an unchanged file costs one streamed hash + one lookup — no extraction, no vision, no embed). NULL for pre-phase rows and until the first post-upgrade sync stamps it. DISTINCT from `content_hash` — the FINAL indexed content's digest (a pictorial PDF's includes vision text; `content_hash` is exposed via `app/rag/retriever.py` and is the dedupe/caching identity — untouched).
+3. Tests (the house migration-test idiom — the integration test that upgrades the test DB to head, or the models unit home): the column exists, is nullable, is NOT indexed (assert against the live table metadata after `upgrade head`); the `Document` model exposes `file_hash` (default None); a row created with no `file_hash` round-trips NULL (the pre-phase shape).
+
+## Testing & Quality
+- Integration: migration upgrade (to head) + downgrade + re-upgrade round-trip on the test DB (the existing migration-test pattern — extend it, don't fork it).
+- Coverage: **>90%** on this task's new/modified code (`app/`).
+
+## Completion Criteria
+- [ ] `0025_documents_file_hash` chains from `0024` and upgrades/downgrades cleanly on the test DB
+- [ ] `file_hash` is nullable String(64) with no index; `content_hash` (indexed, exposed) is untouched
+- [ ] `Document.file_hash` defaults to None and carries the house-style docstring distinguishing it from `content_hash`
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
diff --git a/.agents/phases/todo/147_sync_changed_only/02_unchanged_fast_path.md b/.agents/phases/todo/147_sync_changed_only/02_unchanged_fast_path.md
new file mode 100644
index 0000000..cb99e6d
--- /dev/null
+++ b/.agents/phases/todo/147_sync_changed_only/02_unchanged_fast_path.md
@@ -0,0 +1,51 @@
+# Task 02 — the unchanged fast path: hash first, then skip extraction, vision, and embedding
+
+**Phase:** `147_sync_changed_only` · **Source:** `TODO.md:4` — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
+**Story:** n/a
+
+## Objective
+An unchanged file (raw bytes identical to the last index) is detected at the TOP of `_index_file` — before the image/text/PDF dispatch — and takes one shared unchanged path with ZERO llm calls, ZERO PDF extraction, and ZERO vision work, while the three branches' existing unchanged semantics (summary-NULL backfill, manual-date early-return, date refresh) survive verbatim, and every newly indexed/updated document stamps its file hash.
+
+## Work
+1. `app/rag/importer.py` — new helper `_file_hash(path: Path) -> str` (module level, near the other file helpers): sha256 of the raw bytes, streamed in 1 MiB chunks (`open(path, "rb")` + `hashlib.sha256()` incremental `update` in a loop — a large PDF never loads fully into memory).
+2. `app/rag/importer.py` — new helper `async def _unchanged_doc(session, doc, *, source, rel, llm, summary, raw_date, file_digest)` — the ONE shared implementation of the three near-identical per-branch unchanged blocks (text ~L928, image ~L2660, PDF ~L1303), in this exact order with UNCHANGED log wordings and counts:
+ 1. `summary.unchanged += 1` + `logger.info("import: unchanged source=%s path=%s", source, rel)`;
+ 2. **stamp** `doc.file_hash = file_digest` (a pre-phase NULL row, or a bytes-changed/content-same row, learns its hash here — see step 5);
+ 3. the phase-118 (A2) backfill when `doc.summary is None` — `await _store_summary(session, doc=doc, source=source, rel=rel, content=doc.content, llm=llm, summary=summary, backfill=True)` (EXACTLY what the file's bytes produced is already stored in `doc.content` for all three classes — text: the file text; image: the vision description; PDF: the final constructed content — so the backfill never re-reads the file, never re-extracts, never re-describes; the text branch's pre-phase `content` variable was the freshly-read file text — byte-identical to `doc.content` by the digest-equality invariant);
+ 4. the D1 early-return: `if doc.created_at_manual: return` (no write at all — the manual correction survives);
+ 5. the D4 date refresh: `target = normalize_doc_date(raw_date)`; `if target != doc.created_at:` set + `session.commit()` + `summary.dates_updated += 1` + the `import: date-refreshed` log line (unchanged wording) — the date may go OLDER, no monotonic guard;
+ 6. **final commit for the stamp**: if the row is still dirty after the date block (the stamp set `file_hash` and the date did not change) → `session.commit()` (quiet bookkeeping — no count, no log: the `unchanged` line already fired; a date-change run already committed in step 5).
+3. `app/rag/importer.py` — `_index_file` (L879): BEFORE the image/PDF/text dispatch:
+ - `file_digest = _file_hash(full_path)` (every class hashes the raw bytes — images, text, PDFs alike; this is the single change signal, per the owner's "hash the document contents");
+ - the row lookup moves up here: `doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))` (the branches keep their OWN lookups only if the fast path was not taken — see step 4);
+ - `if raw_date is None: raw_date = file_mtime_datetime(full_path)` (the D2 fallback, read before the unchanged early-return — the branches' existing convention, now once at the top);
+ - `if doc is not None and doc.file_hash == file_digest: await _unchanged_doc(session, doc, source=source, rel=rel, llm=llm, summary=summary, raw_date=raw_date, file_digest=file_digest); return` — **zero** further work: no `read_text`, no `read_bytes`, no `extract_pdf_text`, no fallback/pictorial vision, no `_replace_content_chunks`, no `_store_summary` (unless the backfill fires).
+4. `app/rag/importer.py` — the three branches, rewired minimally:
+ - each branch receives `file_digest` (and the already-computed `raw_date`) from `_index_file`; their internal unchanged blocks are REPLACED by the `_unchanged_doc` call (same arguments — one code site for the semantics);
+ - their create/update writes gain `doc.file_hash = file_digest` (all three — the `Document(...)` constructor for new rows, the `doc.file_hash = ...` line for updates, next to `content_hash`);
+ - their existing row lookups and content-digest computations STAY (reachable only when `file_hash` is NULL — pre-phase row — or the bytes changed: the content digest then decides changed vs. the shared helper, and the helper stamps the new hash);
+ - the image branch's `data = full_path.read_bytes()` and the text branch's `read_text` happen only AFTER the fast-path return point (unchanged — the dispatch is what gates them), so the fast path genuinely pays nothing beyond the streamed hash + lookup.
+5. `app/rag/importer.py` — docstrings: `_index_file`'s gains the phase-147 paragraph (the fast path: what it skips, the NULL-hash fall-through, the stamping rule); the three branches' "unchanged path" docstring lines are updated to point at the shared helper; the module docstring's A9-format paragraph is untouched.
+6. `tests/unit/` — the importer unit home (the existing `tests/unit/test_importer*.py` matrix — extend it): with a call-counting fake llm (embed + chat both counted):
+ - **text unchanged:** index a file, capture the llm call count; re-run `import_sources` → `summary.unchanged == 1`, ZERO new llm calls, `summary.files == 1`, the doc's `indexed_at`/`content_hash` unchanged, `file_hash` stamped after run 1;
+ - **garbage-layer PDF unchanged:** a no-text-layer PDF (the house fixture recipe) indexed with the fake vision; re-run → `summary.unchanged == 1`, ZERO new llm calls (the fallback vision did NOT re-fire — the owner's core complaint), the content row byte-identical;
+ - **pictorial PDF unchanged:** a sparse-page PDF (`pdf_pictorial` on, fake vision) indexed; re-run → ZERO new llm calls (the pictorial pass did NOT re-fire);
+ - **image unchanged:** re-run → ZERO new llm calls;
+ - **changed file:** flip one byte → re-run → `summary.updated == 1`, the llm calls fire again, `file_hash` re-stamped, `indexed_at` bumped;
+ - **pre-phase row (NULL `file_hash`):** a row with content but NULL `file_hash` (seeded directly) + an unchanged file → the branch's content-digest path runs (today's behavior — the llm may or may not be called per the digest match) AND the hash ends stamped (both the changed and the shared-helper outcomes);
+ - **D1/D4 on the fast path:** an unchanged file with `created_at_manual=True` → no write at all (the date stays the manual value, no commit beyond the stamp — assert the row's date unchanged and the flag intact); an unchanged file whose mtime-derived date differs → the date refreshes (may go older), `dates_updated == 1`;
+ - **summary-NULL backfill on the fast path:** an unchanged doc with `summary=None` → the backfill runs from `doc.content` (ONE `is_summary` chunk, no content re-embed — the phase-118 pin, now via the shared helper);
+ - **streamed hash:** a >1 MiB temp file hashes identically to `hashlib.sha256(bytes).hexdigest()` (the chunking is transparent).
+7. The sync status/result wire (`app/api/sync.py` + `ImportSummary` fields) is UNTOUCHED — the fast path feeds the same `unchanged`/`dates_updated`/`added`/`updated` counts the UI already renders.
+
+## Testing & Quality
+- Unit: the matrix above — the ZERO-llm-call assertions on the second sync are the load-bearing pins (the owner's "really long sync times for PDFs that are mostly pictoral" is fixed when a pictorial PDF's second sync burns no vision).
+- Coverage: **>90%** on this task's new/modified code (`app/`); the pre-phase importer pins (progress pre-walk, `limit`, prune, the post-index passes) pass UNCHANGED.
+
+## Completion Criteria
+- [ ] the fast path fires on a `file_hash` match for all three classes and returns before any branch code (zero llm/extraction/vision — pinned by call counters)
+- [ ] the shared `_unchanged_doc` preserves the unchanged semantics verbatim: counts, log wordings, the backfill (from `doc.content`), the D1 early-return, the D4 refresh (may go older, commits, counts)
+- [ ] every create/update in all three branches stamps `file_hash`; the shared helper stamps NULL/changed rows too (the next sync is always fast)
+- [ ] pre-phase rows behave exactly as today on the first post-upgrade sync and are stamped
+- [ ] `content_hash`, the retriever, the progress/chunk hooks, pruning, and the per-source post-index passes are byte-identical to pre-phase
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
diff --git a/.agents/phases/todo/147_sync_changed_only/03_e2e_unchanged_second_run.md b/.agents/phases/todo/147_sync_changed_only/03_e2e_unchanged_second_run.md
new file mode 100644
index 0000000..b15023d
--- /dev/null
+++ b/.agents/phases/todo/147_sync_changed_only/03_e2e_unchanged_second_run.md
@@ -0,0 +1,25 @@
+# Task 03 — the dedicated E2E: the idempotent second sync provably re-indexes nothing
+
+**Phase:** `147_sync_changed_only` · **Source:** `TODO.md:4` — "Local sources, like PDFs, are synced every time even if they haven't changed. This results in really long sync times for PDFs that are mostly pictoral. Only sync documents if they've changed since the last sync (hash the document contents to check)."
+**Story:** n/a
+
+## Objective
+End-to-end proof, through the real UI + the real sync path (a real `git clone` of a local `file://` fixture repo, a real import against the mock LLM): the second sync of an unchanged source reports everything unchanged and touches no document row; a source change syncs ONLY the changed part.
+
+## Work
+1. `tests/e2e/test_sync_changed_only.py` — the dedicated suite (one Playwright file, run in isolation; the `test_sync_button.py` module-app idiom — the module app boots with `BOR_GIT_SOURCES=file://` + its own `BOR_SOURCES_DIR` on its own port (env-overridable, the house port-constant pattern), the real `git` clone prerequisite, the generous `SYNC_TIMEOUT_MS`):
+ - **Fixture:** the house fixture-repo builder (the `test_sync_button.py` recipe — a local git repo with a few importable docs, e.g. the `notes/sync-fixture.md` sentinel doc + one or two more, committed) — NO PDF needed (the unit matrix covers the vision-skip proof; this E2E pins the WIRE + the UI settle line + the row-level proof, and stays fast/deterministic).
+ - **Test 1 — `test_second_sync_unchanged`:** admin login (the house `login` helper); sync run 1 → wait for the settle line (the `Synced HH:MM` pattern) → capture EVERY document row's `indexed_at` + `content_hash` + `file_hash` (via `SessionLocal` + `select(Document)`, the `test_sync_button.py` DB-assertion idiom) → assert `file_hash` is non-NULL on every row (the first sync stamped them) → sync run 2 → the settle line matches the house count shape with `0 added`, `0 updated`, and the unchanged count == the run-1 document count (`fmtSyncResult` renders `N unchanged` — assert the visible `#sync-result` text) → re-query: every row's `indexed_at`/`content_hash` UNCHANGED (a re-index would bump `indexed_at` to now — the load-bearing assertion), `file_hash` unchanged.
+ - **Test 2 — `test_change_syncs_only_the_change`:** from the settled state, append a NEW doc file to the fixture repo working tree + `git commit` (the house `_git` helper) → sync run 3 → the settle line shows `1 added` (and the unchanged count == the run-1 count) → DB: the new row exists with `file_hash` non-NULL; the pre-existing rows' `indexed_at`/`content_hash` unchanged.
+ - **Test 3 — `test_changed_file_reindexes`:** modify the fixture sentinel doc's content (append a line) + `git commit` → sync run 4 → the settle line shows `1 updated` → DB: exactly that row's `indexed_at`/`content_hash`/`file_hash` changed; the others untouched.
+2. Every test leaves the app stopped (the module-app fixture teardown — the house pattern); the suite is hermetic (its own `BOR_SOURCES_DIR` under a tmp dir, its own port) so `--no-cov` isolation holds.
+
+## Testing & Quality
+- E2E: the three tests above, mock-only (the module app's LLM is the mock), real git + real import; each test asserts on the VISIBLE settle line (the owner's surface) AND the DB rows (the proof).
+- Coverage: n/a (E2E task — `app/` coverage stays >90% from tasks 01–02).
+
+## Completion Criteria
+- [ ] run 2 of an unchanged source: the settle line reads `0 added · 0 updated · N unchanged` (N = the KB size) and no document row's `indexed_at`/`content_hash`/`file_hash` moved
+- [ ] a new file syncs as `1 added` without touching the other rows; a modified file syncs as `1 updated` and only its own row changes
+- [ ] every synced row carries a non-NULL `file_hash` after the first run
+- [ ] `uv run pytest tests/e2e/test_sync_changed_only.py -v --no-cov` green in isolation (DB up, git on PATH — the documented prerequisite); `uv run pytest` still green (the pre-existing sync E2Es — the idempotent-second-run pins of `test_sync_button.py` etc. — pass unchanged)
diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/00_phase.md b/.agents/phases/todo/148_pdf_pages_in_sources/00_phase.md
new file mode 100644
index 0000000..7f6ffd0
--- /dev/null
+++ b/.agents/phases/todo/148_pdf_pages_in_sources/00_phase.md
@@ -0,0 +1,41 @@
+# Phase 148 — pictorial PDFs show their PAGES in the sources, not their page descriptions
+
+**Source:** `TODO.md` L5 — "For primarily pictographic PDFs it's not super helpful to see the summaries of the pages. I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages, a description won't really help me. I still want to be able to ask questions, so the summary and embedding is helpful, but not when shown to the user."
+**Story:** n/a (TODO-derived phase, Protocol B append).
+**Context:** the owner's named fixture is the **pictorial class** — `test_sources/ikea_instructions.tar` → `besta-frame-white__AA-1272068-11-100.pdf`: 24 pages, USABLE text layer (`is_usable_pdf_text` True) but 20 of 24 pages carry <100 stripped chars (3 dense text pages, 3k–7.3k chars) — so the phase-133 screenshot fallback does NOT fire for it; the phase-140 pictorial pass (`_pictorial_pdf_pass`, gated on `pdf_pictorial` — default ON — + `pictorial_pages(texts, min_chars=100)`) describes its 20 sparse pages and appends `[Page N illustration: …]` blocks. Today the document viewer (`frontend/assets/document.js` — `renderDocument` L172, the shared core of the doc modal AND `/document.html`) shows such a PDF as its raw text content in `
` + the labeled Summary panel — the descriptions the owner says are "not … helpful" to a human. The rendering machinery already exists: `app/rag/pdf.py` `render_pdf_pages(pdf_path, dpi=150, pages=None)` (L311 — every page to PNG, `b""` placeholder on a per-page failure, `[]` for an unopenable file; the phase-133/140 callers). The persistent-copy convention to follow: phase 122's image copies — `settings.image_dir` (app/config.py L397, raw string, `expanduser`'d by the importer), `.` naming, written only after success, stale copy deleted before replacement (importer ~L2790), deleted on prune (importer `_prune` ~L2924), because "the source file is disposable: uploads are replaced on every upload, git checkouts are re-cloned, local dirs are user-edited". The viewer payload: `DocContent` (app/schemas.py — `image_url`'s additive-omission serializer is the wire precedent; the content endpoint is `GET /api/documents/content`, app/api/docs.py L165 — no doc id in the payload today, so page URLs must ride as full routes). The bytes route precedent: `GET /api/documents/{doc_id}/image` (app/api/docs.py L216 — PUBLIC, the unguessable server-minted `uuid4` IS the credential — phase-124 A4, visible on the shared page; 404 `document not found` for every non-servable case; no path parameter reaches the filesystem — the path comes from the ROW).
+**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):**
+- **Eligibility** — a PDF gets rendered pages iff the sync ALREADY decided its text is not the useful view: the phase-133 fallback fired (unusable text layer — the screenshot class) OR the phase-140 pictorial pass ran and described ≥1 page (the sparse-page class — the IKEA fixture). Text-heavy PDFs (usable layer, zero candidates) keep the text view; `BOR_PDF_PICTORIAL=0` keeps its kill-switch semantics (no pictorial descriptions → no page strip for the sparse class — the toggle off means the pre-phase-148 viewer for those PDFs).
+- **Storage** — rendered at INDEX time (phase 147's fast path makes this cost one-time-per-real-change), persisted as PNGs in a NEW `settings.pdf_page_dir` (default `~/bor-sources/pages`, `BOR_PDF_PAGE_DIR` — the `image_dir` convention) named `-p{N}.png` (N 1-based, every page of the PDF); `documents.page_count` (migration 0026, nullable Int) = the PDF's page count (a per-page render failure leaves a gap — the route 404s that N, the viewer shows its unavailable note); stale copies deleted on re-index, pruned, and on a document that stops qualifying.
+- **Display** — the shared `renderDocument` core: for a doc with `page_urls`, the page strip (lazy ``s, "Page N" captions) REPLACES the text content AND the Summary panel (the owner: the description "is helpful … but not when shown to the user" — it stays embedded + summarized for Q&A, just not displayed); the meta badge row is untouched; both surfaces (modal + full page) get it through the one core.
+
+## Objective
+A pictorial-class PDF (the IKEA assembly-instruction class) opens in the sources with its actual pages — every page as a rendered image — while text-heavy PDFs, images, and text documents render byte-identically to pre-phase, and the Q&A machinery (embeddings, summaries) is untouched.
+
+## Dependencies
+- `147_sync_changed_only` (todo) — REAL coupling: the page render happens in the PDF branch's new/changed path, which phase 147's fast path makes one-time-per-real-file-change (without it, an unchanged pictorial PDF would re-render its pages every sync — the same cost the owner complained about).
+
+## Design (shared by all tasks — the executor reads this, not the chat)
+- **Index (task 01):** in `_index_pdf_file`, AFTER the final content is decided (fallback/pictorial passes done) and the doc is flushed (it has an id): when `new_or_changed` (reaching this code IS new/changed — the fast path returned earlier) AND eligible (the fallback fired, or the pictorial pass returned non-None): `pages = render_pdf_pages(full_path)` (every page, 150 dpi — the existing constant); delete this doc's stale page copies first (the image-copy stale-replacement convention); write each non-empty PNG to `pdf_page_dir` (created on demand) as `-p{N}.png`; `doc.page_count = len(doc)` (the PDF's page count — gaps tolerated). NOT eligible, or zero pages rendered (unopenable file — `render_pdf_pages` `[]`): `doc.page_count = None` + delete any stale copies (a doc that stops qualifying loses its strip). Fail-soft: a render/write exception → `page_count = None`, warning, the document indexes exactly as today. In `_prune` (and the PDF parent's embedded-image deletion site), a deleted page-qualifying doc's copies are removed (a shared `_delete_page_copies(doc)` helper — the `_delete_image_copy` convention; `source_removal.py` deletes rows only — the image-copy convention there — and is untouched).
+- **Serve (task 02):** `GET /api/documents/{doc_id}/pages/{n}` — public (the image route's trust model verbatim: the unguessable `uuid4` is the credential; a shared chat can cite the page), `n: int` path parameter ≥ 1; 404 `document not found` for: unparseable id, unknown doc, `page_count` NULL, `n` outside `1..page_count`, missing file on disk (defensive — the viewer's onerror note); the path is built from the ROW's id + `n` (no traversal surface — `n` is an int); `image/png` + `Cache-Control: private, max-age=3600` (the image route's header). `DocContent.page_urls: list[str] | None = None` — the content endpoint fills `[f"/api/documents/{doc.id}/pages/{i}" for i in 1..doc.page_count]`; the serializer drops the key when None (the `image_url` pattern — never `null`, absent for text/image docs and non-qualifying PDFs).
+- **View (task 03):** `renderDocument` (the shared core — modal + page): `doc.page_urls` a non-empty array → a `.doc-pages` strip: one `` per URL — `` + `Page N`; the markdown/`.doc-raw` content block is NOT rendered for this doc, and the Summary panel is NOT rendered (the owner's contract — the text stays in the DB for Q&A, it is simply not displayed); the meta badge row renders as today; `onerror` on an `` swaps its figure for a "Page N unavailable" note (the phase-122 image-unavailable idiom, `role="status"`); `createElement`-only (the XSS contract — never innerHTML from doc data). CSS: `.doc-pages` in the existing doc content column (the ≤46rem core), figures stacked, `img { max-width: 100% }`, theme vars for border/surface/ink (both themes), `overflow-wrap` not needed (no text flows), contrast AA, `prefers-reduced-motion` irrelevant (no motion).
+
+## Tasks
+1. `01_page_render_at_index.md` — `pdf_page_dir` setting + `page_count` migration/model + the PDF-branch render/persist/cleanup + the prune hook + the unit matrix.
+2. `02_page_route_and_payload.md` — the public page-bytes route + `DocContent.page_urls` + the endpoint + unit/integration tests.
+3. `03_viewer_page_strip.md` — the `renderDocument` strip (content + summary suppressed for qualifying docs) + the CSS.
+4. `04_e2e_pdf_pages.md` — the dedicated E2E: a synthetic pictorial PDF shows its pages in the modal + full page; a text-heavy PDF renders byte-identical.
+
+## Testing & Quality
+- Unit: the eligibility matrix × {new, changed, unchanged-fast-path, unopenable}; copy naming/stale-cleanup/prune-deletion; the route matrix; the payload omission rule.
+- Integration: the content endpoint returns `page_urls` for a seeded page-qualifying doc and omits the key otherwise.
+- E2E: one dedicated Playwright file, run in isolation, mock-only (the house PyMuPDF fixture recipe).
+- Coverage: **>90%** on new/modified code (`app/`).
+
+## Completion Criteria
+- [ ] a pictorial-class PDF (IKEA shape: usable layer, ≥1 sparse page) and a fallback-class PDF (unusable layer) get `-p{N}.png` copies for every page + `page_count` = the page count; a text-heavy PDF gets neither
+- [ ] an unchanged qualifying PDF's second sync (phase 147 fast path) re-renders nothing (the copies persist, zero render calls)
+- [ ] re-indexing a changed PDF replaces its copies (no stale `p{N}` orphans); a PDF that stops qualifying loses them; pruning deletes them
+- [ ] `GET /api/documents/{id}/pages/{n}` serves the PNG (public, uuid4-credential model) and 404s every non-servable case; `DocContent.page_urls` is present for qualifying docs and the key is ABSENT otherwise
+- [ ] the modal AND /document.html show the page strip for qualifying docs (lazy imgs, "Page N" captions) with the text content + Summary panel suppressed; every other document renders byte-identical to pre-phase
+- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
+- [ ] no behavior change in completed phases (the fallback/pictorial content, `content_hash`, embeddings/summaries, the image route, the doc-edit/date/summary admin APIs)
diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/01_page_render_at_index.md b/.agents/phases/todo/148_pdf_pages_in_sources/01_page_render_at_index.md
new file mode 100644
index 0000000..600b4e1
--- /dev/null
+++ b/.agents/phases/todo/148_pdf_pages_in_sources/01_page_render_at_index.md
@@ -0,0 +1,37 @@
+# Task 01 — render qualifying PDF pages at index time: the persistent copies + `page_count`
+
+**Phase:** `148_pdf_pages_in_sources` · **Source:** `TODO.md:5` — "For primarily pictographic PDFs it's not super helpful to see the summaries of the pages. I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages…"
+**Story:** n/a
+
+## Objective
+A qualifying PDF (fallback class or pictorial class) has every page rendered to a persistent PNG at index time — one-time per real file change (phase 147's fast path) — with `documents.page_count` recording the page count, stale copies cleaned on re-index/qualifier-loss/prune, and a fail-soft posture: a render failure never breaks the document's indexing.
+
+## Work
+1. `app/config.py` — beside `image_dir` (L397): `pdf_page_dir: str = "~/bor-sources/pages"` (`BOR_PDF_PAGE_DIR`) with the house docstring: where rendered PDF page PNGs are copied for serving (phase 148) — raw string, `Path.expanduser()` applied by the importer (the `sources_dir`/`upload_dir`/`image_dir` convention); deliberately separate from `sources_dir`/`upload_dir` (git checkouts re-cloned, uploads replaced, local dirs user-edited — the served copy must outlive the source file, the `image_dir` rationale).
+2. `alembic/versions/0026_documents_page_count.py` — `revision = "0026"`, `down_revision = "0025"`: `op.add_column("documents", sa.Column("page_count", sa.Integer(), nullable=True))` (no index — a display fact, never a query surface); downgrade drops it. `app/models.py` — `Document.page_count: Mapped[int | None] = mapped_column(Integer, default=None)` + house docstring: the number of rendered page PNGs persisted in `pdf_page_dir` as `-p{N}.png` (phase 148) — the PDF's page count for a page-qualifying document (the phase-133 fallback class or the phase-140 pictorial class); NULL for every other document (text, images, text-heavy PDFs); set/cleared ONLY by the importer's PDF branch (+ prune cleanup deletes the copies).
+3. `app/rag/importer.py` — `_index_pdf_file` (L1181), in the NEW/CHANGED path AFTER `session.flush()` (the doc has its id) and after the final content is decided (the fallback/pictorial passes have run — the eligibility signals are local: `fallback_fired` = the phase-133 gate fired (the `summary.pdfs_screenshotted += 1` site), `pictorial_described` = `_pictorial_pdf_pass` returned non-None):
+ - `eligible = fallback_fired or pictorial_described`;
+ - when eligible: `pages = render_pdf_pages(full_path)` (every page — `pages=None`, 150 dpi, the existing helper's contract: `b""` placeholder at a failed page's position, `[]` for an unopenable file); FIRST delete this doc's existing page copies (the stale-replacement convention — a changed PDF's old page set, e.g. a 30-page PDF that became 24, must not leak `p25..p30`); then write each non-empty PNG to `Path(llm.settings.pdf_page_dir).expanduser() / f"{doc.id}-p{N}.png"` (N 1-based, list-index aligned — the helper's "list index i is page i+1" default contract; the directory is `mkdir(parents=True, exist_ok=True)` on demand — the `image_dir` pattern); `doc.page_count = total_pages` where `total_pages` is the PDF's page count (the `fitz` doc length — re-derive via a tiny helper or `len(pages)` when the file opened: `render_pdf_pages` returns one slot per page, so `len(pages)` IS the page count for an openable file; `[]` → the unopenable case);
+ - when NOT eligible, or `pages == []` (unopenable), or ALL pages failed (zero non-empty PNGs): `doc.page_count = None` + delete any existing copies for this doc (a document that STOPPED qualifying loses its strip — e.g. a new version gained a real text layer);
+ - **fail-soft:** wrap the render/persist block — any exception → `page_count = None` + `logger.warning` (the house wording style) + the document proceeds to chunk/embed/summary EXACTLY as pre-phase (the render is a display concern — it never gates indexing); a per-page `b""` is NOT an exception (the gap is served as a 404 later, the viewer's note covers it).
+ - the UNCHANGED path (phase 147's fast path — `_unchanged_doc`) does NOT touch pages or `page_count` (the copies persist; zero render work — assert this).
+4. `app/rag/importer.py` — a shared `_delete_page_copies(doc, page_dir) -> None` helper (the `_delete_image_copy` convention, L2626: missing files are a silent no-op, never an error): delete `-p1..p{page_count}.png` (iterate 1..`doc.page_count` — the row knows its own extent; a NULL/zero count is a no-op). Call sites: (a) the stale-replacement in step 3 (pass the doc BEFORE its `page_count` changes), (b) `_prune` (L2870) — beside the existing `_delete_image_copy(doc.image_path)` call (~L2924) for a pruned page-qualifying doc, (c) the embedded-image parent-PDF prune path (the pruned parent's copies too — the same site that deletes the parent's embedded-image copies). `app/rag/source_removal.py` is UNTOUCHED (row-only deletion — the image-copy convention there).
+5. `tests/unit/` — the importer unit home:
+ - **eligibility × lifecycle matrix** (fake llm, tmp `pdf_page_dir`, the house PyMuPDF fixture recipes — the `test_pictorial_pdf.py` builder for the pictorial class, a textless/blank-page PDF for the fallback class, a dense-text PDF for the negative): new index of a qualifying PDF → copies `p1..p{N}` exist (N = page count), `page_count == N`, the PNG bytes are non-empty; re-run unchanged (phase 147 fast path) → ZERO render calls (count via monkeypatched `render_pdf_pages` or call counter), copies + `page_count` untouched; change the file (append a page to the fixture) → re-index → the OLD copy set replaced (a shrunk PDF leaves no stale `p{N}`), `page_count` updated;
+ - **qualifier loss:** a qualifying PDF's new version has a dense text layer (zero candidates, `pdf_pictorial` on) → `page_count` NULL + copies deleted;
+ - **unopenable:** `render_pdf_pages` returns `[]` (monkeypatched) → `page_count` NULL, no copies, no exception, the doc still indexed (chunks/summary ran);
+ - **per-page gap:** one page's render yields `b""` (monkeypatched) → the other copies written, `page_count` = the full count (the gap is a display-level 404, pinned in task 02);
+ - **render exception:** the writer raises → `page_count` NULL, warning logged (caplog), the doc indexed fully (fail-soft);
+ - **prune:** a qualifying doc pruned (its file removed from the source) → row gone + copies gone (no orphans in the tmp dir);
+ - **settings:** `pdf_page_dir` default `~/bor-sources/pages`, env override `BOR_PDF_PAGE_DIR` (the settings test home).
+
+## Testing & Quality
+- Unit: the matrix above; the phase-147 fast-path pins pass UNCHANGED (the fast path skips the render block by construction — it returns before the branch); the pre-phase PDF-branch pins (fallback content, pictorial blocks, `content_hash` over the final content, the embedded-image pass) pass UNCHANGED.
+- Coverage: **>90%** on this task's new/modified code (`app/`).
+
+## Completion Criteria
+- [ ] qualifying PDFs (both classes) get `-p{N}.png` for every renderable page + `page_count` = the page count; non-qualifying PDFs, text docs, and images get neither
+- [ ] unchanged re-syncs render nothing; changed re-syncs replace the copy set without orphans; qualifier-loss and prune delete the copies
+- [ ] a render failure is fail-soft: the document indexes exactly as pre-phase, `page_count` NULL, a warning is the signal
+- [ ] `settings.pdf_page_dir` follows the `image_dir` convention (raw string, expanduser'd at use); migration 0026 round-trips
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/02_page_route_and_payload.md b/.agents/phases/todo/148_pdf_pages_in_sources/02_page_route_and_payload.md
new file mode 100644
index 0000000..eca22e1
--- /dev/null
+++ b/.agents/phases/todo/148_pdf_pages_in_sources/02_page_route_and_payload.md
@@ -0,0 +1,29 @@
+# Task 02 — serve the pages: the public route + `DocContent.page_urls`
+
+**Phase:** `148_pdf_pages_in_sources` · **Source:** `TODO.md:5` — "I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages, a description won't really help me."
+**Story:** n/a
+
+## Objective
+The viewer payload names every rendered page (`page_urls`, full routes, additive-omitted), and a public bytes route serves one page PNG at a time — the image route's trust model and 404 posture verbatim.
+
+## Work
+1. `app/api/docs.py` — new route beside `get_document_image` (L216): `@router.get("/documents/{doc_id}/pages/{n}", response_class=FileResponse)` → `def get_document_page(doc_id: str, n: int, db = Depends(get_db)) -> FileResponse` (the `{n}` int path parameter is parsed by FastAPI — a non-int 422s at the router, mirroring how the image route's unparseable `doc_id` maps to its 404 below):
+ - the docstring: PUBLIC (the phase-124 A4 trust model verbatim — a shared chat can cite a page figure, so the bytes serve ANONYMOUSLY; the document id is an unguessable server-minted `uuid4` — IT IS the credential, no enumeration value — exactly like the image route and the saved-chat id; the document's TEXT stays user-gated: only the page bytes are public);
+ - 404 `detail="document not found"` (the router's unknown-document shape) for EVERY non-servable case: unparseable `doc_id` (the `uuid.UUID` try/except → 404, not 422 — the image route's mapping), an unknown doc, `page_count` NULL (a non-qualifying doc — the route knows nothing of its pages), `n < 1` or `n > doc.page_count` (out of range — the int parameter makes `n < 0`/fractional impossible; a gap — a page whose render failed — hits the missing-file 404 below), or the file on disk missing (defensive — the copy was lost; the viewer's onerror note covers it);
+ - the path is built ONLY from the row's id + `n` (`Path(settings.pdf_page_dir).expanduser() / f"{doc.id}-p{n}.png"` — `settings` via the house import) — no request value reaches a filesystem path (no traversal surface, the image route's property);
+ - `FileResponse(page_file, media_type="image/png", headers={"Cache-Control": "private, max-age=3600"})` (the PNG is always PNG — `render_pdf_pages`'s `tobytes("png")`; the image route's header, the content-hashed-bytes rationale).
+2. `app/schemas.py` — `DocContent`: add `page_urls: list[str] | None = None` + docstring (phase 148 — the rendered-page bytes routes `["/api/documents//pages/" for i in 1..page_count]` for a page-qualifying PDF; ABSENT from the wire for every other document — `None` → dropped by the serializer, never `null`, the `image_url` omission precedent); extend `_serialize` to pop `page_urls` when None (beside the existing `image_url` pop).
+3. `app/api/docs.py` — `get_document_content` (L165, the `GET /documents/content` endpoint): when `doc.page_count` is non-NULL, fill `page_urls = [f"/api/documents/{doc.id}/pages/{i}" for i in range(1, doc.page_count + 1)]` (the `/api` prefix — the `image_url` field carries the same full-route shape; check `image_url`'s exact construction and mirror it byte-for-byte in convention).
+4. `tests/unit/` + the docs-endpoint integration home:
+ - **route matrix** (the TestClient idiom the image-route tests use): a seeded qualifying doc (row with `page_count=2` + real PNG files in a tmp `pdf_page_dir`) → `GET .../pages/1` and `/2` → 200, `content-type: image/png`, the `Cache-Control` header, the body bytes == the stored file's bytes; `n=0`, `n=3` → 404 `document not found`; a text doc row / an image doc row / a PDF row with `page_count=NULL` → 404 on any `n`; a qualifying row whose file was deleted on disk → 404; a non-uuid `doc_id` (`"abc"`) → 404 (not 422);
+ - **payload:** the content endpoint for a qualifying doc → `page_urls` == the exact route list (1-based, count == `page_count`); a text doc / an image doc / a non-qualifying PDF → the `page_urls` KEY is ABSENT from the JSON (assert `"page_urls" not in data` — the omission pin, the `image_url` pattern);
+ - **no traversal:** the route is structurally impossible to point at an arbitrary path (the int parameter + row-derived path — assert the code builds the path from `doc.id` + `n` only, pinned by the out-of-range 404s).
+
+## Testing & Quality
+- Unit + integration: the matrices above; every pre-existing `DocContent` pin passes UNCHANGED (a non-qualifying doc's payload is byte-identical — the key is absent, not null); the image route's tests are untouched.
+- Coverage: **>90%** on this task's new/modified code (`app/`).
+
+## Completion Criteria
+- [ ] `GET /api/documents/{id}/pages/{n}` serves the stored PNG (public, uuid4-credential, `image/png`, the image route's cache header) and 404s EVERY non-servable case with `document not found`
+- [ ] `DocContent.page_urls` is the exact 1-based route list for qualifying docs and the key is ABSENT (never `null`) for every other document
+- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/03_viewer_page_strip.md b/.agents/phases/todo/148_pdf_pages_in_sources/03_viewer_page_strip.md
new file mode 100644
index 0000000..17b6277
--- /dev/null
+++ b/.agents/phases/todo/148_pdf_pages_in_sources/03_viewer_page_strip.md
@@ -0,0 +1,32 @@
+# Task 03 — the viewer shows the pages: the strip replaces the text and the summary panel
+
+**Phase:** `148_pdf_pages_in_sources` · **Source:** `TODO.md:5` — "For primarily pictographic PDFs it's not super helpful to see the summaries of the pages. I want to see the PDF pages in the sources. … I still want to be able to ask questions, so the summary and embedding is helpful, but not when shown to the user."
+**Story:** n/a
+
+## Objective
+For a document whose payload carries `page_urls`, the shared `renderDocument` core renders the page strip — every page as a lazy image with a "Page N" caption — and suppresses the text content AND the Summary panel (the descriptions stay in the DB for Q&A, just not displayed). Every other document renders byte-identically to pre-phase, on both surfaces (the doc modal and `/document.html`).
+
+## Work
+1. `frontend/assets/document.js` — `renderDocument` (L172 — the ONE shared core; the modal and the full page both call it, no per-surface copy):
+ - the gate: `const pageUrls = Array.isArray(doc.page_urls) ? doc.page_urls : null;` — a non-empty array triggers the strip; `null`/absent/empty → the EXACT pre-phase rendering (assertable byte-identity for every other doc);
+ - the strip: a `
` container (replacing what would become the content slot) holding one `` per URL — `` with `alt = `Page ${i} of ${doc.title}`` (the WCAG alt contract — the phase-122 image idiom, the title as the fallback context) and a `Page ${i}` (i 1-based over `page_urls`);
+ - **suppression:** when the strip renders, the markdown/`.doc-raw` content block is NOT built AND the labeled Summary panel (`.doc-summary`, phase 36 — including its admin edit affordance wiring, which is moot without the panel) is NOT built — the owner's contract: the page descriptions are "not … shown to the user"; the meta badge row (Created/Indexed, the phase-106 badges) renders exactly as today;
+ - **unavailable page:** each `` gets an `onerror` handler (the phase-122 "Image unavailable" idiom) that swaps ITS figure's `` for a small "Page N unavailable" note element (`role="status"`, muted styling) — the rest of the strip is untouched (one lost copy never hides the other pages; the defensive 404s of task 02's route are the trigger);
+ - **XSS contract:** `createElement`/`textContent` only — every document-derived string (title, URLs, the note) lands via properties/`textContent`, never innerHTML (the file's pinned contract — the module docstring's "XSS-safe by construction" line gains the phase-148 note).
+2. `frontend/assets/styles.css` — the strip, in the existing doc content column (the core renders into the same `#doc-content` slot the `.doc-raw`/markdown path uses — no new column):
+ - `.doc-pages { display: grid; gap: ; }` (vertical stack — one page per row, the natural reading order for instruction manuals);
+ - `.doc-page { margin: 0; } .doc-page img { display: block; width: 100%; max-width: 100%; height: auto; border: 1px solid ; border-radius: ; background: ; }` (a page PNG on a dark theme must sit on a non-clashing surface — the image route's figures already have a dark/light precedent to mirror);
+ - `.doc-page-num { font: ; color: ; text-align: center; padding: ; }` (contrast ≥4.5:1 in BOTH themes — the WCAG gate);
+ - the "Page N unavailable" note reuses the phase-122 image-unavailable note styling (find the existing class — the same muted note, no new pattern).
+3. Module docstring: the phase-148 paragraph (the gate, the suppression, the strip, the onerror note, both surfaces) in the house style of the file's leading changelog.
+
+## Testing & Quality
+- The house tests `document.js` through E2E + the frontend emoji guard (no JS unit harness) — the rendering pins live in task 04's E2E; this task's gate is that the pre-phase suites (the doc-modal, document-page, image-document, summary-panel E2Es) pass UNCHANGED (a non-qualifying doc is byte-identical — the load-bearing property).
+- Coverage: n/a (frontend task — `app/` coverage stays >90% from tasks 01–02).
+
+## Completion Criteria
+- [ ] a payload with `page_urls` renders the strip (one figure per URL, lazy imgs, "Page N" captions, alt text) with NO text content block and NO Summary panel; the meta badges are untouched
+- [ ] a payload without `page_urls` (text docs, images, text-heavy PDFs, pre-phase saved content) renders byte-identically to pre-phase on BOTH surfaces
+- [ ] one failed page image swaps only its own figure for the "Page N unavailable" note
+- [ ] no innerHTML from document data; the emoji guard stays green
+- [ ] `uv run pytest` green (all pre-existing E2Es); `uv run ruff check . && uv run pyright` clean
diff --git a/.agents/phases/todo/148_pdf_pages_in_sources/04_e2e_pdf_pages.md b/.agents/phases/todo/148_pdf_pages_in_sources/04_e2e_pdf_pages.md
new file mode 100644
index 0000000..5aab477
--- /dev/null
+++ b/.agents/phases/todo/148_pdf_pages_in_sources/04_e2e_pdf_pages.md
@@ -0,0 +1,29 @@
+# Task 04 — the dedicated E2E: a pictorial PDF opens as its pages, a text-heavy PDF as before
+
+**Phase:** `148_pdf_pages_in_sources` · **Source:** `TODO.md:5` — "I want to see the PDF pages in the sources. For example, the test_sources/ikea_instructions.tar are really only helpful if I can see the instruction pages, a description won't really help me."
+**Story:** n/a
+
+## Objective
+End-to-end proof: a synthetic IKEA-shaped PDF (usable text layer, sparse pages → the pictorial class, the owner's named fixture's exact class) syncs, gets its pages, and opens in the doc modal + full page as a strip of real page images — while a text-heavy PDF renders byte-identically to pre-phase.
+
+## Work
+1. `tests/e2e/test_pdf_pages_in_sources.py` — the dedicated suite (one Playwright file, run in isolation, mock-only; the module-app pattern of the phase-126/133/140 PDF suites — its own `BOR_SOURCES_DIR` tmp dir + its own port, the LLM the mock; `pdf_pictorial` ON by default — the IKEA class):
+ - **Fixture builder** (the house PyMuPDF recipe — the `test_pictorial_pdf.py` builder, L214–236, adapted):
+ - `pictorial.pdf` — the IKEA shape: page 1 dense text (a paragraph, >100 stripped chars — the `is_usable_pdf_text` gate passes on the whole layer), pages 2–3 SPARSE (a drawn rectangle + a line, <100 chars — the `pictorial_pages(texts, min_chars=100)` candidates — the mock vision describes them, the `PICTORIAL_PAGE_ANSWER` canned description);
+ - `textheavy.pdf` — 2 dense text pages (zero candidates — the negative class).
+ - Both land in a local-directory source (a `BOR_SOURCES_DIR` subdirectory — local dirs are the owner's named case; a sync run indexes them — the house sync trigger, the admin `Sync sources` click or the API the phase-126 suites use).
+ - **Test 1 — `test_pictorial_pdf_shows_pages_in_modal`:** after the sync settles, assert via the DB (`SessionLocal`, the house idiom) that the pictorial doc row has `page_count == 3` and three `-p{1..3}.png` files exist under the configured `pdf_page_dir`; open the doc modal from the Sources tree (the house `openDocumentModal`/tree-click idiom the doc-modal E2Es use) → inside the modal content: `.doc-pages` present with EXACTLY three `.doc-page` figures; each `` is LOADED (`img.complete && naturalWidth > 0` — the route served a real PNG, not a 404); the figcaptions read `Page 1..3`; the alt texts carry the doc title; NO `.doc-raw` and NO `.doc-summary` in the modal content (the suppression contract — the mock's page descriptions are in the DB but not displayed); the meta badge row (Created/Indexed) is present.
+ - **Test 2 — `test_textheavy_pdf_unchanged`:** open `textheavy.pdf`'s modal → the payload has NO `page_urls` (assert through the page: the content endpoint's JSON via the house fetch idiom, or simply the rendering) → the content renders in `.doc-raw` (the pre-phase shape — the pre-existing doc-viewer pins), the Summary panel renders (the mock summary is present) — byte-identical to pre-phase (the phase-10/26/36 pins hold).
+ - **Test 3 — `test_full_page_surface`:** navigate to the doc's full page (`/document.html?source=…&path=…`, the house URL idiom — admin-logged-in, the phase-79 gate satisfied) → the SAME strip renders (one `renderDocument` core — no per-surface drift) with the same suppression.
+ - **Test 4 — `test_page_route_serves_png`:** a direct `page.request.get` on the first `page_urls` entry (or a fresh page context) → 200, `content-type: image/png`, the `Cache-Control` header, a non-empty body (the bytes route's contract from the UI's perspective).
+2. Teardown: the module app stops; the tmp dirs (sources + `pdf_page_dir`) are disposable (tmp_path_factory); the suite is hermetic (own port, own dirs) so `--no-cov` isolation holds.
+
+## Testing & Quality
+- E2E: the four tests above, mock-only, real rendering (PyMuPDF renders the synthetic PDF's pages for real — `naturalWidth > 0` proves the full pipeline: sync → render → persist → route → viewer).
+- Coverage: n/a (E2E task — `app/` coverage stays >90% from tasks 01–03).
+
+## Completion Criteria
+- [ ] the IKEA-shaped pictorial PDF shows all 3 pages as loaded images in BOTH the modal and the full page, with captions/alt text, no text content, no summary panel
+- [ ] the text-heavy PDF renders byte-identically to pre-phase (`.doc-raw` + summary panel, no strip)
+- [ ] the page route serves a real PNG (200, `image/png`, the cache header, non-empty)
+- [ ] `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` green in isolation; `uv run pytest` still green (the pre-existing PDF/doc-viewer suites pass unchanged)
diff --git a/.agents/phases/todo/149_sync_progress_box/00_phase.md b/.agents/phases/todo/149_sync_progress_box/00_phase.md
new file mode 100644
index 0000000..08106d6
--- /dev/null
+++ b/.agents/phases/todo/149_sync_progress_box/00_phase.md
@@ -0,0 +1,35 @@
+# Phase 149 — sync detail lives in a text box under the button; the button is just the spinner
+
+**Source:** `TODO.md` L6 — "The "Sync Sources" button now has too much detail and you can't see all of it - it gets cut off. I get that this was the solution to show information on mobile, but can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
+**Story:** n/a (TODO-derived phase, Protocol B append).
+**Context:** the Sources page (`frontend/index.html`, the `view-rag` section): `#sync-btn` (L465 — the pill: `.sync-icon` + `.sync-label` with the three PARTS — `.sync-label-main` (the shrinks-to-fit prefix + file/folder, the part CSS ellipsizes), `.sync-label-count` (the pinned "(n/m)"), `.sync-label-chunk` (the phase-129 pinned "· chunk n/m")) + `#sync-cancel-btn` (L477, the phase-134 Cancel control) in the `.page-head-row`; `#sync-result` (L500 — the `role="status"` aria-live announcer: the LIVE untruncated label while a sync runs, the settle counts after); `#sync-error-banner` (L502). The single label writer: `frontend/assets/sources.js` `setSyncLabel(mainText, countText, chunkText)` (~L759 — writes the parts, then the UNTRUNCATED full label rides the button `title` + `#sync-result` — the A4 announcer contract; EVERY label state goes through it: the running builders `fmtSyncLabel`/`fmtSyncPhaseLabel` (L~810–850: "Syncing… (n/m) · chunk n/m", "Writing KB overview…", "Summarizing folders… (n/m)"), the idle/settle labels, the 202/409 click, the re-attach). The running state: `sources.js` ~L909 adds `.is-spinning` to the icon (the CSS spin at 1s — `styles.css` L2200, with the `prefers-reduced-motion: none` kill at L2202) and ~L928 removes it on settle; the label's ellipsis lives in `styles.css` (L2216–2239 — `.sync-label-main` min-width 0 + ellipsis; the mobile media query at L5516+ makes the button full-width and the label fill the pill). The owner's complaint: the FULL live label (a long `source/relative/path` + counts + chunk) is clipped in the pill even on desktop — the mobile solution (stuffing the parts in the button) is now the permanent surface.
+**Locked (owner delegation 2026-09-30, chat — "pick the best choices"):** while a sync/upload run is in flight, the button shows ONLY the spinning icon (the label parts hidden — the button IS the loading spinner, exactly as the owner asked; its `aria-label` carries the full live label for AT), and a NEW bordered text box directly under the button row (`#sync-progress`) shows the SAME full untruncated live label — wrapping, mono, breathing room — hidden at idle/settle. `#sync-result` REMAINS the sole aria-live announcer (the box is presentational, `aria-hidden` — one live region, no double announce); the settle counts line still lands on `#sync-result` exactly as today, and the idle "Sync sources" label returns to the button when the run ends. The box appears for BOTH run kinds (sync + the phase-90 upload "Importing…" run — the shared writer covers both) and re-attaches (a page load mid-run reveals it, the never-stale contract).
+
+## Objective
+The live sync detail (file, position, chunk, phase labels) is fully visible in a calm text box under the button at every viewport, the button reduces to a clean spinner while running, and the accessibility contract (one live region, the button's accessible name, focus-visible, contrast) is preserved.
+
+## Dependencies
+- `148_pdf_pages_in_sources` (todo) — pipeline order only; no shared code (this phase touches the Sources-page sync UI in `index.html`/`sources.js`/`styles.css`; 148 touches the doc viewer + the importer/docs API).
+
+## Design (shared by all tasks — the executor reads this, not the chat)
+- **The box (task 01):** `#sync-progress` — a `
` with an inner ``, placed in `index.html` BETWEEN the `.page-head` block and `#sync-result` (directly under the button row — the owner's "under the button"). `sources.js`: element constants beside the existing ones (~L707–714); `setSyncLabel` (the SINGLE writer — the invariant: every label state, running or not, flows through here) writes the full label into the box's text AND reveals the box for the RUNNING label states (the running builders' outputs + the re-attach running entry) and HIDES it for every non-running state (idle "Sync sources", the settle "Synced HH:MM" + counts, the 202/409 clicks, the error state) — the reveal/hide decision lives at the `setSyncLabel` callsites via a small `running` flag parameter (or a dedicated `setSyncProgress(full, running)` called from the same sites — the executor picks the minimal clean shape; the CONTRACT is: the box is visible iff a run is in flight, and its text is exactly the full label `setSyncLabel` computes). The button: `#sync-btn` gains `is-running` class while in flight (added where `.is-spinning` is added, ~L909; removed where it is removed, ~L928 — including the error settle and the 409 path); its `aria-label` is set to the full live label while running (the icon-only button needs an accessible name — the `title` already carries it for hover, the aria-label for AT) and restored to `"Sync sources"` on every settle. The Cancel button is untouched (it already rides the running state).
+- **The styling (task 02):** `styles.css` — `.sync-progress`: the bordered panel (1px border in the theme border var, the theme surface/background var, the house radius, mono text in `var(--mono)` — the `.sync-label-count`/`.sync-result` mono language: a position reads as data, `overflow-wrap: anywhere` for the long `source/relative/path` (it must WRAP, never clip — the whole point), a comfortable min-height (~2 lines' breathing room, no jump when empty→full because it is hidden when idle), margin below the button row, inside the existing 72rem centered container (the page already rides it — no new width logic); `.sync-btn.is-running .sync-label { display: none; }` (the pill shrinks to an icon-only control — the width adjusts naturally; the `.sync-btn:disabled`/cursor behavior while running is preserved as today); the mobile media query (L5516+): the button stays full-width, the box full-width, the label-hidden rule identical (no mobile special-case — the box REPLACES the old mobile-stuffing reason, which is exactly what the owner asked); `prefers-reduced-motion` untouched (the box is static text; the spinner kill already exists); contrast AA in both themes (the ink vars — the muted `.sync-result` language is ≥4.5:1 already; the box reuses it); focus-visible on the button + Cancel unchanged.
+- **The E2E (task 03):** the `slow_llm` proxy idiom (the `test_sync_chunk_progress.py` recipe — a module app with `BOR_LLM_BASE_URL` = the `tests/e2e/slow_llm.py` delay proxy in front of the mock, ~1.5s per LLM call, the fixture repo with a few docs so the import phase has many polls of observable running state) — observe the in-flight state deterministically.
+
+## Tasks
+1. `01_progress_box_wiring.md` — the box markup + the `setSyncLabel` wiring (reveal/hide contract) + the button's `is-running` + aria-label.
+2. `02_progress_box_styles.md` — the panel + the icon-only running button + mobile + the AA/motion gates.
+3. `03_e2e_progress_box.md` — the dedicated E2E: in-flight (spinner-only button + full untruncated box), settled (box hidden, counts line, label restored).
+
+## Testing & Quality
+- E2E: one dedicated Playwright file, run in isolation, mock-only (slow proxy).
+- Frontend pins: the pre-existing sync E2Es (label parts, chunk progress, mobile label, cancel, summary visibility, upload progress) pass UNCHANGED in their settled/idle assertions; their RUNNING-state assertions that pinned the button label text are updated to the box (the executor finds them via the `sync-label` selectors and migrates the expectation to `#sync-progress` — the A4 announcer (`#sync-result`) is asserted unchanged).
+- Coverage: n/a (frontend phase — `app/` coverage stays >90%).
+
+## Completion Criteria
+- [ ] while a sync (or upload) run is in flight: the button shows only the spinning icon (no visible label text, at any viewport), its `aria-label` carries the full live label, and `#sync-progress` is visible with the EXACT full untruncated label (a long `source/relative/path` wraps — never clipped)
+- [ ] at settle/idle: the box is hidden, the button shows "Sync sources" (the settle "Synced HH:MM" + counts line on `#sync-result` is byte-identical to pre-phase), and the label parts are restored
+- [ ] `#sync-result` remains the sole aria-live region (the box is `aria-hidden`); a page load mid-run re-attaches with the box visible (the never-stale contract)
+- [ ] WCAG: focus-visible on the button + Cancel, contrast ≥4.5:1 both themes, `prefers-reduced-motion` respected (the spinner kill intact)
+- [ ] `uv run pytest` green (all pre-existing E2Es — running-state label pins migrated to the box, settled-state pins untouched); `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
+- [ ] no behavior change in completed phases (the cancel control, the 409 double-trigger, the error banner/modal, the upload run's status, the sync status API)
diff --git a/.agents/phases/todo/149_sync_progress_box/01_progress_box_wiring.md b/.agents/phases/todo/149_sync_progress_box/01_progress_box_wiring.md
new file mode 100644
index 0000000..d01791e
--- /dev/null
+++ b/.agents/phases/todo/149_sync_progress_box/01_progress_box_wiring.md
@@ -0,0 +1,41 @@
+# Task 01 — the `#sync-progress` box: markup + the single-writer wiring + the icon-only running button
+
+**Phase:** `149_sync_progress_box` · **Source:** `TODO.md:6` — "The "Sync Sources" button now has too much detail and you can't see all of it - it gets cut off. … can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
+**Story:** n/a
+
+## Objective
+The full live sync label is written — by the same single writer that owns the button label — into a text box directly under the button, visible only while a run is in flight; the button reduces to the spinning icon while running (label parts hidden, `aria-label` carrying the full label) and restores its idle label on every settle.
+
+## Work
+1. `frontend/index.html` (the `view-rag` section, L458–500): insert BETWEEN the closing `
` of `.page-head` and the `#sync-result` span:
+ ```html
+
+
+
+
+ ```
+ (the comment style matches the file's pinned HTML comments; the box is ship-hidden — the anonymous-safe/idle state).
+2. `frontend/assets/sources.js`:
+ - element constants beside the existing block (~L707–714): `const syncProgress = root.querySelector("#sync-progress");` + `const syncProgressText = syncProgress ? syncProgress.querySelector(".sync-progress-text") : null;` (the null-guarded query pattern of the surrounding constants — the elements are only present on the Sources view);
+ - `setSyncLabel(mainText, countText = "", chunkText = "")` (~L759 — the SINGLE point that writes `#sync-label`): gain a fourth parameter `running = false`. It now ALSO: when `syncProgress` — `syncProgressText.textContent = full` (the SAME untruncated string that rides the title + `#sync-result`; textContent only — the house rule, no HTML) and `syncProgress.hidden = !running` (reveal for a run-in-flight label, hide for everything else). The contract: the box is visible IFF a run is in flight, and its text is EXACTLY the full label;
+ - update EVERY `setSyncLabel` callsite with the correct `running` flag (the executor enumerates them — they are all in this file): the running builders' outputs (the poll tick's `fmtSyncPhaseLabel`/`fmtSyncLabel` application, the re-attach running entry in `initSyncButton`) → `running=true`; the idle label ("Sync sources"), the settle labels ("Synced HH:MM" + counts), the 202-accepted click label, the 409 double-trigger label, the upload's running labels (the phase-90 "Importing…" run — the box serves it too) → `running=true` while in flight / `false` when that run settles; the error-state label → `running=false`;
+ - the running-state toggle sites (~L909 the `is-spinning` add / ~L928 the remove — plus every settle path that removes the spin, including the 409 and error settles): add `syncBtn.classList.add("is-running")` / `remove("is-running")` at the SAME sites (the class and the spin travel together — one in-flight state);
+ - the button's accessible name: while running, set `syncBtn.setAttribute("aria-label", full)` (the full live label — the icon-only button keeps an accessible name; do it in `setSyncLabel` while `running` — the writer already computes `full`); on every non-running `setSyncLabel` call, restore `syncBtn.setAttribute("aria-label", "Sync sources")` (the ship value in the HTML — the static attribute is the idle truth, the JS never removes the attribute, it only overwrites/restores it).
+3. `#sync-result`, `#sync-error-banner`, the Cancel button, and the sync modal are UNTOUCHED (the A4 announcer contract: `#sync-result` hears every label exactly as before — the box is a visual duplicate for sighted users).
+
+## Testing & Quality
+- The behavioral pins come from task 03's E2E; this task's own gate: the pre-existing sync E2Es' SETTLED-state assertions (idle label, the "Synced HH:MM" + counts line, the 409, the error banner, the cancel lifecycle) pass UNCHANGED — and their running-state assertions that read `#sync-label` text while in flight are migrated to read `#sync-progress` (the executor runs each of the six pre-existing sync E2E files and migrates exactly the running-state selectors — the settled pins must not be touched).
+- Coverage: n/a (frontend task).
+
+## Completion Criteria
+- [ ] `#sync-progress` sits under the button row, ship-hidden, `aria-hidden`, with the full untruncated live label while any run (sync or upload) is in flight — and hidden in every non-running state (idle, settled, 202/409, error)
+- [ ] `#sync-btn` carries `is-running` exactly while in flight; its `aria-label` is the full live label while running and "Sync sources" otherwise
+- [ ] `#sync-result` (the sole live region) and every settled-state rendering are byte-identical to pre-phase
+- [ ] `uv run pytest` green with the migrated running-state pins; `uv run ruff check . && uv run pyright` clean
diff --git a/.agents/phases/todo/149_sync_progress_box/02_progress_box_styles.md b/.agents/phases/todo/149_sync_progress_box/02_progress_box_styles.md
new file mode 100644
index 0000000..5d616b1
--- /dev/null
+++ b/.agents/phases/todo/149_sync_progress_box/02_progress_box_styles.md
@@ -0,0 +1,28 @@
+# Task 02 — the box styling: the panel, the icon-only running button, mobile, the AA/motion gates
+
+**Phase:** `149_sync_progress_box` · **Source:** `TODO.md:6` — "… can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
+**Story:** n/a
+
+## Objective
+The box reads as a calm, bordered, mono detail panel with breathing room (long paths WRAP — never clip), and the running button is a clean icon-only spinner control at every viewport — with the WCAG 2.1 AA basics intact in both themes.
+
+## Work
+1. `frontend/assets/styles.css` — beside the existing sync-button block (L2138–2240 region):
+ - `.sync-progress { margin-top: ; border: 1px solid ; background: ; border-radius: ; padding: ; min-height: <~2 lines>; }` — the panel language of the house's existing bordered surfaces (mirror the nearest precedent — e.g. the `.kb-banner`/token-card border treatment — so it belongs to the theme);
+ - `.sync-progress-text { font-family: var(--mono); font-size: ; color: ; overflow-wrap: anywhere; display: block; line-height: ; }` — `overflow-wrap: anywhere` is the point: a long `source/relative/path` wraps inside the panel instead of clipping (the pre-phase ellipsis is retired for the live detail);
+ - **the running button:** `.sync-btn.is-running .sync-label { display: none; }` (the pill shrinks to the icon — the spinner is the whole button while in flight; the pill's padding/border stay, so the control does not jump to zero size — check the `.sync-btn` box model at L2138 and keep a sane icon-only footprint, e.g. the icon centered with the pill's padding — the executor verifies visually via the E2E screenshots); the button keeps its `:disabled` styling/cursor while running (the pre-phase double-trigger posture — whatever disabled state it has in flight today is preserved, only the label is hidden);
+ - **focus-visible + hover** on the running (icon-only) button are unchanged (the pre-phase rules apply to the button element — hiding the label span touches neither);
+ - **mobile media query** (L5516+ region, the `.sync-btn { width: 100% }` block): the box inherits full width naturally (it is a block in the same container — assert no width override is needed); the `.is-running` label-hidden rule is viewport-agnostic (defined once, applies to both) — DELETE nothing from the mobile label rules unless they become dead (the `.sync-label` parts are still rendered at idle — the mobile ellipsis rules for the IDLE label stay; if a rule references only the running state, the executor notes it in the commit, no deletion of idle rules);
+ - **`prefers-reduced-motion`:** untouched (the box is static; the spinner's existing `animation: none` kill at L2202 stands);
+ - **both themes:** every color via the CSS vars (the dark/light theme system — the phase-91/92/93 theme variables; a hardcoded hex would break the semantic theme completion pins).
+2. Verify (via the task-03 E2E, not here): the panel's contrast in both themes (the E2E can assert the computed color contrast is ≥4.5:1 against the panel background — the house contrast-check idiom if one exists in the theme E2Es; else a visual pin + the var-based construction argument in the commit).
+
+## Testing & Quality
+- The visual/behavioral pins live in task 03's E2E (running: icon-only button + the full wrapped label in the panel, desktop AND mobile viewports; settled: idle pill restored). The pre-existing sync E2Es' settled assertions must pass UNCHANGED.
+- Coverage: n/a (frontend task).
+
+## Completion Criteria
+- [ ] the box is a bordered mono panel with the full label WRAPPING (no ellipsis, no horizontal clip) at desktop and mobile widths
+- [ ] the running button shows only the spinner (label span `display: none` via `.is-running`), with an icon-only footprint that does not collapse, and restored "Sync sources" pill at idle
+- [ ] focus-visible, hover, disabled, and `prefers-reduced-motion` behaviors unchanged; contrast ≥4.5:1 both themes (var-based)
+- [ ] `uv run pytest` green (all sync E2Es); `uv run ruff check . && uv run pyright` clean
diff --git a/.agents/phases/todo/149_sync_progress_box/03_e2e_progress_box.md b/.agents/phases/todo/149_sync_progress_box/03_e2e_progress_box.md
new file mode 100644
index 0000000..3b285f1
--- /dev/null
+++ b/.agents/phases/todo/149_sync_progress_box/03_e2e_progress_box.md
@@ -0,0 +1,39 @@
+# Task 03 — the dedicated E2E: spinner-only button + the full untruncated box while running, the restored pill at settle
+
+**Phase:** `149_sync_progress_box` · **Source:** `TODO.md:6` — "The "Sync Sources" button now has too much detail and you can't see all of it - it gets cut off. … can't we just show that information in a nice text box under the button instead to give us more breathing room? Then the button can just be used as a loading spinner."
+**Story:** n/a
+
+## Objective
+End-to-end proof through the real sync path (real `git clone` of a local `file://` fixture repo, the slowed mock LLM so the in-flight state is observable): while running, the button is the spinner and the box carries the FULL untruncated live detail (a long path included); on settle, the box is gone and the pre-phase settle rendering stands.
+
+## Work
+1. `tests/e2e/test_sync_progress_box.py` — the dedicated suite (one Playwright file, run in isolation, mock-only; the `test_sync_chunk_progress.py` module-app recipe — the module app with `BOR_GIT_SOURCES=file://` + its own `BOR_SOURCES_DIR` + its own port (env-overridable house constant) + `BOR_LLM_BASE_URL` = the `tests/e2e/slow_llm.py` delay proxy in front of the mock (`SLOW_DELAY_S = "1.5"` — the proxy delays EVERY LLM call, so the import phase yields many 2s-poll ticks of observable running state)):
+ - **Fixture:** the house fixture-repo builder with a doc whose RELATIVE PATH IS LONG (e.g. `notes/a-rather-deeply-nested-folder/structure/for/the/sync/fixture-document.md` — the clipping trigger the owner hit: a `source/relative/path` that overflows the pill at desktop width) + one or two short-path docs (the sync has several files → the counts/chunk parts are exercised too).
+ - **Test 1 — `test_running_button_is_spinner_only`:** admin login; click `#sync-btn`; poll (the house `expect(...).to_be_visible` with a generous timeout — the slow proxy guarantees a long in-flight window) until `#sync-progress` is visible (a run is in flight) → assert:
+ - `#sync-btn .is-running` present + `#sync-btn .sync-icon.is-spinning` present (the spinner — the loading state);
+ - `#sync-label` is NOT visible (`locator("#sync-label").is_visible() == false` — the label parts are hidden: the button is the icon);
+ - `#sync-btn`'s `aria-label` attribute equals the current full live label (read `#sync-progress-text`'s text — they are written by the same call; assert equality, never staleness);
+ - the Cancel button is visible (the phase-134 contract unchanged).
+ - **Test 2 — `test_box_carries_the_full_untruncated_label`:** during the in-flight window of the LONG-path file (poll until `#sync-progress-text` contains the long fixture path — the slow proxy makes this deterministic):
+ - `#sync-progress-text`'s textContent contains the FULL label shape `Syncing… (/)` — the path string in FULL (assert the complete relative path is a substring — the pre-phase ellipsis would have truncated it; the wrap means nothing is dropped);
+ - the box is NOT clipped: `syncProgress.scrollWidth <= syncProgress.clientWidth` (no horizontal overflow — the wrap engaged) AND `syncProgressText.scrollWidth <= syncProgressText.clientWidth` (the text itself did not overflow its container);
+ - `#sync-result` (the announcer) carries the SAME full string (the A4 contract — one source, two surfaces);
+ - at some tick the chunk part is present (`· chunk /` — the phase-129 part, now in the box) — assert with a retry loop over the slow embed window (the chunk part is only present while an embed batch is in flight — the slow proxy's 1.5s delay makes the window wide).
+ - **Test 3 — `test_mobile_box_full_width` (the viewport half of the owner's ask):** a fresh context at the mobile viewport (the house mobile-width constant the `test_sync_label_mobile.py` uses) → same in-flight assertions: the box is full-width (its bounding box width ≈ the container width minus margins — no horizontal clipping, `scrollWidth <= clientWidth`), the button is icon-only, the label wraps (the old mobile-stuffing ellipsis is gone — assert the full path substring again).
+ - **Test 4 — `test_settled_state_restores_the_pill`:** let the run settle (the `Synced HH:MM` pattern on `#sync-result` — the house settle idiom):
+ - `#sync-progress` is HIDDEN (`is_visible() == false`) — the box is run-scoped only;
+ - `#sync-btn` has NO `is-running`, the icon has NO `is-spinning`, and `#sync-label` is VISIBLE with the settle label ("Synced HH:MM" — the pre-phase byte-identical settle copy); the `aria-label` is back to "Sync sources";
+ - the counts line on `#sync-result` matches the pre-phase shape (`N added` etc. — the `fmtSyncResult` pin).
+ - **Test 5 — `test_reattach_shows_the_box`:** trigger a sync, wait until in-flight (the box visible), then RELOAD the page (the admin session persists) → the load-time re-attach (`initSyncButton`) finds the RUNNING sync → the box is visible with the current label and the button is icon-only again (the never-stale contract — a page load mid-run shows the truth, not an idle pill).
+2. Teardown per the house module-app fixture (app stopped, tmp dirs disposable); hermetic (own port + dirs) so `--no-cov` isolation holds.
+
+## Testing & Quality
+- E2E: the five tests above, mock-only (slow proxy), real git + real import; the load-bearing assertions are the FULL-path substring (no clipping) + the wrap proof (`scrollWidth <= clientWidth`) + the settle byte-identity.
+- Coverage: n/a (E2E task).
+
+## Completion Criteria
+- [ ] in-flight: the button is the spinner only (label hidden, `is-running`, `aria-label` == the full label), the Cancel button rides along, and the box shows the EXACT full untruncated label — long path included, wrapped, `scrollWidth <= clientWidth`, desktop AND mobile
+- [ ] the announcer (`#sync-result`) carries the same full string; the chunk part appears in the box during the embed window
+- [ ] settled: the box hidden, the "Synced HH:MM" pill restored byte-identical, `aria-label` back to "Sync sources", the counts line unchanged
+- [ ] a page load mid-run re-attaches with the box visible (never stale)
+- [ ] `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation (DB up, git on PATH — the documented prerequisite); `uv run pytest` still green (the six pre-existing sync E2Es — migrated running-state pins + untouched settled pins)