commit 73a5797e82b86bb248c6a12bc737da3387a74f14
parent f2123449ace96b7c09b53fb6ab223caad734d472
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 30 Aug 2026 13:11:09 -0400
plans: slice 5 shipped, and the docs say so
CHANGELOG bullet, "Slice 5, as shipped" in the IA doc (the three operator
decisions, the sixteen findings, what stayed), STATE.md #13 with the new
"recommended next" — the UI slices are done, so what is left is the transcode
band, Phase 6 and slice 9 — and a FACTS section for the seams a later reader
would otherwise re-derive: the has-rule anchoring and the query merge, the
metadata template rule, revalidatePath literal vs "layout", seedsSiteParam's
reason beside the new path rule, and why none of the three co-resident site
titles on /sites collides in Playwright.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
4 files changed, 251 insertions(+), 11 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A site has tabs, and the family has one page.** Charts, Search aliases, Deploy, Build and Homepage were five sidebar entries beside *Sites*, three of them reading the site from a `?site=` parameter the sidebar picker had to seed, one of them (Build) about no site at all, and one (Homepage) about the family's own hub. A site is one thing now: **`/sites/<id>` is Settings · Charts · Search aliases · Publish**, the site named in the path, the picker following it (and Dashboard and Channels following the picker). **`/sites` is the family page**: the list, then *Release notes*, *Build all sites* with the Basic/Docker mode, the *Hub*, and the *Pool* — the corpus-wide index, stats, sidecar and archive jobs — folded under a disclosure. Search aliases keep both sections on the site's tab: the global dictionary and the site's overrides. Every button, label and log is unchanged; "Select a specific site from the sidebar" is gone because a site's page always has one. The five routes redirect — a `?site=<id>` bookmark lands on that site's tab (the query rides along), `?site=__all__` and the bare routes on `/sites`; a bookmark to a deleted site 404s there exactly as `/sites/<id>` does. The Sites group is one entry; the nav is **eleven**, the IA doc's end state. **Nothing on disk changes.**
- **Jobs is one list.** `/jobs`, `/jobs/active` and `/jobs/queue` were three pages over three shapes — the directory listing, the registry's running work with its progress bars, and the scheduler's slots with their stuck reasons — and the same job was drawn three ways or hidden by one page's filter. `/jobs` is one table now: **one row per job**, the live head first (running, then queued in queue order with *next in line* / *2nd in line*, then anything that finished in the last half-minute) and the paged history below it, refreshed as before. A running row carries its per-task progress bars, its ETA and its *Transcripts: n / m* inside the Status cell; a queued row its Promote / ↑ / ↓; a stuck slot its `stuck · reason` badge, the last line of its log and **Force-release**, with **Reap stuck** on the health line above (active queues · running · queued · stuck · workers) and the lane strip above that. The kind/status/search filters apply to every row, live ones included. **The scheduler's drift check rides the live payload now**: every surface that draws it — this page, the dashboard, the monitor widget, `/api/jobs/active` — frees a running slot whose record is finished or gone the moment it sees one, after drawing it once. The channel, video, build and operation pages' *Active jobs* cards come off the same builder, so they show the progress bars they used to drop. `/jobs/active` and `/jobs/queue` redirect; the sidebar's *Active* entry and its running-count badge are gone (*Jobs* still counts running + queued); `/api/jobs/active` never moved. **Nothing on disk changes.**
- **Sync is an operation on the board, and the schedule is its page.** The pipelines rail's first row is **Sync** — channels on a cadence, due now, overdue, in the same four state words every other row uses — because a corpus that has stopped noticing new videos is not idle, it is broken, and until now that fact sat in a panel of its own below the rail. Its page is **`/operations/sync`**, which is the schedule that used to be at `/scheduler`: the per-channel cadence table, the bulk retune bar, *Run scheduler now* and the recent ticks, every control unchanged — with **every Sync scheduler setting below it**, the fieldset that used to be on **Settings**, saved by one button. `/scheduler` redirects, so bookmarks land; the API paths and the `pnpm sync:tick` cron client never moved; the sidebar's separate *Schedule* entry is gone, and searching the command palette for "schedule" or "cadence" finds Operations. The two **Storage chores** that ride the same heartbeat — the keep-latest deletion check and the saved-video backup — are named as such on the console, in a tick's own summary line and on **Saved videos**: they are not sync, they just share the one timer the editor has. **Transcription workers are configured on Workers now**, directly under the live list, with their own *Save workers* — so seeing a worker and changing it are no longer two pages. **Settings** keeps the machine and the site, and points at both. The reason a lane is not working is now one table rather than two. **Nothing on disk changes; no setting is renamed.**
- **A video's page shows one panel per operation, not just the digest's.** Speaker diarization and both *Speaker names* operations wrote records that no per-video screen could show — and for diarization that record is often the only surviving evidence of audio the cleanup sweep has since deleted. Each operation now has its own panel: what produced its record (engine, models, threshold, which transcript the names were read from, how many chunks survived), and the **same state word the channel's own counts use** — *current*, *stale*, *partly stale*, *not generated*, *held — …* with the reason spelled out, or *waiting on …* naming what has to happen first. Each speaker panel carries a **Run** button that runs **that one video** on the speaker lane, in front of the sweep's work; the channel's Speakers stage still runs the whole channel. A record whose feature has since been switched off is still shown, marked *switched off*, with no Run button — hiding it is how a record that cost audio nobody has any more becomes invisible. Both *Speaker names* operations read the one `attribution.json` they share, so their panels differ in the state word and not in the record; the `method` line says which lane wrote it. The digest panel is unchanged apart from its heading, which is now **Digest** rather than *AI digest* — the name the rest of the editor already uses for it — and the page no longer works out the digest's freshness a second way of its own, so a video's panel and its channel's count cannot disagree. A re-run of a per-video run from **Jobs** stays per-video. **Nothing on disk changes.**
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -2985,3 +2985,120 @@ is the one list; `components/ReapStuckButton.tsx` is the queue strip's action. D
while reading only `status` and `kind`. The channel page no longer holds `JobRecord`s, so the
input takes the SHAPE (`ReadonlyArray<{ status: string; kind: string }>`) instead. Its other
caller (`videos/page.tsx` → `videoRowsServer.ts`) still passes records and is untouched.
+
+## Verified 2026-08-30 — editor IA slice 5 seams (a site has tabs; the family has one page)
+
+Read-only at `2a8081e`, implemented at `140212a` → `df9b839`. Trust these over re-deriving.
+
+**What the five pages actually were** (the IA bullet's premise, "all per-site facts already;
+only their routes say otherwise", is wrong for two of them):
+
+| page | scope | reads | writes | `?site=` |
+|---|---|---|---|---|
+| `/charts` | **per-site** templates; preview DATA = last-composed site | `readTemplates(paths, siteId)` → `sites/<id>/chart-templates.json`; `useStats`/`useSummaries` fetch `/stats/*` = `export/public/stats` = whichever site `compose-site` ran last | `saveDefaultDashboardAction`, no revalidate | yes, REQUIRED one site |
+| `/aliases` | **global + per-site overlay** | `readGlobalAliases` → `transcripts/search-aliases.json`; `readSiteAliases` → `sites/<id>/search-aliases.json`; merged at compose, per-site id SHADOWS global (`aliasesStore.ts:68-73`) | `saveGlobalAliasesAction`, `saveSiteAliasesAction`, no revalidate | optional |
+| `/deploy` | **per-site target + global chrome** | `listSites`; `export/CHANGELOG.md`; `export/out/index.html` mtime (`resolveOutDir` ignores siteId); `settings.buildPipeline.mode` | `cutReleaseAction`, `setBuildModeAction`, `deployExportAction(siteId)` + four from `buildAction` | optional |
+| `/build` | **corpus-wide, no site notion at all** | `activeQueueNames()`, `liveJobRows(BUILD_KINDS)` — no `searchParams`, no `listSites` | eleven actions in `buildAction.ts` | **never** |
+| `/homepage` | **global — the hub's identity** | `getHomepageConfig` → `sites/_homepage/homepage.json` | `saveHomepageConfigAction` | **never**; page was sync |
+
+**The seven import edges, and where each went.** `build/` and `deploy/` were already one module
+in two directories: `build/buildAction.ts:34` imported `deploy/buildDeployCore`, and four
+`deploy/components/*` imported `build/buildAction`. Now: server modules in
+`editor/app/sites/lib/` — `buildAction.ts`, `deployAction.ts`, `cutReleaseAction.ts`,
+`buildModeAction.ts`, `homepageActions.ts`, `chartsActions.ts`, `aliasesActions.ts` (all
+`"use server"`) plus `buildDeployCore.ts` (deliberately NOT, its header says why). Components
+FLAT in `editor/app/sites/components/`, so every moved file has the same import shape:
+`../lib/x`, `../../components/QueueControl`, `../../jobs/actions` — the last two byte-identical
+to what they were from `deploy/components/`. `changelog/page.tsx:12` is the one outside
+repoint (`CutReleaseForm`, `workspace="editor"`, driven by `cut-release.spec.ts`).
+`SiteTabs.tsx` lives under `sites/[siteId]/components/` (the `ChannelTabs` precedent). Neither
+`sites/lib` nor `sites/components` has a `page.tsx`, so neither is routable and neither can
+shadow a site id — and **no static sibling of `[siteId]` was added**, because `/sites/publish`
+would shadow a site called `publish` (`SITE_ID_RE = /^[a-z0-9][a-z0-9-]*$/`).
+
+**`has`-rule anchoring and the query merge (Next 16.2.3).** `matchHas` compiles the value as
+`new RegExp(`^${hasItem.value}$`)`
+(`next/dist/shared/lib/router/utils/prepare-destination.js:101`) — ANCHORED, so
+`?site=__all__` fails `[a-z0-9][a-z0-9-]*` and falls through to the bare rule. The named group
+becomes a destination param (validator: `next/dist/lib/load-custom-routes.js:210-263`). The
+initial query is MERGED into the destination (`prepare-destination.js:278-284`) and stringified
+into `Location` (`next/dist/server/lib/router-utils/resolve-routes.js:509-517`), so
+**`/charts?site=testsite` lands on `/sites/testsite/charts?site=testsite`** — the query is not
+stripped. Any spec regex on a captured redirect must therefore end `(\?|$)`. Redirects are
+checked BEFORE the filesystem, so a redirect for a route that still has a page hides the page:
+each commit added only the redirects for the pages it deleted.
+
+**The metadata template rule.** A layout's `title.template` applies to CHILD segments and NOT
+to a `title` in the same segment's `page.js`
+(`node_modules/next/dist/docs/.../generate-metadata.md:284-287`). So
+`sites/[siteId]/layout.tsx` exports
+`{ default: `${siteId} — Sites`, template: `%s — ${siteId} — Sites` }`: the index page exports
+no title at all and gets `default` (today's string, unchanged), and each tab exports a bare
+static `title` ("Charts", "Search aliases", "Publish") and gets templated.
+
+**`revalidatePath` literal vs `"layout"`.** A literal path invalidates only that page
+(`revalidatePath.md:38`, `:151` "will not invalidate pages beneath"); `type: "layout"`
+invalidates the layout, nested layouts and every page beneath (`:39`, `:155-162`).
+`saveSiteAction`'s `revalidatePath(`/sites/${siteId}`)` therefore did NOT cover the tabs by the
+letter — it worked only through the "temporary" server-function behaviour that refreshes every
+visited page. It is `revalidatePath("/sites/[siteId]", "layout")` now (the h1 above every tab
+reads `siteTitle`, which that form edits). `buildStatsAction`'s `/charts` became
+`revalidatePath("/sites/[siteId]/charts", "page")` (precedent: `channels/actions.ts:312`).
+`revalidatePath("/sites")` now has **eight** callers (was five).
+
+**`seedsSiteParam` and the path rule beside it.** `SiteScopeSelect.tsx`'s
+`seedsSiteParam = !pathname.startsWith("/sites")` is UNCHANGED and its reason still holds: the
+mount effect's `replace()` clobbered an in-flight push to `/sites/<id>` (a link click on the
+list), bouncing the user back to `/sites?site=<id>`. Beside it now: `siteIdFromPathname()` in
+`app/lib/activeSite.ts` — a pure `SITE_PATH_RE = /^\/sites\/([a-z0-9][a-z0-9-]*)(?:\/([a-z-]+))?\/?$/`
+that rejects `new` (a static route that beats `[siteId]`) and anything deeper than a tab. The
+id pattern is `SITE_ID_RE` **re-spelled**, because `common/lib/site.ts` imports `node:fs` and
+`activeSite.ts` must stay importable from the client picker. On a site path the picker's value
+IS the path segment, the reconcile effect writes it to localStorage (so Dashboard and Channels
+follow), `onChange` does `router.push("/sites/<other>/<segment>")` and "All sites" pushes
+`/sites`.
+
+**`BUILD_KINDS` is wrong in both directions** and was moved verbatim, with a comment saying so:
+six kinds (`build-index`, `build-stats`, `build-export`, `normalize-transcripts`,
+`archive-transcripts`, `archive-combined-transcripts`) — MISSING the three live-chat kinds the
+Pool's own buttons enqueue, and INCLUDING `build-export`, which is enqueued from a site's
+Publish tab and never from here. Not fixed by slice 5.
+
+**`_homepage` can never be a `[siteId]`.** `HomepageConfig` lives at
+`sites/_homepage/homepage.json` — inside `sitesDir`, with a leading underscore that
+`isValidSiteId` rejects. That is why the Hub is a section of `/sites` and not a tab.
+
+**The hub's form is not what deploys the hub.** The `homepage/` package's own `deploy` script
+hardcodes `--project-name archilyzer`, so the form's `cloudflareProject` field is NOT what
+`pnpm deploy:homepage` uses, and no editor action, job or button builds or deploys the hub.
+Pre-existing; slice 5 does not touch the script. Adjacent globals that are NOT the same field:
+`HomepageConfig.siteUrl` (the Hub section) vs `SiteSettings.homepageUrl` ("Family hub URL" on
+`/settings`) vs per-site `Site.hubUrl` (`SiteForm.tsx`).
+
+**`idBaseUrl` is absolute**, so moving the charts client changed nothing about its data:
+`useStats`/`useSummaries` fetch `${idBaseUrl(origin)}/stats/…`
+(`common/components/statsCache.ts:28,44`), served through `next.config.ts`'s
+`/stats|/summaries|/transcripts → /exported/…` rewrites. The preview therefore still draws
+whichever site `compose-site` ran last, which the Charts tab's intro now says out loud.
+
+**`getSite` throws on a malformed id** (`common/lib/site.ts:356`, sync), which is why the tab
+pages do not call it directly: `sites/[siteId]/lib/siteCache.ts` wraps
+`isValidSiteId(...) && listSiteIds(...).includes(...)` and returns `Site | null`, React
+`cache()`d so the layout and the tab page share ONE read per request. The layout's `notFound()`
+is the one 404 for `/sites/<missing>` and every tab under it; each tab page guards through the
+same read (the channel page does likewise). There is no `loading.tsx` anywhere under `sites/`
+— in this app one turns `notFound()` 404s into 200s.
+
+**e2e labels newly co-resident on `/sites`, and why none collides.** A site's title now appears
+THREE times on that page: the picker `<option>`, the list `<Link>`, and `BuildSitesPanel`'s
+visible `<span>{s.siteTitle}</span>` beside its `aria-label="Select <title>"` checkbox.
+`sites-crud.spec.ts` survives because it already scopes by
+`getByRole("link", { name: /Alpha Site/ })`, and its `getByText("Doomed")).toHaveCount(0)` runs
+AFTER the delete, when all three are gone. Its `getByRole("button", { name: /delete/i })` stays
+unique: none of the ~15 buttons added to `/sites` contains "delete".
+`getByRole("button", { name: "Build index" })` is a SUBSTRING match and still unique there
+("Build all sites", "Build selected", "Build stats dataset" do not contain it), and
+`getByLabel("Build index output")` is route-independent. **`getByRole` ignores what a closed
+`<details>` hides**, so anything inside the Pool disclosure or "Individual steps" needs the
+click first — which is why `e2e/helpers.ts` grew `buildIndex(page)` (goto `/sites`, click
+"Pool jobs", press, assert "Done") and now imports `expect` as a value.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,7 +3,21 @@
The working memory for the local-AI derived-corpus work. Rewritten at the end of every
session, before context is cleared. See [`README.md`](README.md) for the protocol.
-**Last updated:** 2026-08-30 — **editor IA slice 8c shipped** (`2226680` → `21feed3`):
+**Last updated:** 2026-08-30 — **editor IA slice 5 shipped** (`140212a` → `df9b839`):
+**a site has tabs, and the family has one page.** Charts, Search aliases, Deploy, Build and
+Homepage were five sidebar entries beside *Sites*, three reading the site from a `?site=`
+param the picker had to seed, one about no site at all, one about the family's own hub.
+`/sites/<id>` is now **Settings · Charts · Search aliases · Publish** — a layout, the site
+named in the PATH, and the picker reading that path through a new pure `siteIdFromPathname()`
+so the two can never disagree (it writes it to storage, so Dashboard and Channels follow).
+`/sites` is the family page: the list, *Release notes*, *Build all sites* with the
+Basic/Docker mode, the *Hub*, and the *Pool* — the corpus-wide jobs — under a disclosure.
+The five routes 307, and a `?site=<id>` bookmark rides a `has` rule to that site's tab.
+`build/` and `deploy/` — already one module in two directories — are `sites/lib` +
+`sites/components`. The nav is **eleven**, the IA doc's end state, held by `nav.test.ts`; the
+IA doc's UI slices are complete. **Nothing on disk changes.** See "Slice 5, as shipped" in
+`editor-operations-ia.md`.
+Previously: 2026-08-30 — **editor IA slice 8c shipped** (`2226680` → `21feed3`):
**`/jobs` is one list, and slice 8 is complete.** The design question #12 asked first — what
is a ROW — is answered *a row is one job*, whichever of three places knows about it: a registry
record, a `.log` + sidecar the registry has forgotten, or a scheduler slot whose record was
@@ -304,11 +318,17 @@ nothing renders.
IA slice 8c seams". The design question was answered **"a row is one job"** — and because
it was, there is no mode: one list, live head and paged tail, one `<tr>` per job. Slice 8
is complete.
-
-**Recommended next (editor IA).** The remaining candidates are **slice 5** (Sites — Charts,
-Search aliases, Deploy, Build and Homepage fold into `/sites/[siteId]`), the **transcode band**
-(`EXTERNAL_BAND_IDS` to three, which needs the snapshot writer to record a transcode
-population), and **Phase 6**.
+13. ~~**Editor IA slice 5** (Sites absorb the five)~~ — **DONE 2026-08-30**, `140212a` →
+ `df9b839`. Plan: [`editor-ia-slice-5.md`](editor-ia-slice-5.md); outcome: "Slice 5, as
+ shipped" in `editor-operations-ia.md` and the FACTS section "Verified 2026-08-30 — editor
+ IA slice 5 seams". A site has tabs and the family page has the globals; the five satellite
+ routes 307. **The nav is eleven** — the IA doc's end state — and the IA doc's UI slices are
+ complete.
+
+**Recommended next (editor IA).** The UI slices are done; what is left is not UI. The
+candidates are the **transcode band** (`EXTERNAL_BAND_IDS` to three, which needs the snapshot
+writer to record a transcode population per channel — a snapshot-shape change, so it is its
+own plan), **Phase 6**, and **slice 9**, which is gated on the sweep running in production.
---
diff --git a/plans/editor-operations-ia.md b/plans/editor-operations-ia.md
@@ -69,7 +69,7 @@ Four groups, eleven top-level entries, down from three groups and nineteen:
- **Corpus** — Dashboard, Channels, Review
- **Operations** — Operations *(Schedule folded in, 2026-08-29: it is `/operations/sync`)*
-- **Sites** — Sites *(+ Charts, Search aliases, Deploy, Build, Homepage until slice 5)*
+- **Sites** — Sites *(Charts, Search aliases, Deploy, Build and Homepage folded in, 2026-08-30)*
- **Machine** — Jobs *(Active and Queue folded in, 2026-08-30)*, Workers, Cleanup, Saved videos, Settings, Changelog
**The rule, and it is umtool's rule, not a new one.** `umtool/components/AppNav.tsx` states
@@ -144,10 +144,15 @@ dependencies allow. Sizes are S/M/L.
it. e2e: `actionable.spec.ts`, `cleanup-actionable.spec.ts`, `site-scope.spec.ts:101`,
`backfill.spec.ts:769`, `attribution.spec.ts:358`, `navigation.spec.ts:38`. **M–L, medium
risk** — dashboard numbers move, and the changelog must say so.
-5. **Sites absorb charts / aliases / deploy / build / homepage.** `/sites/[siteId]/{charts,
- aliases,publish}`; `?site=` stays for Dashboard and Channels. Deletes five pages. e2e:
- `build.spec.ts`, `deploy-page.spec.ts`, `site-scope.spec.ts:63-87`, `aliases.spec.ts`,
- `channel-build-toggle.spec.ts:17`. **M.**
+5. **Sites absorb charts / aliases / deploy / build / homepage — SHIPPED** (`140212a` →
+ `df9b839`; see "Slice 5, as shipped" below). The bullet's premise — "all per-site facts
+ already; only their routes say otherwise" — was wrong for two of five: `/build` has no site
+ notion at all, and `/homepage` edits `sites/_homepage/homepage.json`, which `isValidSiteId`
+ rejects, so the hub can never be a `[siteId]`. `/deploy` was half and half. What shipped:
+ the per-site halves are TABS (`/sites/[siteId]/{charts,aliases,publish}`), the family's
+ halves — Release notes, the batch build with its mode, the Hub and the Pool — are sections
+ of `/sites`. `?site=` stays for Dashboard and Channels; on a site's own pages the PATH is
+ the selection and the picker follows it. Deletes five pages. **M.**
6. **The video page: one panel per operation — SHIPPED** (`6986efc` → `d5ca90a`; see
"Slice 6, as shipped" below). `DigestPanel.tsx` is the digest's BODY inside a generic
`OperationPanel`, built per registry entry from `inspectVideoOperations`; attribution and
@@ -680,3 +685,100 @@ The widget's own `JobRow`. `PulsePayload.runningJobs`. `/api/jobs/active`. `STUC
constant, not a setting. The reorder spec's effect assertion. The `gap-0.5` climb from a task
bar to its elapsed timer — the bars are a shared component, and the table's Status stack is
`gap-1` so the nearest `gap-0.5` ancestor is still the task's own wrapper.
+
+## Slice 5, as shipped
+
+Four commits after the plan ([`editor-ia-slice-5.md`](editor-ia-slice-5.md), `5660fae`):
+`140212a` (a site has tabs; Charts and Search aliases are two of them) → `ffd5ac6` (Publish is
+the site's tab; the family page cuts the release and batches the builds) → `a756049` (the pool
+runs from the family page) → `df9b839` (the hub is the family's, and the Sites group is one
+entry) → this docs commit. This is the LAST UI-only slice and the one that reached the nav's
+end state: **eleven top-level entries, four groups**, with `nav.test.ts` holding it there.
+
+**Three operator decisions, taken before the plan was written:**
+
+- **(A) The globals live on `/sites`, the family page** — not on a site, and not on a new
+ static sibling of `[siteId]` (which would shadow a site with that id). `/sites` is the list
+ PLUS Release notes, Build all sites with the Basic/Docker mode, the Hub and the Pool. It was
+ needed because two of the five pages have no site at all and a third is half the family's:
+ a literal "fold them into `[siteId]`" had nowhere to put them.
+- **(B) The Search aliases tab renders BOTH sections** — h2 *Global* and h2 *This site — <id>*,
+ exactly as `EditorAliasesClient` already drew them. Needed because aliases are a global
+ dictionary with a per-site overlay (a per-site id SHADOWS the global one at compose time), so
+ a tab showing only the overlay would hide the thing it overlays.
+- **The nav end state is one Sites entry, eleven total**, and the five routes 307
+ (`permanent: false`, the "TEMPORARY" rule in `next.config.ts`). Needed because the redirects
+ are the second half of umtool's fold rule, and a permanent redirect on a self-hosted admin
+ surface is a support call with no remedy.
+
+**Sixteen places the slice-5 bullet could not be implemented literally, and what happened
+instead.** The census is in FACTS.md ("Verified 2026-08-30 — editor IA slice 5 seams").
+
+1. **"They are all per-site facts already" is wrong for two of five.** `/build` had no
+ `searchParams`, no `listSites`, eleven corpus-wide actions; `/homepage` edits
+ `sites/_homepage/`. `/deploy` was half and half. → decision (A).
+2. **Aliases are a dictionary with an overlay.** → decision (B).
+3. **The picker and the path disagreed on `/sites/<id>/…`.** `SiteScopeSelect`'s value was
+ `resolveActiveSite(searchParams.site)`, so on a site's page it showed whatever was stored
+ last. → a pure `siteIdFromPathname()` in `lib/activeSite.ts` (six node:test cases): on a
+ site path the picker's value IS the path segment, the reconcile effect writes it to
+ localStorage so Dashboard and Channels follow, `onChange` pushes `/sites/<other>/<segment>`
+ and "All sites" pushes `/sites`. `seedsSiteParam` and its reason stay untouched.
+4. **`?site=` is routed at config level and the query SURVIVES the redirect.** A `has` rule
+ whose value is `(?<site>[a-z0-9][a-z0-9-]*)` — Next anchors it, so `?site=__all__` misses
+ and falls to the bare rule — with `:site` in the destination. The matched query is merged
+ into the destination, so `/charts?site=a` lands on `/sites/a/charts?site=a`: harmless (the
+ tab reads `params.siteId`), but every spec regex on a captured redirect is `(\?|$)`.
+5. **Layout mechanics.** Layout `params` is a Promise; layouts do not rerender and cannot read
+ the pathname, so the active tab is a client component (`SiteTabs`, the `ChannelTabs`
+ precedent) and `useSelectedLayoutSegment()` is `null` on the index page = the Settings tab.
+ A layout's `title.template` applies to CHILD segments only, so the layout exports
+ `{ default: "<id> — Sites", template: "%s — <id> — Sites" }`, the index page exports no
+ title, and each tab exports a bare one.
+6. **`revalidatePath` literal vs `"layout"`.** `saveSiteAction`'s `revalidatePath("/sites/<id>")`
+ does not cover the tabs by the letter — it worked only through the "temporary" behaviour
+ that refreshes every visited page. It is `revalidatePath("/sites/[siteId]", "layout")` now;
+ the stats build's `/charts` became `revalidatePath("/sites/[siteId]/charts", "page")`, and
+ the cut-release, build-mode and homepage actions revalidate `/sites`.
+7. **The all-sites disabled state cannot exist on a site tab.** `BuildDeployButton`,
+ `BuildExportButton` and `DeployButton` take `siteId: string` / `siteTitle: string`; the
+ three "Select a specific site from the sidebar…" notices and both `disabled={!siteId}` are
+ gone. The missing-Cloudflare-project branch is untouched — that is what the button says when
+ it cannot deploy, and "the site's page" is now literally the Settings tab beside it.
+8. **The Pool is eight job consoles.** `RunningJobsList` renders ABOVE the disclosure (a
+ running pool job must be visible without a click); the consoles sit under
+ `<details><summary>Pool jobs`. `getByRole` ignores what a closed disclosure hides, so one
+ `buildIndex(page)` helper in `e2e/helpers.ts` opens it, presses and asserts "Done" —
+ replacing three inline six-line copies across nine call sites.
+9. **`build/` and `deploy/` were already one module in two directories.** Server modules are
+ `editor/app/sites/lib/` (seven `"use server"` files plus `buildDeployCore.ts`, which is
+ deliberately NOT one); components are FLAT in `editor/app/sites/components/`, because the
+ repo's component dirs are flat and every moved file then has the same import shape.
+ `SiteTabs.tsx` is under `sites/[siteId]/components/`. Neither `sites/lib` nor
+ `sites/components` has a `page.tsx`, so neither is routable and neither can shadow a site id.
+10. **The hub's form is not what deploys the hub** — the `homepage` package's own deploy script
+ hardcodes `--project-name archilyzer`. Pre-existing; the section keeps today's copy.
+11. **Charts preview data is whatever site `compose-site` ran last.** One sentence says so in
+ the Charts tab's intro. Out of scope to fix.
+12. **`navigation.spec.ts`'s redirect test had no site**, so a captured redirect would 404. It
+ writes `testsite` before the capture case.
+13. **`/sites` became async and reads six things** — all file/registry reads the two retired
+ pages already did per request; it was `force-dynamic` already.
+14. **`BUILD_KINDS` is wrong in both directions** — six kinds, missing the three live-chat
+ kinds the buttons enqueue and including `build-export`, which none does. Moved verbatim
+ with a comment saying exactly that; **not fixed**.
+15. **`SettingsForm`'s "The mode can also be toggled on the Deploy page"** points at Sites.
+16. **A stale `?site=<deleted>` bookmark 404s at `/sites/<id>/charts`** exactly as `/sites/<id>`
+ does, where `/charts?site=bogus` used to fall back to the lone/all site. The picker never
+ seeds an invalid id, so only a hand-typed URL reaches this. Accepted; in the CHANGELOG.
+
+**What stayed.** `seedsSiteParam` and its reason (seeding on `/sites*` clobbered an in-flight
+push to `/sites/<id>`). `BUILD_KINDS`' mismatch (14). `EditorAliasesClient`'s
+`siteId: string | null` and its now-unreachable global-only branch — a `git mv` plus one import
+line is the whole diff, and narrowing it was not this slice. The charts preview's
+last-composed data (11). The hub deploy script's hardcoded project name (10).
+`RunningJobsList` as the Pool's renderer. `/api/*` — nothing there belonged to any of the five,
+and the charts preview's `/stats` rewrite is absolute and unaffected. `deleteSiteAction` still
+`rm -rf`s the whole site dir including `chart-templates.json` and `search-aliases.json`; the
+tabs only make that visible. No `loading.tsx` anywhere under `sites/` — the layout's
+`notFound()` is the guard, and each tab page guards through the same `cache()`d read.