# Refactor survey of the web viewer (`src/web/src`)

The biggest problems are layering ones. Pure domain logic sits in `sync/` and `layout/`. Hash links are built by hand in about 30 places instead of by the router. The "pick, wait a few seconds, then send" behaviour is written three times. Components call about 38 endpoints by literal verb instead of through named methods on the API client.

Every template is already under the 50-element budget. The closest are `phone/PhoneHelperDetail.vue` (49), `App.vue` (48), `resource/SequenceSteps.vue` (47) and `pages/BoardPage.vue` (47), so any refactor that adds elements to those four breaks the budget. The splits proposed here are by responsibility, because the script blocks are what has grown: PhoneHome has 575 script lines, NewWork 502, Thread 419, Home 384.

Paths below are relative to `ROOT = /Users/jessegall/projects/agent-journal/src/web/src`. Neither CLAUDE.md nor AGENTS.md has viewer-specific rules; I worked from the rules in your brief, the 50-element budget in `.commandments/config.json`, and `scripts/checks/one_client.py` and `funnels.py`.

## What I looked at

| Folder | Coverage |
|---|---|
| `api/` (4) | Read in full. |
| `state/` (2) | Read in full. |
| `sync/` (7) | Read in full. |
| `domain/` (22) | Read all small modules in full. Read the heads and exports of `orchestra`, `panes`, `family`, `schemes`, `thread`. |
| `composables/` (43) | Read all small ones in full; the rest by exports and imports. |
| `format/`, `text/`, `platform/`, `actions/`, root `.js` | Read in full or by exports. |
| `phone/` (90) | Read every `.js` in full. Read the scripts or templates of about 25 `.vue` files: Home, Turn, Compose, Question, Buttons, Ticks, Peer, Parent, Quote, Reactions, Fold, Chevron, Skeleton(s), Mark, Files, Comments, ShareSheet, AgentControls, AtWork, HelperDetail, Hold, Tabs, BoardList, Needs, PlaceList, Reader. The rest I covered by imports and the scans below. |
| `chat/` (75) | Read Thread, Compose, the Turn* pieces, QuestionOverlay, UsageMeter, AgentUsage, outbox and the small `.js` files. The rest by imports. |
| `kit/` (99) | Read props and user counts for every component, plus every import that leaves the kit. Read about 20 components in full. |
| `share/` (9) | Read ShareApp's script, visitor, main, ShareQuestion, ShareComment. CommentBar and the rest by outline. |
| `board/`, `pages/`, `layout/`, `resource/`, `organization/` | Read by outline and imports. Read in full: NewWork and NewBoard (file hand-in), Buttons, ShareDialog, Tunnel*, JournalTile(Env), PSection, PlanList, Sidebar and TopBar hrefs, FeaturePanel, featureSettings. |

I also ran these read-only scans across the whole tree:
- element count per template;
- identical and same-shape function bodies;
- repeated template element sequences;
- unimported files and unused exports;
- endpoint strings written outside `api/`;
- hand-built `#/` hrefs;
- `localStorage` use;
- `x.value = e.message` error handling;
- add/remove listener pairs.

---

## Batches, most valuable first

Waves:
- **Wave 1** (batches 1, 2, 3) share no files and can run in parallel.
- **Wave 2** (batches 4, 5, 6) mostly touch separate hunks of a few files that wave 1 also touched. The overlaps are listed under each batch.
- **Wave 3** (batches 7 to 9) and **Wave 4** (batches 10 to 12) are sequenced because each one reaches across many files.

### Batch 1: Move domain logic out of `sync/` and `layout/` (wave 1)
No behaviour change. Low risk: moves plus import rewrites.

- **`sync/hub.js:20-152`**
  - **Problem:** About 25 pure domain helpers live inside the sync engine, which also holds a Vue composable (`useHub`, `useJournalPoll` at :300-333). Helpers include `STATE_WORDS`, `COUNTS`, `envState`, `leadOf`, `journalState`, `focusOf`, `planMeter`, `idleNote`, `agentLine`, `countsOf`, `asksOf`, `totalsOf`, `projectPath`, `stoppedNote`, `isActive`, `silentIn`, `environmentsOf`, `worksOf`, `isThrowaway`.
  - **Knock-on:** because of this, `domain/orchestra.js:1` imports from `sync/`, which is backwards.
  - **Fix:** pure helpers go to a new `domain/journals.js`. `useHub` and `useJournalPoll` go to a new `composables/hub.js`. `sync/hub.js` keeps only scan, refresh and streams. `domain/orchestra.js` then imports from `domain/journals.js`.
