# 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 `
` 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 — " 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//…`.** `SiteScopeSelect.tsx:30` `seedsSiteParam = !pathname.startsWith("/sites")` (reason at `:25-29`: the mount effect's `replace()` clobbered an in-flight push to `/sites/`); 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//")` 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-(?.*)` 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//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: "**** 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.