` (L511) — today the SOLE aria-live region, which receives (a) the same live label while a run runs (a byte-identical duplicate of the box), (b) the settle counts line (`fmtSyncResult(detail)` — "N added · M updated · …"), (c) the "Sync cancelled" line, (d) the cancel-request failure copy, (e) `""` on the sync-failure / idle / upload-settle paths (plus the settle label "Synced HH:MM" and the idle "Sync sources", written through the shared writer). The single writer is `frontend/assets/sources.js` `setSyncLabel(mainText, countText, chunkText, running)` (L809–843): it writes the button parts, the `title`, the box text + `hidden = !running`, the button `aria-label`, AND `syncResult.textContent = full` (L834). The `syncResult` element handle is L750; the other write sites are L1092 (`applySyncSuccess` — the counts line), L1107 (`applySyncIdle` — clear), L1125 (`applySyncCancelled` — "Sync cancelled"), L1213/L1224 (the upload settles — clear), L1291/L1298/L1310 (the cancel paths — clear + "Couldn't cancel the sync — try again."). CSS: `.sync-progress` L2252, `.sync-progress-text` L2259, `.sync-result` L2284–2290, the mobile `.sync-result` override at L5639, and the "the sync-result shape" comments at L2938 / L3659 (those rules belong to OTHER elements — the upload success line and the action feedback line — and keep their own classes).
+**Locked (owner decision, chat — this phase):** `#sync-progress` becomes the **single** sync text surface AND the **sole** aria-live region: `role="status" aria-live="polite"` moves onto the box, `aria-hidden` is dropped, and `span.sync-result` is **deleted** from the template. The box keeps the phase-149 running behavior AND takes over the announcer's content: it is visible while a run is in flight (the live label) and stays visible after a settle to carry the result line (the counts / "Sync cancelled" / cancel-failure copy), hiding only when its text is empty (idle, upload settles, failures — exactly the states where `#sync-result` is empty today). Every string that reaches `#sync-result` today reaches the box instead — no copy changes.
+
+## Objective
+One element, one live region: the sync/upload live label, the settle result line, the cancel line and the click copy all render in `#sync-progress`, which announces them exactly once; `#sync-result` no longer exists in the DOM, the JS, or the CSS.
+
+## Dependencies
+- `149_sync_progress_box` (complete) — the box, its single-writer wiring, and its CSS are what this phase folds the announcer into. Pipeline order only: no phase is pending in `todo/`.
+
+## Design (shared by all tasks — the executor reads this, not the chat)
+- **The element (task 01):** `#sync-progress` keeps its id, class, and inner `#sync-progress-text` span (the textContent target — the house rule: text only, never HTML) and gains `role="status" aria-live="polite`; `aria-hidden="true"` is REMOVED (an aria-live region that is aria-hidden announces nothing — the two attributes are mutually exclusive by intent). It stays ship-hidden (`hidden`) — the anonymous-safe/idle state. `span.sync-result` (L511) and its explanatory comment block (L496–510) are deleted; the box's comment (L483–492) is rewritten to say it is now the SOLE announcer (the phase-149 "presentational (aria-hidden)" sentence is retired — leave a one-line note that phase 150 folded the announcer in, the file's pinned-comment convention).
+- **The writer (task 02):** introduce ONE function that owns the box — `setSyncProgress(text)` — which sets `syncProgressText.textContent = text` and `syncProgress.hidden = (text === "")` (the global `[hidden]` rule does the display). Two content modes, decided at the callsites (the same "the callsites are the only decision sites" rule phase 149 established):
+ * **running mode** — `setSyncLabel(main, count, chunk, running)` keeps writing the box with `full` **only when `running === true`** (exactly today's reveal rule). When `running === false` it does NOT touch the box: the idle/settle button labels ("Sync sources", "Synced HH:MM") must not fill a big panel (today they land on the small `#sync-result` line where they read as a duplicate of the button — that artifact is retired, not migrated).
+ * **result mode** — the settle paths call `setSyncProgress(text)` explicitly: `applySyncSuccess` → `fmtSyncResult(status.detail)` (the counts line), `applySyncCancelled` → `"Sync cancelled"`, `applySyncFailure` → `""` (the banner + modal carry the error), `applySyncIdle` → `""`, the two upload settles → `""` (the upload result lives on the Sources page — phase 90 A3), `requestCancel`'s network/non-2xx branches → `"Couldn't cancel the sync — try again."` (string unchanged). `requestCancel`'s opening clear (L1291, which today blanks the announcer while the run is still in flight) becomes a NO-OP on the box — the live label stays up until the poll's `cancelled` branch settles it (blanking would hide the running surface for up to one 2 s poll).
+ The `syncResult` handle (L750) and every `if (syncResult)` guard are deleted. There is no 202/409 click copy: `startSync`'s 202/409 branch enters the running state (L1255–1262), so the box reveals the bare "Syncing…" label through `setSyncLabel` as today.
+- **The CSS (task 03):** delete the `.sync-result` rule (L2284–2290) and the mobile `.sync-result` override (L5639); the two "the sync-result shape" comments (L2938, L3659) are reworded to reference `.sync-progress-text` (they describe the mono/soft-ink language those other elements share — the rules themselves and their selectors are untouched). `.sync-progress` needs NO new rule for the settled state (the panel already reads as a calm box; the global `[hidden] { display: none !important }` rule keeps it gone when empty — verify it still applies, the box sets no `display` of its own). Contrast stays AA: `.sync-progress-text` uses `--ink-soft` on `--surface` (8.2:1 built-in dark, AA in both built-ins).
+- **The test migration (tasks 04/05):** mechanical selector migration — `#sync-result` → `#sync-progress` (assert on the box's text, i.e. `#sync-progress-text` for textContent, `#sync-progress` for `hidden`/attributes). Settled-state copy assertions must stay byte-identical (the counts line string itself does not change).
+- **ASSUMPTION A1 (owner-locked):** the box stays visible after settle to hold the counts line and hides only when its text is empty. The alternative (box running-only, result line elsewhere) was rejected.
+- **ASSUMPTION A2:** the settle label "Synced HH:MM" is no longer written to the live region (it stays on the button, visible). The announcer hears the counts line that superseded it in the same tick a moment ago — the merge makes one surface, and a panel reading "Synced HH:MM" under a button reading "Synced HH:MM" is the duplicate this retirement exists to remove.
+- **ASSUMPTION A3:** the box's settled appearance needs no new CSS (the same bordered panel reads fine for a result line); only the running-only min-height stays, so the panel does not jump between modes.
+
+## Tasks
+1. `01_html_single_surface.md` — delete `span#sync-result`, move `role=status aria-live=polite` onto `#sync-progress`, drop `aria-hidden`, rewrite the two comment blocks.
+2. `02_js_single_writer.md` — `sources.js`: add `setSyncProgress`, route all 9 announcer sites through it, delete the `syncResult` handle, change the box visibility rule to text-driven.
+3. `03_css_retire_sync_result.md` — `styles.css`: remove `.sync-result` + its mobile override, reword the two shape comments.
+4. `04_unit_test_migration.md` — `tests/unit/test_sync_button.py` (20 refs) + `tests/unit/test_frontend_sync_upload.py` (26 refs) migrated to the box.
+5. `05_e2e_sync_family.md` — the 12 sync-family E2E files' selectors migrated + the dedicated `tests/e2e/test_sync_progress_box.py` extended with the single-live-region + settled-line assertions.
+6. `06_e2e_image_pdf_family.md` — the 6 image/PDF E2E files migrated (their sync-done barriers move to the box) + the a11y/no-CDN/full-gate pass and the phase commit.
+
+## Testing & Quality
+- Unit: the two frontend-parsing suites (`test_sync_button.py`, `test_frontend_sync_upload.py`) pin the writer contract on the box — running label visible + announced, settle counts line, "Sync cancelled", the idle/upload clears, the "Couldn't cancel" copy — and assert `#sync-result` is ABSENT from the template (the retirement is pinned, not implied).
+- E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` in isolation (the slow-LLM proxy recipe already in that file) proves in-flight → settle → idle on the real sync path; every pre-existing sync/image/PDF E2E that touched the announcer passes after migration (12 sync-family files + 6 image/PDF files).
+- Coverage: **>90%** on `app/` (this phase touches no `app/` logic — the floor must simply not regress).
+- Accessibility gate (AGENTS.md rule 5): exactly ONE `aria-live` region in the sync area; the box is a `status` region (polite — no alert spam); the button keeps its accessible name in both states; focus-visible and contrast unchanged.
+
+## Completion Criteria
+- [ ] `grep -rn "sync-result\|syncResult" frontend/ tests/` returns NOTHING (the element, its class, and its JS handle are gone from source and tests)
+- [ ] `#sync-progress` carries `role="status" aria-live="polite"` and no `aria-hidden`; it is the only `aria-live` element in the sync area
+- [ ] in flight: the box shows the full untruncated live label (wrapping, never clipped); at settle: the box shows the counts line byte-identical to the pre-phase `#sync-result` string ("N added · …") and "Sync cancelled" for a cancel; at idle / upload-settle / sync-failure: the box is `hidden` and empty
+- [ ] the button is still the icon-only spinner while running and restores "Sync sources" on settle (phase 149 behavior unchanged)
+- [ ] `uv run pytest` green; `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean; `app/` coverage >90%
+- [ ] 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)
+- [ ] one atomic commit per AGENTS.md rule 8 (`--no-gpg-sign`)
diff --git a/.agents/phases/todo/150_drop_sync_result/01_html_single_surface.md b/.agents/phases/todo/150_drop_sync_result/01_html_single_surface.md
new file mode 100644
index 0000000..1b0a61c
--- /dev/null
+++ b/.agents/phases/todo/150_drop_sync_result/01_html_single_surface.md
@@ -0,0 +1,28 @@
+# Task 01 — `index.html`: delete `span#sync-result`, make `#sync-progress` the live region
+
+**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
+**Story:** n/a
+
+## Objective
+The template ships ONE sync text surface: `#sync-progress`, which is now the `role="status"` `aria-live="polite"` announcer; `span.sync-result` no longer exists in the DOM.
+
+## Work
+1. `frontend/index.html` (the `view-rag` Sources section, L483–511):
+ - `#sync-progress` (L493) becomes
+ ``
+ — `aria-hidden="true"` is REMOVED (an aria-live region must not be aria-hidden), `role`/`aria-live` are ADDED, `hidden` stays (ship-hidden = the idle state, anonymous-safe). The inner `` (L494) is unchanged (it stays the textContent target).
+ - DELETE the `#sync-result` span (L511) and its comment block (L496–510, the "#sync-result is the aria-live announcer …" paragraph).
+ - REWRITE the box's own comment (L483–492) so it states the phase-150 contract: the box carries the full untruncated live label while a run is in flight AND the settle result line after it; it is the SOLE aria-live announcer for the sync area (one live region, no double announce); the button is the spinner while running (`.is-running` hides the label parts). Keep the file's comment voice (phase-prefixed, rationale-first).
+2. Nothing else in the template changes: `#sync-btn` and its label parts, `#sync-cancel-btn`, `#sync-error-banner` / `#sync-error-text`, the sync modal, and the Sources gate are untouched.
+3. No CDN/external references are introduced (AGENTS.md rule 6 — the page stays fully self-hosted).
+4. ASSUMPTION: the box keeps its current position (between `.page-head` and the error banner) — the owner asked to remove the span, not to move the box.
+
+## Testing & Quality
+- Unit: this task's own gate is the template parse — the pre-existing suites that read `index.html` still find `#sync-progress` (with the new attributes) and no longer find `#sync-result`. Tasks 04/05 do the full migration; if a suite fails ONLY on the missing `#sync-result`, note it for task 04 rather than editing test expectations here beyond what the parse requires.
+- Coverage: n/a (template task — `app/` coverage floor unchanged).
+
+## Completion Criteria
+- [ ] `grep -n "sync-result" frontend/index.html` returns nothing
+- [ ] `#sync-progress` has `role="status"`, `aria-live="polite"`, `hidden`, and NO `aria-hidden`
+- [ ] the sync area contains exactly one `aria-live` element
+- [ ] `uv run pytest tests/unit -q` is green or failing ONLY on the `#sync-result` expectations tasks 04/05 own
diff --git a/.agents/phases/todo/150_drop_sync_result/02_js_single_writer.md b/.agents/phases/todo/150_drop_sync_result/02_js_single_writer.md
new file mode 100644
index 0000000..09f3995
--- /dev/null
+++ b/.agents/phases/todo/150_drop_sync_result/02_js_single_writer.md
@@ -0,0 +1,40 @@
+# Task 02 — `sources.js`: one box writer (`setSyncProgress`), the announcer handle deleted
+
+**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
+**Story:** n/a
+
+## Objective
+Every string that used to land on `#sync-result` lands on `#sync-progress` instead, written by one function; the `syncResult` handle is gone and the box's visibility is owned by that single writer.
+
+## Work
+1. `frontend/assets/sources.js` — add the box writer next to `setSyncLabel` (L809):
+ ```js
+ function setSyncProgress(text) {
+ if (!syncProgressText || !syncProgress) return;
+ syncProgressText.textContent = text;
+ syncProgress.hidden = text === "";
+ }
+ ```
+ (textContent only — the house rule, never HTML; the `hidden` attribute drives `display:none` through the global `[hidden]` rule.)
+2. `setSyncLabel` (L809–843): replace the phase-149 pair `syncProgressText.textContent = full;` + `syncProgress.hidden = !running;` (L838–839) with `if (running) setSyncProgress(full);` — the running label still flows through the single writer, and a non-running call leaves the box alone (the idle/settle button labels "Sync sources" / "Synced HH:MM" must NOT fill the panel; the panel is the live label + the result line, nothing else). The `title`, the `aria-label` (`running ? full : "Sync sources"`), and the label parts are untouched. Delete `if (syncResult) syncResult.textContent = full;` (L834) — the box IS the announcer now.
+3. Delete the element handle `const syncResult = root.querySelector("#sync-result");` (L750) and every `if (syncResult)` guard. Migrate the eight remaining announcer sites to `setSyncProgress(...)`, at the SAME position in the same order:
+ - `applySyncSuccess` (L1092) → `setSyncProgress(fmtSyncResult(status.detail))` (the counts line — the string builder is unchanged);
+ - `applySyncFailure` (L1107) → `setSyncProgress("")` (the banner + modal carry the error; the box hides);
+ - `applySyncCancelled` (L1125) → `setSyncProgress("Sync cancelled")`;
+ - `applySyncIdle` (L~1130) → `setSyncProgress("")` (the true idle — retry-ready, nothing to announce);
+ - the upload-success settle (L1213) and the upload-failed settle (L1224) → `setSyncProgress("")` (the upload's result line lives on the Sources page — phase 90 A3);
+ - `requestCancel`'s network-failure branch (L1298) and its non-2xx tail (L1310) → `setSyncProgress("Couldn't cancel the sync — try again.")` (string byte-identical);
+ - `requestCancel`'s opening clear (L1291) becomes a **no-op on the box** — the run is still in flight at that moment, so blanking would hide the running surface for up to one 2 s poll; the poll's `cancelled` branch settles it. Leave a short comment saying why the clear was dropped (the phase-134 "never stale" contract is kept by the control's immediate disable).
+4. Update the module/function comments that name `#sync-result` as the announcer (the file cites it in ~15 comment lines — L160, 321, 375, 670, 677, 700, 706, 723, 744, 789, 859, 894, 949, 1006, 1120, 1209, 1256, 1274, 1286): rename the references to `#sync-progress` and keep the rationale (one live region, the untruncated label, A4). Do not change any behavior while doing it.
+5. ASSUMPTION A2 (owner-visible): the settle label "Synced HH:MM" is no longer written to the live region — it stays on the button; the counts line is the announced result.
+6. ASSUMPTION: the re-attach path (`initSyncButton`, the page-load mid-run entry) needs no new code — it goes through `enterSyncRunningState` → `setSyncLabel(..., true)`, which reveals the box (the never-stale contract holds).
+
+## Testing & Quality
+- Unit: task 04 migrates the two frontend suites; this task's own gate is `uv run pytest tests/unit -q` failing only on the expectations task 04 owns, and `grep -n "syncResult" frontend/assets/sources.js` returning nothing.
+- Coverage: n/a (frontend task).
+
+## Completion Criteria
+- [ ] `grep -n "syncResult\|sync-result" frontend/assets/sources.js` returns nothing
+- [ ] `setSyncProgress` is the only writer of the box's text/visibility; `setSyncLabel` writes it only when `running`
+- [ ] the counts line, "Sync cancelled", and "Couldn't cancel the sync — try again." all render in `#sync-progress`; the idle/upload-settle/sync-failure states leave it hidden and empty
+- [ ] the running label, the button `title`, and the button `aria-label` behavior are byte-identical to pre-phase
diff --git a/.agents/phases/todo/150_drop_sync_result/03_css_retire_sync_result.md b/.agents/phases/todo/150_drop_sync_result/03_css_retire_sync_result.md
new file mode 100644
index 0000000..0534ab4
--- /dev/null
+++ b/.agents/phases/todo/150_drop_sync_result/03_css_retire_sync_result.md
@@ -0,0 +1,27 @@
+# Task 03 — `styles.css`: retire `.sync-result`, keep the box correct in both modes
+
+**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
+**Story:** n/a
+
+## Objective
+No CSS rule targets the deleted element, and `#sync-progress` reads correctly as both the running label and the settled result line.
+
+## Work
+1. `frontend/assets/styles.css`:
+ - DELETE the `.sync-result` rule (L2284–2290 — `display:block; color:var(--ink-soft); font-family:var(--mono); font-size:0.8rem; white-space:nowrap; padding-block:0.5rem;`) and its "Last-result announcer" comment.
+ - DELETE the mobile `.sync-result` override inside the phone media query (L5639) — read it first and confirm it only tweaks the deleted element (if it also styles a surviving selector, keep that part).
+ - REWORD the two comments that reference "the sync-result shape" (L2938 — the upload success line `role=status`; L3659 — the action feedback line `role=status`) to name `.sync-progress-text` as the shared mono/soft-ink language. The RULES themselves (their own selectors) are untouched — only the cross-reference text changes.
+ - `.sync-progress` (L2252) / `.sync-progress-text` (L2259) need no new rule: the panel already carries the border/surface/radius/padding and the `min-height: 4rem`; the box sets no `display` of its own, so the global `[hidden] { display: none !important }` rule keeps it gone when empty (verify by reading that global rule — if it is scoped to something narrower, add `.sync-progress[hidden] { display: none; }`).
+ - Update the `.sync-progress-text` comments that say "the .sync-result mono language / size / ink" (L2261–2266) so they no longer cite a dead class (keep the contrast note: `--ink-soft` on `--surface` is a pinned AA pair).
+2. No new colors, no new fonts, no external assets (AGENTS.md rule 6). Contrast stays ≥4.5:1 in both built-in themes (the ink/surface pairs are unchanged).
+3. ASSUMPTION A3: the settled result line uses the same panel styling as the running label (no `.is-settled` variant) — the owner asked for one surface, not two looks.
+
+## Testing & Quality
+- Unit/E2E: the migrated suites (tasks 04/05) assert the box's visibility states; the theme suites (`tests/unit/test_theme*.py`, the custom-theme contrast gate) pass unchanged — no theme token changed.
+- Coverage: n/a (CSS task).
+
+## Completion Criteria
+- [ ] `grep -n "sync-result" frontend/assets/styles.css` returns nothing
+- [ ] `#sync-progress` is `display:none` while `hidden` and a bordered panel when shown, at desktop and 360 px
+- [ ] the upload success line and the action feedback line still render exactly as before (their own rules untouched, only comments reworded)
+- [ ] `uv run pytest tests/unit -q` green
diff --git a/.agents/phases/todo/150_drop_sync_result/04_unit_test_migration.md b/.agents/phases/todo/150_drop_sync_result/04_unit_test_migration.md
new file mode 100644
index 0000000..b1642ef
--- /dev/null
+++ b/.agents/phases/todo/150_drop_sync_result/04_unit_test_migration.md
@@ -0,0 +1,29 @@
+# Task 04 — unit suites: migrate the `#sync-result` source pins to the box
+
+**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
+**Story:** n/a
+
+## Objective
+The source-level suites that pin the sync UI no longer reference `#sync-result` / `.sync-result`, and they pin the new contract instead: one live region, box-driven.
+
+## Work
+1. `tests/unit/test_sync_button.py` (20 references) — the pins are regex/string reads of `frontend/index.html`, `frontend/assets/sources.js`, `frontend/assets/styles.css`:
+ - L232–241 (`test …sync-result…`): the "polite live region right after the button" pin moves to `#sync-progress` — assert the div carries `role="status"` and `aria-live="polite"`, does NOT carry `aria-hidden`, and sits after the button row; rename the test accordingly;
+ - ADD a pin that the retired element is gone: `assert 'id="sync-result"' not in index_html` (the retirement is pinned, not implied);
+ - L747–751 (the `.sync-result` CSS block pin): retarget to `.sync-progress-text` (assert the mono family, the `--ink-soft` color, the `overflow-wrap: anywhere`) and assert `.sync-result` has no rule in `styles.css`;
+ - L1984 (the DOM-order pin `main_end < cancel_start < sync-result`): re-anchor on `id="sync-progress"`;
+ - L371, 384, 465, 652, 2067 and the module docstring (L11, 43, 56): reword the "title + #sync-result" language to "title + #sync-progress" (the A4 untruncated-label contract is unchanged — it just names the surviving surface);
+ - the cancel-request pin (~L2067) that asserted the announcer clears on click: update to the new contract — the box is NOT blanked at cancel-request time (the live label stays until the poll settles), the control disables immediately.
+2. `tests/unit/test_frontend_sync_upload.py` (26 references) — same migration: the `fmtSyncLabel` / `enterSyncRunningState` pins that assert the full untruncated label reaches the announcer now assert it reaches `#sync-progress` (via `setSyncProgress` / the `running` branch of `setSyncLabel`); the A3 pin ("the settle never renders upload counts into #sync-result") becomes "the upload settles write an empty string to the box" (`setSyncProgress("")`); the docstring references (L9, 12, 61, 79) are reworded.
+3. Keep every assertion's INTENT: same strings, same states, one different selector. Do not weaken a pin to make it pass — if a behavior genuinely changed (the "Synced HH:MM" announcer write, the cancel-request clear), pin the NEW behavior explicitly and say why in the test docstring.
+4. ASSUMPTION: no new test file is created for the unit layer — the retirement is a migration of existing pins plus the two "element is gone" assertions.
+
+## Testing & Quality
+- `uv run pytest tests/unit/test_sync_button.py tests/unit/test_frontend_sync_upload.py -v` green.
+- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage stays >90% (no `app/` change in this phase).
+- Coverage: n/a for frontend source; the two suites must not lose assertions (compare the collected test count before/after — it must not drop).
+
+## Completion Criteria
+- [ ] `grep -rn "sync-result\|syncResult" tests/unit/` returns nothing
+- [ ] both suites assert the box is the `role="status" aria-live="polite"` region and that `#sync-result` is absent from the template and from `styles.css`
+- [ ] test count is not lower than before the migration; both files green
diff --git a/.agents/phases/todo/150_drop_sync_result/05_e2e_sync_family.md b/.agents/phases/todo/150_drop_sync_result/05_e2e_sync_family.md
new file mode 100644
index 0000000..25d019a
--- /dev/null
+++ b/.agents/phases/todo/150_drop_sync_result/05_e2e_sync_family.md
@@ -0,0 +1,31 @@
+# Task 05 — E2E migration (sync family) + the dedicated `test_sync_progress_box.py` assertions
+
+**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
+**Story:** n/a
+
+## Objective
+Every sync-flow Playwright suite drives and asserts the box instead of the deleted span, and the phase's dedicated suite proves the merged contract end to end (one live region, running label + settled result line).
+
+## Work
+1. Migrate the sync-family suites (the `sync-result` reference count in parentheses) — replace the selector `#sync-result` with the box (`#sync-progress` for visibility/attributes, `#sync-progress-text` for `text_content()`), keeping every expected STRING byte-identical:
+ - `tests/e2e/test_sync_button.py` (2), `tests/e2e/test_sync_cancel.py` (2 — the "Sync cancelled" line and the cancel lifecycle), `tests/e2e/test_sync_model_down.py` (2), `tests/e2e/test_sync_upload_progress.py` (2), `tests/e2e/test_upload_no_scan.py` (2), `tests/e2e/test_sync_summary_visibility.py` (3), `tests/e2e/test_sync_changed_only.py` (3), `tests/e2e/test_sync_label_mobile.py` (1), `tests/e2e/test_git_source_dates.py` (1), `tests/e2e/test_git_source_branch.py` (4), `tests/e2e/test_force_push_resync.py` (4), `tests/e2e/test_sync_chunk_progress.py` (8 — the "· chunk n/m" announcer pins).
+ - Where a suite asserted "the announcer is empty" it now asserts `expect(page.locator("#sync-progress")).to_be_hidden()`; where it asserted announcer text it asserts the box's text. A suite that asserted the announcer was VISIBLE while running keeps that assertion on the box (it is visible while running — phase 149).
+2. `tests/e2e/test_sync_progress_box.py` (11 references) — the dedicated suite for this phase (A16: one file per story, run in isolation). Extend it, keeping the existing running-state proofs:
+ - **template pin:** exactly one `[aria-live]` element in the sync area, and it is `#sync-progress`; `#sync-result` is absent (`page.locator("#sync-result").count() == 0`);
+ - **running:** unchanged (icon-only button + the full untruncated label in the box, the long `source/relative/path` wrapping at 360 px and desktop);
+ - **settled success:** the box is VISIBLE and its text equals the counts line the pre-phase announcer produced (the `fmtSyncResult` shape — "N added · …"), the button reads "Synced HH:MM";
+ - **settled cancel:** the box reads "Sync cancelled" (the phase-134 neutral result — no banner, no modal);
+ - **settled failure:** the box is hidden/empty and the error banner + modal carry the error (unchanged);
+ - **idle re-attach:** a page load with no run leaves the box hidden.
+3. Keep the suites MOCK-ONLY (the `tests/e2e/slow_llm.py` delay-proxy recipe already in the file) and run each migrated file in isolation while editing it: `uv run pytest tests/e2e/test_.py -v --no-cov` (DB up: `podman compose up -d db`).
+4. ASSUMPTION: no new E2E file — the phase's behavioral proof belongs to the existing `test_sync_progress_box.py` (the phase-149 dedicated suite is the box's suite; this phase extends it rather than forking a near-duplicate).
+
+## Testing & Quality
+- `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green IN ISOLATION (the phase's gate file).
+- Every migrated sync-family file green in isolation; then the full `uv run pytest` green.
+- Coverage: >90% on `app/` (unchanged — no `app/` edit).
+
+## Completion Criteria
+- [ ] `grep -rln "sync-result" tests/e2e/` returns nothing after task 06 also lands (this task clears the sync family)
+- [ ] the dedicated suite proves: one live region, running label in the box, counts line visible at settle, "Sync cancelled" on cancel, hidden on failure/idle
+- [ ] `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` green in isolation
diff --git a/.agents/phases/todo/150_drop_sync_result/06_e2e_image_pdf_family.md b/.agents/phases/todo/150_drop_sync_result/06_e2e_image_pdf_family.md
new file mode 100644
index 0000000..c8e5778
--- /dev/null
+++ b/.agents/phases/todo/150_drop_sync_result/06_e2e_image_pdf_family.md
@@ -0,0 +1,30 @@
+# Task 06 — E2E migration (image / PDF family) + the no-CDN and a11y gate pass
+
+**Phase:** `150_drop_sync_result` · **Source:** `TODO.md:5` — "Get rid of the span sync-result now that we have the div sync-progress"
+**Story:** n/a
+
+## Objective
+The remaining suites that touch the announcer — the image and PDF stories, which wait on sync result text — drive the box, and the whole gate passes with the span gone.
+
+## Work
+1. Migrate the image/PDF-family suites (reference count in parentheses), same rule as task 05 — selector changes only, expected strings byte-identical:
+ - `tests/e2e/test_image_documents.py` (2), `tests/e2e/test_image_anchor_context.py` (2), `tests/e2e/test_answer_images.py` (2), `tests/e2e/test_pdf_support.py` (2), `tests/e2e/test_pdf_image_context.py` (2), `tests/e2e/test_pictorial_pdf.py` (2).
+ - These use the announcer mostly as a SYNC-DONE barrier (wait for the counts line before querying the KB). Keep them as barriers, just on the box: wait for `#sync-progress-text` to contain the expected result text (or for the box to become visible with the counts line) — do NOT replace them with `wait_for_timeout`.
+2. Confirm the no-CDN integration test still passes (AGENTS.md rule 6 — the index page has no external `