- **`layout/statusline.js`** (whole file)
  - **Problem:** Agent-state and plan logic live in `layout/`. `sync/hub.js:7`, `actions/work.js:2` and `composables/planRows.js:2` import it, so sync, actions and composables all depend on `layout/`.
  - **Fix:** split it into `domain/agentState.js` and `domain/plans.js`.
    - `domain/agentState.js` gets `stateOf`, `wordOf`, `lineOf`, `currentWork`, `waitsFor`, `backgroundRun`, `named`, `queued`, `phrase`. It also absorbs `domain/agentStates.js` (`SILENT`, `SILENT_WORD`), and that file is deleted.
    - `domain/plans.js` gets `PLAN_STATES`, `NOT_STARTED`, `leadingPlan`, `barPlan`, `cardPlan`, `otherPlans`, `othersLine`, `sizeOf`, `stoppedOf`, `phaseOf`, `rowsOf`, `doneOf`, `planButton`.
  - **Also:**
    - Rename the exported `RUNNING` (:116) to `PLAN_RUNNING`. It collides with `domain/services.js:1 RUNNING`, which means something else.
    - `sizeOf` (:158-165) builds plurals by hand. Use `format/number.js counted`.
    - Delete the dead `parkedWork` (:10).
- **`layout/bar.js`**
  - **Problem:** Pure status-queue logic sits in `layout/` and is used by `composables/barLine.js` and the phone.
  - **Fix:** move it to `domain/statusQueue.js`. Rename its internal `clock` (:41) to `queueClock`, since it shadows `format/time.js clock`. Delete the dead `MINUTE` (:3).
- **`kit/StatusLabel.vue:4`**
  - **Problem:** `LIT` is the same list as `ACTIVE` in hub.js (:19). It is a domain rule hard-coded inside the kit.
  - **Fix:** StatusLabel takes a `lit` boolean prop. Its 4 users pass `isActive(state)`: `layout/JournalTile.vue`, `layout/JournalTileEnv.vue`, `pages/HubPage.vue`, `pages/SettingsEnvironments.vue`.
- **Decision for you (not merged here):** `hub.js:20 STATE_WORDS` and `statusline.js:46 WORDS` describe the same agent states but disagree on `compacting` ("Compacting" vs "Busy"). Once both live in `domain/agentState.js`, merging them is one line, but it changes visible text.

**Files** (all import rewrites except where noted):
- Moved or split: `sync/hub.js`, `layout/statusline.js`, `layout/bar.js`, `domain/agentStates.js`, `domain/orchestra.js`, `kit/StatusLabel.vue`.
- Import rewrites: `actions/work.js`, `App.vue`, `board/AgentWindow.vue`, `chat/PlanCard.vue`, `chat/RunningCommand.vue`, `chat/Thread.vue` (import line only), `composables/barLine.js`, `composables/planRows.js`, `layout/JournalTabs.vue`, `layout/JournalTile.vue`, `layout/JournalTileEnv.vue`, `layout/PlanList.vue`, `layout/PSection.vue`, `layout/StatusBar.vue`, `pages/HubPage.vue`, `pages/SettingsEnvironments.vue`, `phone/PhoneAgent.vue`, `phone/PhoneAgentSheet.vue`, `phone/PhonePlaceList.vue`, `phone/PhoneStatus.vue`.

### Batch 2: The router builds every link (wave 1)
No behaviour change. Low risk.

- **Problem:** About 30 hand-built `#/${env}/...` hrefs bypass `route.js`, which already owns `go` and `showFile`. Sites:
  - `chat/UpdateOverlay.vue:134`
  - `board/BoardRolesPanel.vue:21`, `board/BoardRoles.vue:27`
  - `layout/TopBar.vue:32,33,48,50,100`
  - `layout/Sidebar.vue:48,53,74,89,101,115`
  - `kit/Diff.vue:24`
  - `text/markers.js:11,13`
  - `resource/CheckFindings.vue:17`, `resource/Reader.vue:79`, `resource/Trace.vue:21,43`
  - `pages/Index.vue:134`, `pages/PluginCard.vue:84`, `pages/FilePage.vue:20,91`, `pages/ResourcesPage.vue:17`, `pages/BoardPage.vue:355`, `pages/FilesPage.vue:89`, `pages/CommitPage.vue:66`, `pages/OrganizationPage.vue:43`
  - `domain/updates.js:54` (`commitHref`)
