commit 0321d24053607b5c23125d1f3a715f14fac23eca
parent 237063da988d1604a0bca8c663c6e100d176629c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 00:50:07 -0400
plans: slice SS after the review — M1, L1 and L2 to their commits; L3, L4 and L6 recorded; the rulings
release-15.md: the provider's "Other tabs" bullet; the commit table; the
two-tab case and expectNoSiteParam() under Tests; the re-gates; L3, L4, L6,
the back-forward cache and L5's wait under "Found and left"; the four ruled
decisions; a Review section. FACTS: "every page renders per request" (L1) and
the other-tabs broadcast. Changelog: a pick in one tab reaches the others.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 88 insertions(+), 18 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -12,7 +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.
+- **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. A site picked in one tab reaches the editor's other open tabs without a reload. 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
@@ -3229,14 +3229,24 @@ follow), `onChange` does `router.push("/sites/<other>/<segment>")` and "All site
`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.
+- **The root layout now reads a cookie, so every page renders per request** (the build lists
+ every page `ƒ`; route handlers were `ƒ` already, and `/icon.svg` stays static). The one page
+ whose mode changed is `/_not-found`. 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.
+- **Other tabs.** A client-side navigation does not re-render the root layout, so a tab's
+ `stored` would stay what its layout last read while another tab changed the shared cookie.
+ Every successful write (`choose` and `record`) is therefore posted on
+ `new BroadcastChannel(ACTIVE_SITE_CHANNEL)` (`"archilyzer-active-site"`, per origin and so per
+ port), and the other tabs call `router.refresh()`. A channel instance does not receive its own
+ posts, so a tab does not refresh for its own write. Without `BroadcastChannel` a tab keeps its
+ value until its next full load. Passive refresh (`AutoRefresh`) also re-reads the layout, but
+ only when the pulse token moves.
- **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
diff --git a/plans/release-15.md b/plans/release-15.md
@@ -253,8 +253,9 @@ cannot read localStorage) rendered unscoped until the param arrived.
- 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 layout reading a cookie makes every page render per request: the capped build lists every
+ page `ƒ`. The editor's pages were all `force-dynamic` already, apart from the not-found page;
+ route handlers were `ƒ` already, and `/icon.svg` stays static.
- **The provider** (`app/components/SiteScopeProvider.tsx`, new): a React context, mounted by the
root layout around `AppFrame` with `{ activeSite, fromCookie, siteIds }`, read with
`useSiteScope()`.
@@ -278,6 +279,17 @@ cannot read localStorage) rendered unscoped until the param arrived.
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).
+ - **Other tabs** (`f645572c`, review M1). The cookie is shared by every tab of the origin, but a
+ tab's `stored` comes from its root layout, which a client-side navigation does not re-render.
+ A pick in another tab therefore left this tab's picker on the old site over pages that read
+ the new one, and a client-side visit to a site's page was skipped as already stored.
+ - Every successful write (`choose` and `record`) is posted on the BroadcastChannel
+ `ACTIVE_SITE_CHANNEL` (`"archilyzer-active-site"`). A channel is per origin, so each port
+ has its own, like the cookie's name.
+ - The other tabs answer with `router.refresh()`: the layout and the page re-render with the
+ cookie as it is now, and the tab's router cache is dropped. A channel instance does not
+ receive its own posts, so a tab does not refresh for its own write.
+ - A browser without `BroadcastChannel` keeps each tab's value until its next full load.
- **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
@@ -313,7 +325,11 @@ cannot read localStorage) rendered unscoped until the param arrived.
| `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. |
+| `49802455` | `plans:` this section, the slices row, FACTS ("Superseded by release 15 slice SS"), the editor changelog. |
+| `f645572c` | `editor:` review M1: a successful write is broadcast, and the other tabs refresh. |
+| `09559337` | `editor(e2e):` review M1 and L2: the two-tab case; `expectNoSiteParam()` after the settle waits. |
+| `8104732b` | `editor:` review L1: the layout's comment says "every page". |
+| this commit | `plans:` the review, its findings to their commits, L3, L4 and L6 under "Found and left", the rulings; FACTS (L1, other tabs); the changelog's other-tabs sentence. |
**Tests**
@@ -323,9 +339,14 @@ cannot read localStorage) rendered unscoped until the param arrived.
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".
+ - in "selecting a site scopes the channels list and persists", two `toHaveURL(/site=alpha/)`
+ became `toHaveURL(/\/channels$/)`;
+ - in "charts is a site's tab", `/site=beta/` likewise;
+ - the comments now say "cookie" where they said "localStorage";
+ - after review L2 (`09559337`), each of the three is followed by `expectNoSiteParam()`. It waits
+ for hydration and the 750 ms settle, then checks the URL has no `site` param, with no retry.
+ `toHaveURL` alone passes on its first poll, before a `replace` from an effect could land. The
+ first-paint case makes the same check after each of its settles.
- **New cases:**
| Case | What it pins | On the pre-change code |
@@ -334,6 +355,7 @@ cannot read localStorage) rendered unscoped until the param arrived.
| 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"` |
+| a site picked in another tab reaches this one (`09559337`) | two pages in one context, passive refresh off (`autoRefreshIntervalSeconds: 0`), so only the broadcast can move tab A. Tab A stores alpha and sits on `/settings`; tab B picks beta; tab A's picker becomes beta with no reload, and its next Channels page (a sidebar click) is beta's. A client-side visit from tab A to `/sites/alpha` records alpha, and tab B's picker follows | with the broadcast's `router.refresh()` removed, tab A stays `"alpha"`. With passive refresh left on, tab A had moved anyway, through a pulse-driven tree refresh, and only tab B's check caught the missing broadcast; hence the setting |
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.
@@ -341,7 +363,7 @@ 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
+ full runs, and 60 s for the review fixes. 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:**
@@ -349,7 +371,7 @@ it polls until the value is right and passes.
| Suite | Result |
|---|---|
| common | 2,229/2,229, 44 s |
- | editor unit | **95/95** (87 + 8) |
+ | editor unit | **95/95** (87 + 8), and again after the review fixes |
| `test:scripts` | 191 passed, 1 skipped (192) |
| mcp | 271/271 |
@@ -363,9 +385,12 @@ it polls until the value is right and passes.
- 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**.
+ - at `352ea8f7` (with the Back case): **11 passed, 0 failed, 53 s**;
+ - after the review fixes (the code of `8104732b`, whose layout change is a comment): **12
+ passed, 0 failed, 1.6 min**, after about 2 min in the queue.
- 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.
+ run once with the fix line removed, then restored. So was the two-tab case, with the
+ broadcast's `router.refresh()` replaced by a no-op, twice: with passive refresh on, then off.
- **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
@@ -376,7 +401,8 @@ it polls until the value is right and passes.
`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.
+ two minutes in, to land `352ea8f7` first. It was not rerun for the review fixes, as the parent
+ directed: they touch the provider and `site-scope.spec.ts` only.
#### Found and left
@@ -400,19 +426,53 @@ it polls until the value is right and passes.
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.
+- **Two server renders per change on a site's page, or on a `?site=` page** (review L3). The
+ write's re-render draws the page being left, then the push or replace draws the next one. This
+ is the cost of the accepted round trip.
+- **A narrow migration race** (review L4). On the first visit after the update, a visitor may
+ have the old key and no cookie, and change the server-rendered select before hydration. If
+ React replays that change before the migration's write is queued, the old key's value is
+ written second and wins. The picker then shows that value, and it agrees with the cookie.
+- **A stored `__all__` with one site left** (review L6, not a regression). The server resolves
+ "all sites", but the picker has no "All sites" option with one site, so the select shows the
+ lone site while Dashboard and Channels show the whole pool. `main` did the same through the
+ seeded `?site=__all__`.
+- **A tab restored from the back-forward cache** is not refreshed; the review offered a
+ `pageshow` handler as optional, and it was not added.
+- **Review L5 waits for slice DS:** the comments in `ChannelFormClient.tsx:35`,
+ `ChannelForm.tsx:99`, `SiteMembershipsSection.tsx:31` and `ChannelVolumeBar.tsx:25`, and the
+ one-line `/channels/new` pre-check from the provider. Those files are DS's; the parent hands
+ them to this slice once DS is on `main`.
#### 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 |
+| The cookie's name carries the port, so each editor on one host keeps its own selection, as localStorage did. **Ruled at review: it stays.** | 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 |
+| 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. **Ruled at review: it stays.** | 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. **Ruled at review: it stays.** | 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. **Ruled at review: the round trip is accepted.** | 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) |
+#### Review
+
+**Verdict: SHIP AFTER FIXES** (`ss-review.md` in the job's scratch). There was no High. The review
+held that single-tab first paint, hydration, the cookie, the action and the migration are right.
+It reproduced M1 with a two-tab probe under the queue lock.
+
+| Finding | Where |
+|---|---|
+| M1: a pick in another tab left this tab's picker stale, and a client-side visit to a site's page unrecorded | `f645572c` (the broadcast and refresh), `09559337` (the two-tab case) |
+| L1: FACTS said every route renders per request; it is every page | this commit (FACTS), `8104732b` (the layout's comment); this section said it already |
+| L2: the inverted URL assertions pass on their first poll | `09559337`: `expectNoSiteParam()` |
+| L3: two server renders per change on a site's page or a `?site=` page | "Found and left" |
+| L4: a narrow migration race | "Found and left" |
+| L5: stale `?site=` comments, and the `/channels/new` pre-check | Waits for DS on `main`, as the parent ruled |
+| L6: a stored `__all__` with one site left | "Found and left" (pre-existing) |
+| Questions: the port in the name, `?site=` naming no site, a choice on a `?site=` page, the round trip | Ruled: all four stay (see the decisions table) |
+
**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.