commit 3c634e30cdb3395df44e0dcf87da22532eb5d6b2
parent 4d862762eaaf14f4bff1a384b1dd32eb533b9a7b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 30 Aug 2026 12:49:21 -0400
plans: slice 5 planned
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
1 file changed, 1106 insertions(+), 0 deletions(-)
diff --git a/plans/editor-ia-slice-5.md b/plans/editor-ia-slice-5.md
@@ -0,0 +1,1106 @@
+# Editor IA slice 5 — Sites absorb Charts, Search aliases, Deploy, Build and Homepage: a site has tabs, the family page has the globals
+
+## Context
+
+**Verified read-only against `2a8081e` (clean) on 2026-08-30**, the commit that closed slice
+8c. The census is the appendix of this file (three exploration reports, every file:line
+re-checked at this sha); the findings cite it as "census (a)/(b)/(c)". A `duplicates-page`
+worktree exists (`git worktree list`) — another session commits into this checkout.
+
+This is the IA doc's bullet 5 (`plans/editor-operations-ia.md:147-150`):
+"`/sites/[siteId]/{charts,aliases,publish}`; `?site=` stays for Dashboard and Channels. Deletes
+five pages." It is the last UI-only slice and the last one that reduces the nav: the Sites
+group is six entries (`editor/app/lib/nav.ts:88-95`) and the end state is one, "eleven
+top-level entries" (`nav.ts:42`; IA doc `:68`).
+
+**Decided (operator, 2026-08-30):**
+
+- **(A) Globals live on `/sites`, the family page.** `/sites` = the sites list PLUS everything
+ that applies to the whole family: the EXPORT release notes (`CutReleaseForm
+ workspace="export"`), the build-mode toggle and the batch panel ("Build all sites" / "Or pick
+ specific sites"), the **Hub** (today's `/homepage` form), and the **Pool** (today's `/build`:
+ index, stats, normalize/archive ×3 transcripts and ×3 live chat with their
+ `QueueControl`s/`ArchiveOptionsControl`s, plus `RunningJobsList` over `BUILD_KINDS`). Heavy
+ sections collapse under `<details>` the way `/deploy`'s "Individual steps" does
+ (`deploy/page.tsx:133-167`).
+- **Per-site:** `/sites/[siteId]/charts`, `/sites/[siteId]/aliases`, `/sites/[siteId]/publish`
+ ("Build & deploy" + "Individual steps"). `/sites/[siteId]` stays the settings form.
+- **(B) Aliases: both sections stay on the site page** — h2 "Global" + h2 "This site — <id>"
+ exactly as `EditorAliasesClient.tsx:88,96,183` draws them; `/sites` does not duplicate the
+ global list.
+- **Nav end state:** Sites group = one entry, 11 total. The five routes 307 (`permanent:
+ false`, the "TEMPORARY" rule at `next.config.ts:66-71`). Nothing on disk changes.
+- **Cadence:** Fable reviews; one Opus agent implements. Stage by explicit path; e2e detached;
+ never boot the editor against `transcripts/`; `git mv` for every move.
+
+**What the census found — every place the IA bullet's wording cannot be implemented
+literally, and what to do instead:**
+
+1. **"They are all per-site facts already; only their routes say otherwise" (`nav.ts:89-90`)
+ is wrong for two of five.** `/build` has no site notion at all — no `searchParams`, no
+ `listSites`, eleven corpus-wide actions (`build/page.tsx:20-33`, `build/buildAction.ts`);
+ `/homepage` edits `sites/_homepage/homepage.json`, and `_homepage` is rejected by
+ `isValidSiteId` so the hub can never be a `[siteId]` (census b). `/deploy` is half and
+ half: "Build & deploy" + "Individual steps" target one site; "Release notes", "Build mode"
+ and "Build all sites" are the family's (`deploy/page.tsx:81-196`). **Instead:** decision
+ (A) — the per-site halves become tabs, the global halves become sections of `/sites`.
+ Also: `nav.ts:89` says "these four" and lists five.
+2. **Aliases are a global dictionary with a per-site overlay** (`aliasesStore.ts:68-73`,
+ per-site id shadows global at compose). A site tab that showed only the overlay would hide
+ the thing it overlays. **Instead:** decision (B) — the tab renders both sections;
+ `readGlobalAliases` is read on the site page exactly as `aliases/page.tsx:27-28` reads it.
+3. **The site picker and the path disagree on `/sites/<id>/…`.** `SiteScopeSelect.tsx:30`
+ `seedsSiteParam = !pathname.startsWith("/sites")` (reason at `:25-29`: the mount effect's
+ `replace()` clobbered an in-flight push to `/sites/<id>`); its value is
+ `resolveActiveSite(searchParams.site)` (`:22-23`), so on `/sites/alpha/charts` it shows
+ whatever was stored last. **Instead:** a pure `siteIdFromPathname()` in
+ `lib/activeSite.ts`; on a site path the picker's value IS the path segment, the reconcile
+ effect writes it to localStorage (so Dashboard/Channels follow), `onChange` does
+ `router.push("/sites/<other>/<segment>")` and "All sites" pushes `/sites`;
+ `seedsSiteParam` and its comment stay.
+4. **`?site=` can be routed at config level, and the query survives the redirect.**
+ `redirects.md:142`: "if the value `first-(?<paramName>.*)` is used … then `second` will be
+ usable in the destination with `:paramName`"; the validator accepts destination segments
+ that are `has` named groups (`next/dist/lib/load-custom-routes.js:210-263`). The regex is
+ anchored — `new RegExp(`^${hasItem.value}$`)`
+ (`next/dist/shared/lib/router/utils/prepare-destination.js:101`) — so `__all__` fails
+ `[a-z0-9][a-z0-9-]*` and falls to the bare rule. 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`; `redirects.md:43`):
+ **`/charts?site=testsite` lands on `/sites/testsite/charts?site=testsite`.** Harmless (the
+ tab reads `params.siteId`; the picker's path rule wins over the query), but every spec
+ regex on a captured redirect must be `(\?|$)`, and bare sources (`/build`, `/homepage`)
+ are `/\/sites$/`.
+5. **Layout mechanics, from the docs.** Layout `params` is a Promise (`layout.md:70-72,88`);
+ layouts do not rerender and cannot read `searchParams`/`pathname` (`layout.md:154,180,240`)
+ — so the active tab lives in a client component, exactly `ChannelTabs.tsx:8-12`;
+ `useSelectedLayoutSegment()` returns `null` on the index page
+ (`use-selected-layout-segment.md:50,58`) — Settings is active when `segment === null`.
+ Metadata: a `title.template` in a layout applies to CHILD segments and not to a `title` in
+ the same segment's `page.js` (`generate-metadata.md:284-287`), so the layout exports
+ `{ default: `${siteId} — Sites`, template: `%s — ${siteId} — Sites` }`: the index page
+ keeps today's `${siteId} — Sites` (`[siteId]/page.tsx:26`) by exporting nothing, and each
+ tab exports a static `title` ("Charts", "Search aliases", "Publish") and gets templated.
+6. **`revalidatePath` and the dynamic 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}`)` (`sites/actions.ts:193`) therefore
+ does NOT cover `/sites/<id>/charts` by the letter — it works today only through the
+ "temporary" server-function behaviour that refreshes every visited page (`:16`).
+ **Instead:** `:193` → `revalidatePath("/sites/[siteId]", "layout")` (one line; the h1 reads
+ `siteTitle`, which that form edits). `buildAction.ts:64` `/charts` →
+ `revalidatePath("/sites/[siteId]/charts", "page")` (precedent `channels/actions.ts:312`);
+ `cutReleaseAction.ts:114`, `buildModeAction.ts:24`, `homepage/actions.ts:50` → `/sites`.
+7. **The all-sites disabled state cannot exist on a site tab.** `BuildDeployButton.tsx:26-33`,
+ `BuildExportButton.tsx:39-43`, `DeployButton.tsx:28-31` each carry "Select a specific site
+ from the sidebar…" for `siteId === null`. **Instead:** `siteId: string`, `siteTitle:
+ string` (non-null), the three notices deleted, `disabled={!siteId}`
+ (`BuildExportButton:30`, `DeployButton:21`) deleted; the missing-Cloudflare-project branch
+ (`BuildDeployButton:34-40`, pinned by `deploy-page.spec.ts:60`) is what the button says
+ when it cannot deploy — copy unchanged: "**<title>** has no Cloudflare Pages project
+ configured — set one on the site's page to deploy." ("the site's page" is now literally
+ the Settings tab beside it).
+8. **The Pool is eight job consoles; on the family page it collapses.** `BuildButtons.tsx`
+ renders eight `StreamActionLog`s; `getByRole` ignores what a closed `<details>` hides, so
+ the nine specs that press "Build index" (census a) must open it first. **Instead:** h2
+ "Pool" + description + `<RunningJobsList>` ABOVE the disclosure (it renders `null` when
+ empty, `RunningJobsList.tsx:24`, and a running pool job must be visible without a click),
+ then `<details><summary>Pool jobs</summary><BuildButtons/></details>`; one
+ `buildIndex(page)` helper in `e2e/helpers.ts` opens it, presses, and asserts "Done" — it
+ needs `import { expect } from "@playwright/test"` added (`helpers.ts:12` imports only
+ `type Page` today); the assertion stays inside the helper because all three inline copies
+ (`channel-build-toggle:16-22`, `regional-vtt-fallback:18-27`, `duplicate-shorts:177-188`)
+ assert, and a helper that returned a locator would move six lines into nine call sites.
+9. **`build/` and `deploy/` are already one module in two directories**
+ (`build/buildAction.ts:34` ← `deploy/buildDeployCore`; four `deploy/components/*` →
+ `build/buildAction`; `changelog/page.tsx:12` → `deploy/components/CutReleaseForm`).
+ **Instead:** server modules to `editor/app/sites/lib/` (`buildDeployCore.ts` deliberately
+ NOT `"use server"`, `buildAction.ts`, `deployAction.ts`, `cutReleaseAction.ts`,
+ `buildModeAction.ts`, `homepageActions.ts`, `chartsActions.ts`, `aliasesActions.ts`);
+ components FLAT in the existing `editor/app/sites/components/` beside `SiteForm.tsx` and
+ `SiteListActions.tsx`. Flat, not `{publish,pool,hub,charts,aliases}/`: the repo's component
+ dirs are flat (`jobs/components/`, `channels/[slug]/components/`, `deploy/components/`),
+ every moved file then has the same import shape (`../lib/x`,
+ `../../components/QueueControl`, `../../jobs/actions` — the last two are byte-identical
+ to today's from `deploy/components/`), and the filenames already say which section they
+ are. Neither `sites/lib` nor `sites/components` has a `page.tsx`, so neither is routable
+ (slice 8a+8b finding 8) and neither can shadow a site id; `SiteTabs.tsx` goes under
+ `sites/[siteId]/components/` (precedent `channels/[slug]/components/ChannelTabs.tsx`).
+ **No static route sibling of `[siteId]` is added** — `/sites/publish` would shadow a site
+ called `publish` (`SITE_ID_RE`, `site.ts:110`).
+10. **The hub's form is not what deploys the hub** (census b: `homepage/` package's `deploy`
+ script hardcodes `--project-name archilyzer`; no editor job builds it). Pre-existing; the
+ section keeps today's copy (`homepage/page.tsx:17-24`) verbatim and this plan does not
+ touch the script.
+11. **Charts preview data is whatever site `compose-site` ran last** (`export/public/stats`,
+ census b; `useStats` fetches `${idBaseUrl(origin)}/stats/…` — absolute,
+ `common/components/statsCache.ts:28,44`, so the move changes nothing). **Decided: one
+ sentence** in the Charts tab's intro: "The preview draws the stats dataset as last
+ composed, which may be another site's." Out of scope to fix.
+12. **`navigation.spec.ts`'s redirect test has no site.** Its fixture is
+ `channel-with-counts` (`:49`) and it never calls `writeSite`; a captured redirect to
+ `/sites/testsite/charts` would 404 there. **Instead:** the test writes `testsite` before
+ the capture case (helpers `writeSite`, `:74-105`).
+13. **`/sites` becomes async and reads six things** (`listSites`, `export/CHANGELOG.md`,
+ `settings.buildPipeline.mode`, `activeQueueNames()`, `liveJobRows(BUILD_KINDS)`,
+ `getHomepageConfig`) — all file/registry reads the two retired pages already did per
+ request; it is `force-dynamic` already (`sites/page.tsx:7`).
+14. **`BUILD_KINDS` (`build/page.tsx:11-18`) is wrong in both directions** — six kinds,
+ missing the three live-chat kinds the buttons enqueue, including `build-export` which
+ none does. Moved verbatim with a comment saying exactly that; **not fixed**.
+15. **`SettingsForm.tsx:288`** `<a href="/deploy">Deploy</a>` "The mode can also be toggled on
+ the Deploy page" → `href="/sites"` "the Sites page".
+16. **A stale `?site=<deleted>` bookmark 404s at `/sites/<id>/charts`** exactly as
+ `/sites/<id>` does (`navigation.spec.ts:135` pins the family's 404). Today
+ `/charts?site=bogus` fell back to the lone/all site (`activeSite.ts:46-50`). The picker
+ never seeds an invalid id (`SiteScopeSelect.tsx:60` resolves first), so only a hand-typed
+ URL reaches this. Accepted; recorded in the CHANGELOG bullet.
+
+**Hazards carried through every commit:**
+
+- **Another session commits into this checkout.** `git status` before every commit; stage by
+ explicit path; never `git add -A`.
+- **Playwright strict mode and the site title.** On `/sites` a site's title appears THREE
+ times after this slice: the picker `<option>`, the list `<Link>`, and `BuildSitesPanel`'s
+ visible `<span>{s.siteTitle}</span>` (`BuildSitesPanel.tsx:83`, beside its
+ `aria-label="Select <title>"` checkbox at `:81`). `sites-crud.spec.ts` survives because it
+ already scopes by `getByRole("link", { name: /Alpha Site/ })` (`:59,65`; the comment at
+ `:56-59` records the picker collision) and its `getByText("Doomed")).toHaveCount(0)` (`:91`)
+ runs AFTER the delete, when all three occurrences are gone (the panel takes `sites` as a
+ page prop and re-renders with the revalidated route). **No new unscoped `getByText(<site
+ title>)` on `/sites`.** `sites-crud:89` `getByRole("button", { name: /delete/i })` stays
+ unique: the new buttons are Cut release, Basic, Docker, Build all sites / Build & deploy all
+ sites, Build selected, Save config, Build index, Build stats dataset, Normalize transcripts,
+ Build archives, Build combined archive, Normalize live chat, Build live chat archives, Build
+ combined live chat archive, and `QueueControl`'s Set/Cancel only while editing
+ (`QueueControl.tsx:48-70`) — none contains "delete".
+- **`getByRole("button", { name: "Build index" })` is a substring match**; on `/sites` no other
+ button contains it ("Build all sites", "Build selected", "Build stats dataset" do not).
+ `getByLabel("Build index output")` (`StreamActionLog` label, route-independent) stays unique.
+- **`deleteSiteAction` rm -rf's the whole site dir** including `chart-templates.json` and
+ `search-aliases.json` (census c). Unchanged; the tabs now make that visible. Nothing changes.
+- **`CutReleaseForm` is shared with `/changelog`** (`workspace="editor"`, `cut-release.spec.ts`
+ three tests on `/changelog`) — one import edit at `changelog/page.tsx:12`; no spec change.
+- **`"use server"` files export functions only**; `buildDeployCore.ts` is NOT `"use server"`
+ (its header `:1-5` says why) and stays that way in `sites/lib/`.
+- **`revalidatePath("/sites")`** at `channels/actions.ts:232,285` and `sites/actions.ts:192,
+ 207,217` — unchanged; three more callers join them.
+- **No `loading.tsx`** anywhere under `sites/` (`navigation.spec.ts:7-33`; `:132-140` pins
+ `/sites/definitely-not-a-site` → 404). The layout's `notFound()` is the guard; each tab page
+ also guards through the same `cache()`d read (the channel page does likewise,
+ `channels/[slug]/page.tsx:122-123`).
+- **Redirects are checked before the filesystem** (`redirects.md:39`), so a redirect for a
+ route that still has a page hides the page: each commit adds ONLY the redirects for the
+ pages it deletes.
+- **Tab labels vs sidebar labels.** The Settings tab's accessible name is "Settings tab"
+ (`aria-label`, `ChannelTabs.tsx:38` precedent); `dashboard.spec.ts:125` and
+ `navigation.spec.ts:61` scope sidebar links to `aside`. No unscoped `getByRole("link", {
+ name: /settings|charts/i })` exists in `e2e/` (grep verified).
+- **Hidden `<details>` content**: `toBeVisible`/`getByRole` on anything inside the Pool
+ disclosure or "Individual steps" needs the click first (`build.spec.ts:87` already does it
+ for the latter).
+- **e2e runs detached (~25 min); never boot the editor against `transcripts/`**; the corpus is
+ `test-transcripts/` with site `testsite`.
+
+## Step 0 — the plan on disk
+
+Write this file verbatim to `plans/editor-ia-slice-5.md`; commit alone: `plans: slice 5 planned`.
+
+## Order: four code commits after the plan, then the docs
+
+1. **`sites: a site has tabs, and Charts and Search aliases are two of them`** —
+ `[siteId]/layout.tsx`, `SiteTabs`, `siteCache.ts`, `[siteId]/page.tsx` trimmed,
+ `/sites/[siteId]/charts`, `/sites/[siteId]/aliases`, `chartsActions`/`aliasesActions` + the
+ two clients moved, `siteIdFromPathname` + test, the picker follows the path, `/charts` and
+ `/aliases` redirected and deleted, two nav entries gone, two `revalidatePath` rewrites,
+ four specs.
+2. **`sites: Publish is the site's tab; the family page cuts the release and batches the
+ builds`** — `deploy/*` and `build/buildAction.ts` moved to `sites/lib` + `sites/components`,
+ `/sites/[siteId]/publish`, `/sites` gains Release notes and Build all sites (with the mode
+ toggle), the three all-sites notices deleted, `/deploy` redirected and deleted, Deploy nav
+ entry gone, `SettingsForm` link, five specs.
+3. **`sites: the pool runs from the family page`** — `BuildButtons`/`ArchiveOptionsControl`
+ moved, `/sites` gains Pool under `<details>`, `buildIndex(page)` helper replaces three
+ inline copies, `/build` redirected and deleted, Build nav entry gone, three comment sites,
+ eight specs.
+4. **`sites: the hub is the family's, and the Sites group is one entry`** —
+ `homepageActions`/`HomepageConfigForm` moved, `/sites` gains Hub, `/homepage` redirected
+ and deleted, Homepage nav entry gone, `nav.ts` header says eleven is reached, `nav.test.ts`,
+ the redirect comment paragraph final, one spec.
+5. **`plans: slice 5 shipped, and the docs say so`** — CHANGELOG, IA doc, STATE, FACTS.
+
+Gates after each of 1–4: `pnpm -C <pkg> exec tsc --noEmit` for `common editor export homepage
+umtool mcp`; `pnpm -C common test`; `pnpm -C editor exec tsx --test "app/**/*.test.ts"` (111 at
+`2a8081e`; `activeSite.test.ts` adds in commit 1, `nav.test.ts` in commit 4 — re-measure before
+commit 1). e2e once after commit 5, detached.
+
+---
+
+## Commit 1 — `sites: a site has tabs, and Charts and Search aliases are two of them`
+
+**New `editor/app/sites/[siteId]/lib/siteCache.ts`** (modelled on
+`channels/[slug]/lib/channelConfigCache.ts`):
+
+```ts
+import { cache } from "react";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSite, isValidSiteId, listSiteIds, type Site } from "yt-dlp-transcript-common/lib/site";
+
+// The layout and every tab page need the site — the layout for the title,
+// the page for its own section. React cache() dedupes them into ONE read per
+// request (the channel routes do the same, channelConfigCache.ts). Returns
+// null for an id that is malformed or has no site.json, so the caller's
+// notFound() is the one 404 for /sites/<missing> and every tab under it.
+// Request-scoped, not a process cache: a site renamed by the form on the
+// previous request is read fresh on the next one.
+export const getSiteCached = cache((siteId: string): Site | null => {
+ const paths = getPaths();
+ if (!isValidSiteId(siteId) || !listSiteIds(paths).includes(siteId)) return null;
+ return getSite(siteId, paths);
+});
+```
+
+(the guard is `[siteId]/page.tsx:36-38` verbatim; `getSite` is sync, `site.ts:356`).
+
+**New `editor/app/sites/[siteId]/components/SiteTabs.tsx`** (`"use client"`; `ChannelTabs.tsx`
+copied, four tabs):
+
+```ts
+// Settings | Charts | Search aliases | Publish.
+//
+// A CLIENT component on purpose. Next 16 layouts are cached during navigation
+// and DO NOT RERENDER — they cannot read the pathname and would not re-evaluate
+// an active-tab check on a client-side move between two tabs.
+// useSelectedLayoutSegment is the supported way to ask "which child segment is
+// rendered"; it is null on the index page, which is the Settings tab.
+export function SiteTabs({ siteId }: { siteId: string }) {
+ const segment = useSelectedLayoutSegment();
+ return (
+ <nav aria-label="site sections" className="flex items-center gap-1">
+ <Tab href={`/sites/${siteId}`} active={segment === null} label="Settings" />
+ <Tab href={`/sites/${siteId}/charts`} active={segment === "charts"} label="Charts" />
+ <Tab href={`/sites/${siteId}/aliases`} active={segment === "aliases"} label="Search aliases" />
+ {/* commit 2 adds: <Tab href={`/sites/${siteId}/publish`} active={segment === "publish"} label="Publish" /> */}
+ </nav>
+ );
+}
+```
+
+`Tab` is `ChannelTabs.tsx:25-48` verbatim (`aria-current={active ? "page" : undefined}`,
+`aria-label={`${label} tab`}`, same classes).
+
+**New `editor/app/sites/[siteId]/layout.tsx`** — header comment modelled on
+`channels/[slug]/layout.tsx:9-24` (why a layout: the site has more than one page now; the two
+Next 16 caveats — layouts do not rerender / no `loading.tsx` here because it turns
+`notFound()` 404s into 200s; `force-dynamic` because it reads `site.json` per request). Body:
+
+```ts
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata({ params }: { params: Promise<{ siteId: string }> }): Promise<Metadata> {
+ const { siteId } = await params;
+ // The index page (the settings form) exports no title and gets `default`;
+ // each tab exports a bare title and gets the template
+ // (generate-metadata.md: a layout's template applies to CHILD segments).
+ return { title: { default: `${siteId} — Sites`, template: `%s — ${siteId} — Sites` } };
+}
+
+export default async function SiteLayout({ children, params }: { children: ReactNode; params: Promise<{ siteId: string }> }) {
+ const { siteId } = await params;
+ const site = getSiteCached(siteId);
+ if (!site) notFound();
+ return (
+ <div className="flex flex-col gap-4">
+ {/* breadcrumb: [siteId]/page.tsx:49-55 verbatim */}
+ <div className="flex items-center justify-between gap-2 flex-wrap">
+ <h1 className="text-2xl font-semibold">{site.siteTitle || siteId}</h1>
+ <SiteTabs siteId={siteId} />
+ </div>
+ {children}
+ </div>
+ );
+}
+```
+
+**`editor/app/sites/[siteId]/page.tsx`:** delete `:2` (`Link`), `:7,:9` (`isValidSiteId`,
+`listSiteIds`), `:20-27` (`generateMetadata` — the layout's default is the same string),
+`:49-64` (breadcrumb, h1, the `/charts?site=` link — the tabs replace it). `:35-39` → `const
+site = getSiteCached(siteId); if (!site) notFound();` (import from `./lib/siteCache`; `getSite`
+import dropped). Keep `force-dynamic` (`:18`) — the page reads `listChannelConfigs` and
+`listSites` itself, as the channel page keeps its own (`channels/[slug]/layout.tsx:23-24`).
+The return becomes the `<SiteForm …/>` (`:65-70`) inside the existing wrapper div.
+
+**Moves (`git mv`)**: `editor/app/charts/EditorChartsClient.tsx` →
+`editor/app/sites/components/EditorChartsClient.tsx` (`:13` `./actions` →
+`../lib/chartsActions`); `editor/app/charts/actions.ts` → `editor/app/sites/lib/chartsActions.ts`;
+`editor/app/aliases/EditorAliasesClient.tsx` → `editor/app/sites/components/EditorAliasesClient.tsx`
+(`:19` `./actions` → `../lib/aliasesActions`); `editor/app/aliases/actions.ts` →
+`editor/app/sites/lib/aliasesActions.ts`. **`git rm`** `editor/app/charts/page.tsx`,
+`editor/app/aliases/page.tsx`; the two directories are gone. `EditorAliasesClient`'s `siteId:
+string | null` branch is now unreachable and is left alone (a `git mv` + one import line is
+the whole diff; narrowing it is not this slice).
+
+**New `editor/app/sites/[siteId]/charts/page.tsx`:**
+
+```ts
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Charts" }; // → "Charts — <id> — Sites"
+export default async function SiteChartsPage({ params }: { params: Promise<{ siteId: string }> }) {
+ const { siteId } = await params;
+ if (!getSiteCached(siteId)) notFound();
+ const templates = readTemplates(getPaths(), siteId);
+ return (
+ <section className="flex flex-col gap-4">
+ <p className="text-sm text-muted-foreground">
+ Author the default dashboard for <strong>{siteId}</strong>. Edits save
+ automatically and bake into this site's next export build. Requires the
+ stats dataset — build it on the <Link className="underline" href="/build">Build</Link> page.
+ The preview draws the stats dataset as last composed, which may be another site's.
+ </p>
+ <EditorChartsClient key={siteId} templates={templates} siteId={siteId} />
+ </section>
+ );
+}
+```
+
+No own heading: the h1 (site title) and the active tab name the page; the tab pages start with
+their intro. The `/build` link is repointed to `/sites` in commit 3 (it is true until then).
+The "Select a specific site" branch (`charts/page.tsx:36-48`) and the zero-sites branch
+(`:21-34`) are gone — a tab always has a site. `site-scope.spec.ts:67` `/author the default
+dashboard/i` still matches.
+
+**New `editor/app/sites/[siteId]/aliases/page.tsx`:** same shape; `metadata.title = "Search
+aliases"`; reads `readGlobalAliases(paths)` and `readSiteAliases(paths, siteId)`
+(`aliases/page.tsx:27-28`); intro = `aliases/page.tsx:33-42`'s site branch ("Global aliases
+apply everywhere; per-site aliases below add to or override them for **{siteId}**. Changes bake
+into the site's next export build."); `<EditorAliasesClient key={siteId} globalConfig
+siteConfig siteId={siteId} />`. The client's h2 "Global" and h2 "This site — <id>" and every
+`alias-*` testid are untouched.
+
+**`editor/app/lib/activeSite.ts`:** `:1-6` header → "(Dashboard, Channels)" and a new
+paragraph: "ON A SITE'S OWN PAGES the site is the PATH, not the param: `/sites/<id>/<tab>`
+names it, the tab reads `params.siteId`, and the picker reads the same path through
+`siteIdFromPathname` so the two never disagree." Append:
+
+```ts
+// The routes under /sites where the path names the site. `new` is /sites/new,
+// a static route that beats [siteId] and is not a site. The id pattern is
+// SITE_ID_RE re-spelled: common/lib/site.ts imports node:fs and this module
+// must stay importable from the client picker.
+const SITE_PATH_RE = /^\/sites\/([a-z0-9][a-z0-9-]*)(?:\/([a-z-]+))?\/?$/;
+
+export type SitePath = { siteId: string; segment: string | null };
+
+// "/sites/alpha" → { alpha, null }; "/sites/alpha/charts" → { alpha, "charts" };
+// "/sites", "/sites/new", "/channels", "/sites/a/b/c" → null.
+export function siteIdFromPathname(pathname: string): SitePath | null {
+ const m = SITE_PATH_RE.exec(pathname);
+ if (!m || m[1] === "new") return null;
+ return { siteId: m[1], segment: m[2] ?? null };
+}
+```
+
+**New `editor/app/lib/activeSite.test.ts`** (node:test): the six cases above.
+`resolveActiveSite` is not re-tested (unchanged).
+
+**`editor/app/components/SiteScopeSelect.tsx`:** `:5-9` import adds `siteIdFromPathname`;
+`:14-16` comment → "…so the server-rendered scoped pages (Dashboard, Channels) can read it from
+their `searchParams`. On a site's own pages (`/sites/<id>/…`) the path IS the selection: the
+picker shows it, writes it to storage so Dashboard and Channels follow, and changing it
+navigates to the same tab of the other site. See app/lib/activeSite.ts."; after `:19` add
+`const onSite = siteIdFromPathname(pathname);`; `:23` → `const resolved =
+resolveActiveSite(onSite?.siteId ?? urlValue, siteIds);`; `:25-30` comment keeps its reason and
+gains one sentence: "On `/sites/<id>/…` the picker reads the path instead (below) and never
+seeds either."; `:30` unchanged. Effect `:43-52` → `const chosen = onSite?.siteId ?? urlValue;
+const chosenIsValid = chosen === ALL_SITES || (!!chosen && siteIds.includes(chosen)); if
+(chosenIsValid) { …setItem(ACTIVE_SITE_KEY, chosen)…; return; }`; `:53-62` unchanged (deps
+already include `pathname`). `onChange` `:64-71`:
+
+```ts
+function onChange(value: string) {
+ try { window.localStorage.setItem(ACTIVE_SITE_KEY, value); } catch { /* ignore */ }
+ if (onSite) {
+ // The site is the path here, so changing it is a navigation, not a param:
+ // the same tab of the other site, or the family page for "All sites".
+ router.push(value === ALL_SITES ? "/sites" : `/sites/${value}${onSite.segment ? `/${onSite.segment}` : ""}`);
+ return;
+ }
+ setParam(value);
+}
+```
+
+**`editor/app/build/buildAction.ts:64`** → `revalidatePath("/sites/[siteId]/charts", "page");`
+with `// every site's Charts tab previews the dataset this just rebuilt.`
+**`editor/app/sites/actions.ts:193`** → `revalidatePath("/sites/[siteId]", "layout");` with
+`// "layout": the h1 above every tab reads siteTitle, and a literal path would revalidate only
+the settings page (revalidatePath.md).`
+
+**`editor/next.config.ts`:** the redirect comment gains, before the TEMPORARY paragraph:
+
+```
+ // /charts, /aliases, /deploy, /build and /homepage were the Sites group's
+ // five satellites. A site's charts, search aliases and publish controls are
+ // its TABS now (/sites/<id>/{charts,aliases,publish}); what applies to the
+ // whole family — the release notes, the batch build, the hub, the pool —
+ // is on /sites itself. The `has` rules carry a ?site=<id> bookmark to the
+ // site's tab: the value regex is SITE_ID_RE, anchored by Next, so
+ // ?site=__all__ (underscores) misses it and lands on the family page. The
+ // matched query is NOT stripped — /charts?site=a lands on
+ // /sites/a/charts?site=a — which the tab ignores and the picker reconciles.
+ // Order matters: first match wins, so each `has` rule precedes its bare one.
+```
+
+(commit 1 writes the paragraph naming all five; the array grows per commit). Above
+`redirects()`: `const siteQuery = { type: "query", key: "site", value:
+"(?<site>[a-z0-9][a-z0-9-]*)" } as const;`. Array, commit 1 adds after `:78`:
+
+```ts
+{ source: "/charts", has: [siteQuery], destination: "/sites/:site/charts", permanent: false },
+{ source: "/charts", destination: "/sites", permanent: false },
+{ source: "/aliases", has: [siteQuery], destination: "/sites/:site/aliases", permanent: false },
+{ source: "/aliases", destination: "/sites", permanent: false },
+```
+
+**`editor/app/lib/nav.ts`:** delete `:91-92` and the `ChartColumnBig` (`:3`) and `Regex`
+(`:12`) imports; `:88` → `{ href: "/sites", label: "Sites", icon: Globe, keywords: "family
+charts stats graphs search aliases synonyms suggestions regex search terms" }`; `:89-90` →
+`// Interim: slice 5 folds these three into /sites — Deploy into the site's Publish tab and
+the family page, Build and Homepage into the family page.`
+
+**Specs (commit 1):**
+
+- `site-scope.spec.ts:61-68` → title "charts is a site's tab, and the picker follows the path":
+ ```ts
+ await twoSites();
+ await page.goto("/sites/alpha/charts");
+ await expect(page.getByText(/author the default dashboard/i)).toBeVisible();
+ await expect(page.getByLabel("Active site")).toHaveValue("alpha");
+ await page.getByLabel("Active site").selectOption("beta");
+ await expect(page).toHaveURL(/\/sites\/beta\/charts$/);
+ await expect(page.getByText(/author the default dashboard/i)).toContainText("beta");
+ // The path wrote localStorage, so the scoped pages follow.
+ await page.goto("/channels");
+ await expect(page.getByLabel("Active site")).toHaveValue("beta");
+ await expect(page).toHaveURL(/site=beta/);
+ // "All sites" is the family page.
+ await page.goto("/sites/beta/charts");
+ await page.getByLabel("Active site").selectOption("__all__");
+ await expect(page).toHaveURL(/\/sites$/);
+ ```
+- `aliases.spec.ts:4-5` header → "…on the site's Search aliases tab: the global dictionary and
+ the site's overrides, both."; `:16,:25,:52` → `/sites/testsite/aliases`.
+- `navigation.spec.ts:2` → `import { resetData, writeSite } from "./helpers";`; `:105-109`
+ comment gains "The Sites group's five satellites fold into /sites and the site's tabs.";
+ after `:129` (grown per commit — commit 1 has `/charts`, `/aliases`):
+ ```ts
+ await writeSite("testsite", {});
+ for (const retired of ["/charts", "/aliases"]) {
+ await page.goto(retired);
+ await expect(page).toHaveURL(/\/sites$/);
+ await expect(page.getByRole("heading", { name: "Sites", level: 1 })).toBeVisible();
+ }
+ // A ?site= bookmark is carried to the site's tab; the query survives the redirect.
+ await page.goto("/charts?site=testsite");
+ await expect(page).toHaveURL(/\/sites\/testsite\/charts(\?|$)/);
+ await expect(page.getByRole("link", { name: "Charts tab" })).toHaveAttribute("aria-current", "page");
+ ```
+- `sites-crud.spec.ts`: no change (the h1, the form and `role=status` are as before; nothing
+ pins the deleted link).
+
+---
+
+## Commit 2 — `sites: Publish is the site's tab; the family page cuts the release and batches the builds`
+
+**Moves (`git mv`)**, then the import edits:
+
+| from | to | import edits |
+|---|---|---|
+| `editor/app/deploy/buildDeployCore.ts` | `editor/app/sites/lib/buildDeployCore.ts` | none (header `:1-5` still true) |
+| `editor/app/build/buildAction.ts` | `editor/app/sites/lib/buildAction.ts` | `:34` `../deploy/buildDeployCore` → `./buildDeployCore` |
+| `editor/app/deploy/deployAction.ts` | `editor/app/sites/lib/deployAction.ts` | none (`./buildDeployCore`) |
+| `editor/app/deploy/buildModeAction.ts` | `editor/app/sites/lib/buildModeAction.ts` | `:24` → `revalidatePath("/sites")`; `:11` comment "from the deploy page" → "from the family page (/sites)" |
+| `editor/app/deploy/cutReleaseAction.ts` | `editor/app/sites/lib/cutReleaseAction.ts` | `:114` → `revalidatePath("/sites")` |
+| `editor/app/deploy/components/BuildAllSitesButton.tsx` | `editor/app/sites/components/` | `:7` `../../build/buildAction` → `../lib/buildAction` |
+| `…/BuildDeployButton.tsx` | `editor/app/sites/components/` | `:5` → `../lib/buildAction`; props/notice per finding 7 |
+| `…/BuildExportButton.tsx` | `editor/app/sites/components/` | `:5` → `../lib/buildAction`; finding 7 |
+| `…/BuildModeToggle.tsx` | `editor/app/sites/components/` | `:5` `../buildModeAction` → `../lib/buildModeAction` |
+| `…/BuildSitesPanel.tsx` | `editor/app/sites/components/` | `:4` → `../lib/buildAction`; `:61-64` comment "on /deploy" → "on /sites" |
+| `…/CutReleaseForm.tsx` | `editor/app/sites/components/` | `:4-7` `../cutReleaseAction` → `../lib/cutReleaseAction` |
+| `…/DeployButton.tsx` | `editor/app/sites/components/` | `:5` `../deployAction` → `../lib/deployAction`; finding 7 |
+| `…/JobLane.tsx` | `editor/app/sites/components/` | none (`../../jobs/actions` is the same depth) |
+
+`editor/app/changelog/page.tsx:12` → `import { CutReleaseForm } from
+"../sites/components/CutReleaseForm";`. **Interim (undone in commit 3):**
+`editor/app/build/components/BuildButtons.tsx:11-20` `../buildAction` →
+`../../sites/lib/buildAction`. **`git rm`** `editor/app/deploy/page.tsx`; `editor/app/deploy/`
+is gone.
+
+**Finding 7, exactly:** `BuildDeployButton.tsx:8-14` → `siteId: string; siteTitle: string;
+cloudflareProject: string | null;`; `:23` `ready = Boolean(cloudflareProject)`; `:26-33`
+deleted, the `if/else if/else` becomes `if (!cloudflareProject) … else …`; `:51` `siteId as
+string` → `siteId`. `BuildExportButton.tsx:9-14` → `siteId: string; siteTitle: string;`; `:30`
+`disabled={!siteId}` deleted; `:35-43` → the `<p>` "Building **{siteTitle}** (`{siteId}`)."
+only; `:26` cast dropped. `DeployButton.tsx:7-11`, `:21`, `:23-32` likewise. Grep gate: `Select
+a specific site` → 0 after this commit.
+
+**New `editor/app/sites/[siteId]/publish/page.tsx`:** `metadata.title = "Publish"`; guard via
+`getSiteCached`; `existingQueues = getRegistry().activeQueueNames()`; `getLastDeployedAt()`
+moved from `deploy/page.tsx:37-44` into this file. Body = `deploy/page.tsx:108-168` with the
+site's values:
+
+- `<section>` h2 **"Build & deploy"** (`:111`), the two `<p>`s (`:112-121`, "Last deployed:
+ …"), one added line "Build mode (Basic or Docker) is set on the <Link
+ href="/sites">Sites</Link> page.", `<BuildDeployButton siteId={siteId} siteTitle={site.siteTitle
+ || siteId} cloudflareProject={site.cloudflareProject ?? null} />`. **No `BuildModeToggle`
+ here** — the mode is the family's (decision A) and `deploy-page.spec.ts:70,114` finds the
+ group on `/sites`.
+- `<section>` `<details><summary>`**Individual steps**`</summary>` (`:133-167` verbatim): h3
+ "Build static export" + `<BuildExportButton existingQueues siteId siteTitle />`, h3 "Deploy
+ static export" + `<DeployButton siteId siteTitle />`.
+
+`SiteTabs.tsx` gains the Publish tab (the commented line in commit 1).
+
+**`editor/app/sites/page.tsx`** (commit 2 state; commits 3 and 4 add sections): `export default
+async function SitesPage()`; reads add `loadExportChangelog()` (moved from
+`deploy/page.tsx:26-35`), `lastVersion`/`suggestedVersion`/`unreleased`/`hasUnreleased`
+(`:53-56`), `buildMode = getSettings().buildPipeline.mode`. Sections, in order, each after the
+first `border-t border-border pt-6`:
+
+1. The list as today (`:13-56`), wrapped in a `<section>`.
+2. `<section>` h2 **"Release notes"** — `deploy/page.tsx:82-106` verbatim (`Changelog` limit 1,
+ `CutReleaseForm workspace="export"`).
+3. `<section>` h2 **"Build all sites"** — `<p>` (`:174-177`), then `<BuildModeToggle
+ mode={buildMode} />` (moved up from the retired "Build & deploy" section: the mode decides
+ whether the batch fans out, `serial={buildMode === "basic"}`), `<BuildAllSitesButton
+ dockerMode={buildMode === "docker"} />`, h3 **"Or pick specific sites"** + `<BuildSitesPanel
+ …/>` (`:181-195` verbatim).
+
+Order rationale, stated in a comment: the list first because the family is the page's noun;
+then the release → batch lifecycle the old page read as (`deploy-page.spec.ts:28`). The intro
+`<p>` (`sites/page.tsx:23-27`) stays.
+
+**`editor/app/settings/components/SettingsForm.tsx:288`** → `<a href="/sites"
+className="underline">Sites</a> page`.
+
+**`editor/next.config.ts`:** add `{ source: "/deploy", has: [siteQuery], destination:
+"/sites/:site/publish", permanent: false }, { source: "/deploy", destination: "/sites",
+permanent: false }`.
+
+**`nav.ts`:** delete `:93` (Deploy) and `Rocket` (`:13`); Sites keywords += ` deploy publish
+release cloudflare`; the interim comment → "folds these two into the family page".
+
+**Specs (commit 2):**
+
+- `deploy-page.spec.ts:5-7` comment "/deploy renders two controls" → "/sites renders two
+ controls"; test 1 (`:15-34`) → title "publishing splits: the family page releases and
+ batches, the site page builds & deploys":
+ ```ts
+ await writeSite("testsite", { cloudflareProject: "proj" });
+ await page.goto("/sites");
+ const release = page.getByRole("heading", { name: "Release notes" });
+ const batch = page.getByRole("heading", { name: "Build all sites" });
+ await expect(release).toBeVisible();
+ await expect(batch).toBeVisible();
+ expect((await release.boundingBox())!.y).toBeLessThan((await batch.boundingBox())!.y);
+ await expect(page.getByRole("heading", { name: "Build & deploy", exact: true })).toHaveCount(0);
+ await page.goto("/sites/testsite/publish");
+ await expect(page.getByRole("heading", { name: "Build & deploy", exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Build all sites" })).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Release notes" })).toHaveCount(0);
+ ```
+ Test 2 (`:36-62`): `:43` → `/sites/with-proj/publish`, `:52` → `/sites/no-proj/publish`;
+ comments `:45-47,:54-56` → "exact: true — kept from the /deploy days, where 'Build & deploy
+ all sites' sat below; harmless here." Test 3 (`:64-117`): `:68,:111` → `/sites`. Test 4
+ (`:119-147`): `:127` → `/sites`.
+- `site-scope.spec.ts:70-93` → title "publish targets the site in the path, and says when it
+ cannot deploy":
+ ```ts
+ await twoSites(); // alpha has no cloudflareProject
+ await page.goto("/sites/alpha/publish");
+ await expect(page.getByRole("heading", { name: "Build & deploy", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Build & deploy", exact: true })).toBeDisabled();
+ await expect(page.getByText(/has no Cloudflare Pages project configured/i)).toBeVisible();
+ await expect(page.getByText(/select a specific site/i)).toHaveCount(0);
+ await expect(page.getByLabel("Active site")).toHaveValue("alpha");
+ await page.getByLabel("Active site").selectOption("beta");
+ await expect(page).toHaveURL(/\/sites\/beta\/publish$/);
+ ```
+ (`:88` comment gone with the test.)
+- `build.spec.ts:86` → `/sites/testsite/publish`; `:85` comment "On the deploy page" → "On the
+ site's Publish tab". (`:79` stays `/build` until commit 3.)
+- `settings.spec.ts:92-93` → "The Sites page reflects the persisted mode." / `/sites`.
+- `navigation.spec.ts`: `/deploy` joins the retired list.
+
+---
+
+## Commit 3 — `sites: the pool runs from the family page`
+
+**Moves (`git mv`):** `editor/app/build/components/BuildButtons.tsx` →
+`editor/app/sites/components/BuildButtons.tsx` (`:11-20` → `../lib/buildAction`; `:9-10,:21`
+unchanged); `editor/app/build/components/ArchiveOptionsControl.tsx` →
+`editor/app/sites/components/`. **`git rm`** `editor/app/build/page.tsx`; `editor/app/build/`
+is gone.
+
+**`editor/app/sites/page.tsx`** gains (after "Build all sites"): `BUILD_KINDS` moved from
+`build/page.tsx:11-18` verbatim with:
+
+```ts
+// Which live jobs the Pool section lists. SIX KINDS, AND THE SET IS WRONG IN
+// BOTH DIRECTIONS: the three live-chat kinds the buttons below enqueue are
+// missing, and build-export (enqueued from a site's Publish tab, never here)
+// is in. Carried as-is from /build — recorded in plans/editor-ia-slice-5.md,
+// not fixed by it.
+```
+
+reads `existingQueues = getRegistry().activeQueueNames()` (the Pool's `BuildButtons` takes it)
+and `activeJobs = await liveJobRows((j) => BUILD_KINDS.has(j.kind))` (`build/page.tsx:23-25`
+with its comment). Section:
+
+```tsx
+<section className="flex flex-col gap-3 border-t border-border pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Pool</h2>
+ <p className="text-sm text-muted-foreground">
+ The shared channel pool every site is a selection over: the search index, the
+ stats dataset, the transcript and live-chat sidecars and the downloadable
+ archives. Corpus-wide — no site is involved.
+ </p>
+ </div>
+ {/* Above the disclosure: a running pool job must be visible without a click. */}
+ <RunningJobsList jobs={activeJobs} />
+ {/* Eight job consoles; the family page does not open on them. */}
+ <details>
+ <summary className="cursor-pointer text-lg font-semibold select-none">Pool jobs</summary>
+ <div className="pt-4"><BuildButtons existingQueues={existingQueues} /></div>
+ </details>
+</section>
+```
+
+**`editor/e2e/helpers.ts`:** `:12` → `import { expect, type Page } from "@playwright/test";`;
+append:
+
+```ts
+// Run the corpus-wide Build index job from the family page, the way a user
+// does: open the Pool disclosure, press the button, wait for "Done". The
+// controls sit under <details> so /sites does not open on eight job consoles,
+// and getByRole ignores what a closed disclosure hides — hence the click first.
+// Replaces the six-line copy three specs carried inline. goto() resets the
+// disclosure, so calling this twice in one test is fine.
+export async function buildIndex(page: Page, timeout = 30_000) {
+ await page.goto("/sites");
+ await page.getByText("Pool jobs", { exact: true }).click();
+ await page.getByRole("button", { name: "Build index" }).click();
+ await expect(page.getByLabel("Build index output")).toContainText("Done", { timeout });
+}
+```
+
+**Specs (commit 3):**
+
+- `build.spec.ts`: `:9-13`, `:28-32`, `:55-59` → `await buildIndex(page);` (import added at
+ `:2`); test 4 (`:70-103`) → title "build static export lives on the site's Publish tab, not
+ the family page"; `:78-83` →
+ ```ts
+ await page.goto("/sites");
+ await page.getByText("Pool jobs", { exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Build static export" })).toHaveCount(0);
+ await expect(page.getByRole("heading", { name: "Build index" })).toBeVisible();
+ ```
+ (`:86-103` as commit 2 left them.)
+- `channel-build-toggle.spec.ts:16-22` deleted; `:2` imports `buildIndex`; `:32,:65,:93`
+ `runBuild(page)` → `buildIndex(page)`.
+- `regional-vtt-fallback.spec.ts:18-27` → keeps a local `seedAndBuild(page)` =
+ `writeSite("testsite", { channels: [{ slug: CHANNEL, groupId: "default" }] })` then
+ `buildIndex(page)`; `:41,:58` call it (the local name `buildIndex` would shadow the import).
+- `duplicate-shorts.spec.ts:177-188` → `await buildIndex(page);` then the stats click and its
+ assertion (`:183-187`) unchanged — the disclosure is still open on the same page.
+- `jobs.spec.ts:21-25` → `await buildIndex(page);`. `availability.spec.ts:190-194` → `await
+ buildIndex(page);` ("Done" is a substring of "Done in").
+- `dashboard.spec.ts:108` title → "sidebar links to Channels, Sites, Jobs, Settings";
+ `:116-119` → `sidebar.getByRole("link", { name: /sites/i })` / `"/sites"`.
+- `navigation.spec.ts`: `/build` joins the list.
+
+**Copy and comments:** `EditorChartsClient.tsx:34-38` "on the Build page first" → "under Pool
+on the Sites page first"; `sites/[siteId]/charts/page.tsx` link → `href="/sites"` "the Pool on
+the Sites page"; `channels/[slug]/normalizeActions.ts:10` "a corpus-wide button on /build" →
+"a corpus-wide button in the Pool on /sites"; `common/controller/normalizeAll.ts:16` "what the
+/build button has always done" → "what the Pool's button on /sites has always done";
+`common/jobs/jobKinds.ts:134` "corpus-wide from /build" → "corpus-wide from the Pool on /sites".
+
+**`next.config.ts`:** `{ source: "/build", destination: "/sites", permanent: false }`.
+**`nav.ts`:** delete `:94` (Build) and `Hammer` (`:7`); keywords += ` build static export pool
+index stats archives normalize live chat`.
+
+---
+
+## Commit 4 — `sites: the hub is the family's, and the Sites group is one entry`
+
+**Moves (`git mv`):** `editor/app/homepage/actions.ts` →
+`editor/app/sites/lib/homepageActions.ts` (`:50` → `revalidatePath("/sites")`);
+`editor/app/homepage/HomepageConfigForm.tsx` →
+`editor/app/sites/components/HomepageConfigForm.tsx` (`:5` `./actions` →
+`../lib/homepageActions`). **`git rm`** `editor/app/homepage/page.tsx`; the directory is gone.
+
+**`editor/app/sites/page.tsx`** gains, between "Build all sites" and "Pool": `<section>` h2
+**"Hub"** + the `<p>` from `homepage/page.tsx:17-24` verbatim (it already explains the
+`homepage` package and that only the operator-facing bits are edited) + `<HomepageConfigForm
+config={getHomepageConfig(paths)} />`. (`homepage/page.tsx:16` h1 "Homepage (project site)"
+and `:28` h2 "Config" do not survive — the section is the config.) Final order: list, Release
+notes, Build all sites, Hub, Pool.
+
+**`editor/next.config.ts`:** `{ source: "/homepage", destination: "/sites", permanent: false }`.
+Final array order: the five slice-4/8 entries (`:74-78`), then `/charts` has, `/charts`,
+`/aliases` has, `/aliases`, `/deploy` has, `/deploy`, `/build`, `/homepage`.
+
+**`editor/app/lib/nav.ts`:** delete `:95` (Homepage), `House` (`:8`), the interim comment;
+`:42` → "The entries marked "interim" were folded by later slices; the end state is eleven, and
+slice 5 reached it (2026-08-30)."; Sites final: `{ href: "/sites", label: "Sites", icon: Globe,
+keywords: "family charts stats graphs search aliases synonyms suggestions regex search terms
+deploy publish release cloudflare build static export pool index stats archives normalize live
+chat hub homepage" }` — the palette (`CommandPalette.tsx:256-263` matches on `label + keywords
++ group`) still answers "charts", "deploy", "aliases", "build", "hub".
+
+**New `editor/app/lib/nav.test.ts`** (node:test): `NAV_GROUPS` labels are
+`["Corpus","Operations","Sites","Machine"]`; `NAV_LINKS.length === 11`; hrefs unique; the
+Sites group is exactly `["/sites"]`; none of `/charts /aliases /deploy /build /homepage
+/jobs/active /jobs/queue /actionable /auto-queue /scheduler` is an href. Caveat: `nav.ts`
+imports `lucide-react` values; if `tsx --test` cannot load it, the test is dropped and the
+grep gate (`label: "Charts"|…` → 0; link count by grep) stands in — report which.
+
+**Specs (commit 4):** `navigation.spec.ts`: `/homepage` joins the list (final: `["/charts",
+"/aliases", "/deploy", "/build", "/homepage"]`). No homepage e2e existed (census a); none is
+added — the form's action is unchanged and `HomepageConfigForm` is a `git mv`.
+
+---
+
+## Locator / URL table (old → new; which spec pins it)
+
+| old | new | pinned by |
+|---|---|---|
+| `/charts?site=<id>` | `/sites/<id>/charts` (307, query kept) | `site-scope:63,66` (rewritten), `navigation` (new) |
+| `/charts?site=__all__`, bare `/charts` | `/sites` (307) | `navigation` (new) |
+| `/aliases`, `/aliases?site=testsite` | `/sites/testsite/aliases` | `aliases:16,25,52` |
+| `/deploy?site=<id>` | `/sites/<id>/publish` (307) | `deploy-page:43,52`, `site-scope:74,87` (rewritten), `navigation` |
+| `/deploy?site=<id>` (mode / batch tests), bare `/deploy` | `/sites` | `deploy-page:19,68,111,127`, `settings:93`, `build:86` → publish |
+| `/build` | `/sites` + "Pool jobs" click | `build:9,28,55,79`, `channel-build-toggle:17`, `regional-vtt-fallback:22`, `duplicate-shorts:178`, `jobs:21`, `availability:190` (all via `buildIndex(page)`) |
+| `/homepage` | `/sites` | `navigation` (new) |
+| h1 "Charts" / "Search aliases" / "Deploy" / "Build" / "Homepage (project site)" | gone; h1 is the site title (tabs) or "Sites" (family) | `navigation` (heading "Sites" level 1) |
+| h2 "Release notes", "Build all sites", h3 "Or pick specific sites", group "Build specific sites", group "Build mode", "Select Alpha", "2 selected", "Deploy after build", "Build selected"/"Build & deploy selected", `/Basic mode runs these one at a time/i`, `/Docker builds are a follow-up/i`, Basic/Docker `aria-pressed` | unchanged, on `/sites` | `deploy-page:21-33,70-116,129-146`, `settings:94-98` |
+| h2 "Build & deploy", "Individual steps", h3 "Build static export", button "Build static export", "Skip data rebuild (index, stats, charts)", button "Build & deploy" (exact), `/has no Cloudflare Pages project configured/i` | unchanged, on `/sites/<id>/publish` | `deploy-page:48,57,60`, `build:87-102`, `site-scope` (rewritten) |
+| `/select a specific site from the sidebar to build & deploy\./i`, `/select a specific site/i` (charts) | **gone** (count 0 pinned) | `site-scope` (rewritten) |
+| `/author the default dashboard/i` | unchanged, on the Charts tab | `site-scope` (rewritten) |
+| `alias-section-global|site`, `alias-label-*`, `alias-triggers-*`, `alias-suggestion-*`, `alias-add-site`, `alias-save-*`, `alias-saved-*`, h2 "Global", h2 "This site — testsite" | unchanged, on the Search aliases tab | `aliases:17-59` |
+| button "Build index", label "Build index output", "Build stats dataset", "Build stats dataset output", "Stats built" | unchanged, inside the Pool disclosure | the nine build specs |
+| h2 "Build index" visible / h2 "Build static export" absent | on `/sites` after opening "Pool jobs" | `build:80-83` |
+| sidebar link `/build/i` → `/build` | `/sites/i` → `/sites` | `dashboard:116-119` |
+| `getByLabel("Active site")` value on `/sites/<id>/<tab>` | = the path's id; `selectOption` navigates | `site-scope` (rewritten) |
+| `"Charts tab"` link `aria-current="page"` | new | `navigation` (new) |
+| `/site id/i`, `/site title/i`, `/save site/i`, `role=status` "Saved", `/delete/i`, `/migrate existing settings/i`, link `/Alpha Site/` | unchanged | `sites-crud` (11 tests) |
+| `/^cut release$/i`, "Cut release 9.9.10." on `/changelog` | unchanged (file moved, import repointed) | `cut-release.spec.ts` |
+| `/sites/definitely-not-a-site` → 404 | unchanged (the layout's `notFound()`) | `navigation:132-140` |
+
+## Verification
+
+1. After each of commits 1–4: tsc in the six packages; `pnpm -C common test`; editor units
+ (`activeSite.test.ts`, `nav.test.ts` included).
+2. Grep gates after commit 4, over `editor/ common/` excluding `node_modules`, `.next`,
+ `CHANGELOG.md`, `plans/`:
+ - `"/(charts|aliases|deploy|build|homepage)(\?|"|/)` and the backtick form as URLs → only
+ `editor/next.config.ts` (the `source`s + comment) and `editor/e2e/navigation.spec.ts`.
+ (`site-scope.spec.ts` deliberately pins no retired URL.)
+ - `editor/app/charts editor/app/aliases editor/app/deploy editor/app/build
+ editor/app/homepage` do not exist; `editor/app/sites/lib/{buildDeployCore,buildAction,
+ deployAction,cutReleaseAction,buildModeAction,homepageActions,chartsActions,
+ aliasesActions}.ts` and `editor/app/sites/[siteId]/{layout.tsx,lib/siteCache.ts,
+ components/SiteTabs.tsx,charts/page.tsx,aliases/page.tsx,publish/page.tsx}` do.
+ - `Select a specific site|from the sidebar|in the sidebar to` → 0 under `editor/app`.
+ - `label: "Charts"|label: "Deploy"|label: "Build"|label: "Homepage"|label: "Search
+ aliases"|ChartColumnBig|Hammer|House|Regex|Rocket` in `nav.ts` → 0; `href:` count in
+ `nav.ts` → 11; `Interim` in `nav.ts` → 0.
+ - `revalidatePath("/charts")|revalidatePath("/deploy")|revalidatePath("/homepage")` → 0;
+ `revalidatePath("/sites")` → 8 (was 5); `revalidatePath("/sites/[siteId]"` → 2 (`"layout"`
+ in `sites/actions.ts`, `/charts", "page"` in `sites/lib/buildAction.ts`).
+ - `seedsSiteParam = !pathname.startsWith("/sites")` → still exactly one hit
+ (`SiteScopeSelect.tsx`); `siteIdFromPathname` → `activeSite.ts`, its test,
+ `SiteScopeSelect.tsx`.
+ - `^"use server"` → present in the seven action files under `sites/lib/`, absent from
+ `buildDeployCore.ts`.
+ - `from "\.\./\.\./(build|deploy)/` → 0; `from "\.\./deploy/components/CutReleaseForm"` → 0
+ (`changelog/page.tsx` repointed).
+ - `page.goto("/build")|goto("/deploy|goto("/charts|goto("/aliases|goto("/homepage` under
+ `editor/e2e` → only `navigation.spec.ts`; `getByRole("button", { name: "Build index" })`
+ under `editor/e2e` → only `helpers.ts`.
+ - `Individual steps` → `sites/[siteId]/publish/page.tsx` + `build.spec.ts`; `Pool jobs` →
+ `sites/page.tsx`, `helpers.ts`, `build.spec.ts`.
+ - the render-path guard: `pnpm -C common exec tsx --test
+ controller/noCorpusWalkInRenderPaths.test.ts`.
+3. e2e once after commit 5, detached (`cd editor && setsid nohup sh -c 'pnpm e2e -- <specs>;
+ echo exit=$?' > $CLAUDE_JOB_DIR/tmp/e2e.log 2>&1 < /dev/null & disown`):
+ `site-scope.spec.ts deploy-page.spec.ts build.spec.ts aliases.spec.ts navigation.spec.ts
+ dashboard.spec.ts settings.spec.ts sites-crud.spec.ts cut-release.spec.ts
+ channel-build-toggle.spec.ts regional-vtt-fallback.spec.ts duplicate-shorts.spec.ts
+ jobs.spec.ts availability.spec.ts channel-site-membership.spec.ts perf-budget.spec.ts
+ pulse.spec.ts auto-refresh.spec.ts` (there is no `changelog.spec.ts`; `cut-release.spec.ts`
+ is the changelog page's). Port fallback `PORT=3111 EXPORT_PORT=3110 OLLAMA_STUB_PORT=11535`;
+ never kill anything.
+4. **No editor boot against `transcripts/`, nothing written under it.**
+5. Manual (optional, `PORT=3021 pnpm dev:test`): `/sites` reads list → Release notes → Build
+ all sites (Basic|Docker) → Hub → Pool (closed); open "Pool jobs", run Build index, see its
+ card above the disclosure; `/sites/testsite` shows the h1 and four tabs with Settings
+ current; Charts/Search aliases/Publish tabs render, the picker reads `testsite`, switching
+ it (with a second site) lands on the same tab of the other site, "All sites" lands on
+ `/sites`; `/charts?site=testsite` → the Charts tab with `?site=testsite` still in the bar;
+ `/deploy`, `/build`, `/homepage` → `/sites`; the sidebar's Sites group has one entry; the
+ palette finds Sites by "deploy" and "hub"; `/sites/nope/charts` → 404.
+
+## Commit 5 — `plans: slice 5 shipped, and the docs say so`
+
+- `editor/CHANGELOG.md` `[Unreleased]` first bullet, house voice (`:4` is the model): **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.**
+- `plans/editor-operations-ia.md:72` → `- **Sites** — Sites *(Charts, Search aliases, Deploy,
+ Build and Homepage folded in, 2026-08-30)*`; `:147-150` bullet 5 → "**Sites absorb charts /
+ aliases / deploy / build / homepage — SHIPPED** (`<sha1>` → `<sha5>`; see "Slice 5, as
+ shipped" below). The bullet's premise — "all per-site facts already" — was wrong for two of
+ five …"; new `## Slice 5, as shipped` after the 8c section (`:603-`): the four commits; the
+ three operator decisions (A, B, the nav end state) with the reason each was needed; findings
+ 1–16; what stayed (`seedsSiteParam` and its reason, `BUILD_KINDS`' mismatch,
+ `EditorAliasesClient`'s null branch, the charts preview's last-composed data, the hub deploy
+ script's project name, `RunningJobsList` as the Pool's renderer, `/api/*` untouched); the
+ flat `sites/components/` decision and why.
+- `plans/STATE.md:6` "Last updated" prepend (previous demoted to "Previously:"); after `:301`
+ (#12) a `13. ~~**Editor IA slice 5** (Sites absorb the five)~~ — **DONE 2026-08-30**, `<sha>`
+ → `<sha>`. Plan: [`editor-ia-slice-5.md`]; outcome: "Slice 5, as shipped" and the FACTS
+ section "Verified 2026-08-30 — editor IA slice 5 seams". The nav is eleven; the IA doc's UI
+ slices are complete.`; `:308-311` "Recommended next (editor IA)" → the **transcode band**
+ (`EXTERNAL_BAND_IDS` to three, needs the snapshot writer), **Phase 6**, and **slice 9**
+ (gated on the sweep running in production).
+- `plans/FACTS.md`: append `## Verified 2026-08-30 — editor IA slice 5 seams (a site has tabs;
+ the family has one page)` after the 8c section: census (b)'s table; the seven import edges
+ and where each went; `matchHas` anchoring (`prepare-destination.js:101`) and the query merge
+ (`:278-284`, `resolve-routes.js:509-517`); the metadata template rule
+ (`generate-metadata.md:284-287`); `revalidatePath` literal vs `"layout"`
+ (`revalidatePath.md:38-39,151,155-162`); `seedsSiteParam`'s reason and the path rule beside
+ it; `BUILD_KINDS` in both directions; `_homepage` and `isValidSiteId`; the hub deploy
+ script's hardcoded project; `idBaseUrl` absolute URLs; `getSite` throwing on a malformed id
+ (why the tab pages guard through `getSiteCached`); the e2e labels newly co-resident on
+ `/sites` (three occurrences of a site title) and why none collides.
+- Memory: left to the reviewer (`ia-slice-5-shipped.md` + a `MEMORY.md` line).
+
+## Out of scope
+
+Fixing `BUILD_KINDS`. Narrowing `EditorAliasesClient`'s `siteId: string | null`. Per-site
+charts preview data (composing the `[siteId]` for the preview). The hub's deploy script vs the
+form's `cloudflareProject`; `socialLinks` on `HomepageConfig`; the three adjacent "hub URL"
+globals; the dead `readHomepageTemplates`/`writeHomepageTemplates` (census b). Cookies for the
+active site. A nav-count e2e (the node test is it). `/api/*` — nothing there moves. Data
+migration — none.
+
+## Handoff — the cadence
+
+On approval, Fable does not implement (memory `plan-then-opus-implements`): write the plan to
+`plans/editor-ia-slice-5.md`, commit it alone, then spawn one `general-purpose` agent, `model:
+"opus"`, with: the plan path; the shell caveats (the Bash tool runs zsh — quote every glob and
+every `[siteId]`/`[slug]` path, `--include='*.ts'`; no word-splitting of `$VAR` commands; POSIX
+loops inside `sh -c`; `git commit -F <file under $CLAUDE_JOB_DIR/tmp>`; stage by explicit path,
+never `git add -A`; `git mv` for every move so history survives); never boot against
+`transcripts/`; e2e detached (memory `e2e-run-detached`); tmp under `$CLAUDE_JOB_DIR/tmp`; the
+two trailer lines; the report contract (shas + one line each; exact gate outputs incl. the
+unit-test count before and after; e2e per spec with retries; every divergence and why; whether
+`nav.test.ts` loaded `lucide-react`; anything undone). Fable reviews on return (`git log
+--oneline 2a8081e..`; `[siteId]/layout.tsx`, `siteCache.ts`, `SiteTabs.tsx`; `activeSite.ts` +
+test; the `SiteScopeSelect.tsx` hunks — `onSite`, the effect, `onChange`, the untouched `:30`;
+`next.config.ts` array order and `siteQuery`; the three tab pages; `sites/page.tsx` section
+order; every moved file diff-against-rename (import lines only, plus finding 7's three
+components); `nav.ts`; `sites/actions.ts:193`; the deletions; the twelve repointed specs and
+`helpers.ts`; re-runs grep gates + `pnpm -C common test` + editor units, not e2e), sends fixes
+via SendMessage, and reports.
+
+---
+
+# Appendix — the census (verified 2026-08-30 at `2a8081e`)
+
+## Census (a) — specs, links, nav arithmetic
+
+**Nav today** (`nav.ts`): Corpus 3, Operations 1, Sites 6 (`/sites`, `/charts`, `/aliases`,
+`/deploy`, `/build`, `/homepage`), Machine 6 = 16. Minus five = **11** (`nav.ts:42` "the end
+state is eleven" holds). `nav.ts:89-90` says "folds these FOUR" but lists five. Palette
+(`CommandPalette.tsx:254`) draws `NAV_GROUPS`, so the five leave it too. `navigation.spec.ts`
+has NO nav-count test; its redirect test (`:110`) is the shape to extend, and its 404 test
+(`:132`) already covers `/sites/definitely-not-a-site`.
+
+**Specs that hit the five URLs:**
+- `/build` (9 gotos): `build.spec.ts:9,28,55,79`, `channel-build-toggle.spec.ts:17` (helper
+ `runBuild`), `regional-vtt-fallback.spec.ts:22` (helper `buildIndex`),
+ `duplicate-shorts.spec.ts:178` (helper `buildData`, also "Build stats dataset"),
+ `jobs.spec.ts:21`, `availability.spec.ts:190`. All pin `getByRole("button", { name: "Build
+ index" })` + `getByLabel("Build index output")` (aria-label is `${title} output`,
+ `deploy/components/JobLane.tsx:201` — route-independent). `build.spec.ts:79` pins h2 "Build
+ index" visible AND "Build static export" absent on `/build`, then `/deploy` (`:86`) →
+ "Individual steps" click → h2 "Build static export", button, label "Skip data rebuild
+ (index, stats, charts)". Three specs carry the same six-line build helper inline.
+- `/deploy` (8 gotos): `deploy-page.spec.ts:19,43,52,68,111,127` (all `?site=<id>`; pins h2
+ "Release notes" / "Build & deploy" / "Build all sites" and their vertical order; button
+ "Build & deploy" exact enabled/disabled; `/has no Cloudflare Pages project configured/i`;
+ group "Build mode" → "Basic"/"Docker" `aria-pressed`; `/Docker builds are a follow-up/i`
+ (`BuildModeToggle.tsx:58`); group "Build specific sites" → "Select Alpha"/"Select Beta", "2
+ selected", "Deploy after build", "Build selected"/"Build & deploy selected", `/Basic mode
+ runs these one at a time/i`); `settings.spec.ts:93` (bare `/deploy`, Build mode → Docker
+ pressed); `site-scope.spec.ts:74,87` (`?site=__all__` → `/select a specific site from the
+ sidebar to build & deploy\./i` + "Build & deploy" disabled; `?site=alpha` → text count 0).
+- `/aliases` (3 gotos): `aliases.spec.ts:16,25,52` — testids `alias-section-global|site`,
+ `alias-label-*`, `alias-triggers-*`, `alias-suggestion-*`, `alias-add-site`, `alias-save-*`,
+ `alias-saved-*` (templated in `EditorAliasesClient.tsx`; route-independent). `:25` uses
+ `?site=testsite`; `:16,:52` are bare (Global section). Reads `test-transcripts/
+ search-aliases.json` (global) and `sites/testsite/search-aliases.json` (per-site).
+- `/charts` (2 gotos): `site-scope.spec.ts:63,66` — `?site=__all__` → `/select a specific
+ site/i`; `?site=alpha` → `/author the default dashboard/i`.
+- `/homepage`: **zero e2e coverage**. h1 "Homepage (project site)" (`homepage/page.tsx:16`).
+- `dashboard.spec.ts:108-119`: `sidebar.getByRole("link", { name: /build/i })` →
+ `href="/build"` — the ONLY nav-href assertion for any of the five.
+
+**In-app links to the five** (non-e2e): `nav.ts:91-95`; `sites/[siteId]/page.tsx:59`
+`href={`/charts?site=${siteId}`}` "Edit charts dashboard →" (the one existing per-site
+cross-link); `aliases/page.tsx:47` → `/build`; `charts/page.tsx:59` → `/build`, `:27` →
+`/sites`; `settings/components/SettingsForm.tsx:288` `<a href="/deploy">`. No `router.push`/
+`redirect()` targets them. `common/` has none.
+
+**`revalidatePath`:** `homepage/actions.ts:50` (`/homepage`); `build/buildAction.ts:64`
+(`/charts`); `deploy/buildModeAction.ts:24` (`/deploy`); `deploy/cutReleaseAction.ts:112-114`
+(`/`, `/changelog`, `/deploy`); `sites/actions.ts:192-193,207,217-218`.
+
+**Comment-only mentions to reword:** `nav.ts:42,89-90`; `channels/[slug]/normalizeActions.ts:10`;
+`common/controller/normalizeAll.ts:16`; `common/jobs/jobKinds.ts:134`;
+`deploy/components/BuildSitesPanel.tsx:62`; `SiteScopeSelect.tsx:14-16,25-30`;
+`deploy-page.spec.ts:5-7`, `site-scope.spec.ts:88`, `build.spec.ts:99`.
+
+**HAZARD — `SiteScopeSelect.tsx:30`:** `seedsSiteParam = !pathname.startsWith("/sites")` —
+the picker deliberately does not seed `?site=` on `/sites*` routes. Charts and Deploy read
+`?site=` today; under `/sites/[siteId]/…` the site is the PATH segment, so those pages must
+read `params.siteId`, not `searchParams.site`, and the picker's guard stays.
+
+**No `/api/*` route** belongs to any of the five. Charts preview data comes via
+`next.config.ts` `rewrites()` (`/stats|/summaries|/transcripts` → `/exported/…`), absolute
+paths — unaffected by the move.
+
+**Layout:** `layout.tsx:66-120` `renderLink` is flat, one level; a per-site sub-nav is new
+construction. `sites/[siteId]/page.tsx`: `notFound()` guard `:36-38` (`isValidSiteId` +
+`listSiteIds`), `force-dynamic`, breadcrumb `:49-55`, h1 `{siteTitle || siteId}` `:57`,
+`generateMetadata` `${siteId} — Sites`.
+
+## Census (b) — what the five pages actually are (the IA bullet's premise is wrong for 2 of 5)
+
+| page | files / lines | scope | reads | writes (actions) | `?site=` |
+|---|---|---|---|---|---|
+| `/charts` | 3 / 171 | **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 (`compose-site.ts:682-686`) | `saveDefaultDashboardAction(siteId, dashboard)` (`charts/actions.ts:13-22`), no revalidate | yes, REQUIRES one site (`page.tsx:38-48`); `resolveActiveSite` (`lib/activeSite.ts:30-51`) |
+| `/aliases` | 3 / 453 | **global + per-site overlay** | `readGlobalAliases` → `transcripts/search-aliases.json` (`DEFAULT_ALIASES` fallback); `readSiteAliases` → `sites/<id>/search-aliases.json`; merged at compose (`effectiveSiteAliases`, `aliasesStore.ts:68-73`, per-site id SHADOWS global) | `saveGlobalAliasesAction`, `saveSiteAliasesAction` (`aliases/actions.ts:15-29`), no revalidate | optional; all-sites renders Global only; `EditorAliasesClient` draws h2 "Global" + h2 `This site — <id>` |
+| `/deploy` | 13 / 1706 | **per-site target + global chrome** | `listSites` (active site's `siteTitle`/`cloudflareProject` AND the whole batch list); `export/CHANGELOG.md` (global); `export/out/index.html` mtime (`resolveOutDir` ignores siteId, `buildDeployCore.ts:20-22`); `settings.buildPipeline.mode` (global) | `cutReleaseAction` (`revalidatePath` `/`,`/changelog`,`/deploy`), `setBuildModeAction` (`/deploy`), `deployExportAction(siteId)` (kind `deploy-export`, queue `deploy`); from `build/buildAction`: `buildExportAction`, `buildAndDeployAction`, `buildAllSitesAction`, `buildAndDeployAllSitesAction` | optional; null = single-site controls disabled, batch panel still lists every site |
+| `/build` | 4 / 889 | **corpus-wide, no site notion at all** | `activeQueueNames()`, `liveJobRows(BUILD_KINDS)` — no `searchParams`, no `getPaths`, no `listSites` | 11 actions in `build/buildAction.ts` (index, stats [`revalidatePath("/charts")` `:64`], export(siteId), build-deploy, normalize/archive ×3 transcripts, ×3 live-chat, build-all, build-deploy-all) | **never** |
+| `/homepage` | 3 / 170 | **global — the hub's identity** | `getHomepageConfig` → `transcripts/sites/_homepage/homepage.json` (`_homepage` is inside `sitesDir` with an underscore so it can never be a siteId, `paths.ts:22-28`) | `saveHomepageConfigAction` (`revalidatePath("/homepage")`) | **never**; page is sync |
+
+**Deploy's sections** (`deploy/page.tsx`): h2 "Release notes" (`:84`, `CutReleaseForm
+workspace="export"`), h2 "Build & deploy" (`:111`, `BuildModeToggle`, `BuildDeployButton`),
+`<details><summary>Individual steps` (`:135`: h3 "Build static export" `BuildExportButton`,
+h3 "Deploy static export" `DeployButton`), h2 "Build all sites" (`:173`, `BuildAllSitesButton`,
+h3 "Or pick specific sites" `BuildSitesPanel` `role=group aria-label="Build specific sites"`,
+visible `<span>{s.siteTitle}</span>` at `:83`).
+
+**Build's sections** (`build/components/BuildButtons.tsx`): eight `StreamActionLog`s (Build
+index, Build stats dataset, Normalize transcripts, Archive transcripts, Archive combined
+transcripts, Normalize live chat, Archive live chat, Archive combined live chat), each with a
+`QueueControl`, archives with `ArchiveOptionsControl`; then `RunningJobsList` over
+`BUILD_KINDS` (six kinds — missing the three live-chat kinds it enqueues and including
+`build-export` which no button here enqueues; pre-existing, note only).
+
+**`build/` ↔ `deploy/` are already circular:** `build/buildAction.ts:34` imports
+`deploy/buildDeployCore`; four `deploy/components/*` import `build/buildAction`. Also
+`changelog/page.tsx:12` imports `deploy/components/CutReleaseForm` (`workspace="editor"`;
+`cut-release.spec.ts` drives it on `/changelog`).
+
+**Homepage extra facts:** the hub package (`homepage/`) has its own `compose`/`build`/`deploy`
+scripts (`deploy` hardcodes `--project-name archilyzer`, so the form's `cloudflareProject`
+field is NOT what `pnpm deploy:homepage` uses); no editor action/job/button builds or deploys
+the hub. `socialLinks` exists on `HomepageConfig` but has no form field. Three adjacent
+globals: `HomepageConfig.siteUrl` (this page) vs `SiteSettings.homepageUrl` ("Family hub URL"
+on `/settings`) vs per-site `Site.hubUrl` (`SiteForm.tsx:249-251`). Dead neighbour:
+`readHomepageTemplates`/`writeHomepageTemplates` (`chartsStore.ts:90-105`) have no consumer.
+
+**The site picker** (`SiteScopeSelect.tsx`): localStorage key `"activeSite"`
+(`lib/activeSite.ts:13`), `ALL_SITES = "__all__"`, `usePathname`/`useSearchParams`/`useRouter`,
+`router.replace(`${pathname}?${params}`)`; seeds `?site=` from storage on every non-`/sites`
+route (`:30`). No cookies anywhere in the five directories.
+
+**Only poll among the five:** `deploy/components/JobLane.tsx:56-85` — `/api/jobs/<id>/log?from=…`
+at 1 s until terminal (a `/jobs` route; unaffected).
+
+## Census (c) — the `/sites` tree, the site model, scoping, precedents (Next 16.2.3)
+
+- **`/sites` tree** is six files: `sites/page.tsx` (59, sync, no `searchParams`; list + `+ New
+ site` + `MigrateButton`/`DeleteSiteButton`), `sites/[siteId]/page.tsx` (73: `force-dynamic`,
+ `generateMetadata` `${siteId} — Sites`, `notFound()` at `:36-38`, breadcrumb div `:49-55`,
+ h1 `{siteTitle || siteId}` `:57`, the `/charts?site=` link `:58-63`, `<SiteForm>`),
+ `sites/new/page.tsx` (53), `sites/actions.ts` (220: `saveSiteAction` one FormData over every
+ field incl. `cloudflareProject`, `archives`, `archiveMaxBytes`, `duplicates`, `pwa`, `hubUrl`;
+ `deleteSiteAction` rm -rf the site dir incl. `chart-templates.json` + `search-aliases.json`;
+ `migrateToSitesAction`), `components/SiteForm.tsx` (693, four fieldsets Channels / Channel
+ groups / Social links / Related sites, `role=status` "Saved."), `SiteListActions.tsx`.
+ **No `layout.tsx`, no tabs; `[siteId]` is a leaf.** `/sites/new` is a static sibling —
+ static wins over `[siteId]`, so a static child of `[siteId]` is fine, but a static child of
+ `/sites` (e.g. `/sites/publish`) would shadow a real site with that id (`SITE_ID_RE =
+ /^[a-z0-9][a-z0-9-]*$/`). Don't add static siblings of `[siteId]`.
+- **Model** (`common/lib/site.ts`): `Site` type `:38-100`; `getSite` `:356` (sync, throws on a
+ malformed id), `listSiteIds` `:371` (dir + `site.json`, sorted), `listSites` `:388`,
+ `defaultSiteId` `:394`, `writeSite` `:399` (atomic), `deleteSite` `:469`. Charts' store =
+ `siteChartTemplatesFile` `:124`; aliases' per-site store = `siteAliasesFile` `:131`.
+ `HomepageConfig` (`common/lib/homepage.ts:21-34`) at `sites/_homepage/homepage.json` —
+ `_homepage` is rejected by `isValidSiteId`, so the hub can never be a `[siteId]`.
+- **Scoping**: `lib/activeSite.ts` — `ALL_SITES="__all__"`, `ACTIVE_SITE_KEY="activeSite"`
+ (localStorage, no cookie), `resolveActiveSite(param, ids)` (lone site when unset).
+ Consumers today: `/` (`page.tsx:50-56`), `/channels` (`:117-123`), `/charts`, `/aliases`,
+ `/deploy`, `/channels/new` (client, `window.location.search`). `SiteScopeSelect.tsx`:
+ `seedsSiteParam = !pathname.startsWith("/sites")` `:30` (comment `:26-29`: seeding there
+ clobbered an in-flight push to `/sites/<id>`); reconcile effect `:41-62` WRITES storage when
+ a valid `?site=` is in the URL; `onChange` `:64-71` writes storage then `router.replace
+ (pathname?site=)`; "All sites" option only when `sites.length > 1`. **On a `/sites/<id>/…`
+ URL the picker would show the previously stored site while the page shows another.**
+- **`site-scope.spec.ts` test 5 (`:70-93`)** pins copy that says "from the sidebar" and an
+ all-sites disabled state that cannot exist under `/sites/<id>/…`; test 4 (`:61-68`) likewise
+ pins `/charts?site=__all__`. Both must be rewritten, not repointed. `sites-crud.spec.ts` (11
+ tests, 307 lines) is the `/sites` contract: `/site id/i`, `/site title/i`, `/save site/i`,
+ `getByRole("status").filter({ hasText: "Saved" })`, `getByRole("link", { name: /Alpha Site/
+ })` (`:59,65`), `getByText("Doomed")).toHaveCount(0)` after delete (`:91`); note `:56-59` —
+ the site title also appears in the picker, so bare `getByText(title)` matches two elements.
+- **Layout/nav**: `layout.tsx:60-63` reads `listSites()` only for the picker; `renderLink`
+ `:66-120` flat; **no active-link highlighting exists anywhere in the sidebar** (only
+ `ChannelTabs.tsx:37` uses `aria-current`). Docs verified: layouts do not rerender and cannot
+ read search params/pathname (`layout.md:154,180,240`); layout `params` is a Promise.
+- **Precedent for a dynamic segment with children**: `channels/[slug]/layout.tsx` (73 lines:
+ `force-dynamic`, own `notFound()` `:37`, React `cache()`d config read `:36`, breadcrumb
+ `:47-53`, h1 `:54-56`, `<ChannelTabs>` `:64`) + `ChannelTabs.tsx` (`"use client"`,
+ `useSelectedLayoutSegment()`, `aria-current="page"`, `aria-label="${label} tab"`). No
+ `loading.tsx` anywhere (navigation.spec header: it breaks 404 status). `/operations/sync` is
+ NOT a directory — it is `[id]/page.tsx` with a catalog entry; not a precedent here.
+- **Redirects** (`next.config.ts:48-80`): fixed source → fixed destination, always
+ `permanent: false`; docs `redirects.md:43-53,134-142`: query values pass through, `/:param`
+ path params, and `has: [{ type: "query", key, value: "(?<name>…)" }]` with `:name` in the
+ destination is the config-level way to route on `?site=`. `revalidatePath("/operations/[id]",
+ "page")` (`channels/actions.ts:312,388`) is the precedent for revalidating a dynamic route.
+- **`/api`**: nothing site/build/deploy/homepage-specific (26 handlers, zero `siteId` hits).
+ `exported/[...path]/route.ts` serves `exportPublicDir` for the charts preview; not
+ site-scoped (pre-existing).
+- **e2e helper** `helpers.ts:74-99` `writeSite(siteId, site)` supports `channels`, `groups`,
+ `cloudflareProject`, `siteUrl`, `relatedSites`, `socialLinks`; omits `pwa`/`archives`/
+ `duplicates`/`archiveMaxBytes`/`hubUrl`. `helpers.ts:12` imports only `type { Page }` from
+ Playwright.