- **Fix:** add an `href` object to `route.js` with `page(env, page, n)`, `file(env, path, line, sub)`, `commit(env, sha)`, `pluginPage(env, page)`, `organization(env, domain)` and `reportUpdates(env)`. Have `go` and `showFile` use it. Replace every site above.
  - `kit/Diff.vue` should not import `route`. Give it a `fileHref` function prop, passed by its two users (`pages/FilePage.vue`, `pages/CommitPage.vue`).
- **Problem:** the chip-click body ("closest `[data-peek]`, preventDefault, stopPropagation, read `dataset.peek`") is written three times: `route.js peekChip`, `chat/turnLinks.js:11-18 openChip`, `phone/peeked.js:1-11`.
- **Fix:** add `chipTarget(event)` to `route.js` (returns the peek string or `""`) and have all three call it.
- Delete the dead `route.js:69 peekIn`.

**Files:** `route.js`, the 18 component and module files listed above, `chat/turnLinks.js`, `phone/peeked.js`.

### Batch 3: One "pick, wait, then send" behaviour (wave 1)
Medium risk because the timing is user-visible. No behaviour change intended.

- **Problem:** the countdown is written three times, each defaulting to 3 seconds:
  - `kit/OptionList.vue:6,33-54`, which also reads `store.settings.ask_questions.hold` (:34), so a kit component reaches into app state;
  - `phone/PhoneQuestion.vue:12,110-118`, plus its pagehide and visibility "send on leave" at :50-52 and :120-131;
  - `phone/planGo.js:6,20-90`.
- **Fix:**
  - Create `composables/heldSend.js` with `useHeldSend({seconds, send})` returning `{left, holding, start(value), undo, sendNow}`, with optional send-on-leave.
  - OptionList gets the hold length as a prop from `resource/OptionsPicker.vue`, so the kit no longer imports `store`.
  - PhoneQuestion and `usePlanGo` both use the composable.
- **Also in these files:**
  - The inline "That didn't go through: …" strings at `planGo.js:47` and `PhoneQuestion.vue:92` should use the existing `tryAgain` in `phone/announce.js:15`.
  - Move the `HELD` offline sentence (`PhoneQuestion.vue:14`, and a near copy at `PhoneReader.vue:155`) into `announce.js`. PhoneReader is in batch 8; it picks up the constant there.
  - Rename `planGo.js:12 counted(phase)` to `phaseProgress`, and update `PhonePlanSheet.vue` and `PhonePlanStrip.vue`.
- **Watch:** OptionList uses one `setTimeout` plus a CSS hold bar. The phone uses per-second intervals. The composable must expose both the remaining seconds and the total length.

**Files:** new `composables/heldSend.js`, `kit/OptionList.vue`, `resource/OptionsPicker.vue`, `phone/PhoneQuestion.vue`, `phone/planGo.js`, `phone/announce.js`, `phone/PhonePlanSheet.vue`, `phone/PhonePlanStrip.vue`.

### Batch 4: The API client names every endpoint (wave 2)
Low risk, apart from 4b.

- **Problem: literal verbs outside the client.** About 33 `api.act(type, n, "literal")` and 5 `api.command(...)` calls in components and composables. The client already names some (`shift`, `cancelWork`, `dismissQuestion`, `moveTicket`), so the rule is half-applied. Sites:
  - `composables/revisions.js:90`, `composables/checkRun.js:10`, `composables/shares.js:34,36,41`
  - `chat/ChatNotice.vue:35`, `chat/CrewList.vue:33`, `chat/Thread.vue:347`
  - `board/Lane.vue:28`, `board/Suggestion.vue:100`, `board/NewWork.vue:221,395,420,424,425,428`, `board/AgentDrawer.vue:14`, `board/NewBoard.vue:67`
  - `resource/Comments.vue:89,97`, `resource/StageMeanings.vue:21`, `resource/ResourceActions.vue:27,47`, `resource/StartsOn.vue:49`, `resource/RuleControls.vue:24`, `resource/CheckResult.vue:43`, `resource/SequenceSteps.vue:56`, `resource/Asked.vue:18`, `resource/ShareDialog.vue:38,63`
  - `pages/PluginsPage.vue:183,188`, `pages/BoardPage.vue:89,104,186,246`, `pages/SettingsEnvironments.vue:85,95`

  Ticket `stop` and `accept_dependencies` each appear twice.
