commit 65172404180bc9553b7b544652cc9800b282ae01
parent 39a36becdc4f81628c2fa78b59cf0ff0536e0320
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 12:52:49 -0400
Merge r10/hub-lows — release 10 slice L1: the hub's subs query (404 = empty, one retry, never fails an archive), /ask honours the scope chips (none in scope: disabled + one line), official instances in the homepage's order and colours, playwright + compose never write through export/public links
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
22 files changed, 1231 insertions(+), 139 deletions(-)
diff --git a/common/bin/_publicFile.test.ts b/common/bin/_publicFile.test.ts
@@ -0,0 +1,112 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ symlinkSync,
+ utimesSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// A worktree's export/public entries are symlinks into the primary checkout; a
+// compose run there must write into the worktree and leave the primary's files
+// exactly as they were.
+
+const OLD = new Date("2026-09-25T20:47:37Z");
+
+// A "primary" dir holding the real file and dir, and a "worktree" public dir
+// whose entries are links to them.
+function fixture() {
+ const root = mkdtempSync(path.join(tmpdir(), "public-file-"));
+ const primary = path.join(root, "primary");
+ const worktree = path.join(root, "worktree");
+ mkdirSync(path.join(primary, "subs"), { recursive: true });
+ mkdirSync(worktree, { recursive: true });
+ writeFileSync(path.join(primary, "hub-sites.json"), "[live pool]");
+ writeFileSync(path.join(primary, "subs", "manifest.json"), "live subs");
+ utimesSync(path.join(primary, "hub-sites.json"), OLD, OLD);
+ symlinkSync(path.join(primary, "hub-sites.json"), path.join(worktree, "hub-sites.json"));
+ symlinkSync(path.join(primary, "subs"), path.join(worktree, "subs"));
+ return { root, primary, worktree, cleanup: () => rmSync(root, { recursive: true, force: true }) };
+}
+
+test("writePublicFile replaces a link with a file and leaves its target as it was", async () => {
+ const { primary, worktree, cleanup } = fixture();
+ try {
+ await writePublicFile(path.join(worktree, "hub-sites.json"), "[]");
+ const at = path.join(worktree, "hub-sites.json");
+ assert.ok(lstatSync(at).isFile());
+ assert.equal(readFileSync(at, "utf8"), "[]");
+ const target = path.join(primary, "hub-sites.json");
+ assert.equal(readFileSync(target, "utf8"), "[live pool]");
+ assert.equal(statSync(target).mtimeMs, OLD.getTime());
+ } finally {
+ cleanup();
+ }
+});
+
+test("writePublicFile over a real file or a dangling link just writes it", async () => {
+ const { root, worktree, cleanup } = fixture();
+ try {
+ const real = path.join(worktree, "corpus.json");
+ writeFileSync(real, "old");
+ await writePublicFile(real, "new");
+ assert.equal(readFileSync(real, "utf8"), "new");
+
+ const dangling = path.join(worktree, "llms.txt");
+ symlinkSync(path.join(root, "nowhere", "llms.txt"), dangling);
+ await writePublicFile(dangling, "text");
+ assert.ok(lstatSync(dangling).isFile());
+ assert.equal(readFileSync(dangling, "utf8"), "text");
+ assert.ok(!existsSync(path.join(root, "nowhere")));
+ } finally {
+ cleanup();
+ }
+});
+
+test("copyPublicFile replaces a link with a copy and leaves its target as it was", async () => {
+ const { root, primary, worktree, cleanup } = fixture();
+ try {
+ const src = path.join(root, "sw-hub.js");
+ writeFileSync(src, "hub worker");
+ await copyPublicFile(src, path.join(worktree, "hub-sites.json"));
+ assert.ok(lstatSync(path.join(worktree, "hub-sites.json")).isFile());
+ assert.equal(readFileSync(path.join(worktree, "hub-sites.json"), "utf8"), "hub worker");
+ assert.equal(readFileSync(path.join(primary, "hub-sites.json"), "utf8"), "[live pool]");
+ } finally {
+ cleanup();
+ }
+});
+
+test("ownDir replaces a linked dir with an empty real one, keeps a real one, creates a missing one", async () => {
+ const { primary, worktree, cleanup } = fixture();
+ try {
+ const linked = path.join(worktree, "subs");
+ await ownDir(linked);
+ assert.ok(lstatSync(linked).isDirectory());
+ assert.deepEqual(readdirSync(linked), []);
+ assert.equal(readFileSync(path.join(primary, "subs", "manifest.json"), "utf8"), "live subs");
+
+ writeFileSync(path.join(linked, "keep.json"), "{}");
+ await ownDir(linked);
+ assert.deepEqual(readdirSync(linked), ["keep.json"]);
+
+ const missing = path.join(worktree, "posts");
+ await ownDir(missing);
+ assert.ok(lstatSync(missing).isDirectory());
+ } finally {
+ cleanup();
+ }
+});
diff --git a/common/bin/_publicFile.ts b/common/bin/_publicFile.ts
@@ -0,0 +1,39 @@
+// Writes into a SERVED public dir (export/public) that never follow a link.
+//
+// In a git worktree the gitignored entries of export/public are symlinks into
+// the primary checkout (plans/tools/implementer-rules.md), and writeFile / cp
+// write THROUGH a link: a worktree's compose — e2e:2origin's `build:hub` —
+// wrote its empty hub pool into the primary's hub-sites.json, corpus.json,
+// llms.txt, robots.txt and _headers. Removing the path first (fs.rm reads it
+// with lstat) removes only the link, so the write lands in this checkout and
+// the link's target is untouched. Where the path is a real file — the primary
+// checkout, a docker build's /site/public — the result is what a plain write
+// gave: the same path with the new content.
+//
+// ownDir is the same rule for a directory a compose writes INTO: a linked
+// directory is replaced by an empty real one (its target untouched), a real one
+// is kept as it is, and a missing one is created.
+
+import { cp, lstat, mkdir, rm, unlink, writeFile } from "node:fs/promises";
+
+export async function writePublicFile(
+ file: string,
+ data: string | Uint8Array,
+): Promise<void> {
+ await rm(file, { force: true });
+ await writeFile(file, data);
+}
+
+export async function copyPublicFile(src: string, dest: string): Promise<void> {
+ await rm(dest, { force: true });
+ await cp(src, dest);
+}
+
+export async function ownDir(dir: string): Promise<void> {
+ try {
+ if ((await lstat(dir)).isSymbolicLink()) await unlink(dir);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
+ }
+ await mkdir(dir, { recursive: true });
+}
diff --git a/common/bin/compose-hub.test.ts b/common/bin/compose-hub.test.ts
@@ -1,6 +1,17 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ statSync,
+ symlinkSync,
+ utimesSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { getPaths, type Paths } from "../lib/paths";
@@ -81,3 +92,41 @@ test("hub-sites.json publishes every accent as a hex: an id becomes its on-dark
rmSync(root, { recursive: true, force: true });
}
});
+
+test("in a worktree, compose-hub writes its own files and never through the links into the primary", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "compose-hub-"));
+ const log = console.log;
+ try {
+ const paths = fixturePaths(root);
+ // The primary checkout's composed hub files, and the worktree's public/
+ // entries linking to them (as the worktree seed does).
+ const primary = path.join(root, "primary-public");
+ mkdirSync(primary, { recursive: true });
+ const old = new Date("2026-09-25T20:47:37Z");
+ const files = ["hub-sites.json", "corpus.json", "llms.txt", "robots.txt", "_headers", "sw.js", "hub-summary.json"];
+ for (const f of files) {
+ writeFileSync(path.join(primary, f), `live ${f}`);
+ utimesSync(path.join(primary, f), old, old);
+ symlinkSync(path.join(primary, f), path.join(paths.exportPublicDir, f));
+ }
+ console.log = () => {};
+ await main({ paths });
+ console.log = log;
+ for (const f of files) {
+ const target = path.join(primary, f);
+ assert.equal(readFileSync(target, "utf8"), `live ${f}`, `${f} in the primary`);
+ assert.equal(statSync(target).mtimeMs, old.getTime(), `${f}'s mtime in the primary`);
+ }
+ // The worktree has its own: an empty pool, and no summary (no index).
+ const at = (f: string) => path.join(paths.exportPublicDir, f);
+ assert.ok(lstatSync(at("hub-sites.json")).isFile());
+ assert.equal(readFileSync(at("hub-sites.json"), "utf8"), "[]");
+ for (const f of ["corpus.json", "llms.txt", "robots.txt", "_headers"]) {
+ assert.ok(lstatSync(at(f)).isFile(), `${f} is the worktree's own file`);
+ }
+ assert.ok(!existsSync(at("hub-summary.json")));
+ } finally {
+ console.log = log;
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -16,7 +16,7 @@
import path from "node:path";
import { existsSync } from "node:fs";
-import { cp, rm, writeFile, access } from "node:fs/promises";
+import { cp, rm, access } from "node:fs/promises";
import { getPaths, type Paths } from "../lib/paths";
import { accentHex } from "../lib/accent";
import { listSites, resolveHubUrl } from "../lib/site";
@@ -32,6 +32,7 @@ import { HUB_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers";
import { buildPoolSummary } from "../controller/poolSummary";
import { HUB_SUMMARY_FILE, toHubSummary } from "../lib/hubSummary";
import { runIfEntryPoint } from "./_cli";
+import { writePublicFile } from "./_publicFile";
async function exists(p: string): Promise<boolean> {
try {
@@ -66,7 +67,7 @@ async function composeHubSummary(
statsDir: path.join(paths.exportIndexDir, "hub-stats"),
});
const hubSummary = toHubSummary(summary);
- await writeFile(dest, JSON.stringify(hubSummary));
+ await writePublicFile(dest, JSON.stringify(hubSummary));
return `hub-summary.json covers ${hubSummary.sites.length} official instance(s)`;
} catch (err) {
await rm(dest, { force: true });
@@ -99,7 +100,10 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> {
contract: SITE_DESCRIPTOR_VERSION,
});
}
- await writeFile(
+ // Every file below is written with writePublicFile, never through a link:
+ // in a worktree these paths are links into the primary checkout, whose live
+ // hub files a worktree compose (e2e:2origin's build:hub) used to overwrite.
+ await writePublicFile(
path.join(publicDir, "hub-sites.json"),
JSON.stringify(builtins),
);
@@ -113,20 +117,20 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> {
hubUrl: hub.siteUrl,
generatedAt: new Date().toISOString(),
});
- await writeFile(
+ await writePublicFile(
path.join(publicDir, "corpus.json"),
JSON.stringify(hubCorpus),
);
- await writeFile(
+ await writePublicFile(
path.join(publicDir, "llms.txt"),
renderHubLlmsTxt(hubCorpus),
);
- await writeFile(
+ await writePublicFile(
path.join(publicDir, "robots.txt"),
renderRobotsTxt({ siteUrl: hub.siteUrl }),
);
- await writeFile(
+ await writePublicFile(
path.join(publicDir, "_headers"),
renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS),
);
diff --git a/common/bin/compose-site.test.ts b/common/bin/compose-site.test.ts
@@ -1,6 +1,16 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ mkdtempSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { MANIFEST_ONLY_SIGNATURE, reconcileChannelTree } from "./compose-site";
@@ -106,3 +116,28 @@ test("a member with no source (not even a manifest) is removed, and a non-member
cleanup();
}
});
+
+test("a linked tree (a worktree's public/<tree>) becomes this checkout's own; the primary's is untouched", async () => {
+ const { src, dest, cleanup } = fixture();
+ try {
+ // The primary's composed tree, and the worktree's public/transcripts
+ // linking to it.
+ const primaryTree = path.join(path.dirname(dest), "primary-transcripts");
+ mkdirSync(path.join(primaryTree, "live-chan"), { recursive: true });
+ writeFileSync(path.join(primaryTree, "live-chan", "page-0000.json"), "live");
+ symlinkSync(primaryTree, dest);
+ mkdirSync(path.join(src, "chan"));
+ writeFileSync(path.join(src, "chan", "manifest.json"), MANIFEST);
+ writeFileSync(path.join(src, "chan", "page-0000.json"), "[]");
+
+ await reconcileChannelTree("transcripts", src, dest, ["chan"], {}, quiet);
+
+ assert.ok(lstatSync(dest).isDirectory() && !lstatSync(dest).isSymbolicLink());
+ assert.deepEqual(readdirSync(dest), ["chan"]);
+ // Nothing was copied into, or pruned from, the primary's tree.
+ assert.deepEqual(readdirSync(primaryTree), ["live-chan"]);
+ assert.equal(readFileSync(path.join(primaryTree, "live-chan", "page-0000.json"), "utf8"), "live");
+ } finally {
+ cleanup();
+ }
+});
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -61,15 +61,21 @@ import {
type ArchiveManifestEntry,
} from "../lib/archiveOptions";
import { runIfEntryPoint } from "./_cli";
+import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile";
// Emit the public federation contract: /site.json (branding + channels +
// freshness) and the CORS _headers file. Emitted for EVERY site regardless of
// whether it ships a PWA — a dumb instance is still federatable.
+//
+// Every file this compose puts in public/ is written with writePublicFile /
+// copyPublicFile, and every tree it writes into is ownDir'd first, so nothing
+// is written THROUGH a link: in a git worktree public/'s entries are links into
+// the primary checkout (_publicFile.ts).
async function emitFederationFiles(
site: Site,
paths: ReturnType<typeof getPaths>,
): Promise<void> {
- await writeFile(
+ await writePublicFile(
path.join(paths.exportPublicDir, "_headers"),
renderHeadersFile("compose-site.ts"),
);
@@ -81,7 +87,7 @@ async function emitFederationFiles(
pwa: shipsPwa(site),
hubUrl: resolveHubUrl(site),
});
- await writeFile(
+ await writePublicFile(
path.join(paths.exportPublicDir, "site.json"),
JSON.stringify(descriptor),
);
@@ -156,15 +162,15 @@ async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> {
digestCounts,
hasTags,
});
- await writeFile(
+ await writePublicFile(
path.join(paths.exportPublicDir, "corpus.json"),
JSON.stringify(corpus),
);
- await writeFile(
+ await writePublicFile(
path.join(paths.exportPublicDir, "llms.txt"),
renderSiteLlmsTxt(corpus),
);
- await writeFile(
+ await writePublicFile(
path.join(paths.exportPublicDir, "robots.txt"),
renderRobotsTxt({ siteUrl: descriptor.siteUrl }),
);
@@ -178,7 +184,7 @@ async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> {
if (await exists(path.join(paths.exportPublicDir, DUPLICATES_FILENAME))) {
routes.push("/duplicates");
}
- await writeFile(
+ await writePublicFile(
sitemapPath,
renderSitemapXml({ siteUrl: descriptor.siteUrl, routes }),
);
@@ -600,7 +606,9 @@ export async function reconcileChannelTree(
prev: Record<string, string>,
log: (m: string) => void,
): Promise<Record<string, string>> {
- await mkdir(destRoot, { recursive: true });
+ // A linked tree (a worktree's public/<tree> → the primary's) becomes this
+ // checkout's own before anything is copied into or pruned from it.
+ await ownDir(destRoot);
const next: Record<string, string> = {};
const memberSet = new Set(memberSlugs);
let copied = 0;
@@ -732,7 +740,8 @@ export async function main(
"manifest.json",
);
if (await exists(subsManifestSrc)) {
- await cp(subsManifestSrc, path.join(paths.exportSubsDir, "manifest.json"));
+ await ownDir(paths.exportSubsDir);
+ await copyPublicFile(subsManifestSrc, path.join(paths.exportSubsDir, "manifest.json"));
}
// Same for the per-site posts manifest (which channels carry posts).
const postsManifestSrc = path.join(
@@ -742,8 +751,8 @@ export async function main(
"manifest.json",
);
if (await exists(postsManifestSrc)) {
- await mkdir(paths.exportPostsDir, { recursive: true });
- await cp(postsManifestSrc, path.join(paths.exportPostsDir, "manifest.json"));
+ await ownDir(paths.exportPostsDir);
+ await copyPublicFile(postsManifestSrc, path.join(paths.exportPostsDir, "manifest.json"));
}
// Same for the per-site digests manifest (which channels carry digests).
const digestsManifestSrc = path.join(
@@ -753,8 +762,8 @@ export async function main(
"manifest.json",
);
if (await exists(digestsManifestSrc)) {
- await mkdir(paths.exportDigestsDir, { recursive: true });
- await cp(
+ await ownDir(paths.exportDigestsDir);
+ await copyPublicFile(
digestsManifestSrc,
path.join(paths.exportDigestsDir, "manifest.json"),
);
@@ -777,7 +786,7 @@ export async function main(
// staging step. Always emitted — a fresh install ships the seeded defaults so
// the viewer's suggestion chip works out of the box.
const aliases = effectiveSiteAliases(paths, siteId);
- await writeFile(
+ await writePublicFile(
path.join(paths.exportPublicDir, "search-aliases.json"),
JSON.stringify({ aliases }),
);
@@ -808,7 +817,7 @@ export async function main(
const publishedTags = publishedTagsFrom(tagDefs, tagCounts);
const tagsDest = path.join(paths.exportPublicDir, TAGS_FILENAME);
if (publishedTags.tags.length > 0) {
- await writeFile(tagsDest, JSON.stringify(publishedTags));
+ await writePublicFile(tagsDest, JSON.stringify(publishedTags));
} else {
await rm(tagsDest, { force: true });
}
@@ -900,7 +909,7 @@ export async function main(
},
clusters,
};
- await writeFile(dupDest, JSON.stringify(filtered));
+ await writePublicFile(dupDest, JSON.stringify(filtered));
wrote = true;
}
}
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -27,6 +27,7 @@ import type { DisplaySummary } from "../lib/transcripts";
import type { Manifest, SubsManifest } from "../lib/manifest";
import type { PostsManifest } from "../lib/posts";
import { readerFor } from "../lib/archive/readers";
+import { ArchiveHttpError } from "../lib/archive/reader";
import {
DEFAULT_GROUP_FALLBACK_ID,
FALLBACK_GROUP,
@@ -343,11 +344,35 @@ export function MultiSiteDataProvider({
// merged below only once the archive is ready, so all of an archive's
// contributions land in the search in one re-run. A posts 404 (no posts
// corpus) resolves to an empty manifest, not a failure.
+ //
+ // A subs 404 is the same answer — a member with no live chat has no subs
+ // manifest — so it resolves to an empty one at once. It used to throw, and
+ // the client's retry held that archive at "loading" for another second. Any
+ // OTHER error (network, 5xx, or a 404 served without CORS, which the browser
+ // reports as a network error — `serve` on a self-hosted archive does that)
+ // is retried once and then counts as SETTLED: the archive is ready without
+ // its live chat. Live chat is the auxiliary layer; its failure never takes an
+ // archive's videos out of the search or makes it read as "did not answer".
const subsQueries = useQueries({
queries: sites.map((s) => ({
queryKey: ["subs-manifest", s.origin],
- queryFn: () => readerFor(s.origin).readSubsSiteManifest(),
+ queryFn: () =>
+ readerFor(s.origin)
+ .readSubsSiteManifest()
+ .catch((err: unknown): SubsManifest => {
+ if (err instanceof ArchiveHttpError && err.status === 404) {
+ return {
+ version: 0,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: "",
+ };
+ }
+ throw err;
+ }),
enabled: inScope(s),
+ retry: 1,
})),
});
const postsQueries = useQueries({
@@ -387,8 +412,9 @@ export function MultiSiteDataProvider({
const pages = pagesByOrigin.get(s.origin) ?? [];
const count = m?.data?.totalCount;
const withCount = count === undefined ? base : { ...base, count };
- // A subs manifest that errored still counts as settled (the archive just
- // has no live chat in the search); posts never error (404 → empty).
+ // Subs and posts manifests resolve a 404 to an empty manifest; posts never
+ // errors. A subs query that still errors after its retry counts as settled
+ // (the archive is ready without its live chat) and never as a failure.
const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) =>
!!q && (q.isSuccess || (q.isError && !q.isFetching));
if (
@@ -427,9 +453,16 @@ export function MultiSiteDataProvider({
);
const readyKey = Array.from(readyOrigins).join("\u0000");
const scoped = siteStates.filter((s) => s.status !== "off");
- const allSettled = scoped.every(
- (s) => s.status === "ready" || s.status === "failed",
- );
+ // Nothing in scope — every archive switched off, or the hub's list not
+ // arrived yet — is NOT ready for a surface that answers from the whole
+ // federation (not `progressive`: the /ask chat). There is nothing to ground a
+ // question in, and "ready" there would send one over zero records. The
+ // progressive front page settles on an empty scope instead, so its results
+ // read "no videos" rather than loading forever; its chips and "Searching 0
+ // archives" say why.
+ const allSettled =
+ (progressive || scoped.length > 0) &&
+ scoped.every((s) => s.status === "ready" || s.status === "failed");
const summariesReady = allSettled || (progressive && readyOrigins.size > 0);
const loadedPages = pageQueries.filter((q) => q.data).length;
diff --git a/common/lib/hubSummary.test.ts b/common/lib/hubSummary.test.ts
@@ -3,9 +3,12 @@ import assert from "node:assert/strict";
import {
HUB_SUMMARY_VERSION,
hubSummarySiteFor,
+ officialInstances,
parseHubSummary,
toHubSummary,
+ type HubSummary,
} from "./hubSummary";
+import { seriesColor } from "./homepageChart";
import type { HomepageSummary } from "./homepageSummary";
// Run with:
@@ -139,3 +142,104 @@ test("hubSummarySiteFor matches by siteId, then by origin", () => {
assert.equal(hubSummarySiteFor(hub, { siteId: "gamma", origin: "https://gamma.example" }), null);
assert.equal(hubSummarySiteFor(null, { siteId: "alpha" }), null);
});
+
+// The hub's official instances, three of them, as hub-sites.json lists them
+// (alphabetically), none with an accent of its own.
+function pool() {
+ return [
+ { siteId: "anilyzer", origin: "https://anilyzer.example" },
+ { siteId: "hasanalyzer", origin: "https://hasanalyzer.example" },
+ { siteId: "jeralyzer", origin: "https://jeralyzer.example" },
+ ];
+}
+
+// The homepage's order: transcripts, most first.
+function homepageOrder(): HubSummary {
+ const site = (siteId: string, transcripts: number) => ({
+ siteId,
+ siteTitle: siteId,
+ siteUrl: `https://${siteId}.example`,
+ transcripts,
+ });
+ return {
+ version: HUB_SUMMARY_VERSION,
+ generatedAt: "2026-09-26T00:00:00.000Z",
+ official: null,
+ sites: [site("jeralyzer", 300), site("anilyzer", 200), site("hasanalyzer", 100)],
+ };
+}
+
+test("officialInstances lists the members in the summary's order, each in its homepage colour", () => {
+ const out = officialInstances(pool(), homepageOrder());
+ assert.deepEqual(
+ out.map((o) => [o.site.siteId, o.figures?.transcripts, o.accent]),
+ [
+ ["jeralyzer", 300, seriesColor(0)],
+ ["anilyzer", 200, seriesColor(1)],
+ ["hasanalyzer", 100, seriesColor(2)],
+ ],
+ );
+});
+
+test("officialInstances: with no summary the given order stands, coloured by place", () => {
+ const out = officialInstances(pool(), null);
+ assert.deepEqual(
+ out.map((o) => [o.site.siteId, o.figures, o.accent]),
+ [
+ ["anilyzer", null, seriesColor(0)],
+ ["hasanalyzer", null, seriesColor(1)],
+ ["jeralyzer", null, seriesColor(2)],
+ ],
+ );
+});
+
+test("officialInstances: an accent the site sets wins; its place still follows the summary", () => {
+ const members = pool().map((m) =>
+ m.siteId === "anilyzer" ? { ...m, accent: "#e6a1c0" } : m,
+ );
+ const summary = homepageOrder();
+ summary.sites[2] = { ...summary.sites[2], accent: "#b49cf2" }; // hasanalyzer
+ const out = officialInstances(members, summary);
+ assert.deepEqual(
+ out.map((o) => [o.site.siteId, o.accent]),
+ [
+ ["jeralyzer", seriesColor(0)],
+ ["anilyzer", "#e6a1c0"], // hub-sites.json's own
+ ["hasanalyzer", "#b49cf2"], // the summary's, when hub-sites.json has none
+ ],
+ );
+});
+
+test("officialInstances: a member the summary does not name follows, in colours no named one wears", () => {
+ const members = [
+ { siteId: "zeta", origin: "https://zeta.example" },
+ ...pool(),
+ { siteId: "alpha", origin: "https://alpha.example" },
+ ];
+ // The summary also names a site the hub does not list (index 1): its colour
+ // stays its own, and is not handed to an unnamed member.
+ const summary = homepageOrder();
+ summary.sites.splice(1, 0, {
+ siteId: "gone",
+ siteTitle: "gone",
+ siteUrl: "https://gone.example",
+ });
+ const out = officialInstances(members, summary);
+ assert.deepEqual(
+ out.map((o) => [o.site.siteId, o.accent]),
+ [
+ ["jeralyzer", seriesColor(0)],
+ ["anilyzer", seriesColor(2)],
+ ["hasanalyzer", seriesColor(3)],
+ ["zeta", seriesColor(4)],
+ ["alpha", seriesColor(5)],
+ ],
+ );
+ // Matched by origin when the id changed between builds.
+ const renamed = officialInstances(
+ [{ siteId: "jer", origin: "https://jeralyzer.example" }],
+ homepageOrder(),
+ );
+ assert.equal(renamed[0].figures?.siteId, "jeralyzer");
+ assert.equal(renamed[0].accent, seriesColor(0));
+});
diff --git a/common/lib/hubSummary.ts b/common/lib/hubSummary.ts
@@ -13,12 +13,14 @@
// never an error. It is read same-origin, so it is not in HUB_CORS_PATHS, and
// the deploy guard (builtHubProblem) does not require it.
//
-// Pure and client-safe: the hub page parses it in the browser.
+// Pure and client-safe: the hub page parses it in the browser, and lists and
+// colours its official instances by it (officialInstances, below).
import type {
HomepageOfficialTotals,
HomepageSummary,
} from "./homepageSummary";
+import { seriesColor } from "./homepageChart";
export const HUB_SUMMARY_FILE = "hub-summary.json";
export const HUB_SUMMARY_VERSION = 1;
@@ -162,3 +164,52 @@ export function hubSummarySiteFor(
}) ?? null
);
}
+
+// One official instance as every hub surface shows it: its figures, and the
+// colour it wears on its card's stripe, its scope chip's dot, its results' left
+// edge and its channel group.
+export type OfficialInstance<T> = {
+ site: T;
+ figures: HubSummarySite | null;
+ accent: string;
+};
+
+// The official instances in the homepage's order, each with one colour.
+//
+// ORDER: the summary's `sites` order — the homepage's (transcripts, most first:
+// homepageSummary.ts), the order its ArchiveCards and growth chart draw in, so
+// the hub and the homepage list the instances on the same rows. A member the
+// summary does not name follows, in the order given. With no summary the order
+// given (hub-sites.json's) stands.
+//
+// COLOUR: the site's own accent, else the summary's, else seriesColor() at its
+// index in the summary — the colour its homepage card and chart layer wear. A
+// member the summary does not name takes the colours after the summary's, so it
+// never wears one a named instance wears. With no summary that is its place in
+// the list.
+export function officialInstances<
+ T extends { siteId: string; origin?: string; accent?: string },
+>(members: readonly T[], summary: HubSummary | null): OfficialInstance<T>[] {
+ const placed = members.map((site, given) => {
+ const figures = hubSummarySiteFor(summary, site);
+ const at = figures && summary ? summary.sites.indexOf(figures) : -1;
+ return { site, figures, at, given };
+ });
+ const named = placed
+ .filter((p) => p.at >= 0)
+ .sort((a, b) => a.at - b.at || a.given - b.given);
+ const unnamed = placed.filter((p) => p.at < 0);
+ const listed = summary?.sites.length ?? 0;
+ return [
+ ...named.map((p) => ({
+ site: p.site,
+ figures: p.figures,
+ accent: p.site.accent || p.figures?.accent || seriesColor(p.at),
+ })),
+ ...unnamed.map((p, k) => ({
+ site: p.site,
+ figures: null,
+ accent: p.site.accent || seriesColor(listed + k),
+ })),
+ ];
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## [Unreleased]
+- **The hub lists its official instances in the homepage's order, and each wears one colour everywhere.** The cards and the scope chips follow the homepage (most transcripts first) instead of `hub-sites.json`'s alphabetical order. An archive whose site sets no accent wears its homepage card's colour, and now on every surface: its card, its chip's dot, the left edge of its search results and its channel group. Before, it had a card stripe and nothing else. The hub waits for `/hub-summary.json`, which is requested together with the list, before it lists the official instances, so they never reorder a moment later.
+- **The hub's Ask AI searches only the archives the scope chips leave in.** An archive switched off on the hub's front page is not fetched on `/ask` either, so the chat answers from what the search showed. It still waits for every archive in scope before it answers. With every archive switched off, the question box is disabled and one line under it says why. Before, a question went out over no records at all.
+- **A member archive with no live chat is ready about a second sooner on the hub.** Its missing subs manifest (a 404) is read as "no live chat" at once, instead of as an error and a second request. A subs manifest that fails for any other reason is asked for once more. If it fails again, the archive is searched without its live chat, and its chip reads ready.
+
## [0.9.0] - 2026-09-26
- **A reader picks a ground and an accent; the five theme families are gone.** The header's theme menu has **Base** (System, Light, Sepia, Dark) and **Accent** (Signal, Brass, Vermilion, Violet, Sakura, Blue, Green, with the site's own tagged *default*, and *Site colour* first on a site with a custom hex). The toggle beside it cycles System → Light → Sepia → Dark; on a phone both lists are in the menu sheet. A site opens on the reader's system setting in the accent from its `site.json` (a named id such as `brass`, or a hex), rendered as `<html data-accent>` so it is right before any script runs; the hub opens on Dark in Signal, already in the server markup. A reader's accent is stored only while it differs from the site's. A stored theme from before carries over once (Archive light becomes Sepia) and the old keys are deleted. Type is Archivo, IBM Plex Sans and IBM Plex Mono; status text reads at 4.5:1 on its tinted fill on every ground; charts have fixed colours per ground; the browser bar follows the ground (a light/dark pair for a site, dark for the hub).
- **The Found-line mark, and a split wordmark.** The site header's rotated square is now the family mark — four transcript lines on an ink tile, the second lit in the reader's accent (the site's by default) and carrying a play head — and the header title splits at the site's `wordmarkLead` (heavy lead, light rest: Jer|alyzer). The hub wears the parent mark, bone on slate, and splits as Archi|lyzer. The footer credit has the small parent mark before "Built with"; the link's name is still exactly "Archilyzer". The icons are no longer committed: `app/icons/[file]/route.ts` and `app/favicon.ico/route.ts` render them from `common/lib/brand.ts` at build (static export writes `out/icons/*` and `out/favicon.ico`), lit with the site's accent. `<head>` links `icon.svg` first, then `icon-32.png`, 192, 512 and the touch icon. The manifest lists `icon.svg` (sizes "any"); its `theme_color` is the dark base's ground `#0c0a08` and its `background_color` the icon's tile. Both service workers fetch `/icons/` network-first (a cached copy serves offline), so a later accent change reaches installed apps, and rename only their shell cache (`shell-v2`) so installed apps drop the old icons. The data caches, and readers' offline downloads, are kept; the old shell's cached pages go with it, so an installed app opens offline again after one more online visit.
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
+import Link from "next/link";
import { ArrowDownIcon, PlusIcon } from "lucide-react";
import { usePlayerOptional } from "yt-dlp-transcript-common/components/PlayerProvider";
+import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery";
import { DEFAULT_SWEEP_DIRECTIVE, useAskChat } from "./useAskChat";
import { ProviderSettings } from "./ProviderSettings";
@@ -13,6 +15,7 @@ import { GroundingPalette } from "./GroundingPalette";
import { SavedChatsRow } from "./SavedChatsRow";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
+import { NO_ARCHIVES_IN_SCOPE, copyWithLink } from "./hubScopeCopy";
export default function AskChat() {
const s = useAskChat();
@@ -28,6 +31,17 @@ export default function AskChat() {
markdownOn,
} = s;
+ // Hub only: this browser switched every archive off with the front page's
+ // scope chips. The provider then never reads ready (nothing to ground in), and
+ // the composer says why instead of "Loading transcripts…". Single-site has no
+ // `federation`.
+ const { federation } = useSearchData();
+ const noneInScope =
+ !!federation &&
+ federation.sites.length > 0 &&
+ federation.sites.every((f) => f.status === "off");
+ const noneLine = copyWithLink(NO_ARCHIVES_IN_SCOPE);
+
// Opening a citation seeks the shared transcript modal to the cited line. It
// writes ?v=&t=&vm= via replaceState on this same /ask route (no navigation,
// no history push) and doesn't touch `messages`, so the bottom-scroll effect
@@ -461,6 +475,19 @@ export default function AskChat() {
stop={s.stop}
busy={busy}
summariesReady={summariesReady}
+ blocked={
+ noneInScope ? (
+ <>
+ {noneLine.before}
+ {noneLine.link && (
+ <Link href="/" className="text-brand underline-offset-2 hover:underline">
+ {noneLine.link}
+ </Link>
+ )}
+ {noneLine.after}
+ </>
+ ) : undefined
+ }
hasKey={!!apiKey.trim()}
markdownOn={markdownOn}
setMarkdownOn={s.setMarkdownOn}
diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx
@@ -2,18 +2,21 @@
// Hub variant of the /ask chat: wires the federated site registry into the
// multi-origin search data source so AskChat's retrieval (runQueryTree) searches
-// across every shelved archive. Mirrors HubHome's MultiSiteDataProvider wiring.
+// the archives the hub's search covers. Mirrors HubHome's MultiSiteDataProvider
+// wiring from the same list (useFederatedSites): the same order, the same
+// colour per archive, and the same scope — an archive this browser switched off
+// with its chip on the front page is not fetched here either, so the chat
+// grounds in what the search showed. Not progressive: the chat waits until
+// every archive in scope has settled, so it never answers from a half-loaded
+// federation — and with NONE in scope (every chip off, or the list not in yet)
+// it is not ready at all; AskChat then says so (NO_ARCHIVES_IN_SCOPE).
-import { useMemo } from "react";
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
import PostModal from "yt-dlp-transcript-common/components/PostModal";
-import {
- MultiSiteDataProvider,
- type FederatedSite,
-} from "yt-dlp-transcript-common/components/SearchDataContext";
+import { MultiSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext";
-import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
+import { useFederatedSites } from "../components/hub/useHubSites";
import AskChat from "./AskChat";
// `transcriptDownloads` comes from the server parent's currentSite() (a client
@@ -24,16 +27,7 @@ export default function AskHub({
}: {
transcriptDownloads?: boolean;
}) {
- const { sites } = useRegistry();
- const federated = useMemo<FederatedSite[]>(
- () =>
- sites.map((s) => ({
- origin: s.origin,
- siteTitle: s.siteTitle,
- accent: s.accent,
- })),
- [sites],
- );
+ const { federated } = useFederatedSites();
// The whole provider stack, in the order SiteWorkspace mounts it for a single
// site — PlayerProvider, then the data source, then the session — because
diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { RefObject } from "react";
+import type { ReactNode, RefObject } from "react";
type Props = {
input: string;
@@ -9,6 +9,9 @@ type Props = {
stop: () => void;
busy: boolean;
summariesReady: boolean;
+ // Why the chat cannot ask at all, shown as one line under the box (the box is
+ // disabled). Set on the hub when every archive is switched off.
+ blocked?: ReactNode;
hasKey: boolean;
markdownOn: boolean;
setMarkdownOn: (v: boolean) => void;
@@ -23,6 +26,7 @@ export function Composer(props: Props) {
stop,
busy,
summariesReady,
+ blocked,
hasKey,
markdownOn,
setMarkdownOn,
@@ -54,11 +58,13 @@ export function Composer(props: Props) {
}}
rows={2}
placeholder={
- summariesReady
- ? "Ask about the transcripts… (Enter to send · Shift+Enter for a new line)"
- : "Loading transcripts…"
+ blocked
+ ? undefined
+ : summariesReady
+ ? "Ask about the transcripts… (Enter to send · Shift+Enter for a new line)"
+ : "Loading transcripts…"
}
- disabled={!summariesReady}
+ disabled={!summariesReady || !!blocked}
// text-base below md: iOS zooms the page on focus for anything under
// 16px, and it never zooms back out.
onFocus={(e) => e.currentTarget.scrollIntoView({ block: "end" })}
@@ -67,7 +73,7 @@ export function Composer(props: Props) {
<div className="flex flex-wrap items-center gap-2">
<button
type="submit"
- disabled={busy || !summariesReady || !input.trim() || !hasKey}
+ disabled={busy || !summariesReady || !!blocked || !input.trim() || !hasKey}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-brand-strong disabled:opacity-50"
>
{busy ? "Thinking…" : "Ask"}
@@ -81,10 +87,16 @@ export function Composer(props: Props) {
Stop
</button>
)}
- {!hasKey && (
- <span className="text-xs text-muted-foreground">
- Set an API key above to start.
+ {blocked ? (
+ <span role="status" data-testid="ask-blocked" className="text-xs text-muted-foreground">
+ {blocked}
</span>
+ ) : (
+ !hasKey && (
+ <span className="text-xs text-muted-foreground">
+ Set an API key above to start.
+ </span>
+ )
)}
{/* Formatted / Plain escape hatch for Markdown rendering. */}
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts
@@ -0,0 +1,24 @@
+// The hub's /ask line for a browser that has switched EVERY archive off with
+// the front page's scope chips: the chat has nothing to search, so its composer
+// is disabled and this one line says why. The operator's copy — change it here
+// and nowhere else (AskChat renders it, the hub e2e asserts it). The words in
+// [brackets] become a link to the hub's front page, where the chips are; drop
+// the brackets for no link.
+export const NO_ARCHIVES_IN_SCOPE =
+ "No archives selected. Choose some on the [hub's front page] to ask.";
+
+// A copy line split around its one [bracketed] link.
+export function copyWithLink(copy: string): {
+ before: string;
+ link?: string;
+ after: string;
+} {
+ const m = /^([^[]*)\[([^\]]+)\]([\s\S]*)$/.exec(copy);
+ return m ? { before: m[1], link: m[2], after: m[3] } : { before: copy, after: "" };
+}
+
+// The line as a reader sees it (the brackets gone).
+export function copyText(copy: string): string {
+ const { before, link = "", after } = copyWithLink(copy);
+ return before + link + after;
+}
diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx
@@ -14,27 +14,22 @@
// file behind it, so its card shows only the live channel count its
// descriptor gave. The live federation total is HubStats, under the shelf.
//
-// Each card wears its archive's OWN accent when the site sets one (tokens.css,
-// the archilyzer family: the tool has no colour, the archives do). An official
-// instance that sets none falls back to seriesColor() at its index in the
-// summary's `sites` — the order the homepage's ArchiveCards and growth chart
-// use — so a hub card and its homepage card wear the same colour. With no
-// summary it is the card's own index. An added archive with no accent wears
-// the family's signal colour.
+// The official cards come in the homepage's order (the summary's `sites`, the
+// order its ArchiveCards and growth chart draw in), and each wears its
+// archive's OWN accent when the site sets one (the tool has no colour, the
+// archives do); one that sets none wears seriesColor() at its index in the
+// summary, the colour of its homepage card. Order and colour are useHubSites'
+// (officialInstances), shared with the scope chips, the results' stripes and
+// /ask, so an archive is one colour on every surface. With no summary the order
+// is hub-sites.json's and the colour the card's place. An added archive with
+// no accent wears the family's signal colour.
import { X } from "lucide-react";
import { Badge } from "yt-dlp-transcript-common/components/ui/badge";
-import {
- useRegistry,
- type RegisteredSite,
-} from "yt-dlp-transcript-common/components/siteRegistry";
-import {
- hubSummarySiteFor,
- type HubSummarySite,
-} from "yt-dlp-transcript-common/lib/hubSummary";
-import { seriesColor } from "yt-dlp-transcript-common/lib/homepageChart";
+import type { RegisteredSite } from "yt-dlp-transcript-common/components/siteRegistry";
+import type { HubSummarySite } from "yt-dlp-transcript-common/lib/hubSummary";
import AddArchive from "./AddArchive";
-import { useHubSummary } from "./useHubSummary";
+import { useHubSites } from "./useHubSites";
// Fall back to the family's signal colour when a site declares no accent.
const FALLBACK_ACCENT = "var(--brand)";
@@ -55,18 +50,19 @@ function ArchiveCard({
site,
figures,
index,
- fallbackAccent = FALLBACK_ACCENT,
+ accent,
onRemove,
}: {
site: RegisteredSite;
- fallbackAccent?: string;
- // Official: the build-time figures (null until/unless the summary loads).
+ // The stripe's colour: an official card's from useHubSites, an added one's
+ // own accent or the family's signal colour.
+ accent: string;
+ // Official: the build-time figures (null when the summary lacks them).
// Added: undefined — the card shows the descriptor's channel count only.
figures?: HubSummarySite | null;
index: number;
onRemove?: (origin: string) => void;
}) {
- const accent = site.accent || figures?.accent || fallbackAccent;
const href = site.siteUrl || site.origin;
const channels =
figures === undefined ? site.channelCount : figures?.channels;
@@ -136,10 +132,7 @@ const GRID =
"grid list-none grid-cols-1 border-t border-l border-border sm:grid-cols-2 lg:grid-cols-3";
export default function ArchiveShelf() {
- const { sites, removeSite } = useRegistry();
- const summary = useHubSummary();
- const official = sites.filter((s) => s.kind === "builtin");
- const added = sites.filter((s) => s.kind === "external");
+ const { official, added, summary, removeSite } = useHubSites();
const hours = summary?.official?.hoursArchived;
return (
@@ -174,19 +167,15 @@ export default function ArchiveShelf() {
Official Instances
</h2>
<ul className={GRID}>
- {official.map((site, i) => {
- const figures = hubSummarySiteFor(summary, site);
- const at = figures ? (summary?.sites.indexOf(figures) ?? -1) : -1;
- return (
- <ArchiveCard
- key={site.origin || site.siteId}
- site={site}
- figures={figures}
- index={i}
- fallbackAccent={seriesColor(at >= 0 ? at : i)}
- />
- );
- })}
+ {official.map(({ site, figures, accent }, i) => (
+ <ArchiveCard
+ key={site.origin || site.siteId}
+ site={site}
+ figures={figures}
+ index={i}
+ accent={accent}
+ />
+ ))}
</ul>
</div>
) : (
@@ -207,6 +196,7 @@ export default function ArchiveShelf() {
key={site.origin || site.siteId}
site={site}
index={i}
+ accent={site.accent || FALLBACK_ACCENT}
onRemove={removeSite}
/>
))}
diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx
@@ -6,21 +6,16 @@
// component wires the registry into the multi-origin data source so the shared
// TranscriptSearch renders one merged, origin-qualified view.
-import { useMemo } from "react";
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
import PostModal from "yt-dlp-transcript-common/components/PostModal";
import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
-import {
- MultiSiteDataProvider,
- type FederatedSite,
-} from "yt-dlp-transcript-common/components/SearchDataContext";
-import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
+import { MultiSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
import ArchiveShelf from "./ArchiveShelf";
import HubStats from "./HubStats";
import HubOfflineManager from "./HubOfflineManager";
import HubScope from "./HubScope";
-import { useHubScope } from "./useHubScope";
+import { useFederatedSites } from "./useHubSites";
// `transcriptDownloads` comes from the server parent's currentSite() (a client
// component cannot read site.json): false hides the modal's per-video export
@@ -30,23 +25,11 @@ export default function HubHome({
}: {
transcriptDownloads?: boolean;
}) {
- const { sites } = useRegistry();
- // Which archives the search covers (every one unless this browser switched
- // it off with its chip).
- const { isOn, toggle } = useHubScope();
-
- // Carry each site's accent through to the merged search for provenance, and
- // its scope: an archive switched off is not fetched at all.
- const federated = useMemo<FederatedSite[]>(
- () =>
- sites.map((s) => ({
- origin: s.origin,
- siteTitle: s.siteTitle,
- accent: s.accent,
- enabled: isOn(s.origin),
- })),
- [sites, isOn],
- );
+ // Every archive in the shelf's order, each with its colour (carried through
+ // to the merged search for provenance: result stripes, chip dots) and its
+ // scope — every one unless this browser switched it off with its chip; an
+ // archive switched off is not fetched at all.
+ const { federated, toggle } = useFederatedSites();
return (
<PlayerProvider features={{ transcriptDownloads }}>
diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx
@@ -8,7 +8,6 @@
// what actually loaded, from the archives this browser has in scope.
import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
-import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
function part(n: number, one: string, many: string) {
return (
@@ -20,16 +19,16 @@ function part(n: number, one: string, many: string) {
}
export default function HubStats() {
- const { sites } = useRegistry();
const { summariesState, channels, federation } = useSearchData();
- // The archives in scope (the visitor's chips), and what has arrived from them:
- // channels as each manifest lands, videos as each archive is ready.
- const archives =
- federation?.sites.filter((s) => s.status !== "off").length ?? sites.length;
+ // The archives the search knows (the list the chips draw), those in scope,
+ // and what has arrived from them: channels as each manifest lands, videos as
+ // each archive is ready.
+ const known = federation?.sites ?? [];
+ const archives = known.filter((s) => s.status !== "off").length;
// The ready archives' record counts — the same figures the chips show.
const videos = summariesState.manifest?.totalCount ?? 0;
- if (sites.length === 0) return null;
+ if (known.length === 0) return null;
return (
<p className="text-sm text-muted-foreground">
diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts
@@ -0,0 +1,66 @@
+"use client";
+
+// The archives on this hub as every hub surface lists them — the shelf's cards,
+// the scope chips, the search (its result stripes, chip dots and channel groups)
+// and /ask: ONE order and ONE colour per archive, decided here.
+//
+// The official instances come in the homepage's order, in the homepage's
+// colours unless a site sets its own accent (officialInstances,
+// common/lib/hubSummary.ts). That order is /hub-summary.json's, so they are
+// listed only once that file has settled — found, missing or unreadable —
+// rather than in hub-sites.json's order first and reordered a moment later.
+// Both files are small, same-origin and requested together, so the wait is the
+// gap between two requests already in flight. The archives a visitor added
+// follow, in the order they were added, each in its own accent or none.
+
+import { useMemo } from "react";
+import {
+ useRegistry,
+ type RegisteredSite,
+} from "yt-dlp-transcript-common/components/siteRegistry";
+import type { FederatedSite } from "yt-dlp-transcript-common/components/SearchDataContext";
+import { officialInstances } from "yt-dlp-transcript-common/lib/hubSummary";
+import { useHubScope } from "./useHubScope";
+import { useHubSummary } from "./useHubSummary";
+
+export function useHubSites() {
+ const { sites, removeSite } = useRegistry();
+ const { summary, settled } = useHubSummary();
+ return useMemo(() => {
+ const official = settled
+ ? officialInstances(
+ sites.filter((s) => s.kind === "builtin"),
+ summary,
+ )
+ : [];
+ const added = sites.filter((s) => s.kind === "external");
+ // Every archive, in the one order, each official one wearing its colour.
+ const all: RegisteredSite[] = [
+ ...official.map((o) => ({ ...o.site, accent: o.accent })),
+ ...added,
+ ];
+ return { official, added, all, summary, removeSite };
+ }, [sites, summary, settled, removeSite]);
+}
+
+// What the hub's search surfaces hand MultiSiteDataProvider: every archive in
+// the one order, with its colour and this browser's scope (the chips on the
+// front page; an archive switched off there is not fetched).
+export function useFederatedSites(): {
+ federated: FederatedSite[];
+ toggle: (origin: string) => void;
+} {
+ const { all } = useHubSites();
+ const { isOn, toggle } = useHubScope();
+ const federated = useMemo<FederatedSite[]>(
+ () =>
+ all.map((s) => ({
+ origin: s.origin,
+ siteTitle: s.siteTitle,
+ accent: s.accent,
+ enabled: isOn(s.origin),
+ })),
+ [all, isOn],
+ );
+ return { federated, toggle };
+}
diff --git a/export/app/components/hub/useHubSummary.ts b/export/app/components/hub/useHubSummary.ts
@@ -5,6 +5,10 @@
// homepage renders. Same-origin, fetched once. OPTIONAL: an older hub build or
// a hub composed with no index has no file, and every failure — 404, bad JSON,
// an unknown version — reads as "no numbers", never as an error on the page.
+//
+// `settled` turns true once the answer is in, whatever it was: the hub lists
+// its official instances in the summary's order (useHubSites), so it waits for
+// this before listing them rather than reordering them a moment later.
import { useQuery } from "@tanstack/react-query";
import {
@@ -23,12 +27,20 @@ async function fetchHubSummary(): Promise<HubSummary | null> {
}
}
-export function useHubSummary(): HubSummary | null {
- const { data } = useQuery({
+export function useHubSummary(): {
+ summary: HubSummary | null;
+ settled: boolean;
+} {
+ const { data, isPending } = useQuery({
queryKey: ["hub-summary"],
queryFn: fetchHubSummary,
staleTime: Infinity,
retry: false,
+ // Always run it, even while the browser reports itself offline: TanStack's
+ // default ("online") would PAUSE the query then, leaving it pending and the
+ // official instances unlisted until the connection returned. The fetch
+ // never throws — offline it settles at once as "no summary".
+ networkMode: "always",
});
- return data ?? null;
+ return { summary: data ?? null, settled: !isPending };
}
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -1,4 +1,9 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import {
+ NO_ARCHIVES_IN_SCOPE,
+ copyText,
+ copyWithLink,
+} from "../app/ask/hubScopeCopy";
// The hub's federated search, per archive: two official members (hub-sites.json)
// served by route mocks WITH CORS, each with one video. Proves the scope chips
@@ -7,6 +12,14 @@ import { expect, test, type Page, type Route } from "@playwright/test";
// says "1 of 2 archives answered" with a Retry, and the healthy member's results
// still render), Retry, progressive readiness (a slow member does not hold the
// fast one's results back), and a card naming its source archive in text.
+// Release 10 (L1): a member with no live chat is ready on its first subs
+// answer (a 404 is an empty manifest), and a subs manifest that cannot be read
+// is retried once and never fails its archive (ready, without live chat); the
+// official archives follow the homepage's
+// order (hub-summary.json's) on the cards and the chips, and an archive with no
+// accent wears ONE colour — its homepage card's — on its card, its chip and its
+// results; /ask searches only the archives the chips leave in, and with every
+// archive switched off it says so and cannot ask.
const ORIGIN_A = "http://localhost:4598";
const ORIGIN_B = "http://localhost:4599";
@@ -62,16 +75,18 @@ function pageOf(m: Member) {
}
// Every request to a member origin is counted; `pages` decides how its
-// summaries page is answered.
+// summaries page is answered, `subs` whether it has a subs manifest at all
+// ("missing": a 404) or cannot serve it ("error": a 500).
type PageMode = "ok" | "abort" | "hold";
type MemberMock = {
requests: string[];
pages: PageMode;
+ subs: "ok" | "missing" | "error";
held: Route[];
};
async function mockMember(page: Page, m: Member): Promise<MemberMock> {
- const mock: MemberMock = { requests: [], pages: "ok", held: [] };
+ const mock: MemberMock = { requests: [], pages: "ok", subs: "ok", held: [] };
await page.route(`${m.origin}/**`, async (route) => {
const url = new URL(route.request().url());
mock.requests.push(url.pathname);
@@ -86,7 +101,10 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> {
}
return fulfillJson(route, pageOf(m));
}
- if (url.pathname === "/subs/manifest.json") {
+ if (url.pathname === "/subs/manifest.json" && mock.subs === "error") {
+ return route.fulfill({ status: 500, headers: CORS, body: "" });
+ }
+ if (url.pathname === "/subs/manifest.json" && mock.subs === "ok") {
return fulfillJson(route, {
version: 4,
channels: [],
@@ -95,19 +113,26 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> {
generatedAt: "2026-01-01T00:00:00.000Z",
});
}
- // No posts, no aliases: a clean 404 (a member without a posts corpus is
- // not a failure).
+ // No posts, no aliases (and, when `subs` is "missing", no live chat): a
+ // clean 404 (a member without that corpus is not a failure).
return route.fulfill({ status: 404, headers: CORS, body: "" });
});
return mock;
}
-async function setup(page: Page, members: Member[] = [A, B]) {
+// `summary`: the members hub-summary.json names, in its order (the homepage's);
+// absent, the file is a 404.
+async function setup(
+ page: Page,
+ members: Member[] = [A, B],
+ { summary }: { summary?: Member[] } = {},
+) {
+ const siteIdOf = (m: Member) => `origin${members.indexOf(m)}`;
await page.route("**/hub-sites.json", (r) =>
fulfillJson(
r,
- members.map((m, i) => ({
- siteId: `origin${i}`,
+ members.map((m) => ({
+ siteId: siteIdOf(m),
siteTitle: m.title,
siteUrl: m.origin,
pwa: false,
@@ -116,7 +141,18 @@ async function setup(page: Page, members: Member[] = [A, B]) {
),
);
await page.route("**/hub-summary.json", (r) =>
- r.fulfill({ status: 404, body: "" }),
+ summary
+ ? fulfillJson(r, {
+ version: 1,
+ generatedAt: "2026-09-26T00:00:00.000Z",
+ sites: summary.map((m, i) => ({
+ siteId: siteIdOf(m),
+ siteTitle: m.title,
+ siteUrl: m.origin,
+ transcripts: 100 * (summary.length - i),
+ })),
+ })
+ : r.fulfill({ status: 404, body: "" }),
);
const a = await mockMember(page, A);
const b = await mockMember(page, B);
@@ -272,4 +308,214 @@ test.describe("hub federated search — scope, per-archive state, attribution",
await expect(status(page)).toContainText("Origin B did not");
await expect(resultFrom(page, C)).toHaveCount(1);
});
+
+ test("a member with no live chat is ready on its first subs answer: a 404 is not retried", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ mocks.b.subs = "missing";
+ await page.goto("/");
+
+ // Ready means every feed of the archive is in, subs included. A 404 is an
+ // empty subs manifest, at once. It used to be an error, retried after ~1 s,
+ // so the chip turned ready with two requests on record; now there is one.
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ const subsAsks = (m: MemberMock) =>
+ m.requests.filter((p) => p === "/subs/manifest.json").length;
+ expect(subsAsks(mocks.b)).toBe(1);
+ expect(subsAsks(mocks.a)).toBe(1);
+ });
+
+ test("the official archives follow the homepage's order, each in one colour on its card, chip and results", async ({
+ page,
+ }) => {
+ // hub-sites.json lists A then B; the summary (the homepage's order,
+ // transcripts first) lists B then A. Neither sets an accent.
+ await setup(page, [A, B], { summary: [B, A] });
+ await page.goto("/");
+
+ const cards = page.getByTestId("shelf-spine");
+ await expect(cards).toHaveCount(2);
+ await expect(cards.nth(0)).toContainText("Origin B");
+ await expect(cards.nth(1)).toContainText("Origin A");
+ const chips = page.locator('[data-testid^="hub-scope-chip-"]');
+ await expect(chips).toHaveCount(2);
+ await expect(chips.nth(0)).toHaveAttribute("data-testid", `hub-scope-chip-${B.origin}`);
+ await expect(chips.nth(1)).toHaveAttribute("data-testid", `hub-scope-chip-${A.origin}`);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(resultFrom(page, B)).toHaveCount(1);
+
+ // The colour a CSS colour resolves to on this page.
+ const resolve = (css: string) =>
+ page.evaluate((c) => {
+ const el = document.createElement("span");
+ el.style.backgroundColor = c;
+ document.body.append(el);
+ const out = getComputedStyle(el).backgroundColor;
+ el.remove();
+ return out;
+ }, css);
+ const coloursOf = async (m: Member) => ({
+ card: await cards
+ .filter({ hasText: m.title })
+ .locator(":scope > span[aria-hidden='true']")
+ .evaluate((el) => getComputedStyle(el).backgroundColor),
+ dot: await chip(page, m)
+ .locator("span.rounded-full")
+ .evaluate((el) => getComputedStyle(el).backgroundColor),
+ result: await resultFrom(page, m).evaluate(
+ (el) => getComputedStyle(el).borderLeftColor,
+ ),
+ });
+ // Each wears seriesColor() at its summary index — its homepage card's
+ // colour — on all three surfaces: B the first series colour, A the second.
+ const first = await resolve("var(--chart-1)");
+ const second = await resolve("var(--chart-2)");
+ expect(first).not.toBe(second);
+ expect(await coloursOf(B)).toEqual({ card: first, dot: first, result: first });
+ expect(await coloursOf(A)).toEqual({ card: second, dot: second, result: second });
+ });
+
+ test("/ask searches only the archives the chips leave in", async ({ page }) => {
+ const mocks = await setup(page);
+ await page.goto("/");
+ await chip(page, B).getByRole("button", { name: /Origin B/ }).click();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "off");
+
+ // A fresh page life on /ask: the scope is this browser's, so Origin B is
+ // not fetched there either, and the chat is ready once Origin A is in.
+ // (An empty scope is never ready on /ask — not even for the instant before
+ // the hub's list loads — so "ready" below means Origin A is in.)
+ const ready = page.getByPlaceholder(/^Ask about the transcripts/);
+ mocks.a.requests.length = 0;
+ mocks.b.requests.length = 0;
+ await page.goto("/ask");
+ await expect
+ .poll(() => mocks.a.requests)
+ .toContain("/summaries/manifest.json");
+ await expect(ready).toBeVisible();
+ expect(mocks.a.requests).toContain("/summaries/page-0000.json");
+ expect(mocks.b.requests).toEqual([]);
+
+ // Back on (on the front page), /ask reads it again.
+ await page.goto("/");
+ await chip(page, B).getByRole("button", { name: /Origin B/ }).click();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ mocks.b.requests.length = 0;
+ await page.goto("/ask");
+ await expect
+ .poll(() => mocks.b.requests)
+ .toContain("/summaries/manifest.json");
+ await expect(ready).toBeVisible();
+ expect(mocks.b.requests).toContain("/summaries/page-0000.json");
+ });
+
+ test("a subs manifest that cannot be read is retried once and never fails its archive", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ mocks.b.subs = "error";
+ await page.goto("/");
+
+ // Live chat is the auxiliary layer: after one retry the error counts as
+ // settled, so Origin B is ready — its videos searched, without live chat —
+ // and nothing says it did not answer.
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ await expect(
+ chip(page, B).getByRole("button", { name: "Retry Origin B" }),
+ ).toHaveCount(0);
+ await expect(status(page)).toHaveCount(0);
+ // Ready waits for the retry to settle, so the count is final here.
+ expect(
+ mocks.b.requests.filter((p) => p === "/subs/manifest.json").length,
+ ).toBe(2);
+ });
+
+ test("/ask with every archive switched off says so and cannot ask; with one back on, it asks", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ // The AI provider, in Scripted mode: every call is one SSE text reply, and
+ // every question it is sent is recorded. "Asks" means the question reaches
+ // the provider — these member mocks serve no transcript tree, so the chat's
+ // own search over them never finishes and no answer is awaited.
+ const AI_CORS = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-headers": "*",
+ "access-control-allow-methods": "*",
+ };
+ const sent: string[] = [];
+ await page.route("https://api.anthropic.com/**", async (route) => {
+ if (route.request().method() === "OPTIONS") {
+ return route.fulfill({ status: 204, headers: AI_CORS });
+ }
+ sent.push(route.request().postData() ?? "");
+ const text = "DONE";
+ await route.fulfill({
+ status: 200,
+ headers: { ...AI_CORS, "content-type": "text/event-stream" },
+ body: [
+ `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text } })}\n\n`,
+ `data: ${JSON.stringify({ type: "message_stop" })}\n\n`,
+ ].join(""),
+ });
+ });
+
+ await page.goto("/");
+ for (const m of [A, B]) {
+ await chip(page, m).getByRole("button", { name: new RegExp(m.title) }).click();
+ await expect(chip(page, m)).toHaveAttribute("data-status", "off");
+ }
+
+ // Nothing in scope: one line says why, the box is disabled, and nothing is
+ // fetched from either archive.
+ mocks.a.requests.length = 0;
+ mocks.b.requests.length = 0;
+ await page.goto("/ask");
+ const composer = page
+ .locator("form")
+ .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) });
+ const blocked = composer.getByTestId("ask-blocked");
+ await expect(blocked).toHaveText(copyText(NO_ARCHIVES_IN_SCOPE));
+ await expect(page.getByTestId("ask-blocked")).toHaveCount(1);
+ // Its [bracketed] words (when the copy has any) link to the front page,
+ // where the chips are.
+ const { link } = copyWithLink(NO_ARCHIVES_IN_SCOPE);
+ if (link) {
+ await expect(
+ blocked.getByRole("link", { name: link, exact: true }),
+ ).toHaveAttribute("href", "/");
+ }
+ await expect(composer.locator("textarea")).toBeDisabled();
+ await expect(
+ composer.getByRole("button", { name: "Ask", exact: true }),
+ ).toBeDisabled();
+ await expect(page.getByPlaceholder("Loading transcripts…")).toHaveCount(0);
+ expect(mocks.a.requests).toEqual([]);
+ expect(mocks.b.requests).toEqual([]);
+
+ // Origin A back on: the line goes, and a question is asked and answered.
+ await page.goto("/");
+ await chip(page, A).getByRole("button", { name: /Origin A/ }).click();
+ await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
+ await page.goto("/ask");
+ await page.getByRole("button", { name: "Scripted" }).click();
+ const key = page.locator('input[placeholder^="sk-ant"]');
+ await key.fill("sk-ant-test");
+ await expect(key).toHaveValue("sk-ant-test");
+ await expect
+ .poll(() => mocks.a.requests)
+ .toContain("/summaries/manifest.json");
+ const box = page.getByPlaceholder(/^Ask about the transcripts/);
+ await expect(box).toBeEnabled();
+ await expect(page.getByTestId("ask-blocked")).toHaveCount(0);
+ await box.fill("what did they say?");
+ await page.getByRole("button", { name: "Ask", exact: true }).click();
+ await expect
+ .poll(() => sent.some((body) => body.includes("what did they say?")))
+ .toBe(true);
+ });
});
diff --git a/export/playwright.config.ts b/export/playwright.config.ts
@@ -42,9 +42,18 @@ const FIXTURE_SITE = path.join(TEST_SITES_DIR, "testsite", "site.json");
// left on disk. Stage it here (mirroring compose) so the service-worker test is
// deterministic on a clean checkout. The icons it caches need no staging: the
// `app/icons/[file]` and `app/favicon.ico` route handlers render them.
+//
+// Unlink before copying. In a git worktree `public/sw.js` is a symlink into
+// the primary checkout (the gitignored entries of `public/` are linked from
+// there), and copyFileSync writes THROUGH a link: a site-mode run in a worktree
+// replaced the primary's composed hub worker with the site one. Removing the
+// path first removes only the link, so the copy lands in this checkout.
const SW_SRC = path.resolve(process.cwd(), "service-worker", "site-sw.js");
const SW_DEST = path.resolve(process.cwd(), "public", "sw.js");
-if (fs.existsSync(SW_SRC)) fs.copyFileSync(SW_SRC, SW_DEST);
+if (fs.existsSync(SW_SRC)) {
+ fs.rmSync(SW_DEST, { force: true });
+ fs.copyFileSync(SW_SRC, SW_DEST);
+}
export default defineConfig({
testDir: "./e2e",
diff --git a/plans/release-10.md b/plans/release-10.md
@@ -472,6 +472,295 @@ costs, and nothing reads that log.
(`l2-e2e-3.log`): **12 passed, 0 failed, 1.1 min** (9 s in the queue). `pre-clean-availability`
was added to the four named specs because it drives all three tiers of the gate end to end.
+### Slice L1, as shipped — hub lows (2026-09-26)
+
+Branch `r10/hub-lows` off `main` `5dfc9c3a`, worktree `/home/user/Projects/r10-hub-lows` (port
+block #6). All five items are done. Item 4 follows the homepage: the code gave no reason to keep
+alphabetical (below).
+
+1. **`retry: false` on the federated subs manifest** (superseded by review fix 4 below: a 404 is an
+ empty manifest, and a real error is retried once) (`common/components/SearchDataContext.tsx`,
+ the `subsQueries` of `MultiSiteDataProvider`). Readiness waits for the subs query to settle. A
+ member with no live chat answers 404, and the client default (`retry: 1`, `QueryProvider.tsx`)
+ held its chip at `loading` for one more second. The single-site `useSubsManifest("")` shares the
+ query key shape but never an origin the hub uses, so it is unchanged. **Cost, accepted in the C2
+ re-read:** a transient network error on a member's subs manifest is also not retried. Its chip
+ reads `ready` without that member's live chat, and no Retry is offered, because the chip is not
+ `failed`.
+2. **`/ask` honours the scope chips.** `AskHub` builds its list with `useFederatedSites()`, the same
+ hook `HubHome` uses, so an archive switched off on the front page is not fetched on `/ask`. It is
+ still not `progressive`: the chat waits for every archive in scope to settle.
+3. **One colour per archive, on every surface.** `officialInstances(members, summary)`
+ (`common/lib/hubSummary.ts`, pure) resolves each official instance's colour: the site's own
+ accent, else the summary's, else `seriesColor()` at its index in the summary, the colour of its
+ homepage card and chart layer. It replaces `ArchiveShelf`'s private fallback (C1). A member the
+ summary does not name takes the colours after the summary's, so it cannot share a colour with a
+ named instance. Before, an unnamed member took its card index, which could collide.
+ `useHubSites()` (`export/app/components/hub/useHubSites.ts`) is the one list every hub surface
+ reads, each official entry wearing its resolved colour: the shelf's cards, the scope chips' dots,
+ the result cards' left edge (`accentOf`), and the channel groups' dots (`FiltersPanel`,
+ `HubOfflineManager`). An added archive with no accent still has no stripe in the results. Its
+ shelf card keeps `var(--brand)`.
+4. **One order.** `officialInstances` also orders the official instances by the summary's `sites`,
+ the homepage's order (transcripts, most first: `homepageSummary.ts:160`). A member the summary
+ does not name follows, in `hub-sites.json` order, and with no summary that order stands. The
+ cards, the chips, the filter panel's groups and `/ask` all follow it.
+ **Why not alphabetical:** nothing in the code depends on `hub-sites.json`'s order. The channel
+ list sorts by origin itself, `corpus.json`/`llms.txt` keep their own (compose-time, unchanged),
+ and the merge sorts records by date. The one cost is that the order moves when two instances'
+ transcript counts cross. The homepage already does that, and the hub now does it on the same
+ build.
+ **No reorder flash:** `useHubSites` lists the official instances only once `/hub-summary.json`
+ has settled (`useHubSummary` now returns `{summary, settled}`), whether it was found, missing or
+ unreadable. Both files are small, same-origin and requested together (the hub service worker
+ passes both straight through), so the wait is the gap between two requests already in flight.
+ `HubStats` now counts the provider's list (`federation.sites`) rather than the registry. It
+ therefore never reads "Searching 0 archives" while that list waits.
+5. **`export/playwright.config.ts` unlinks `public/sw.js` before copying `site-sw.js`**
+ (`fs.rmSync(SW_DEST, {force: true})`). In a worktree that path is a link into the primary, and
+ `copyFileSync` writes through a link. Proof below.
+
+| sha | what |
+|---|---|
+| `1f852979` | `export: playwright.config.ts` unlinks `public/sw.js` before copying the site worker (item 5) |
+| `6fa6bd3b` | `common: officialInstances`: the summary's order and one colour each, plus 4 unit tests (items 3–4) |
+| `cf5455c0` | `hub:` `retry: false` on the federated subs manifest; e2e counts one subs request at `ready` (item 1) |
+| `90ec10a1` | `hub:` `useHubSites` / `useFederatedSites`; `ArchiveShelf`, `HubHome`, `HubStats`, `AskHub` read it; `useHubSummary` `settled`; e2e for order + colour (items 3–4) |
+| `21be8700` | `hub: /ask` honours the scope chips; e2e (item 2) |
+| `86d686fb` | `export: [Unreleased]` (a new section above `[0.9.0]`) |
+| `a9d31063` | `plans:` this record |
+
+**Gates**
+- **tsc:** clean before every code commit. Five runs, 37–91 s (`l1-tsc-1..5.log`). `cf5455c0` was
+ staged as a subset of a tree that had passed tsc (`l1-tsc-3`), with no type dependency on the
+ unstaged rest.
+- **Unit and script tests** on `86d686fb`: common **1,917/1,917** (1,913 + 4, all `officialInstances`),
+ editor unit **79/79**, `test:scripts` **162 + 1 skip**, mcp **219/219**.
+- **Builds** on `86d686fb` (`l1-gates.log`), with `export/public` seeded under sh (no dangling links)
+ and `homepage/public` data copied (60 MB):
+ - editor ok (44 s);
+ - export site ok (30 s), `data-accent="signal"`;
+ - export hub ok (31 s), `dark data-base="dark"`, `out/ask/index.html` present;
+ - homepage ok (22 s).
+- **e2e on `86d686fb`** (`l1-e2e.log`):
+ - export full **204 passed**, 9.6 min;
+ - `e2e:hub` **22 passed** (19 + the 3 new tests), 46.1 s, after 18.4 min in the queue behind L2;
+ - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`; the seven composed entries + `sw.js` swapped for copies,
+ relinked after) **3 passed**, 35.7 s;
+ - homepage full **27 passed**, 42.6 s.
+ - During development: `e2e:hub` 21/21 (56.8 s, with the item-1 and items-3–4 tests). Then
+ `federated-search` + `ask` + `ask-grounding` went 9 passed, 1 failed, and 10/10 after a fix to
+ the new `/ask` test's own wait. `AskChat`'s composer reads ready for the instant before the
+ registry loads, so the test now waits for Origin A's fetch first (below).
+- **Red on the base source** (`l1-redgreen.log`). The three new tests were run with the six source
+ files checked out at `5dfc9c3a` and the specs kept. Each failed for the reason it exists:
+ - the subs test made **2** subs requests by `ready` (expected 1);
+ - the order test's first card read "Origin A" (expected "Origin B"), so its colour assertions
+ never ran;
+ - `/ask` made **7** requests to the switched-off Origin B (expected none).
+
+ The source was restored and the tree left clean.
+- **Item 5, proved.** Before the export run the worktree's `export/public/sw.js` was a LINK to the
+ primary's. The config loaded, and the worktree's became a plain 11,172 B file (md5 = `site-sw.js`).
+ The primary's `export/public/sw.js` read **10,027 B, mtime 2026-09-25 20:47:37.183119759, md5
+ `55cbf381…`** before the run, during it (02:38), after it, and after every later run. The other
+ seven composed files kept their sizes and mtimes throughout (`l1-primary-public-before.txt`,
+ `l1-gates.log`, `l1-e2e.log`).
+- Numbers tools: none. No `settings.json`, `site.json` or `config.json` key changed. `hub-sites.json`
+ and `hub-summary.json` are unchanged on disk: the order is applied in the browser.
+
+**Found and left**
+- **The `/ask` composer reads ready before the hub's list loads** (pre-existing; CLOSED by review
+ fix 1 below: an empty scope is never ready on `/ask`). With no archives
+ listed yet, the provider has nothing in scope, so it counts as settled and `summariesReady` is
+ true for that instant. A question sent then would ground in nothing. `ask.spec` relies on the
+ empty-hub case being ready. The window used to end when `hub-sites.json` arrived. It now ends
+ when both it and `hub-summary.json` have arrived: same-origin, and requested together. A fix would
+ be for `AskHub` to count as ready only once the registry's built-ins have loaded. That needs a
+ "loaded" signal from `siteRegistry.ts`, which does not expose one.
+- **`/ask` does not show which archives are in scope.** The front page has the chips; `/ask` silently
+ follows them. A visitor who switched an archive off weeks ago gets answers from the rest. A
+ one-line "Searching N of M archives — change on the front page" would need a copy ruling.
+- **After the rollout the hub's cards and the homepage's cards differ in colour.** The hub prefers a
+ site's own accent (C1, kept); the homepage's `ArchiveCards` deliberately draws only
+ `seriesColor(i)` so a card matches its chart layer. The two sites now list the instances in the
+ same order.
+- **FACTS is stale after merge:** "Replace `export/public/sw.js` with a copy before any export e2e in
+ a worktree" (Release 9 facts, "Worktree e2e can write INTO the primary") no longer holds for
+ `sw.js`. Review fix 2 below retires the compose-output swap too. Not edited here (a shared file).
+
+`[Unreleased]` bullets: `export/CHANGELOG.md` (three: order and colour, `/ask` scope, subs).
+`editor/CHANGELOG.md` and `homepage/CHANGELOG.md` are unchanged, because neither app changed. The
+item-5 harness fix is not in the public changelog.
+
+**Review fixes** (review SHIP AFTER FIXES, `l1-review.md`; four findings, all fixed on `r10/hub-lows`).
+
+1. **Must-fix: `/ask` with every archive switched off** (`efe6e48f`).
+ - **The failure.** With L1's scope, an empty in-scope list counted as settled, so the composer
+ enabled and a question went out over zero records.
+ - **Readiness.** `MultiSiteDataProvider` now counts an empty scope as NOT ready unless it is
+ `progressive`. `/ask` is not progressive, so it stays disabled. That also closes the instant
+ before the hub's list arrives (the early-ready window from "Found and left", which no longer
+ applies). The progressive front page still settles on an empty scope. Its results read "No
+ videos match" rather than "loading index…" forever, and its chips and "Searching 0 archives"
+ say why.
+ - **The line.** `AskChat` shows one line in the composer when the hub has archives and every one
+ is off: `NO_ARCHIVES_IN_SCOPE`, `role="status"`, `data-testid="ask-blocked"`. It replaces the
+ placeholder and the key hint. The box and the Ask button are disabled.
+ - **The copy** is a single exported constant in `export/app/ask/hubScopeCopy.ts`, for the
+ operator. The draft is now "No archives selected. Choose some on the [hub's front page] to ask."
+ (re-read, `0fc63ed7`). The first draft said "Turn one on above", but `/ask` shows no chips.
+ The [bracketed] words render as a link to `/`, where the chips are; dropping the brackets
+ drops the link.
+ - **A hub with no archives at all** now reads "Loading transcripts…" on `/ask`, instead of being
+ ready over nothing. `ask.spec` accepts either.
+2. **Should-fix: the compose scripts wrote through the worktree links** (`1203f4a6`).
+ - **The helpers.** `common/bin/_publicFile.ts`: `writePublicFile` and `copyPublicFile` `rm` the
+ path first (`fs.rm` uses lstat, so only a link is removed, never its target). `ownDir` replaces
+ a linked directory with an empty real one.
+ - **compose-hub** routes every write through them.
+ - **compose-site** routes through them every top-level file and the per-site subs/posts/digests
+ manifests, and `reconcileChannelTree` owns its tree first. The paths that already removed first
+ (summaries/stats `replaceDir`, archives, `sw.js`, chart-templates) are unchanged.
+ - **Real builds are unchanged.** Where the paths are real (the primary checkout's
+ `export/public`, which holds no links, and the docker per-site `/site/public` dirs), the result
+ is what a plain write gave.
+ - **Tests.** `_publicFile` (4). `compose-hub`: a worktree compose leaves all seven of the
+ primary's files' content and mtime as they were. This one was red on the old `compose-hub`.
+ `compose-site`: a linked tree is owned, and the primary's is untouched.
+ - **Proof:** below.
+3. **Nit** (`609a7fe1`): `useHubSummary` runs with `networkMode: "always"`, so a first mount
+ after an `offline` event settles as "no summary" instead of pausing.
+4. **Nit: the subs query** (`78e837f1`, replacing `cf5455c0`'s `retry: false`; its failure rule
+ was corrected in the re-read, `d0fa13ae`).
+ - **A 404** resolves to an empty manifest at once, the way posts does. It is one request.
+ - **Any other error** keeps one retry (explicit on this query). If it still errors, the query
+ counts as SETTLED: the archive is ready, its videos are searched, and only its live chat is
+ missing. This is C2's rule. `78e837f1` had instead made such an archive `failed`, which the
+ re-read reverted (below).
+ - **Why a 404 is readable.** A member's `/subs/*` carries CORS on 404s too: checked live,
+ `https://jeralyzer.pages.dev/subs/<missing>.json` → `404` + `access-control-allow-origin: *`.
+ So a missing file reads as a 404, not as a network error. `serve`, which self-hosted docker
+ archives use, sends a 404 with no CORS header, and that is the case the settled rule covers.
+
+| sha | what |
+|---|---|
+| `78e837f1` | `hub:` subs: a 404 is an empty manifest; a real error is retried once, then failed its archive (reverted by `d0fa13ae`); e2e (finding 4) |
+| `efe6e48f` | `hub: /ask` with every archive off is not ready and says why (`NO_ARCHIVES_IN_SCOPE`); an empty scope is never ready when not progressive; e2e (finding 1) |
+| `609a7fe1` | `hub: useHubSummary` `networkMode: "always"` (finding 3) |
+| `1203f4a6` | `common:` `_publicFile.ts`; compose-hub and compose-site never write through a link; 6 unit tests (finding 2) |
+| `323c878f` | `export: [Unreleased]` — the two reader-visible fixes |
+| `5304ce2d` | `plans:` these review fixes |
+
+**Gates on `323c878f`**
+- **tsc:** clean before every code commit (`l1-tsc-6..8`). `78e837f1` was staged as a subset of the
+ tsc-green `l1-tsc-7` tree.
+- **Unit tests:**
+ - common **1,923** (1,917 + 6);
+ - editor unit **79**;
+ - `test:scripts` **162 + 1 skip**;
+ - mcp **219**.
+- **Builds** (`l1-fix-gates.log`), all ok:
+ - editor (53 s);
+ - export site (43 s);
+ - export hub (33 s);
+ - homepage (20 s).
+- **e2e** (`l1-fix-e2e.log`):
+ - export full **204 passed**, 7.1 min (after 9.0 min in the queue behind L2);
+ - `e2e:hub` **24 passed** (22 + the 2 new tests), 1.4 min;
+ - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, links in place, **no swap**) **3 passed**, 34.0 s;
+ - homepage full **27 passed**, 44.8 s.
+- **During development:** the three touched hub specs went 11 passed, 1 failed. The failure was the
+ new `/ask` test waiting for an answer the fixture member cannot produce: it serves no transcript
+ tree, so the chat's own search never finishes. The test now asserts the question reaches the
+ provider. After that, 1/1.
+- **Red on the pre-fix source** (`l1-fix-e2e-red.log`, with the four `/ask` and provider files at
+ `a9d31063`): the two new tests fail for the reason each exists.
+ - The subs-500 chip read `ready` (expected `failed`).
+ - With every chip off, `/ask` showed no blocked line.
+
+ The source was restored and the tree left clean.
+- **Proof of finding 2:** the export and hub runs left the seven composed entries as LINKS into
+ the primary (`sw.js` was already the worktree's own, from item 5).
+ - **Before**, primary `export/public` (size, mtime, md5):
+
+ | file | size | mtime | md5 |
+ |---|---|---|---|
+ | `sw.js` | 10,027 | 20:47:37.183119759 | `55cbf381…` |
+ | `hub-summary.json` | 1,441 | 20:48:07.499176932 | `7d8a101e…` |
+ | `hub-sites.json` | 579 | 20:47:37.173642710 | `28b9893c…` |
+ | `corpus.json` | 1,535 | 20:47:37.177642649 | `54b64280…` |
+ | `llms.txt` | 1,031 | 20:47:37.177642649 | `87255fa7…` |
+ | `robots.txt` | 130 | 20:47:37.178642634 | `60217070…` |
+ | `sitemap.xml` | 364 | 18:08:42.340736263 | `76ffaa3c…` |
+ | `_headers` | 459 | 20:47:37.178642634 | `a5f88f82…` |
+
+ All mtimes are 2026-09-25 (`l1-fix-primary-before.txt`).
+ - `e2e:2origin` ran `build:hub` through those links.
+ - **After, the worktree** holds its own plain files: `hub-sites.json` 2 B (`[]`), `corpus.json`
+ 468 B, `llms.txt` 494 B, `robots.txt` 76 B, `_headers` 459 B, `sw.js` 11,005 B.
+ `hub-summary.json`'s link was removed (no index). `sitemap.xml`, which compose-hub does not
+ write, is still a link.
+ - **After, the primary's eight files** are byte-identical, with the same sizes and mtimes
+ (`l1-fix-primary-after-2origin.txt`, `diff` empty). They were identical again after the homepage
+ run (`l1-fix-primary-after.txt`).
+
+**Retired at merge (for FACTS; not edited here):** both worktree workarounds become unnecessary.
+- "Replace `export/public/sw.js` with a copy before any export e2e in a worktree" (Release 9 facts):
+ `playwright.config.ts` unlinks it (item 5).
+- "Swap the compose outputs for copies before `e2e:2origin` and relink after" (Brand facts,
+ "Worktree e2e and builds write through the `export/public` links"): compose-hub and compose-site
+ unlink before writing.
+
+The seed (per-path links from the primary) stays as it is. A worktree run now replaces a link with
+its own file, and the next seed relinks it.
+
+**Re-read of the fixes** (`l1-review.md`, "## Re-read of fixes"; SHIP AFTER FIXES).
+- **R1, must-fix: a subs failure must never fail an archive** (`d0fa13ae`). After `78e837f1`, a
+ non-404 subs error, after its retry, made the archive `failed`. Its already-loaded videos then left
+ the search and `/ask`, and the line said it "did not answer". This was reachable on a self-hosted
+ archive with no subs manifest, where `serve` sends a 404 without CORS. C2's rule is restored: a
+ subs query that still errors after its retry counts as settled, so the archive is `ready` without
+ its live chat. The 404 → empty manifest and the one retry are kept. The e2e is inverted: a 500
+ gives 2 subs requests, the chip reads `ready` with no Retry, Origin B's result is present, and
+ there is no status line. The changelog sentence now says what a reader sees: the archive is
+ searched without its live chat, and its chip reads ready.
+- **The copy** (`0fc63ed7`). The new draft, with its front-page link, is described under finding 1
+ above. `copyWithLink` / `copyText` split and flatten the one constant. The e2e takes the link text
+ from the constant and checks that it points to `/`.
+- **R2** (`ownDir` is safe) and **R4** (the non-progressive rule is confined to `/ask`) were verified
+ by the re-read. No change.
+
+| sha | what |
+|---|---|
+| `d0fa13ae` | `hub:` a subs manifest that still fails after its retry never fails its archive (C2's settled rule); e2e inverted; changelog sentence |
+| `0fc63ed7` | `hub: NO_ARCHIVES_IN_SCOPE` draft "No archives selected. Choose some on the [hub's front page] to ask.", the phrase a link to `/` |
+| _this_ | `plans:` this re-read record |
+
+**Gates on `0fc63ed7`** (`l1-fix2.log`)
+- **tsc:** clean (`l1-tsc-9`, on this tree; `d0fa13ae` is a subset of it).
+- **Unit tests:** common **1,923**, editor unit **79**.
+- **Build:** export hub ok (26 s), `out/ask/index.html` present.
+- **e2e:**
+ - `e2e:hub` **24 passed**, 51.1 s;
+ - export full **204 passed**, 6.7 min, `sw.js` a link at the start;
+ - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, the seven composed entries links in place, no swap)
+ **3 passed**, 36.3 s.
+- **The primary's eight `export/public` files** kept the size, mtime and md5 in the table above
+ through all three runs (`l1-fix2-primary-before.txt` vs `-after.txt`, `diff` empty). The worktree
+ again held its own `hub-sites.json` (2 B), `corpus.json`, `llms.txt`, `robots.txt` and `_headers`
+ after 2origin.
+
+**Found and left (new lows, both need copy):**
+- **`/ask` waits forever on a hub with no archives.** When `hub-sites.json` is `[]` (a fresh
+ self-hosted hub) or failed (`loadBuiltins` never retries), and the reader has added none, `/ask`
+ reads "Loading transcripts…" forever. The fix is a "built-ins loaded" flag from `siteRegistry.ts`
+ plus a blocked line for "this hub has no archives", whose copy goes to the operator. The production
+ hub (five members) never reaches this state online.
+- **Show a member's missing live chat.** A subs manifest that settled in error leaves its archive
+ `ready` with no sign that its live chat is missing. A chip note with a subs-only Retry would show
+ it.
+
## Rollout
Nothing is rolled out. The live :3001 editor still runs `0213f6c8` (the pre-brand build); the five