commit bddc4e4110303b2b9af6503c4b34919bb1582651
parent f27aeaca7f99265a9463e1612a9e97f3fe4114f5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 21:18:56 -0400
plans: slice HS as shipped — hidden sites (`listed: false`): the record, the proof through the real builds (0 occurrences of the unlisted fixture in the homepage and hub outs), the gates; the three changelogs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 189 insertions(+), 0 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -10,6 +10,7 @@
- **A social icon is checked by what it may contain, when it is saved new or edited and every time it is shown, and a refused one says why.** An icon must be one well-formed `<svg>` of shapes, groups, gradients, clips, masks, filters, text and simple animation, with SVG presentation attributes: no script, `style` block, `foreignObject`, link, embedded image, `title`/`desc` with anything but text (text-only ones are removed), or HTML element; no event handler, however it is written; a `style` attribute of presentation properties only; a reference only to something inside the icon, written plainly; and nothing that could load from elsewhere (a CSS escape or comment, `image-set(`, `image(`, `cross-fade(`, `element(`, `src(`, `paint(`, `@import`). Comments, a leading XML declaration and a plain DOCTYPE are removed. A refused save ends with the reason ("… has an invalid SVG: it has an event handler attribute.", "… it links to something outside the icon.") and never repeats the markup; for a drawing program's file it says to export it with presentation attributes rather than a style block (in Inkscape, save as Plain SVG). **Upgrading:** an icon an older build stored is kept as it is when a save does not change it — a pause, a priority or a title still saves — but a page shows it as its label until its SVG is replaced; `archilyzer doctor`'s new "social icons" line names every stored icon that fails, by file and label, with the reason.
- **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.
## [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/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -6,6 +6,7 @@
- **A chart's stacked bars are separated by a 2 px gap in the chart card's colour.** A stacked bar's segments were drawn touching; they now have a 2 px gap in the card's colour between them, and in high-contrast mode the system's background colour. Stacked areas keep their line in each series' colour along the top, charts of one series, line charts and side-by-side bars are unchanged. Needs a rebuild and deploy of each site.
- **Two grounds, Light and Dark, and each site in its own accent.** The third ground, the warm paper one, is gone: the header's toggle cycles System, Light and Dark. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light (the old paper theme's `archive` + `light` too). The theme menu's accent picker is gone from the header and the slide-out menu: every page wears the site's own accent (`site.json` `accent`), and a reader's stored pick from before is not read and is left in storage. Needs a rebuild and deploy of each site.
- **The header carries the operator's social links and one theme toggle, keeps the site's name on a small screen, and links to the Archilyzer home in place of the sites menu.** Every site's header and the hub's end with the social icons (the site's `socialLinks`, else `settings.json`'s) followed by the theme toggle, all 36 px keys (44 px on a touch screen) with a focus ring. From 520 px wide the header shows every link, up to four (with more, the ones marked **Keep in header on small screens** first, then the last of the rest); below 520 px it shows only the marked ones (none marked → none) and keeps the site's name beside them. The switch is 32.5rem, so at a larger text size it comes later. With one marked link, every current site's name shows in full from 360 px wide on a touch screen. The footer keeps every link, in the same keys (its icons were 20 px and turned the accent on hover; they now turn the text colour), and wraps them rather than widen the page. The **Sites** dropdown and the **Hub** link are gone from the header and the slide-out menu: in their place a link, **Archilyzer**, goes to the Archilyzer home's Official Instances, in the same tab (not on the hub, which lists them itself). **Changelog** moved from the header and the menu to the footer, after Use with AI. The nav and the Archilyzer link are inline from 1024 px wide; below that they are in the slide-out menu, which now holds only them. Only as last resorts, for a very long name on a phone, does the name drop (its mark stays; the same before and after the page's font has loaded, and never with its last letter cut off) and do the icons scroll sideways in their own box. A site's `hubUrl` still loads and is no longer shown. Needs a rebuild and deploy of each site.
+- **An unlisted site is not in the hub or in another site's footer.** The hub's members (`hub-sites.json`), and so its federated search, `corpus.json` and `llms.txt`, leave out a site whose `listed` is `false`; the hub's instance figures count none of the channels only it carries; and no other site's footer links it, even from a featured group. The unlisted site's own pages are unchanged.
## [0.10.0] - 2026-09-28
- **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site.
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -1,6 +1,7 @@
# Homepage Changelog
## [Unreleased]
+- **An unlisted site is not on the homepage.** A site whose settings turn off **List on the Archilyzer homepage and hub** (`listed: false`) has no Official Instances card, chart series, `/stats` entry or recent item, is not in `channel-sites.json` or `stats/`, and the channels only it carries count in none of the numbers, the headline totals included. The summary's version is 6. The e2e fixture has a seventh, unlisted site that no page names.
- **`/#instances` goes straight to Official Instances.** The section carries `id="instances"`, clear of the sticky header, and every archive's header now links there (`INSTANCES_URL` in `common/lib/project.ts`). With no sites the link lands on the top of the page.
- **The social links are in the header beside the theme toggle, and on a small screen the header keeps the name.** The operator's social icons (`homepage.json`'s, else `settings.json`'s) sit in the header's bar as well as in the footer's Elsewhere column, followed by the theme toggle, all spaced alike. From 768 px wide the bar is wordmark, nav, icons, toggle; below 768 px it is wordmark, icons, toggle, and the nav has the rule below to itself, where its four links fit. From 520 px wide the header shows every link, up to four (with more, the ones marked **Keep in header on small screens** first, then the last of the rest); below 520 px it shows only the marked ones (none marked → none; the switch is 32.5rem, so at a larger text size it comes later), and the wordmark keeps its full name: "Archilyzer" shows from 292 px wide on a touch screen with one marked link. The footer always shows every link. Only as last resorts, on a screen narrower still or at a much larger text size, does the wordmark's text drop (its mark stays) and do the icons scroll sideways in their own box, the last one in view first. Each is an icon named by its label, with no text beside it.
diff --git a/plans/release-14.md b/plans/release-14.md
@@ -1004,6 +1004,192 @@ There the bar overflows and the wide row scrolls while the name shows: measured
1100 px at 150 % and from 779 to 1300 px at 200 %. With the browser's own text size `md` moves too
and nothing overflows. The `md` layout is slice HP's and unchanged here.
+### Slice HS, as shipped — hidden sites: a site that builds and deploys, and is listed nowhere (2026-09-29)
+
+Branch `r14/hidden-sites` off `main` `99d4d76a`, worktree `~/Projects/homepage-social-visible`
+(block #3: editor test 3311, export 3310, homepage e2e 3340, hub e2e 3341), one Opus implementer.
+Scratch files `hs-*` in the job's `tmp`. The rulings (2026-09-29, not re-opened):
+1. A hidden site is absent from the homepage (cards, chart, `/stats`), the hub (members, federated
+ search, `corpus.json`, `llms.txt`), every other site's related-sites footer, and the published
+ id lists (`channel-sites.json`, the pooled `stats/`).
+2. Its hours and transcripts are in no public total.
+3. The editor has one checkbox in each site's settings, and no homepage settings page.
+4. The hidden site itself builds and deploys exactly as before.
+
+| sha | what |
+|---|---|
+| `5918ba27` | `common:` `site.json` `listed` (schema, docs, parser, writer; `SITE.md`); `isListedSite` and `channelsOnlyOnUnlistedSites`; the footer drops an unlisted sibling |
+| `10378f7f` | `common:` the homepage summary (v6), `channel-sites.json` and the whole-pool stats leave out an unlisted site and the channels only it exposes |
+| `1491a7ab` | `common:` `hub-sites.json` (and through it the hub's `corpus.json` and `llms.txt`) lists listed sites only |
+| `ca22f0e6` | `sites:` the checkbox, and `saveSiteAction` carries the key; `sites-crud` e2e |
+| `e12515c2` | `homepage:` the e2e fixture's seventh, unlisted site; `unlisted-site.spec.ts` |
+| _this_ | `plans:` this record; the three changelogs |
+
+- **The key:** `listed?: boolean` in `site.json`, after `siteUrl`, in the type, `SITE_FIELD_DOCS`,
+ `siteFieldsSchema` and `siteToDisk`. Absent or anything but `false` reads `true`; only `false` is
+ written (the `archives` idiom). `SITE.md` regenerated.
+- **One predicate.** `isListedSite(site)` (`site.listed !== false`) and
+ `channelsOnlyOnUnlistedSites(sites)` — the channels at least one site exposes and no listed site
+ does. Both live beside the key in `common/lib/siteSchema.ts` and are exported from `lib/site`
+ (its `export *`), like `isValidSiteId`: the summary builder is pure and imports them without
+ `lib/site`'s file I/O. Every filter below calls them.
+- **What each public output does now:**
+
+ | Output | Code | An unlisted site | A channel only unlisted sites expose |
+ |---|---|---|---|
+ | Homepage summary: `sites`, `official`, `monthly`, `series`, `recent`, `channels` | `buildHomepageSummary`, the public-site filter | absent | absent |
+ | Homepage summary: `totals`, `availability` | the same, over the in-scope stats | — | not counted |
+ | `channel-sites.json` | `channelSitesOf` (`controller/poolSummary.ts`) | absent from every list | absent |
+ | The homepage's `stats/` (whole-pool bundle) | `buildStats`, the whole-pool block | — | its records and its manifest entry absent |
+ | `hub-summary.json` | `toHubSummary` of the same summary | absent | not counted |
+ | `hub-sites.json`, the hub's `corpus.json`, `llms.txt` | `compose-hub.ts`, the built-in pool | absent | — |
+ | Every other site's footer | `resolveRelatedSites` | not linked, even from a featured group | — |
+
+ - A channel a listed site also exposes is credited to the listed site: `primarySiteOf` runs over
+ listed public sites only.
+ - The summary's version is 6. No field was added or removed; the number marks the scope change.
+- **What stays, and why:**
+ - The unlisted site's own build and deploy: `buildAll` and the deploy paths
+ (`common/publish/build.ts`), its per-site stats and summaries (`buildStats`, `buildIndex`), its
+ archives and chart templates, its own `/site.json`, `corpus.json`, `llms.txt`, sitemap. Its own
+ footer still lists its listed siblings.
+ - The editor's pages (`/sites`, the channel page's memberships, the nav's site switcher, the
+ priority focus), which list every site.
+ - `siteChannelIndex`, `renameChannel`, `migrateToSites`, `listSiteIds` for the CLI's
+ site argument, the export's default site: none is a public list.
+ - The per-site staging in `buildIndex.ts` (another slice's file, and per site).
+- **Editor.** The site form has **List on the Archilyzer homepage and hub** after Public URL, on
+ by default, with a hint. `saveSiteAction` rebuilds the `Site` from the form; it now carries
+ `listed: false`, so a save of any other field keeps it. The other writers (`writeSite` from the
+ channel page, `renameChannel`, `migrateToSites`) spread the stored site or create a new one.
+- **Tests:**
+ - `siteSchema.test.ts`: absent and `true` read listed, only `false` unlists and only `false` is
+ written, through `writeSite`/`getSite`; the "everything" round-trip fixture carries
+ `listed: false`; `channelsOnlyOnUnlistedSites` keeps a shared channel with the listed site; the
+ footer drops an unlisted sibling named in a featured group, and an unlisted site's footer
+ still lists the rest.
+ - `homepageSummary.test.ts`: an unlisted site with its own channel and one shared with a listed
+ site gives a summary deep-equal to the one without it (every array, every total), and its id,
+ title and channel appear nowhere in the JSON; an unlisted site with no `siteUrl`, and one whose
+ channels are all shared, change nothing either; `listed: true` equals no key.
+ - `buildStats.test.ts` (k): the whole-pool bundle leaves out the unlisted-only channel (records,
+ manifest, count, log line) and keeps a pool-only one; the unlisted site's own bundle keeps all
+ three of its videos.
+ - `poolSummary.test.ts` (new): `channelSitesOf` names listed sites only.
+ - `compose-hub.test.ts`: `hub-sites.json`, `corpus.json` and `llms.txt` name the listed site and
+ not the unlisted one.
+ - Editor `sites-crud`: a `listed: false` file opens unticked; a save that changes only the title
+ keeps `false`; ticking removes the key; unticking writes it again.
+ - Homepage `unlisted-site.spec.ts`: the summary the dev server reads equals the one built
+ without the unlisted site, and `/` and `/stats/` (served HTML and DOM) name all six listed
+ sites and not the unlisted one.
+- **The homepage e2e fixture** (`homepage/e2e/fixture-summary.ts`), for the slices that build on
+ it:
+ - `FIXTURE_SITES`: the six listed sites, unchanged.
+ - `FIXTURE_UNLISTED_SITE`: `fixture-unlisted`, "Fixture Unlisted", `listed: false`, two channels
+ of its own (`fixture-unlisted-ch1/2`, six a day, like the rest) and `fixture-one-ch1` shared.
+ - `buildFixtureSummary(fixtureSites = FIXTURE_SITES, unlistedSites = [FIXTURE_UNLISTED_SITE])`.
+ The unlisted sites' records are generated last, after the megaspike, so every listed record is
+ the same with them or without, and `buildFixtureSummary()` equals
+ `buildFixtureSummary(FIXTURE_SITES, [])`.
+
+#### Proof: a hidden fixture site through the real builds
+
+A throwaway corpus in the job's scratch dir (`$T/hs-proof/`, `make-corpus.mjs`): three channels
+of three captioned videos each, and two sites with public URLs — `fixture-listed` (the listed
+channel and the shared one) and `fixture-unlisted` (`listed: false`; the shared channel and one of
+its own). Every path the builds write was pinned there (`TRANSCRIPTS_DIR`, `SETTINGS_FILE`,
+`EXPORT_INDEX_DIR`, …) except the two apps' own `public/` and `out/`. The worktree's own gitignored
+`homepage/public` data and `export/out` were set aside first and put back after; the
+`export/public` links were dropped (never their targets) and re-seeded from the primary after. The
+primary's `export/public` was untouched (no entry newer than the slice's start). Each build was
+capped at 5 GB with no swap.
+
+| Build | Result | Time | Max RSS |
+|---|---|---|---|
+| `archilyzer index` | 0 | 4 s | — |
+| `archilyzer build homepage --no-source` | 0 | 20 s | 776 MB |
+| `archilyzer build hub` | 0 | 34 s | 1,005 MB |
+| `archilyzer build site fixture-unlisted --skip-archives` | 0 | 41 s | 962 MB |
+| `archilyzer build site fixture-listed --skip-archives` | 0 | 84 s | 970 MB |
+
+Counts (files holding the string / occurrences, `grep -rF`):
+
+| Tree | `fixture-unlisted` | its title | its own channel's slug | its own channel's name | `fixture-listed` |
+|---|---|---|---|---|---|
+| `homepage/public` (summary, `channel-sites.json`, `stats/`) | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | 2 / 25 |
+| `homepage/out` | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | 10 / 145 |
+| `export/public` (hub compose) | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | 4 / 10 |
+| `export/out` (hub) | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | 4 / 10 |
+| `export/out` (the listed site) | 0 / 0 | 0 / 0 | 0 / 0 | — | 9 / 30 (its URL) |
+
+- The compose lines: `compose-homepage: 2 channel(s) mapped across 1 listed site(s) (1 unlisted
+ left out); summary covers 6 transcription(s) / 6 download(s) across 1 public site(s)` and
+ `compose-hub: 1 built-in pool site(s) …; hub-summary.json covers 1 official instance(s)`.
+- The summary: version 6; `totals` 6 transcripts, 6 downloads, 1 site, 2 channels, 6 hours;
+ `official` the same; `availability.counted` 6. With the unlisted site counted they would have
+ been 9 transcripts and 9 hours.
+- The pooled `stats/` manifest: 6 records, channels `proof-listed-channel` and
+ `proof-shared-channel`. `channel-sites.json` maps both to `fixture-listed` alone.
+- **The unlisted site still builds:** its own stats bundle holds all six of its videos (both its
+ channels); its build ships its own pages (its id in 17 files), and its footer links
+ `https://fixture-listed.example`. The listed site's footer links nothing: its only sibling is
+ unlisted.
+
+#### Gates (at `e12515c2`; logs `$T/hs-*.log`)
+
+- **tsc** was clean before every commit (69 s, 33 s, 44 s — the last over the tip's code).
+- **Unit:**
+
+ | Suite | Result |
+ |---|---|
+ | common | 2,218/2,218 (8 new) |
+ | editor unit | 87/87 |
+ | homepage unit | 12/12 |
+ | `test:scripts` | 191 passed, 1 skipped (192) |
+ | mcp | 271/271 |
+
+- **Docs:** `docs files --check`, `settings example --check` and `docs env --check` all exit **0**.
+- **e2e**, each detached and queued:
+
+ | Suite | Passed | Failed | Time |
+ |---|---|---|---|
+ | homepage, full (the new `unlisted-site.spec.ts` 3) | 97 | 0 | 3.6 min |
+ | hub, full | 36 | 0 | 1.4 min (after 3.5 min in the queue) |
+ | editor: `sites-crud` (the new listed round-trip 1) | 15 | 0 | 0.9 min (after 11 min in the queue) |
+
+- **Builds:** the five above, in the proof. The editor's and umtool's `next build` were not run (no
+ route or bundled path changed; tsc covers the form and the action).
+- **Numbers tool:** none.
+
+#### Found and left
+
+- **The unlisted site's own `/site.json` and `/corpus.json` still carry its `hubUrl`** (ruling 4:
+ it deploys exactly as before). A visitor who adds its origin to the hub by hand gets it as any
+ added origin, and the hub can read that `hubUrl` as a family member's.
+- **`listed` has another meaning nearby:** `useHubSites`' `listed` flag
+ (`export/app/components/hub/useHubSites.ts`) says the hub's list has been answered. A grep for
+ the key finds both.
+- **The Rollout's hub check** ("`hub-summary.json covers N official instance(s)`, where N is the
+ number of public sites") now counts public LISTED sites.
+- **Unlisting takes effect at the next builds.** The homepage, the hub and every other site are
+ static: until each is rebuilt and deployed, it still lists the site.
+- **The editor's `/sites` list** shows no marker for an unlisted site; the form's checkbox is the
+ one place (ruling 3).
+- **The commit trailer** is this implementer's own model line, not `implementer-rules.md`'s.
+
+#### Decisions the operator could overturn
+
+| What I assumed | The alternative |
+|---|---|
+| `totals` and `availability` keep counting pool-only channels and channels of sites with no public URL, as before; only a channel exposed by unlisted sites alone leaves them | `totals` count the listed public sites only, the same scope as `official` |
+| A channel an unlisted site shares with a listed site with NO public URL is not "only on unlisted sites", so `totals` count it | count a channel only when a listed PUBLIC site exposes it |
+| The pooled `stats/` keep pool-only channels, as before | the pooled `stats/` hold only channels a listed public site exposes |
+| `isListedSite` lives beside the key in `siteSchema.ts`, exported from `lib/site` | define it in `lib/site.ts` itself, and have the summary builder import `lib/site`'s file I/O |
+| The summary's version is 6 | stay at 5 (no field changed) |
+| An unlisted site's own footer still links its listed siblings | an unlisted site shows no related-sites footer |
+| The checkbox sits after Public URL, with a hint naming what it removes | at the end of the form, or without a hint |
+
## Rollout
Release 14 is slice HP (merged, `bfa1ff3c`) and `r14/two-grounds-headers` (after the parent's