- **Fix:** add named methods to `api/client.js` and replace every site. Examples: `stopTicket`, `confirmTicket`, `acceptDependencies(n, only)`, `declineDependencies`, `stopShare`, `approveShare`, `tunnelStatus`, `shareReachable`, `shareOpens`, `questionsLinkedTo`, `planFromDoc`, `runCheck`, `closeNotice`, `editMessage`, `pinRule`, `archiveBoard`, … Spec-driven calls such as `act(type, n, word(type, "complete"))` stay generic.
- **Problem: endpoint URLs outside `api/`.**
  - `phone/PhoneViewer.vue:22` and `phone/PhoneFiles.vue:11` build `./file/...`.
  - `share/ShareApp.vue:67` builds `./files/...`.
  - `scripts/checks/one_client.py` misses these because its regex does not cover relative paths.
- **Fix:** add `phone.fileUrl(type, n, name)` and `shared.fileUrl(...)`, and extend the check's regex.
- **Problem: the same response handling written three times.** `transport.js:53-57`, `phone.js:11-15` and `shared.js:7-14` each parse `body.error || fallback`. `phone.js` also repeats a "fetch a file and check it" body three times (:32-41, :78-87).
- **Fix:** one `answered(response, fallback, ErrorClass)` in `api/transport.js`, plus one `fetchedFile(path, failText)` in `phone.js`.
- **Problem: a domain lookup inside the API client.** `api/client.js:391 stopAgentNamed` searches environment rows by title.
- **Fix:** move it to `actions/agents.js stopAgentIn(client, name)`. Its callers are `layout/JournalTile.vue:97`, `layout/JournalTileEnv.vue:57` and `layout/StatusBar.vue:157`.
- **4b, needs your decision.** `resource/Buttons.vue:17-32` re-implements button-press semantics on the client: say → message, else act or command, then a separate `set pressed`. That is three requests, not atomic, with no staleness check. The phone uses the server's `_press` (`features/phone/controller.py:457`).
  - **Option:** expose that server logic as one viewer action and have Buttons call it.
  - **Changes behaviour:** a stale press is refused, and the press becomes atomic.
  - **Needs a backend change.** Leave it out unless you want it.

**Overlaps:**
- `chat/Thread.vue` and `pages/SettingsEnvironments.vue` (with batch 1).
- `pages/BoardPage.vue` (with batch 2).
- `layout/JournalTile.vue`, `layout/JournalTileEnv.vue`, `layout/StatusBar.vue` (with batch 1).

All are separate hunks. Merge after batches 1 and 2.

### Batch 5: Phone pieces that duplicate kit pieces (wave 2)
Low to medium risk (visual). No behaviour change: each merged component keeps its look through a prop.

- **`phone/PhoneTicks.vue`**
  - **Problem:** same state logic and SVG as `kit/ReadTicks.vue`; only the colours differ.
  - **Fix:** add a `tone="bubble"` prop to ReadTicks and delete PhoneTicks.
- **`phone/PhoneChevron.vue`**
  - **Problem:** a hand-rolled SVG with 10 users. `kit/Icon.vue:27` only maps `chevron` to `arrow`.
  - **Fix:** add a real `chevron` shape and a `facing` rotation to Icon. Delete PhoneChevron and update its users: PhoneHomeBar, PhoneNotices, PhoneViewer, PhonePlanSheet, PhoneNeeds, PhonePlaceList, PhonePlanStrip, PhoneBoardList, PhonePlaceDetail, PhoneReaderBar.
  - **Visual risk:** stroke width 2 vs 1.4.
- **`phone/PhoneFold.vue`**
  - **Problem:** the same job as `kit/Folded.vue` ("Read more" vs "Show more"; folds by line count vs pixels).
  - **Fix:** give Folded a `lines` mode and a label prop, then delete PhoneFold.
