Archilyzer · Source

archilyzer

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

commit 8b16be4fd4d1b15e6f32580eb0d522d4d7f3236d
parent b2417977f0616dce23272efdf1f44816dbf8c519
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 00:19:14 -0400

plans: slice SS as shipped — the editor's site scope is a cookie

release-15.md: the SS row in the slices table and "Slice SS, as shipped"
before the Rollout (what was wrong, the store, the one writer and one read,
the provider and its two write-only effects, the picker's precedence, where
?site= is still read, commits, tests with the pre-change column, gates, found
and left, decisions). FACTS: the seedsSiteParam entry is marked superseded,
with the cookie's facts beside it. editor/CHANGELOG.md: one [Unreleased]
bullet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Mplans/FACTS.md | 34++++++++++++++++++++++++++++++++++
Mplans/release-15.md | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 236 insertions(+), 0 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -12,6 +12,7 @@ - **Two grounds, Light and Dark, and no accent picker in the header.** The editor's header keeps its theme toggle, which cycles System, Light and Dark; the theme menu (Base and Accent) is gone, and the editor wears its own accent, Signal. A stored choice of the retired third ground loads as Light and is rewritten once; a stored accent is not read and is left in storage. A site's accent is still set in its form; the form's hint no longer says a reader can pick another. - **The hub URL hints say what the setting does now.** Settings' **Family hub URL** and a site's **Hub URL** no longer promise a Hub link in the header (it was removed): the value is published as `hubUrl` in each site's `/site.json` and `/corpus.json`, so the hub can tell its member sites. `SETTINGS.md` and `SITE.md` say the same. - **A site can be left off the homepage and the hub.** A site's settings have a new checkbox, **List on the Archilyzer homepage and hub**, on by default (`listed` in `site.json`; only `false` is written). Turned off, the site still builds and deploys at its own URL as before, but the homepage has no card, chart series, `/stats` entry or recent item for it; the hub does not list it as a member, search it, or name it in its `corpus.json` and `llms.txt`; no other site's footer links it; and `channel-sites.json` and the homepage's `stats/` leave it out. A channel only unlisted sites carry is in none of the published totals, the homepage's headline numbers included; a channel a listed site also carries is counted under the listed site. The editor's own pages still show every site. It takes effect at the next homepage, hub and site builds. +- **The sidebar's site picker shows your site from the first paint.** It used to show "All sites" on every page and then jump to the site you had picked, and Dashboard and Channels came up in your site only after a `?site=` had been added to the address. The picked site is now kept in a cookie that the editor reads before it draws a page, so the picker, Dashboard and Channels open in it at once, and the address is left alone. A link that carries `?site=<id>` still opens that page in that site, without changing the one you picked; picking a site on such a page drops the `?site=` from the address. On a site's own pages (Charts, Publish, …) the picker still follows the page, and opening one still makes that site the picked one. The first time you open the editor after updating, a site picked before is moved into the cookie; the picker may show "All sites" for a moment that once. Each editor keeps its own pick, as before, when several run on one machine on different ports. ## [0.10.0] - 2026-09-28 - **The homepage can be built and deployed from `/sites`.** Under a new **Homepage** section, after Hub, there is **Build homepage** (tick **Deploy after build** to ship it in the same job, only if the build succeeds) and **Deploy homepage**, which ships the build already in `homepage/out`. A **Preview branch** box beside them sends either deploy to a Cloudflare Pages preview of the `archilyzer` project instead of production, and shows the preview's address as you type; a name Cloudflare would refuse or rewrite, or `main`, greys the deploy buttons out and says why. A line under the buttons says what a deploy would ship: when `homepage/out` was built (or that it holds no build yet), and where it goes, with the live URL. Deploy homepage with nothing built is refused before any job starts. The homepage reads the search index as it stands, so run **Build index** first when its numbers should move. The jobs run the same code as `archilyzer build homepage` / `deploy homepage`, and show on `/jobs` as `build-homepage`, `deploy-homepage` and `build-deploy-homepage`. The Hub section no longer describes the homepage. diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -3212,6 +3212,40 @@ IS the path segment, the reconcile effect writes it to localStorage (so Dashboar follow), `onChange` does `router.push("/sites/<other>/<segment>")` and "All sites" pushes `/sites`. +**Superseded by release 15 slice SS (2026-09-29): the active site is a cookie.** +`seedsSiteParam`, the reconcile effect and every localStorage read on the render path are gone; +`siteIdFromPathname` and the path rule stand. +- **Store:** the cookie `archilyzer-active-site-<port>` (`activeSiteCookieName(host)` in + `app/lib/activeSite.ts`; base name `ACTIVE_SITE_COOKIE`, a host with no port uses it bare): + `path=/`, `SameSite=Lax`, one year, `httpOnly`. The port is in the name because a cookie is + shared by every port on a host and localStorage was per origin. +- **One writer:** `setActiveSiteAction` (`app/lib/activeSiteActions.ts`, `"use server"`), which + only shape-checks (`isStorableActiveSite`: a `SITE_ID_RE`-shaped id of ≤ 128 chars, or + `__all__`). Setting the cookie makes Next re-render the current page and its layouts, which + is how Dashboard and Channels re-scope; nothing calls `router.refresh()`. +- **One read:** `readActiveSite(param?, siteIds?)` (`app/lib/activeSiteServer.ts`, + `server-only`). The root layout calls it with no param, and Dashboard (`app/page.tsx`) and + Channels (`app/channels/page.tsx`) with `searchParams.site`. The precedence is + `resolveActiveSiteFrom(candidates, siteIds)`: the first candidate naming a configured site or + `__all__` wins, else `resolveActiveSite`'s default (the lone site, else all). The server passes + `[?site=, cookie]`; the picker passes `[its held choice, the path's site, ?site=, stored]`. +- **The root layout now reads a cookie, so every route renders per request** (the build lists + every page `ƒ`). The picker's Suspense stays for the build's missing-Suspense check. +- **`SiteScopeProvider`** (`app/components/SiteScopeProvider.tsx`, context + `useSiteScope()`) + holds `stored`, following the layout's value when it changes and not while a picker write is + in flight. `choose(value)` (the picker's changes) updates `stored` optimistically; the two + effects, visiting `/sites/<id>/…` and the one-time localStorage migration, only WRITE + (`record`), because a state change during hydration re-renders the picker before React + replays a pre-hydration change, and the replayed event then reads the reset value. +- **A `?site=` link** governs its own request and is not stored. Choosing on such a page writes + the cookie, THEN `router.replace`s the URL without `site` (`withoutSiteParam`). On + `/sites/<id>/…` the picker writes the cookie, THEN pushes, so a page opened once the URL has + moved reads the new value. In both, the choice is held for the URL it was made on, so the + controlled select does not snap back while the navigation is in flight, and dropped on the first + render at another URL (else Back to that URL showed the old choice over the path). +- **Left:** `ChannelFormClient` (`/channels/new`) still pre-checks the site from `?site=` only + (it reads `window.location.search` on mount); no editor link to `/channels/new` carries one. + **`BUILD_KINDS` is wrong in both directions** and was moved verbatim, with a comment saying so: six kinds (`build-index`, `build-stats`, `build-export`, `normalize-transcripts`, `archive-transcripts`, `archive-combined-transcripts`) — MISSING the three live-chat kinds the diff --git a/plans/release-15.md b/plans/release-15.md @@ -19,6 +19,7 @@ prompt carries its ruling, and this record carries what was built. Rules: | IG | `r15/index-hold` | The index build holds an unreachable channel instead of emptying it | `common/controller/buildIndex.ts` + new `buildIndex.test.ts`, `common/controller/buildStats.ts` (the hold's words move to a shared module), new `common/lib/channelMediaHold.ts`, `common/lib/envVars.ts`, `ENVIRONMENT.md`; records: `plans/{STATE,FACTS,stats-cache-key}.md` | | DS | `r15/drive-stall` | A stalled drive does not stop the editor answering | per its prompt | | UT | `r15/umtool-trace` | umtool's build stops tracing the whole `umtool/` folder | per its prompt | +| SS | `r15/site-scope` | The editor's site picker paints the stored site at once: the selection is a cookie | `editor/app/lib/activeSite{,Server,Actions}.ts` + `activeSite.test.ts`, `editor/app/components/SiteScope{Provider,Select}.tsx`, `editor/app/layout.tsx`, the scope lines of `editor/app/page.tsx` and `editor/app/channels/page.tsx`, a comment in `editor/next.config.ts`, `editor/e2e/site-scope.spec.ts`; records: `plans/FACTS.md` | **Order:** IG → DS. DS adds a health gate inside `inspectChannelMedia`, which IG's hold calls through its public signature. UT is independent. The shared files are `editor/CHANGELOG.md`'s @@ -216,4 +217,204 @@ checkout's code, so they hold from the moment `main` has this branch. The editor **Build index** button runs its built bundle, so it holds only after the editor is rebuilt and restarted. +### Slice SS, as shipped — the editor's site scope is a cookie (2026-09-29) + +Branch `r15/site-scope` off `main` `721ed0eb`, worktree +`~/Projects/plans-export-header-first-search` (block #4: editor 3401, test 3411, export 3410), one +Opus implementer. Scratch files `ss-*` in the job's `tmp`. The ruling: the active site is a cookie, read once per request by the root layout and +supplied through a provider, so the picker, Dashboard and Channels render the stored site on the +first paint. + +**What was wrong.** The sidebar's "Active site" picker (`SiteScopeSelect.tsx`) derived its value +from the URL on every render: a `?site=` param, else the `/sites/<id>` path, else "All sites". The +stored choice was `localStorage["activeSite"]`, read only in an effect after paint. That effect +`router.replace`d `?site=<id>` onto the URL, which re-rendered the picker with the right value. So +every navigation painted "All sites" first, and Dashboard and Channels (server components that +cannot read localStorage) rendered unscoped until the param arrived. + +- **The store** is a cookie, `archilyzer-active-site-<port>`: `path=/`, `SameSite=Lax`, one year, + `httpOnly`. The value is a site id or `__all__`. + - The name and its rules are in `app/lib/activeSite.ts`: `ACTIVE_SITE_COOKIE`, + `activeSiteCookieName(host)`, `isStorableActiveSite`, `ACTIVE_SITE_COOKIE_MAX_AGE`. + - The port is in the name because a cookie is shared by every port on a host, and localStorage + was per origin. The live editor and a worktree's editor on one machine keep one selection each, + as before. A host with no port (the default port, behind a proxy) uses the base name. +- **One writer:** `setActiveSiteAction` (`app/lib/activeSiteActions.ts`, `"use server"`). + - It checks shape only: a `SITE_ID_RE`-shaped id of at most 128 characters, or `__all__`. + Anything else writes nothing and returns false. Whether the id names a site is decided at every + read, so a site deleted later resolves as no selection. + - Setting a cookie in a server action makes Next re-render the current page and its layouts. + That re-render is how Dashboard and Channels re-scope, and how the layout hands the provider + the new value; nothing calls `router.refresh()`. +- **One read:** `readActiveSite(param?, siteIds?)` (`app/lib/activeSiteServer.ts`, `server-only`). + - The root layout calls it with no param (a layout has no `searchParams`). Dashboard + (`app/page.tsx`) and Channels (`app/channels/page.tsx`, the scope line only) call it with + `searchParams.site`. + - The precedence is one pure function, `resolveActiveSiteFrom(candidates, siteIds)`: the first + candidate naming a configured site or `__all__` wins, else `resolveActiveSite`'s default (the + lone site, else all sites). The server passes `[?site=, cookie]`. + - The layout reading a cookie makes every route render per request: the capped build lists every + page `ƒ`. The editor's pages were all `force-dynamic` already, apart from the not-found page. +- **The provider** (`app/components/SiteScopeProvider.tsx`, new): a React context, mounted by the + root layout around `AppFrame` with `{ activeSite, fromCookie, siteIds }`, read with + `useSiteScope()`. + - It holds `stored`, which starts from the layout's value and follows it when that value changes. + It does not follow while one of the picker's writes is in flight, so the first of two quick + choices cannot paint over the second. + - `choose(value)` is for the picker's own changes. It updates `stored` optimistically, writes + through the action, and puts the previous value back if the write fails. + - **Two effects, and neither rewrites a URL:** + - Visiting `/sites/<id>/…` records that site, so Dashboard and Channels follow. It writes once + per arrival at that site's pages (a StrictMode or Fast Refresh re-run does not write again), + and not when the store already holds the site. + - The one-time **migration**: a visitor with `localStorage["activeSite"]` and no cookie has the + key copied into the cookie through the action, then removed once the write succeeds. With a + cookie, or on a site's page, a leftover key is removed without being read. This is the only + localStorage read. On that one visit the server had no cookie to render from, so the picker + paints the default until the write's re-render. + - **Both effects only write** (`record`); the value comes back through the server's re-render. + They run as the page hydrates. With `choose`'s optimistic state change there, the picker + re-rendered before React replayed a change made on the server-rendered select before + hydration. The re-render reset the select to its old value, the replayed change read that + value, and the two `/sites/<id>/…` cases of `site-scope.spec.ts` navigated nowhere + (`d394d0b6`; traced with a throwaway instrumented spec, not committed). +- **The picker** (`SiteScopeSelect.tsx`) renders from the context, the path and the URL. It has no + effect and reads no storage, and every accessible name is unchanged. + - What it shows, first match wins: the choice just made on this URL, the site the path names, a + valid `?site=`, then `stored`. + - On a site's pages it writes the cookie, **then** pushes the same tab of the other site (or + `/sites` for "All sites"). A page opened once the URL has moved therefore reads the new value. + - On a `?site=` link's page it writes, then `router.replace`s the URL without `site` + (`withoutSiteParam`), so the page and the picker agree. Elsewhere it only writes. + - In the first two cases the choice is held for the URL it was made on, so the controlled select + does not snap back while the navigation is in flight. The old picker snapped back on a site's + pages until the push landed. The hold is dropped on the first render at another URL + (`352ea8f7`); before that, Back to the page it was made on showed the old choice over the + path. + +**`?site=` after this slice.** The editor's own navigation no longer appends it; nothing in +`editor/app` builds a `?site=` link. What still reads or carries one: + +| Where | What it does with `?site=` | +|---|---| +| Dashboard, Channels (`readActiveSite(site)`) | A valid one governs that request; it is not stored | +| The picker | Shows a valid one on that page; a choice there drops it | +| `next.config.ts` redirects | A retired `/charts`, `/aliases` or `/deploy` bookmark carrying `?site=<id>` lands on that site's tab (the comment now says the picker ignores the query there, `e684668c`) | +| `ChannelFormClient` (`/channels/new`) | Pre-checks that site's membership box; read from `window.location.search` on mount. It is the only source of that pre-check, as before (see "Found and left") | +| `ChannelVolumeBar`'s chips | Keep whatever `?site=` the URL has when they add `?location=` | +| e2e (`channel-groups`, `channel-priority`, `channels-rack-*`, `channel-site-membership`, `site-scope`, `navigation`) | Deep links; all still work | + +**Commits** + +| Commit | What | +|---|---| +| `9dc33d59` | `editor:` the cookie, its one writer and one read; `SiteScopeProvider`; the picker rewritten; the layout mounts the provider; Dashboard and Channels read through `readActiveSite`; `activeSite.test.ts` +8 (87 → 95). | +| `d394d0b6` | `editor:` the provider's two effects write the cookie without an optimistic state change (the hydration replay above); the visit is recorded once per arrival. | +| `0465d37b` | `editor(e2e):` `site-scope.spec.ts`: three `?site=` URL assertions inverted, two comments; three new cases. | +| `e684668c` | `editor:` `next.config.ts`'s retired-satellite comment. | +| `352ea8f7` | `editor:` the picker drops a held choice once the URL moves; the site-scope case for Back. | +| this commit | `plans:` this section, the slices row, FACTS ("Superseded by release 15 slice SS"), the editor changelog. | + +**Tests** + +- **Unit** (`app/lib/activeSite.test.ts`, +8): the cookie name per port (IPv4, IPv6, no port, no + host); what may be stored (a header-injection string, `_homepage`, 129 characters and non-strings + refused; 128 accepted); the precedence (a param beats the cookie, a param naming no site falls + through to it, `__all__` is a choice, the default); `withoutSiteParam` keeps every other param. +- **e2e** (`editor/e2e/site-scope.spec.ts`). The contract cases keep their names and labels. The + seeding removal changed three assertions and two comments, and nothing else: + - `:34` and `:41`: `toHaveURL(/site=alpha/)` became `toHaveURL(/\/channels$/)`; + - `:76`: `/site=beta/` likewise; + - the comments now say "cookie" where they said "localStorage". +- **New cases:** + +| Case | What it pins | On the pre-change code | +|---|---|---| +| a stored site is the picker's first paint on every page, never All sites | alpha stored through the picker. On `/`, `/channels`, `/jobs`, `/settings`, `/sites/alpha` and `/`, the value is read ONCE, with no retry, right after `domcontentloaded`. After each page hydrates and settles for 750 ms, an init script's record of every value the select ever had (at every DOM mutation and every animation frame) is exactly `["alpha"]`. The same holds across client-side navigations through the sidebar. `/sites/beta` records beta, and the next `/` paints beta. No console message or page error matching `/hydrat/i` | fails at the first read: `/: first paint` expected `"alpha"`, received `"__all__"`. With that read removed, it fails at the record: `["__all__", "alpha"]` | +| a `?site=` link scopes its own page and is not stored; a choice there drops it | `/channels?site=beta` paints beta and lists beta's channel; the cookie stays alpha, and the next `/channels` is alpha; choosing "All sites" on `/channels?site=beta` leaves `/channels` with both channels and the cookie `__all__` | not run (the cookie assertions cannot hold) | +| a choice the old picker kept in localStorage moves to the cookie once | the key seeded from a route that mounts no app; `/channels` then shows beta scoped, the cookie is beta and the key is gone; the next `/` paints beta; a leftover key with a cookie is removed and never read | not run | +| Back to a site's page shows that site, not the choice made there | on `/sites/alpha/charts`, choosing beta goes to `/sites/beta/charts`; Back shows alpha | not run on the pre-change code; with `0465d37b`'s picker (the fix line removed) it received `"beta"` | + +The spec's comment says why: Playwright's auto-retrying `toHaveValue` cannot see a one-paint flash; +it polls until the value is right and passes. + +#### Gates (logs `$T/ss-*.log`) + +- **tsc** (all workspaces) was clean before every commit: 69 s, 38 s, 102 s and 28 s at the four + full runs. The editor-only runs for `0465d37b` and `e684668c` took about 13 s. A killed dev + server left a truncated `.next/dev/types/*.ts` once; that directory is generated, and it was + removed after each stopped run. +- **Unit:** + + | Suite | Result | + |---|---| + | common | 2,229/2,229, 44 s | + | editor unit | **95/95** (87 + 8) | + | `test:scripts` | 191 passed, 1 skipped (192) | + | mcp | 271/271 | + +- **Docs:** `docs env --check`, `docs files --check` and `settings example --check` all exit **0**. +- **Build:** the editor's `next build`, with the primary's `transcripts/` linked in and capped at + 5 GB with no swap: **33 s, max RSS 1,628 MB**, exit 0. Every page is listed `ƒ`. The link was + removed after the build, and nothing ran through it. +- **e2e** (editor, detached and queued): + - `site-scope.spec.ts` alone: + - at `9dc33d59`: 8 passed, 2 failed (the two `/sites/<id>/…` cases, fixed by `d394d0b6`); + - at `d394d0b6`: 9 passed, 1 failed, 3.4 min. The failure was "creating a channel under a + site": the create never navigated within 10 s, with the Next dev indicator on "Rendering…" + and a load average of 26. It passed in the run before, and in every run after; + - at `352ea8f7` (with the Back case): **11 passed, 0 failed, 53 s**. + - The pre-change checks above: the old code swapped in once, then restored. The Back case was + run once with the fix line removed, then restored. + - **The spec list** (at `e684668c`; `$T/ss-specs.txt`: `site-scope` plus every spec that visits + `/` or `/channels` or uses `?site=`, 28 specs, 169 tests): + - a first run was spoiled by this implementer. `next.config.ts` was edited mid-run, and the dev + server restarted and served 404s. It was stopped, and its orphaned servers were killed. + Before the edit, one case had failed: `backfill.spec.ts:462`, whose `uncheck` of "Enable + auto-backfill" did not change the box. That spec passed in the clean run; + - the clean run: **157 passed, 0 failed, 12 skipped** (the rack screenshot audit, which needs + `E2E_RACK_SHOTS=1`), **8.2 min**. + - **The full editor suite** at `352ea8f7`: **658 passed, 0 failed, 12 skipped** (the same rack + audit), **39.2 min**, after less than a minute in the queue. An earlier full run was stopped + two minutes in, to land `352ea8f7` first. + +#### Found and left + +- **`export/app/(workspace)/WorkspaceView.tsx:60-73` (`splitOn`)** has the same class of one-paint + flash: a localStorage value restored in an effect after the first paint. `export/**` is not this + slice's. +- **`/channels/new` pre-checks a site only from a `?site=` link.** `ChannelFormClient` reads + `window.location.search` once, on mount, and no editor link to `/channels/new` carries one. + The old picker added `?site=` by a `router.replace` from an effect, which by my reading of the + effect order landed after the form's read, so that route did not pre-check before this slice + either (not measured). With the provider, the fix is one line in `ChannelFormClient`: + `useSiteScope().stored` as the fallback. The comments that still say the picker mirrors the + scope into `?site=` (`ChannelFormClient.tsx:35`, `ChannelForm.tsx:99`, + `SiteMembershipsSection.tsx:31`) are wrong now. All three files are under + `editor/app/channels/**`, which is slice DS's. +- **A change on a site's pages waits one server round trip before it navigates** (the cookie write + comes first), and a change there right after landing also waits for the visit's own write, + because Next runs server actions one at a time. The select shows the choice at once. +- **A change made before hydration** reaches the picker through React's replay, as before. It + would be lost again if anything set state in the provider or picker during hydration; the + comment in `SiteScopeProvider.tsx` says so. +- **`app/sites/[siteId]/layout.tsx:11`** still describes the retired satellites as reading + `?site=`. That is history, and it is accurate. + +#### Decisions the operator could overturn + +| What I assumed | The alternative | +|---|---| +| The cookie's name carries the port, so each editor on one host keeps its own selection, as localStorage did | One host-wide name: selecting in a worktree's editor would change the live editor's selection, and a site id the other has not got resolves as "All sites" there | +| `httpOnly`: the page gets the value through the layout, never from `document.cookie` | Readable from script; nothing needs it | +| The action checks shape only, and existence is decided at read | Refuse ids that name no site at write time; a site deleted later still needs the read-time check | +| A `?site=` naming no site falls through to the cookie. The old picker ended there too, by replacing the param with the stored value after the first paint | Fall to the default (the lone site, else "All sites") for that page | +| Choosing on a `?site=` link's page drops the param and stores the choice | Keep the param and store nothing, as a link's page is "just that page"; the picker and the page would then disagree | +| On a site's pages, the cookie is written before the push | Push first and write after: faster, but a page opened right after the URL moves could read the old value, and a navigation started while an action is pending discards the action's re-render | +| The migration writes without an optimistic update, so its one flash lasts until the write's re-render | Update at once: a shorter flash, but a state change during hydration (see above) | + +**What runs which code, for the rollout.** The picker, the provider and the pages are in the +editor's built bundle, so all of it takes effect only after the editor is rebuilt and restarted. +After that, each browser's first visit migrates its localStorage selection once. + ## Rollout