Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aplans/editor-ia-slice-5.md | 1106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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&apos;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&apos;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.