- **Hand-made usage and context progress tracks**
  - **Problem:** written four times while `kit/ProgressBar.vue` and `kit/Meter.vue` exist: `chat/UsageMeter.vue:13`, `chat/AgentFacts.vue:77,91`, `phone/PhoneAgentControls.vue:127,134`. `PhoneAgentControls.vue:25` also re-clamps to 0-100 by hand.
  - **Fix:** move UsageMeter into the kit as `kit/UsageMeter.vue`, built on ProgressBar. All of the above use it, with `format/usage.js` and `format/number.js clamp`. (`layout/PSection.vue:72` is the same pattern; it belongs to batch 1, so do it as a follow-up there.)
- **Comment bylines**
  - **Problem:** the avatar + name + when header is written three times: `resource/CommentByline.vue`, `share/ShareComment.vue` (header), `phone/PhoneComments.vue:16-19`.
  - **Fix:** move CommentByline to `kit/Byline.vue` and use it in all three.
- **Tappable phone rows**
  - **Problem:** "row button + content + trailing chevron" is repeated in `PhoneBoardList.vue`, `PhoneNeeds.vue` and `PhonePlaceList.vue`.
  - **Fix:** new `phone/PhoneNavRow.vue` with a slot.
- Delete the dead `kit/FactBar.vue` (no importer anywhere, demo included).

**Overlaps:**
- PhoneViewer (batch 4).
- PhonePlanSheet and PhonePlanStrip (batch 3).
- PhonePlaceList (batch 1).
- `resource/Comments.vue` (batch 4).

All are import-line hunks.

### Batch 6: One home for each small helper (wave 2)
No behaviour change, except two items marked below.

- **`counted`**
  - **Problem:** written three times: `format/number.js:7`, `domain/helpers.js:1`, `phone/doing.js:29`. Delete the last two.
  - (`state/store.js:59 counted` means something else; it is renamed in batch 10.)
- **Chip and markdown stripping**
  - **Problem:** written four times: `text/words.js` `words`, `phone/plain.js`, `domain/documents.js:28-33` (private `plain`), `kit/ChatLine.vue:47`.
  - **Fix:** one `plainText` (strips chips, `**` and backticks) and one `withoutChips` in `text/words.js`. Delete `phone/plain.js`.
  - Rename `domain/documents.js:22 words` (splits a search query) to `searchTerms`; it collides with `text/words.js words`.
  - **Changes behaviour:** desktop copy in `chat/TurnActions.vue:6,19` would then also strip `**` and backticks, as the phone already does. Keep TurnActions on `withoutChips` if you do not want that.
- **Picture test**
  - **Problem:** written four times: `chat/Attachments.vue:10`, `phone/PhoneViewer.vue:15` (adds `heic`), `phone/PhoneFiles.vue:9`, `resource/ResourceFiles.vue:10`.
  - **Fix:** `isPicture(name)` in a new `format/files.js`.
  - **Changes behaviour** only if you adopt `heic` on desktop.
- **File size**
  - **Problem:** written three times: `layout/QuickMenu.vue:79-83`, `kit/FileSlip.vue:17-19`, `resource/FileRow.vue:11-15`.
  - **Fix:** `fileSize(bytes)` in `format/number.js`.
  - **Changes text:** they currently differ in rounding and the minimum KB. Pick one.
- **Date formats written in place**
  - **Problem:** `composables/phones.js:11 phoneTime`, `composables/shares.js:29`, `resource/PlanTimeline.vue:14-15`, `share/ShareApp.vue:151`, `resource/DocumentRow.vue:62`.
  - **Fix:** named exports in `format/time.js`. (The day-divider naming in PhoneHome moves in batch 8.)
- **`clampTo`**
  - **Problem:** `kit/MenuPanel.vue:30` duplicates `format/number.js clamp`. Use it.
- **`pause(ms)`**
  - **Problem:** written twice (`board/NewWork.vue:413`, `pages/AboutPage.vue:13`).
  - **Fix:** `wait(ms)` in `platform/timing.js`.
- **Demo replay gate**
  - **Problem:** written twice (`chat/Compose.vue:8-9`, `phone/PhoneCompose.vue:11-12`).
  - **Fix:** `replayBlocks(text)` in `platform/demo.js`.
- **Quote parsers**
  - **Problem:** two of them: `format/quote.js quoted` and `phone/quoted.js splitQuote`.
  - **Fix:** keep `quoted` (add blank-line tolerance); `phone/PhoneTurn.vue` joins the quote lines itself. Delete `phone/quoted.js`.
- **`phone/` storage**
  - **Problem:** `phone/` re-implements `composables/remembered.js` in `phone/outbox.js:18-30` (`kept`/`keep`), `phone/looked.js`, `phone/once.js`, `phone/PhoneNotify.vue:13,51` and `phone/PhoneCompose.vue:20` (raw `removeItem`).
  - **Fix:** add `forget(key)` to `remembered.js` and use `remembered`, `remember` and `forget` throughout.
  - Stored values stay readable, since `JSON.parse("1")` and `JSON.parse("123")` both work.
- **`phone/cache.js`**
  - **Problem:** its exported `remember` collides with `remembered.js remember`.
  - **Fix:** rename to `cache`/`cached` and update `PhoneReader.vue:19`.
- **`composables/agentLinks.js:1-14`**
  - **Problem:** pure link classification (`kindOf`, `standing`) lives in a composable, and both names collide (`domain/chatShown.js:52 kindOf`, `domain/documents.js standing`).
  - **Fix:** move it to `domain/links.js` as `linkKind` and `standingLink`.
- **`pages/FeaturePanel.vue:15 saved`**
  - **Problem:** copies `pages/featureSettings.js:6`. Export it once.
- **Dead code:** `domain/records.js:84 toldToUser` and `domain/helpers.js:52 helperView`.

**Files:**
- `format/*`, `text/words.js`, `platform/demo.js`, new `platform/timing.js`
- `domain/helpers.js`, `domain/documents.js`, `domain/records.js`, new `domain/links.js`
- `phone/doing.js`, `phone/plain.js`, `phone/quoted.js`, `phone/outbox.js`, `phone/looked.js`, `phone/once.js`, `phone/cache.js`
- `phone/PhoneNotify.vue`, `phone/PhoneCompose.vue`, `phone/PhoneTurn.vue`, `phone/PhoneHold.vue`, `phone/PhoneAtWork.vue`, `phone/PhoneAtWorkChip.vue`, `phone/PhonePlaceDetail.vue`, `phone/PhoneFiles.vue`, `phone/PhoneViewer.vue`
- `chat/Attachments.vue`, `chat/TurnActions.vue`, `chat/Compose.vue`, `chat/SubagentChat.vue`
- `kit/MenuPanel.vue`, `kit/FileSlip.vue`, `kit/ChatLine.vue`
- `resource/FileRow.vue`, `resource/ResourceFiles.vue`, `resource/PlanTimeline.vue`, `resource/DocumentRow.vue`, `resource/AgentLinks.vue`
- `layout/QuickMenu.vue`
- `composables/phones.js`, `composables/shares.js`, `composables/agentLinks.js`
- `pages/FeaturePanel.vue`, `pages/featureSettings.js`, `pages/DocumentLibrary.vue`, `pages/FilesPage.vue`, `pages/AboutPage.vue`
- `share/ShareApp.vue` (date only)

**Overlaps:** PhoneViewer, PhoneFiles and ShareApp (batch 4) and PhoneHome's import of `plain` (batch 8). Merge after batch 4.

### Batch 7: Split desktop `chat/Thread.vue` and share one stick-to-bottom scroller (wave 3)
Medium to high risk: scroll behaviour. No behaviour change: each caller passes its current threshold.

- **Problem:** "keep the view pinned to the bottom when near it, count what arrived while away, jump back" is written five times, with thresholds 8, 60, 24, 40 and 120:
  - `composables/follow.js` (FileFeed)
  - `composables/transcript.js:50-53`
  - `kit/ChatPanel.vue:16-19`
  - `chat/Thread.vue:219-345` (`toBottom`, `holdBottom`, `watchScroll`)
  - `phone/PhoneHome.vue:142,289,382`
- **Fix:** grow `follow.js` into `useStickToBottom(scroller, {near, glide})` and use it in all five. PhoneHome adopts it in batch 8.
- **Problem:** Thread.vue (419 script lines) holds about seven jobs: pane routing, dump pane, docks, seen-marking, older paging, scroll, and send/promise.
- **Fix:**
  - `useThreadSend` gets `post`, `promised`, `measured`, `pin` (:345-395).
  - `chat/ThreadDocks.vue` gets the plan, report and dump docks with their computeds (:109-114).
  - `useDumpPane` gets :56-95.
  - Thread keeps the composition.
- **Problem:** `chat/questionView.js` and `chat/updateView.js` are the same "overlay open/close with owner" module written twice.
- **Fix:** one `overlayView()` factory in `state/overlays.js`. Users: Thread, QuestionOverlay, UpdateOverlay, PlanCard and the other openers.

**Files:** `composables/follow.js`, `composables/transcript.js`, `kit/ChatPanel.vue`, `chat/Thread.vue`, new `chat/ThreadDocks.vue`, `chat/questionView.js`, `chat/updateView.js`, `chat/QuestionOverlay.vue`, `chat/UpdateOverlay.vue` (overlaps batch 2), `chat/FileFeed.vue`.

### Batch 8: Split `phone/PhoneHome.vue` (wave 3, after batch 7)
Medium risk. No behaviour change.

- **Problem:** PhoneHome (575 script lines) does feed polling and older paging, the page stack and history, arrival animation, notices, reactions, quote and copy, the hold menu, offline handling and version reload.
- **Fix:**
  - `phone/feed.js usePhoneFeed` gets :118-210, :293-380.
  - `phone/pageStack.js usePageStack` gets `entry`, `saved`, `open`, `back`, `popped` (:385-440).
  - `phone/arrivals.js` gets :279-290.
  - `phone/PhoneTurnMenu` actions get react, quote and copy (:474-560).
  - It also adopts `useStickToBottom`.
- **Problem:** turn predicates are written twice: `fromDesktop` and `peer` in `PhoneHome.vue:238-240`, `elsewhere` and `between` in `phone/PhoneTurn.vue`.
- **Fix:** move them into `phone/turns.js`, along with the day-divider naming (:225-235, which goes to `format/time.js dayName`).
- **Problem:** reading progress is written twice (`phone/PhoneReader.vue:173`, `share/ShareApp.vue:138-142`).
- **Fix:** `composables/readProgress.js`.
- **Renames:**
  - `phone/waiting.js ordered` → `waitingInOrder` (it collides with `domain/orchestra.js:138 ordered`).
  - `phone/reveal.js reveal` → `scrollIntoRoom` (it collides with `composables/reveal.js`).

**Files:** `phone/PhoneHome.vue`, `phone/PhoneTurn.vue`, `phone/PhoneReader.vue`, `phone/waiting.js` and its users (PhoneNeeds, PhoneWaiting), `phone/reveal.js` (PhoneViewer), new phone modules, `share/ShareApp.vue` (progress only).

### Batch 9: Window listeners and file hand-in (wave 3)
Low risk. No behaviour change.

- **Problem:** the same window-level file hand-in (dragover, drop, paste, plus mounting a `LISTENERS` map) is written twice: `board/NewBoard.vue:99-119` and `board/NewWork.vue:251-262,350-363`.
- **Fix:** `composables/fileHandIn.js useFileHandIn({active, take, pastedText})`.
- **Problem:** "add a window listener on mount, remove it on unmount" is written by hand in about 25 places.
- **Fix:** `composables/windowEvent.js useWindowEvent`. Main sites:
  - `App.vue:162-170`
  - `composables/slashFocus.js`, `composables/outside.js`
  - `phone/keyboard.js`
  - `layout/AwayCard.vue:24`
  - `pages/SettingsPage.vue:182`, `pages/BoardPage.vue:70`
- **Problem:** Escape-to-close is wired by hand in 8 places: `QuestionOverlay.vue:96`, `UpdateOverlay.vue:137`, `AwayCard.vue:21`, `FocusStage.vue:15`, `Lightbox.vue:12`, `kit/closing.js:11`, `composables/tour.js:55`, `composables/paneDrag.js:26`.
- **Fix:** one `useEscape(close, when)`.
- **Problem:** `actions/anchored.js` is a composable in the wrong folder, and its `toggle` is copied at `chat/FeedBar.vue:33` and `layout/PageJump.vue:17`.
- **Fix:** move it to `composables/anchored.js` and use it in both.

**Overlaps:** several of these files (do it after waves 1 and 2).

### Batch 10: The store holds state only (wave 4, run alone)
Low risk but wide (about 45 importers). No behaviour change.

- **Problem:** `state/store.js:55-71` holds selectors, not state:
  - `types`, `meta`, `word`, `label`;
  - `counted`, whose name also collides with the phrase helper;
  - the lead `agent`;
  - `feedOn`, `boardOn`, `steered`, `autoOn`, `workMode`, `sharingOn`.
- **Problem:** `store.js:2` imports `composables/remembered.js`, so state depends on composables.
- **Fix:**
  - `meta`, `word`, `label`, `types` go to `domain/spec.js`.
  - Feature flags go to `composables/settings.js`. Merge `composables/viewerSetting.js` into it, plus the optimistic save in `actions/work.js setAuto` and the `settings[key] || {}` reader (`featureSettings.js:6`).
  - The lead agent goes to `composables/leadAgent.js`.
  - Rename `counted` to `openCount`.
  - Move `remembered.js` to `platform/storage.js`.
- **Problem:** `layout/Sidebar.vue:31` and `pages/SettingsEnvironments.vue:27` both define the same `live(name)`.
- **Fix:** one `agentRunningIn(name)` in `composables/settings.js` or the same new selectors module.
- **Problem:** UI state is scattered as loose refs: `state/prefill.js`, `composables/litCard.js`, `chat/agentStop.js stopsOpen`, `phone/wanted.js`.
- **Fix:** move them to `state/ui.js`.
- **Optional, same wave:**
  - `domain/records.js`, `domain/ticketAgents.js:22` and `text/cards.js` read `sync/rows` directly, so the domain depends on sync. The store-reading queries (`open`, `byRef`, `linkedTo`, `waitsOn`, `planOf`, `groupOf`, `missed`, `ticketOf`) could move to `sync/queries.js`, leaving only pure functions in `domain/`.

### Batch 11: Put each file in the right folder (wave 4)
Pure moves. No behaviour change.

| From | To | Why |
|---|---|---|
| `poll.js` | `composables/` | It is a composable. |
| `agents.js` | `domain/` | Domain helpers. |
| `faults.js` | `platform/` | Error reporting. |
| `identity.js` | `state/` | Derived identity state. |
| `kit/copy.js` | `platform/clipboard.js` | Not a component. |
| `kit/closing.js` | `composables/` | It is a composable. |
| `composables/fitLines.js`, `hydrate.js`, `passage.js`, `pointer.js` | `platform/` | DOM utilities, not composables. |
| `board/AgentGrid`, `AgentWindow`, `AgentHome`, `AgentPanes`, `AgentDrawer`, `AgentStrip`, `WorkingAgents`, `TicketAgent`, `AgentTodo` | `agents/` | Agent views, not kanban. |
| `pages/PaneMenu*` (13 files) | `panes/` | Not pages. |
| `pages/Rail*`, `Section`, `UList`, `SkillsRow` | a non-page component folder | Not pages. |
| `resource/TunnelLogin.vue`, `TunnelProblem.vue` | next to `pages/SettingsTunnel.vue` | Not resource views. |
| `organization/RoleCard.vue` (a one-file folder) | next to its users in `board/` and `pages/` | One-file folder. |
| `chat/helpers.js` (a `useHelpers` poll) | `composables/helpers.js` | It is a poll composable, and the name collides with `domain/helpers.js`. |

### Batch 12, optional: one error-and-busy wrapper (wave 4)
Low risk. No behaviour change. Medium value.

- **Problem:** "clear error, try, set `x.value = e.message`" is written 62 times in 44 files. `layout/PlanList.vue:26` and `layout/StatusBar.vue:107` are identical bodies. `actions/anchored.js run` and `composables/service.js` are partial versions.
- **Fix:** one `useAttempt()` returning `{error, busy, attempt(fn)}`, adopted file by file.

---

### Critical Files for Implementation
- /Users/jessegall/projects/agent-journal/src/web/src/sync/hub.js
- /Users/jessegall/projects/agent-journal/src/web/src/layout/statusline.js
- /Users/jessegall/projects/agent-journal/src/web/src/route.js
- /Users/jessegall/projects/agent-journal/src/web/src/api/client.js
- /Users/jessegall/projects/agent-journal/src/web/src/state/store.js