commit b6b008a5ce7c29017bbc7743c7fb3ddffb3359bf
parent d481ba6a7a1c83e36ad0ec21fac25917ca456956
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 03:18:53 -0400
Merge r11/hub-lows — release 11 slice O1: the hub's /ask waits for its own list and, with no archives, says so (DRAFT copy); a scope line on /ask (DRAFT); a member's missing live chat noted on its chip with a live-chat-only Retry (DRAFT); a single-colour social icon follows the footer colour; mcp README's fetch_clip env lines
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
19 files changed, 1222 insertions(+), 95 deletions(-)
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -106,13 +106,26 @@ export type FederatedSiteState = {
count?: number;
// Why it failed, when it did.
error?: string;
+ // Ready only: its live-chat (subs) manifest could not be read, even after its
+ // retry, so its videos are searched WITHOUT their live chat. `retrying` while
+ // a live-chat Retry (FederationState.retryLiveChat) is in flight; `error` is
+ // the last failure's reason.
+ liveChatMissing?: { retrying: boolean; error?: string };
};
export type FederationState = {
// In registry order, off archives included.
sites: FederatedSiteState[];
+ // Whether `sites` is the hub's whole list (the provider's `listed` prop):
+ // false while the hub's own list of archives is still arriving, so an empty
+ // `sites` then means "not yet", not "none".
+ listed: boolean;
// Refetch everything of this archive that failed.
retry: (origin: string) => void;
+ // Refetch this archive's live-chat (subs) manifest alone — the Retry beside
+ // a ready archive's `liveChatMissing` note. Its videos stay in the search
+ // while it runs.
+ retryLiveChat: (origin: string) => void;
};
// Single-site mode has no provenance accents; a module constant keeps the
@@ -284,13 +297,22 @@ function errorText(e: unknown): string | undefined {
// ready (the hub's search page, where results should not wait for the slowest
// member). Without it, summariesReady waits until every in-scope archive has
// settled, ready or failed (the /ask chat, which grounds in what it was given).
+//
+// `listed` (default true): whether `sites` is the hub's WHOLE list yet. /ask
+// passes false until the hub's own list (`/hub-sites.json`) has been answered:
+// the archives a browser added are known at once, the hub's own a moment later,
+// and a non-progressive surface is not ready over the first few alone. Its
+// archives are fetched meanwhile; only readiness waits. `federation.listed`
+// carries it, so an empty list can be told apart from one not in yet.
export function MultiSiteDataProvider({
sites,
progressive = false,
+ listed = true,
children,
}: {
sites: FederatedSite[];
progressive?: boolean;
+ listed?: boolean;
children: ReactNode;
}) {
const queryClient = useQueryClient();
@@ -353,6 +375,9 @@ export function MultiSiteDataProvider({
// 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".
+ // The archive's chip notes it instead (`liveChatMissing`), with a Retry for
+ // the live chat alone, and the archive stays ready while that runs (the rule
+ // is in step 3).
const subsQueries = useQueries({
queries: sites.map((s) => ({
queryKey: ["subs-manifest", s.origin],
@@ -415,15 +440,46 @@ export function MultiSiteDataProvider({
// 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));
+ //
+ // "Has errored" is `errorUpdateCount > 0`, not `isError`: the count moves
+ // only once the query's own retry is spent, and it survives a refetch —
+ // TanStack puts an errored query with no data back to `pending` while it
+ // refetches, so `isError` would take the archive (and its videos) out of
+ // the search for the length of a live-chat Retry. With the count, the
+ // archive stays ready through it, still noted as missing its live chat.
+ //
+ // That holds for the PROGRESSIVE front page only. The count lives in the
+ // shared query client, so it also survives into a later refetch the front
+ // page never asked for: /ask's own mount (an errored query with no data is
+ // refetched when a new observer mounts), a scope toggle off and on, a
+ // whole-archive Retry. /ask grounds in what it was given, so it waits for
+ // such a refetch to settle rather than answer without live chat that is
+ // about to arrive.
+ const posts = postsQueries[i];
+ const postsSettled = !!posts && (posts.isSuccess || posts.isError);
+ const subs = subsQueries[i];
+ const subsErrored = !!subs && subs.data === undefined && subs.errorUpdateCount > 0;
+ const subsSettled =
+ !!subs &&
+ (subs.data !== undefined || (subsErrored && (progressive || !subs.isFetching)));
if (
m?.isSuccess &&
pages.length === m.data.pageCount &&
pages.every((p) => p.isSuccess) &&
- settled(subsQueries[i]) &&
- settled(postsQueries[i])
+ subsSettled &&
+ postsSettled
) {
+ if (subsErrored) {
+ const why = errorText(subs.error);
+ return {
+ ...withCount,
+ status: "ready",
+ liveChatMissing: {
+ retrying: subs.isFetching,
+ ...(why ? { error: why } : {}),
+ },
+ };
+ }
return { ...withCount, status: "ready" };
}
const fetching = !!m?.isFetching || pages.some((p) => p.isFetching);
@@ -438,7 +494,11 @@ export function MultiSiteDataProvider({
return { ...withCount, status: "loading" };
});
const statusKey = rawStates
- .map((s) => `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}`)
+ .map((s) => {
+ const chat = s.liveChatMissing;
+ const chatKey = chat ? `${chat.retrying ? "retrying" : "missing"}:${chat.error ?? ""}` : "";
+ return `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}|${chatKey}`;
+ })
.join("\n");
const siteStates = useMemo(
() => rawStates,
@@ -453,14 +513,18 @@ export function MultiSiteDataProvider({
);
const readyKey = Array.from(readyOrigins).join("\u0000");
const scoped = siteStates.filter((s) => s.status !== "off");
- // 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.
+ // Nothing in scope — every archive switched off, the hub's list not arrived
+ // yet, or a hub with no archives at all — 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. Nor is a list that is not yet the whole list (`listed` false:
+ // the archives this browser added are in, the hub's own still arriving). /ask
+ // says which it is once the list is in (`federation.listed`). 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 =
+ listed &&
(progressive || scoped.length > 0) &&
scoped.every((s) => s.status === "ready" || s.status === "failed");
const summariesReady = allSettled || (progressive && readyOrigins.size > 0);
@@ -730,9 +794,20 @@ export function MultiSiteDataProvider({
},
[queryClient],
);
+ // The live chat alone: a ready archive's subs manifest, which settled in
+ // error. Nothing else of the archive is refetched, and it stays ready.
+ const retryLiveChat = useCallback(
+ (origin: string) => {
+ void queryClient.refetchQueries({
+ queryKey: ["subs-manifest", origin],
+ exact: true,
+ });
+ },
+ [queryClient],
+ );
const federation = useMemo<FederationState>(
- () => ({ sites: siteStates, retry }),
- [siteStates, retry],
+ () => ({ sites: siteStates, listed, retry, retryLiveChat }),
+ [siteStates, listed, retry, retryLiveChat],
);
const value = useMemo<SearchDataValue>(
diff --git a/common/components/siteRegistry.test.ts b/common/components/siteRegistry.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ __resetRegistryForTests,
+ getBuiltinsLoaded,
+ getRegisteredSites,
+ loadBuiltins,
+ subscribeRegistry,
+} from "./siteRegistry";
+
+// ─── the built-ins' "loaded" flag ───
+//
+// The hub's /ask counts as ready only once `/hub-sites.json` has been ANSWERED:
+// before that, the list may hold only the archives this browser added, and a
+// question would ground in half the hub. So the flag must turn true for every
+// answer — a list, an empty list, a 404, a network error, bad JSON — and never
+// before the built-ins are in the list (release 11, O1).
+
+const A = {
+ siteId: "a",
+ siteTitle: "Archive A",
+ siteUrl: "https://a.example.com",
+ pwa: true,
+ contract: 1,
+};
+
+type Answer = { status: number; body: string } | "throw";
+
+let restore: (() => void) | null = null;
+
+// A client-side module: it only loads with a `window` to load into.
+function install(answer: Answer): { release: () => void; asked: string[] } {
+ const asked: string[] = [];
+ const realFetch = globalThis.fetch;
+ const hadWindow = "window" in globalThis;
+ (globalThis as { window?: unknown }).window = globalThis;
+ let release!: () => void;
+ const gate = new Promise<void>((r) => (release = r));
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ asked.push(String(input));
+ await gate;
+ if (answer === "throw") throw new TypeError("Failed to fetch");
+ return new Response(answer.body, { status: answer.status });
+ }) as typeof fetch;
+ restore = () => {
+ globalThis.fetch = realFetch;
+ if (!hadWindow) delete (globalThis as { window?: unknown }).window;
+ };
+ return { release, asked };
+}
+
+beforeEach(() => __resetRegistryForTests());
+afterEach(() => {
+ restore?.();
+ restore = null;
+ __resetRegistryForTests();
+});
+
+test("a listed built-in: loaded turns true only once it is in the list, in one update", async () => {
+ const { release, asked } = install({ status: 200, body: JSON.stringify([A]) });
+ const seen: { loaded: boolean; origins: string[] }[] = [];
+ const unsubscribe = subscribeRegistry(() =>
+ seen.push({
+ loaded: getBuiltinsLoaded(),
+ origins: getRegisteredSites().map((s) => s.origin),
+ }),
+ );
+ const pending = loadBuiltins();
+ assert.equal(getBuiltinsLoaded(), false, "not loaded while the file is in flight");
+ release();
+ await pending;
+ unsubscribe();
+ assert.deepEqual(asked, ["/hub-sites.json"]);
+ assert.equal(getBuiltinsLoaded(), true);
+ assert.deepEqual(
+ getRegisteredSites().map((s) => [s.origin, s.kind]),
+ [["https://a.example.com", "builtin"]],
+ );
+ // One notification, and it already carries the built-in: no reader sees
+ // "loaded" over a list that lacks it.
+ assert.deepEqual(seen, [{ loaded: true, origins: ["https://a.example.com"] }]);
+});
+
+for (const [name, answer] of [
+ ["an empty list ([])", { status: 200, body: "[]" }],
+ ["a 404 (no hub-sites.json)", { status: 404, body: "" }],
+ ["a 500", { status: 500, body: "" }],
+ ["a network error", "throw"],
+ ["unreadable JSON", { status: 200, body: "{not json" }],
+ ["JSON that is not a list", { status: 200, body: '{"sites":[]}' }],
+] as [string, Answer][]) {
+ test(`${name}: loaded turns true with no built-ins`, async () => {
+ const { release } = install(answer);
+ let notified = 0;
+ const unsubscribe = subscribeRegistry(() => notified++);
+ const pending = loadBuiltins();
+ assert.equal(getBuiltinsLoaded(), false);
+ release();
+ await pending;
+ unsubscribe();
+ assert.equal(getBuiltinsLoaded(), true);
+ assert.deepEqual(getRegisteredSites(), []);
+ // Subscribers hear it even though the list did not change.
+ assert.equal(notified, 1);
+ });
+}
+
+test("it is asked for once: a second load neither refetches nor re-notifies", async () => {
+ const { release, asked } = install({ status: 200, body: JSON.stringify([A]) });
+ release();
+ await loadBuiltins();
+ let notified = 0;
+ const unsubscribe = subscribeRegistry(() => notified++);
+ await loadBuiltins();
+ unsubscribe();
+ assert.equal(asked.length, 1);
+ assert.equal(notified, 0);
+ assert.equal(getBuiltinsLoaded(), true);
+});
diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts
@@ -67,6 +67,12 @@ const HUB_SITES_URL = "/hub-sites.json";
let sites: RegisteredSite[] = [];
const EMPTY: readonly RegisteredSite[] = Object.freeze([]);
const listeners = new Set<() => void>();
+// `/hub-sites.json` asked for (the one-shot guard) and ANSWERED — found, empty,
+// missing or unreadable. Until it has been answered the list is not the hub's
+// whole list: a surface that must not act on half of it (the /ask chat) waits
+// for `builtinsLoaded`. It is never retried, so a failure settles it too: the
+// hub then lists no built-ins, and says so, rather than loading forever.
+let builtinsRequested = false;
let builtinsLoaded = false;
let hydratedFromStorage = false;
@@ -83,6 +89,11 @@ function getSnapshot(): readonly RegisteredSite[] {
return sites;
}
+// The store outside React (useRegistry is the hook): its current list, and a
+// subscription to every change of it or of `getBuiltinsLoaded()`.
+export const getRegisteredSites = getSnapshot;
+export const subscribeRegistry = subscribe;
+
function getServerSnapshot(): readonly RegisteredSite[] {
// No registry on the server (localStorage/builtins are client-only). A stable
// frozen constant avoids the useSyncExternalStore "getServerSnapshot should be
@@ -90,6 +101,16 @@ function getServerSnapshot(): readonly RegisteredSite[] {
return EMPTY;
}
+// Whether `/hub-sites.json` has been answered (see `builtinsLoaded`). Never on
+// the server: the prerendered page is the "not yet" state.
+export function getBuiltinsLoaded(): boolean {
+ return builtinsLoaded;
+}
+
+function getServerBuiltinsLoaded(): boolean {
+ return false;
+}
+
// Replace the store with a new immutable array (stable ref for the snapshot).
function setSites(next: RegisteredSite[]) {
sites = next;
@@ -154,20 +175,27 @@ function coerceStored(raw: unknown): RegisteredSite | null {
// ---- builtins (trusted pool, emitted by compose-hub) --------------------
-// Load `/hub-sites.json` once. It is absent on non-hub builds and pre-Phase-6
-// hub builds, so a 404/parse failure degrades silently to an empty pool.
-async function loadBuiltins() {
- if (builtinsLoaded || typeof window === "undefined") return;
- builtinsLoaded = true;
- let data: unknown;
+// The entries of `/hub-sites.json`, or none. It is absent on non-hub builds and
+// pre-Phase-6 hub builds, so a 404/parse failure degrades silently to an empty
+// pool.
+async function fetchBuiltins(): Promise<unknown[]> {
try {
const res = await fetch(HUB_SITES_URL);
- if (!res.ok) return;
- data = await res.json();
+ if (!res.ok) return [];
+ const data: unknown = await res.json();
+ return Array.isArray(data) ? data : [];
} catch {
- return;
+ return [];
}
- if (!Array.isArray(data)) return;
+}
+
+// Load `/hub-sites.json` once, then mark the built-ins loaded — whatever the
+// answer was. The built-ins join the list and the flag turns true in ONE
+// update, so no reader ever sees "loaded" over a list that lacks them.
+export async function loadBuiltins(): Promise<void> {
+ if (builtinsRequested || typeof window === "undefined") return;
+ builtinsRequested = true;
+ const data = await fetchBuiltins();
const seen = new Set(sites.map((s) => s.origin));
const builtins: RegisteredSite[] = [];
for (const raw of data) {
@@ -178,10 +206,10 @@ async function loadBuiltins() {
}
if (builtins.length) {
// Builtins sort first (addedAt 0), then externals by add time.
- setSites(
- [...sites, ...builtins].sort((a, b) => a.addedAt - b.addedAt),
- );
+ sites = [...sites, ...builtins].sort((a, b) => a.addedAt - b.addedAt);
}
+ builtinsLoaded = true;
+ emit();
}
// A hub-sites.json entry describes a pool site by its public URL. Its origin is
@@ -336,6 +364,10 @@ function removeSite(origin: string) {
export type RegistryApi = {
sites: readonly RegisteredSite[];
+ // True once `/hub-sites.json` has been answered (found, empty, missing or
+ // unreadable): `sites` then holds every built-in this hub has. Until then it
+ // may hold only the archives this browser added.
+ builtinsLoaded: boolean;
addSite: (site: RegisteredSite) => void;
removeSite: (origin: string) => void;
hasOrigin: (origin: string) => boolean;
@@ -347,6 +379,11 @@ export function useRegistry(): RegistryApi {
getSnapshot,
getServerSnapshot,
);
+ const loaded = useSyncExternalStore(
+ subscribe,
+ getBuiltinsLoaded,
+ getServerBuiltinsLoaded,
+ );
useEffect(() => {
// Client-only initialization; both are idempotent (guarded internally).
@@ -356,6 +393,7 @@ export function useRegistry(): RegistryApi {
return {
sites: snapshot,
+ builtinsLoaded: loaded,
addSite,
removeSite,
hasOrigin: (origin) => sites.some((s) => s.origin === origin),
@@ -366,6 +404,7 @@ export function useRegistry(): RegistryApi {
// so a fresh mount re-hydrates. Not used in production paths.
export function __resetRegistryForTests() {
sites = [];
+ builtinsRequested = false;
builtinsLoaded = false;
hydratedFromStorage = false;
emit();
diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts
@@ -0,0 +1,159 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { normalizeSocialSvg } from "./settingsSchema";
+
+// normalizeSocialSvg runs on every save of a social link (Settings, a site's
+// form, the homepage config). It used to theme only the ROOT <svg>'s fill, so a
+// child that paints itself kept its colour: the operator's x.com icon — X's
+// official file, `fill="none"` on the root and `fill="white"` on its one path —
+// was white on the light footer (1.11:1; release 10, slice O's screenshots).
+// A SINGLE-colour icon's fills — root and children — now become currentColor
+// (the footer link's colour, which follows the theme); no paint, gradients and
+// mask values stay. An icon of two or more colours draws its shape with them
+// (YouTube's mark, a badge) and keeps every colour as pasted (review, O1).
+
+// The two icons in the operator's settings.json, verbatim (2026-09-28).
+const X_COM = `<svg aria-hidden="true" viewBox="0 0 1200 1227" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z" fill="white"/>
+</svg>`;
+
+const KIWI_FARMS = `<svg aria-hidden="true" fill="currentColor" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 70.307863 74.803198" enable-background="new 0 0 198.689 74.803" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs15"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-296.84601" y1="-520.1969" x2="-296.84601" y2="-595" gradientTransform="matrix(1,0,0,-1,331.99986,-520.1968)"><stop offset="0" style="stop-color:#D7DF23" id="stop1" /><stop offset="1" style="stop-color:#8DC63F" id="stop5" /></linearGradient></defs><path fill="url(#SVGID_1_)" d="m 38.615858,74.803198 c 12.037,-10.62 z" id="path5" style="fill:url(#SVGID_1_)" /><circle fill="#414042" cx="31.16486" cy="9.6271973" r="2.1010001" id="circle5" /></svg>`;
+
+// The export e2e fixture's icon (already normalized) and the editor e2e's
+// un-normalized one.
+const FIXTURE = `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path d="M1 1h2"/></svg>`;
+const BARE = `<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>`;
+
+// YouTube's official mark (Wikimedia's "YouTube full-color icon"): a red
+// rounded rectangle and a white play triangle. Two colours ARE its shape.
+const YOUTUBE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 159 110"><path d="m154 17.5c-1.82-6.73-7.07-12-13.8-13.8-9.04-3.49-96.6-5.2-122 0.1-6.73 1.82-12 7.07-13.8 13.8-4.08 17.9-4.39 56.6 0.1 74.9 1.82 6.73 7.07 12 13.8 13.8 17.9 4.12 103 4.7 122 0 6.73-1.82 12-7.07 13.8-13.8 4.35-19.5 4.66-55.8-0.1-75z" fill="#f00"/><path d="m105 55-40.8-23.4v46.8z" fill="#fff"/></svg>`;
+
+// The common "badge": a dark disc with a white glyph cut into it.
+const BADGE = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#1d1d1d"/><path fill="#ffffff" d="M7 7h10v10H7z"/></svg>`;
+
+// Every fill attribute / style declaration, in document order.
+const fills = (svg: string) =>
+ [...svg.matchAll(/\sfill\s*=\s*"([^"]*)"|[;"']\s*fill\s*:\s*([^;"']*)/g)].map(
+ (m) => m[1] ?? m[2],
+ );
+
+function normalized(raw: string): string {
+ const out = normalizeSocialSvg(raw);
+ assert.ok(out, "accepted");
+ return out;
+}
+
+test("x.com: the path's white becomes currentColor; the root keeps fill=none", () => {
+ const out = normalized(X_COM);
+ assert.deepEqual(fills(out), ["none", "currentColor"]);
+ assert.ok(out.startsWith('<svg aria-hidden="true" viewBox="0 0 1200 1227" fill="none"'));
+ assert.ok(!/white/i.test(out));
+ // Nothing else moved: only the one attribute value changed.
+ assert.equal(out, X_COM.replace('fill="white"', 'fill="currentColor"'));
+});
+
+test("Kiwi Farms: its gradient stays, its stops untouched; the grey dot follows the theme", () => {
+ const out = normalized(KIWI_FARMS);
+ assert.deepEqual(fills(out), [
+ "currentColor", // the root, as stored
+ "url(#SVGID_1_)", // the path's attribute
+ "url(#SVGID_1_)", // and its style
+ "currentColor", // the circle, was #414042
+ ]);
+ assert.ok(out.includes('style="stop-color:#D7DF23"'));
+ assert.ok(out.includes('style="stop-color:#8DC63F"'));
+ assert.equal(out, KIWI_FARMS.replace('fill="#414042"', 'fill="currentColor"'));
+});
+
+test("an icon that already follows the theme is unchanged", () => {
+ assert.equal(normalized(FIXTURE), FIXTURE);
+ const stroked = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M1 1h2" stroke="#000"/></svg>`;
+ assert.equal(normalized(stroked), stroked, "fill=none and strokes are not fills");
+});
+
+test("a bare icon still gains the root's fill and aria-hidden, as before", () => {
+ assert.equal(
+ normalized(BARE),
+ `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>`,
+ );
+});
+
+test("one colour in every spelling is themed, on the root and in a style; other fill-* are not", () => {
+ const out = normalized(
+ `<svg viewBox="0 0 8 8" fill="#FFFFFF"><path fill='#fff' fill-rule="evenodd" d="M0 0"/>` +
+ `<rect style="opacity:.5; fill: rgb(255, 255, 255) ;fill-opacity:.4" fill-opacity="0.5"/>` +
+ `<circle fill="White"/><ellipse fill="url(#g) #000"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
+ );
+ assert.equal(
+ out,
+ `<svg aria-hidden="true" viewBox="0 0 8 8" fill="currentColor"><path fill='currentColor' fill-rule="evenodd" d="M0 0"/>` +
+ `<rect style="opacity:.5; fill: currentColor;fill-opacity:.4" fill-opacity="0.5"/>` +
+ `<circle fill="currentColor"/><ellipse fill="url(#g) #000"/><g fill="inherit"><path fill="transparent"/></g></svg>`,
+ );
+});
+
+test("YouTube's mark keeps its red and its white: two colours are its shape", () => {
+ const out = normalized(YOUTUBE);
+ assert.deepEqual(fills(out), ["currentColor", "#f00", "#fff"]);
+ // Only the root gains what it always did (a fill for any unpainted child,
+ // aria-hidden); the paths are as pasted.
+ assert.equal(
+ out,
+ YOUTUBE.replace("<svg ", '<svg aria-hidden="true" fill="currentColor" '),
+ );
+});
+
+test("a dark disc with a white glyph keeps both colours", () => {
+ const out = normalized(BADGE);
+ assert.deepEqual(fills(out), ["currentColor", "#1d1d1d", "#ffffff"]);
+ assert.equal(out, BADGE.replace("<svg ", '<svg aria-hidden="true" fill="currentColor" '));
+});
+
+test("a root colour and a different child colour are two colours: nothing is themed", () => {
+ const raw = `<svg aria-hidden="true" viewBox="0 0 8 8" fill="#fff"><path fill="#000" d="M0 0"/><path d="M1 1"/></svg>`;
+ assert.equal(normalized(raw), raw);
+});
+
+test("a mask's fills are how much shows through, not a colour: kept", () => {
+ const raw =
+ `<svg viewBox="0 0 8 8"><mask id="m"><rect fill="white" width="8" height="8"/><circle fill="black" r="2"/></mask>` +
+ `<path mask="url(#m)" fill="#fff" d="M0 0"/></svg>`;
+ const out = normalized(raw);
+ assert.ok(out.includes(`<mask id="m"><rect fill="white" width="8" height="8"/><circle fill="black" r="2"/></mask>`));
+ assert.ok(out.includes(`<path mask="url(#m)" fill="currentColor" d="M0 0"/>`));
+});
+
+test("an animation's fill is timing, not paint: never touched, never counted", () => {
+ const raw =
+ `<svg viewBox="0 0 8 8"><path fill="#fff" d="M0 0">` +
+ `<animate attributeName="opacity" from="0" to="1" dur="1s" fill="freeze"/>` +
+ `<animateTransform attributeName="transform" type="rotate" dur="1s" fill="remove"/></path>` +
+ `<set attributeName="opacity" to="1" fill="freeze"/></svg>`;
+ const out = normalized(raw);
+ assert.ok(out.includes(`<path fill="currentColor" d="M0 0">`), "the one colour is themed");
+ assert.ok(out.includes(`dur="1s" fill="freeze"/>`));
+ assert.ok(out.includes(`type="rotate" dur="1s" fill="remove"/>`));
+ assert.ok(out.includes(`<set attributeName="opacity" to="1" fill="freeze"/>`));
+});
+
+test("an empty self-closing <mask/> does not swallow what follows it", () => {
+ const raw =
+ `<svg viewBox="0 0 8 8"><mask id="a"/><path fill="#fff" d="M0 0"/>` +
+ `<mask id="b"><rect fill="white" width="8" height="8"/></mask></svg>`;
+ const out = normalized(raw);
+ assert.ok(out.includes(`<mask id="a"/><path fill="currentColor" d="M0 0"/>`));
+ assert.ok(out.includes(`<mask id="b"><rect fill="white" width="8" height="8"/></mask>`));
+});
+
+test("idempotent: a stored icon normalizes to itself", () => {
+ for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE, YOUTUBE, BADGE]) {
+ const once = normalized(raw);
+ assert.equal(normalized(once), once);
+ }
+});
+
+test("still refuses what is unsafe to inline", () => {
+ assert.equal(normalizeSocialSvg(`<svg viewBox="0 0 1 1"><script>x()</script></svg>`), null);
+ assert.equal(normalizeSocialSvg(`<svg viewBox="0 0 1 1" onload="x()"></svg>`), null);
+ assert.equal(normalizeSocialSvg(`<svg><path fill="white"/></svg>`), null, "no viewBox");
+});
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -1294,10 +1294,78 @@ export function parseSocialLinks(input: unknown): SocialLink[] {
return out;
}
+// A paint that is not a colour: no paint (`none`, `transparent`), a paint
+// server (a gradient or pattern, `url(#…)`, with or without a fallback), or
+// what the element takes from its parent already. Never counted, never swapped.
+const KEPT_PAINT = /^(?:none|transparent|currentcolor|inherit)$|^url\(/i;
+
+// One solid colour however it is spelled, so `#FFF`, `#ffffff`, `white` and
+// `rgb(255, 255, 255)` count as ONE colour of an icon, not four.
+function paintKey(value: string): string {
+ const v = value.trim().toLowerCase().replace(/\s+/g, "");
+ if (v === "white") return "#ffffff";
+ if (v === "black") return "#000000";
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
+ if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
+ const rgb = /^rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)$/.exec(v);
+ if (rgb) {
+ return `#${rgb
+ .slice(1, 4)
+ .map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0"))
+ .join("")}`;
+ }
+ return v;
+}
+
+// Every fill of one tag, mapped: its `fill` attribute and any `fill:`
+// declaration in its `style` (which beats the attribute). `fill-rule`,
+// `fill-opacity`, strokes and gradient stops are not fills and are left alone.
+function mapTagFills(tag: string, paint: (value: string) => string): string {
+ return tag
+ .replace(
+ /(\sfill\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
+ (_m, pre: string, dq?: string, sq?: string) =>
+ dq !== undefined ? `${pre}"${paint(dq)}"` : `${pre}'${paint(sq ?? "")}'`,
+ )
+ .replace(
+ /(\sstyle\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
+ (_m, pre: string, dq?: string, sq?: string) => {
+ const css = (dq ?? sq ?? "").replace(
+ /(^|;)(\s*fill\s*:\s*)([^;]*)/gi,
+ (_d, lead: string, prop: string, v: string) => `${lead}${prop}${paint(v)}`,
+ );
+ return dq !== undefined ? `${pre}"${css}"` : `${pre}'${css}'`;
+ },
+ );
+}
+
+// The children, tag by tag. Passed over whole, never read or changed:
+// - a <mask> (its white and black say how much shows through, not what
+// colour) — self-closing first, so an empty `<mask …/>` cannot swallow
+// everything up to the next `</mask>`;
+// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing.
+const CHILD_TAG = /<mask\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<[a-zA-Z][^>]*>/gi;
+const PASSED_OVER = /^<(?:mask|animate\w*|set)\b/i;
+
+function mapChildFills(body: string, paint: (value: string) => string): string {
+ return body.replace(CHILD_TAG, (m) => (PASSED_OVER.test(m) ? m : mapTagFills(m, paint)));
+}
+
// Normalize an admin-provided SVG snippet for inline use in the export
// footer. Returns null on anything that looks unsafe or unrenderable.
// Steps: trim, allowlist-check, strip width/height, force fill="currentColor"
// + aria-hidden on the root <svg>. Requires a viewBox so the icon scales.
+//
+// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its
+// children's together, outside a <mask> — hold at most one solid colour, each
+// becomes `currentColor`, the footer link's colour. Drawn for one background,
+// such an icon vanishes on another (X's official logo is a `<path
+// fill="white">`: 1.11:1 on the light footer). An icon of TWO or more colours
+// draws its shape with them (YouTube's mark is a red rounded rectangle with a
+// white play triangle; flattened, it is a blank rectangle), carries its own
+// contrast, and keeps every colour as pasted. Paints that are not colours
+// (`none`, `url(…)`, `inherit`) are never counted or changed. Idempotent: a
+// themed icon has no solid colour left.
export function normalizeSocialSvg(raw: string): string | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
@@ -1312,20 +1380,33 @@ export function normalizeSocialSvg(raw: string): string | null {
const openEnd = trimmed.indexOf(">");
if (openEnd < 0) return null;
let opening = trimmed.slice(0, openEnd);
- const rest = trimmed.slice(openEnd);
+ let body = trimmed.slice(openEnd);
if (!/\sviewBox\s*=\s*"/i.test(opening)) return null;
opening = opening.replace(/\s(width|height)\s*=\s*"[^"]*"/gi, "");
opening = opening.replace(/\s(width|height)\s*=\s*'[^']*'/gi, "");
+ const colours = new Set<string>();
+ const count = (v: string) => {
+ if (!KEPT_PAINT.test(v.trim())) colours.add(paintKey(v));
+ return v;
+ };
+ mapTagFills(opening, count);
+ mapChildFills(body, count);
+ if (colours.size <= 1) {
+ const themed = (v: string) => (KEPT_PAINT.test(v.trim()) ? v : "currentColor");
+ opening = mapTagFills(opening, themed);
+ body = mapChildFills(body, themed);
+ }
+
if (!/\sfill\s*=/i.test(opening)) {
opening = opening.replace(/^<svg/i, '<svg fill="currentColor"');
}
if (!/\saria-hidden\s*=/i.test(opening)) {
opening = opening.replace(/^<svg/i, '<svg aria-hidden="true"');
}
- return opening + rest;
+ return opening + body;
}
// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -2,6 +2,11 @@
## [Unreleased]
- **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.
+- **The hub's Ask AI says when the hub has no archives.** On a hub whose list of archives is empty or could not be read, with none added in this browser, the question box read "Loading transcripts…" forever. It is now disabled, and one line under it says the hub has no archives yet, with a link to the front page, where one can be added.
+- **The hub's Ask AI waits for the hub's own list of archives.** The archives added in this browser are known at once and the hub's own a moment later, and the chat counted as ready in between: a question sent then was answered from the added archives alone. It now waits for both.
+- **The hub's Ask AI says how many archives it searches.** One line under the heading counts the archives in its search out of all the hub's, the rest being those switched off with the scope chips, and links to the front page, where the chips are.
+- **A hub archive whose live chat did not load says so on its chip.** Its videos are searched as before, and its chip now notes the missing live chat, with a Retry that asks for the live chat alone. The archive stays ready, its videos in the results, while the Retry runs. Ask AI, which answers from everything it searches, waits instead while it asks for a missing live chat again.
+- **A single-colour social icon shows on every ground.** The footer's social icons take the footer's colour, but that reached only an icon's outer shape: a part that carried its own colour kept it, so X's official logo, which is white, was invisible on the Light ground. An icon drawn in one colour now follows the footer throughout. An icon of two or more colours, such as YouTube's red mark with its white triangle, keeps its colours as pasted, since they are its shape. "No fill", gradients, masks and animation timing are never changed. It applies when the settings are next saved, then needs a rebuild and deploy of every export site and the hub.
## [0.9.4] - 2026-09-28
- **On the Dark ground the mark's tile has a thin outline.** The header mark's ink tile is the colour of the Dark page, so only its lines showed. On Dark the tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines. The small Archilyzer mark by the footer credit and the hub's mark get the same ring in their own slate's unlit colour. Light and Sepia are unchanged, and so are the icons. Needs a rebuild and deploy of every export site and the hub.
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -1,7 +1,6 @@
"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";
@@ -15,7 +14,8 @@ import { GroundingPalette } from "./GroundingPalette";
import { SavedChatsRow } from "./SavedChatsRow";
import { MessageBubble } from "./MessageBubble";
import { Composer } from "./Composer";
-import { NO_ARCHIVES_IN_SCOPE, copyWithLink } from "./hubScopeCopy";
+import { NO_ARCHIVES_IN_SCOPE, NO_ARCHIVES_ON_HUB } from "./hubScopeCopy";
+import { LinkedCopy } from "./LinkedCopy";
export default function AskChat() {
const s = useAskChat();
@@ -31,16 +31,19 @@ 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`.
+ // Hub only (single-site has no `federation`): nothing to search. The
+ // provider never reads ready over an empty scope, so once the hub's list is
+ // in the composer says why instead of "Loading transcripts…" — the hub has
+ // no archives at all, or this browser switched every one off with the front
+ // page's scope chips. Before the list is in, it is still loading.
const { federation } = useSearchData();
- const noneInScope =
- !!federation &&
- federation.sites.length > 0 &&
- federation.sites.every((f) => f.status === "off");
- const noneLine = copyWithLink(NO_ARCHIVES_IN_SCOPE);
+ const blockedCopy = !federation?.listed
+ ? undefined
+ : federation.sites.length === 0
+ ? NO_ARCHIVES_ON_HUB
+ : federation.sites.every((f) => f.status === "off")
+ ? NO_ARCHIVES_IN_SCOPE
+ : undefined;
// 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,
@@ -475,19 +478,7 @@ 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
- }
+ blocked={blockedCopy ? <LinkedCopy copy={blockedCopy} /> : undefined}
hasKey={!!apiKey.trim()}
markdownOn={markdownOn}
setMarkdownOn={s.setMarkdownOn}
diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx
@@ -8,8 +8,11 @@
// 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).
+// federation — nor before the hub's own list is in (`listed`: the archives a
+// browser added are known at once, /hub-sites.json's a moment later). With
+// NONE in scope it is not ready at all; once the list is in AskChat says why
+// (NO_ARCHIVES_ON_HUB, NO_ARCHIVES_IN_SCOPE), and AskScope says how many of
+// the hub's archives the chat searches (ASK_SCOPE_LINE).
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
@@ -18,6 +21,7 @@ import { MultiSiteDataProvider } from "yt-dlp-transcript-common/components/Searc
import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext";
import { useFederatedSites } from "../components/hub/useHubSites";
import AskChat from "./AskChat";
+import AskScope from "./AskScope";
// `transcriptDownloads` comes from the server parent's currentSite() (a client
// component cannot read site.json): false hides the modal's per-video export
@@ -27,7 +31,7 @@ export default function AskHub({
}: {
transcriptDownloads?: boolean;
}) {
- const { federated } = useFederatedSites();
+ const { federated, listed } = useFederatedSites();
// The whole provider stack, in the order SiteWorkspace mounts it for a single
// site — PlayerProvider, then the data source, then the session — because
@@ -41,7 +45,8 @@ export default function AskHub({
// the stack, never an opt-out of prerendering.
return (
<PlayerProvider features={{ transcriptDownloads }}>
- <MultiSiteDataProvider sites={federated}>
+ <MultiSiteDataProvider sites={federated} listed={listed}>
+ <AskScope />
<SearchSessionProvider>
<AskChat />
</SearchSessionProvider>
diff --git a/export/app/ask/AskScope.tsx b/export/app/ask/AskScope.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+// Hub /ask only: one line under the page's header saying what the chat
+// searches — how many of the hub's archives are in scope, the rest switched off
+// with the front page's chips (ASK_SCOPE_LINE, the operator's copy). Nothing
+// until the hub's list is in (the count would move), and nothing with none in
+// scope: the composer's blocked line says that.
+
+import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
+import { ASK_SCOPE_LINE } from "./hubScopeCopy";
+import { LinkedCopy } from "./LinkedCopy";
+
+export default function AskScope() {
+ const { federation } = useSearchData();
+ if (!federation?.listed) return null;
+ const total = federation.sites.length;
+ const inScope = federation.sites.filter((s) => s.status !== "off").length;
+ if (inScope === 0) return null;
+ const line = ASK_SCOPE_LINE(inScope, total);
+ if (!line) return null;
+ return (
+ <p data-testid="ask-scope" className="-mt-2 text-sm text-muted-foreground">
+ <LinkedCopy copy={line} />
+ </p>
+ );
+}
diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx
@@ -10,7 +10,8 @@ type Props = {
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.
+ // disabled). Set on the hub when it has no archives, or every one is switched
+ // off.
blocked?: ReactNode;
hasKey: boolean;
markdownOn: boolean;
diff --git a/export/app/ask/LinkedCopy.tsx b/export/app/ask/LinkedCopy.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+// A copy line from hubScopeCopy.ts as a reader sees it: its one [bracketed]
+// phrase, when it has one, a link to the hub's front page.
+
+import Link from "next/link";
+import { copyWithLink } from "./hubScopeCopy";
+
+export function LinkedCopy({ copy }: { copy: string }) {
+ const { before, link, after } = copyWithLink(copy);
+ return (
+ <>
+ {before}
+ {link && (
+ <Link href="/" className="text-brand underline-offset-2 hover:underline">
+ {link}
+ </Link>
+ )}
+ {after}
+ </>
+ );
+}
diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts
@@ -7,6 +7,32 @@
export const NO_ARCHIVES_IN_SCOPE =
"No archives selected. Choose some on the [hub's front page] to ask.";
+// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
+// The hub's /ask line for a hub with NO archives at all: its /hub-sites.json
+// lists none (a fresh self-hosted hub) or could not be read, and this browser
+// has added none. The composer is disabled and this line says why, instead of
+// "Loading transcripts…" forever. The [bracketed] words link to the hub's front
+// page, where the form to add an archive is; drop the brackets for no link.
+export const NO_ARCHIVES_ON_HUB =
+ "This hub has no archives yet. Add one on the [hub's front page] to ask.";
+
+// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
+// The note on a hub scope chip (the front page) when that archive's live chat
+// could not be read: its videos are searched, its live chat is not. A Retry
+// for the live chat alone sits beside it. Chip notes are lower case, like the
+// chip's own "loading…" and "failed".
+export const LIVE_CHAT_MISSING = "live chat didn't load";
+
+// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
+// The one line at the top of the hub's /ask that says what the chat searches:
+// `inScope` of the hub's `total` archives, the rest switched off with the
+// front page's scope chips. The [bracketed] words link to the front page;
+// drop the brackets for no link. Return null to show no line — e.g.
+// `inScope === total ? null : …` to show it only when some archives are off.
+// Not shown when none are in scope (NO_ARCHIVES_IN_SCOPE says so instead).
+export const ASK_SCOPE_LINE = (inScope: number, total: number): string | null =>
+ `Searching ${inScope} of ${total} ${total === 1 ? "archive" : "archives"} — [change on the front page].`;
+
// A copy line split around its one [bracketed] link.
export function copyWithLink(copy: string): {
before: string;
diff --git a/export/app/components/hub/HubScope.tsx b/export/app/components/hub/HubScope.tsx
@@ -6,12 +6,16 @@
// archive is doing — loading, its record count once ready (its summaries
// manifest's totalCount, the number the results header counts), or failed with
// a Retry beside it — so a slow or dead member is visible, not a silent gap.
+// A ready archive whose live chat could not be read says that too
+// (LIVE_CHAT_MISSING), with a Retry for its live chat alone: its videos stay
+// in the search meanwhile.
import { cn } from "yt-dlp-transcript-common/lib/utils";
import {
useSearchData,
type FederatedSiteState,
} from "yt-dlp-transcript-common/components/SearchDataContext";
+import { LIVE_CHAT_MISSING } from "../../ask/hubScopeCopy";
function detail(s: FederatedSiteState): string {
switch (s.status) {
@@ -33,7 +37,7 @@ export default function HubScope({
}) {
const { federation } = useSearchData();
if (!federation || federation.sites.length === 0) return null;
- const { sites, retry } = federation;
+ const { sites, retry, retryLiveChat } = federation;
return (
<div
@@ -96,6 +100,30 @@ export default function HubScope({
Retry
</button>
)}
+ {s.status === "ready" && s.liveChatMissing && (
+ <>
+ <span
+ data-testid="hub-scope-live-chat"
+ className="flex items-center border-l border-border px-2.5 py-1.5 text-xs text-muted-foreground"
+ >
+ {LIVE_CHAT_MISSING}
+ </span>
+ <button
+ type="button"
+ onClick={() => retryLiveChat(s.origin)}
+ disabled={s.liveChatMissing.retrying}
+ aria-label={`Retry live chat from ${s.siteTitle}`}
+ title={
+ s.liveChatMissing.error
+ ? `${s.liveChatMissing.error} — try again`
+ : "Try again"
+ }
+ className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent disabled:opacity-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
+ >
+ Retry
+ </button>
+ </>
+ )}
</span>
);
})}
diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts
@@ -13,6 +13,12 @@
// 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.
+//
+// `listed` says the list is the hub's WHOLE list: `/hub-sites.json` has been
+// answered (siteRegistry's `builtinsLoaded`) and the summary has settled. Until
+// then it may hold only the archives this browser added (they are read from
+// storage at once). A surface that must not act on half the hub — /ask —
+// waits for it; the front page does not.
import { useMemo } from "react";
import {
@@ -25,8 +31,9 @@ import { useHubScope } from "./useHubScope";
import { useHubSummary } from "./useHubSummary";
export function useHubSites() {
- const { sites, removeSite } = useRegistry();
+ const { sites, removeSite, builtinsLoaded } = useRegistry();
const { summary, settled } = useHubSummary();
+ const listed = builtinsLoaded && settled;
return useMemo(() => {
const official = settled
? officialInstances(
@@ -40,18 +47,20 @@ export function useHubSites() {
...official.map((o) => ({ ...o.site, accent: o.accent })),
...added,
];
- return { official, added, all, summary, removeSite };
- }, [sites, summary, settled, removeSite]);
+ return { official, added, all, summary, removeSite, listed };
+ }, [sites, summary, settled, removeSite, listed]);
}
// 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).
+// front page; an archive switched off there is not fetched), and whether that
+// is the whole list yet (`listed`, above).
export function useFederatedSites(): {
federated: FederatedSite[];
toggle: (origin: string) => void;
+ listed: boolean;
} {
- const { all } = useHubSites();
+ const { all, listed } = useHubSites();
const { isOn, toggle } = useHubScope();
const federated = useMemo<FederatedSite[]>(
() =>
@@ -63,5 +72,5 @@ export function useFederatedSites(): {
})),
[all, isOn],
);
- return { federated, toggle };
+ return { federated, toggle, listed };
}
diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts
@@ -1,4 +1,9 @@
-import { expect, test, type Page } from "@playwright/test";
+import { expect, test, type Page, type Route } from "@playwright/test";
+import {
+ NO_ARCHIVES_ON_HUB,
+ copyText,
+ copyWithLink,
+} from "../app/ask/hubScopeCopy";
// The hub's /ask route.
//
@@ -11,15 +16,21 @@ import { expect, test, type Page } from "@playwright/test";
// other half, covered by the 2-origin suite which builds the hub for real.
// No built-in pool: this route has to stand up on a hub with no archives,
-// which is what a fresh hub is.
-async function stubBuiltins(page: Page) {
- await page.route("**/hub-sites.json", (r) =>
- r.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: "[]",
- }),
+// which is what a fresh hub is. `answer` is how /hub-sites.json replies: an
+// empty list, or a failure (the registry never asks twice).
+async function stubBuiltins(
+ page: Page,
+ answer: "empty" | "error" | "abort" = "empty",
+) {
+ await page.route("**/hub-sites.json", (r: Route) =>
+ answer === "abort"
+ ? r.abort()
+ : r.fulfill({
+ status: answer === "error" ? 500 : 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: answer === "error" ? "" : "[]",
+ }),
);
}
@@ -38,13 +49,12 @@ test.describe("hub /ask", () => {
).toBeVisible();
// The composer is AskChat's whole point, and it only renders once the
- // provider stack AskHub now mounts is there. Located by its placeholder,
- // the way ask-chat.spec.ts does it — the first textbox on the page is the
- // provider panel's API-key field, not this.
- const box = page.getByPlaceholder(
- /Ask about the transcripts|Loading transcripts/,
- );
- await expect(box).toBeVisible();
+ // provider stack AskHub now mounts is there. On a hub with no archives it
+ // is there but blocked (below), so it is found by its form.
+ const composer = page
+ .locator("form")
+ .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) });
+ await expect(composer.locator("textarea")).toBeVisible();
await expect(
page.getByRole("button", { name: "Ask", exact: true }),
).toBeVisible();
@@ -52,4 +62,39 @@ test.describe("hub /ask", () => {
// A missing provider surfaces as a client-side throw, not a blank page.
expect(errors).toEqual([]);
});
+
+ // Release 11 (O1): a hub with no archives at all used to read "Loading
+ // transcripts…" forever on /ask. Once /hub-sites.json has answered — an
+ // empty list, or a failure the registry does not retry — and this browser
+ // has added none, the composer says so instead (NO_ARCHIVES_ON_HUB).
+ for (const answer of ["empty", "error", "abort"] as const) {
+ test(`a hub with no archives (hub-sites.json ${answer}) says so and cannot ask`, async ({
+ page,
+ }) => {
+ await stubBuiltins(page, answer);
+ 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_ON_HUB));
+ await expect(blocked).toHaveAttribute("role", "status");
+ // Its [bracketed] words (when the copy has any) link to the front page,
+ // where the form to add an archive is.
+ const { link } = copyWithLink(NO_ARCHIVES_ON_HUB);
+ 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);
+ // No archives, so no "Searching N of M" line either.
+ await expect(page.getByTestId("ask-scope")).toHaveCount(0);
+ });
+ }
});
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -1,5 +1,7 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import {
+ ASK_SCOPE_LINE,
+ LIVE_CHAT_MISSING,
NO_ARCHIVES_IN_SCOPE,
copyText,
copyWithLink,
@@ -20,6 +22,12 @@ import {
// 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.
+// Release 11 (O1): /ask says how many of the hub's archives it searches
+// (ASK_SCOPE_LINE), and is not ready until the hub's own list is in — the
+// archives a browser added are known at once, /hub-sites.json's a moment
+// later; a member whose live chat cannot be read says so on its chip
+// (LIVE_CHAT_MISSING), with a Retry for the live chat alone that keeps its
+// videos in the search while it runs.
const ORIGIN_A = "http://localhost:4598";
const ORIGIN_B = "http://localhost:4599";
@@ -76,17 +84,33 @@ function pageOf(m: Member) {
// Every request to a member origin is counted; `pages` decides how its
// summaries page is answered, `subs` whether it has a subs manifest at all
-// ("missing": a 404) or cannot serve it ("error": a 500).
+// ("missing": a 404), cannot serve it ("error": a 500) or answers only when
+// the test says so ("hold": parked in `heldSubs`).
type PageMode = "ok" | "abort" | "hold";
type MemberMock = {
requests: string[];
pages: PageMode;
- subs: "ok" | "missing" | "error";
+ subs: "ok" | "missing" | "error" | "hold";
held: Route[];
+ heldSubs: Route[];
+};
+
+const SUBS_OK = {
+ version: 4,
+ channels: [],
+ totalCount: 0,
+ liveChatTotalCount: 0,
+ generatedAt: "2026-01-01T00:00:00.000Z",
};
async function mockMember(page: Page, m: Member): Promise<MemberMock> {
- const mock: MemberMock = { requests: [], pages: "ok", subs: "ok", held: [] };
+ const mock: MemberMock = {
+ requests: [],
+ pages: "ok",
+ subs: "ok",
+ held: [],
+ heldSubs: [],
+ };
await page.route(`${m.origin}/**`, async (route) => {
const url = new URL(route.request().url());
mock.requests.push(url.pathname);
@@ -104,14 +128,12 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> {
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 === "hold") {
+ mock.heldSubs.push(route);
+ return;
+ }
if (url.pathname === "/subs/manifest.json" && mock.subs === "ok") {
- return fulfillJson(route, {
- version: 4,
- channels: [],
- totalCount: 0,
- liveChatTotalCount: 0,
- generatedAt: "2026-01-01T00:00:00.000Z",
- });
+ return fulfillJson(route, SUBS_OK);
}
// No posts, no aliases (and, when `subs` is "missing", no live chat): a
// clean 404 (a member without that corpus is not a failure).
@@ -398,6 +420,21 @@ test.describe("hub federated search — scope, per-archive state, attribution",
expect(mocks.a.requests).toContain("/summaries/page-0000.json");
expect(mocks.b.requests).toEqual([]);
+ // And it says so: one line, its [bracketed] words a link to the chips.
+ const scope = page.getByTestId("ask-scope");
+ const oneOfTwo = ASK_SCOPE_LINE(1, 2);
+ if (oneOfTwo === null) {
+ await expect(scope).toHaveCount(0);
+ } else {
+ await expect(scope).toHaveText(copyText(oneOfTwo));
+ const { link } = copyWithLink(oneOfTwo);
+ if (link) {
+ await expect(
+ scope.getByRole("link", { name: link, exact: true }),
+ ).toHaveAttribute("href", "/");
+ }
+ }
+
// Back on (on the front page), /ask reads it again.
await page.goto("/");
await chip(page, B).getByRole("button", { name: /Origin B/ }).click();
@@ -409,6 +446,75 @@ test.describe("hub federated search — scope, per-archive state, attribution",
.toContain("/summaries/manifest.json");
await expect(ready).toBeVisible();
expect(mocks.b.requests).toContain("/summaries/page-0000.json");
+ const twoOfTwo = ASK_SCOPE_LINE(2, 2);
+ if (twoOfTwo === null) await expect(scope).toHaveCount(0);
+ else await expect(scope).toHaveText(copyText(twoOfTwo));
+ });
+
+ test("/ask is not ready until the hub's own list is in, even with an added archive ready", async ({
+ page,
+ }) => {
+ // Origin B is an archive this browser added (read from storage at once);
+ // Origin A is the hub's own, from /hub-sites.json — held here.
+ const mocks = await setup(page, [A]);
+ const heldList: Route[] = [];
+ await page.route("**/hub-sites.json", (r) => {
+ heldList.push(r);
+ });
+ await page.addInitScript((origin) => {
+ window.localStorage.setItem(
+ "ytdlp-tb:hub-sites",
+ JSON.stringify([
+ {
+ origin,
+ siteId: "added-b",
+ siteTitle: "Origin B",
+ pwa: false,
+ kind: "external",
+ contract: 1,
+ addedAt: 1,
+ },
+ ]),
+ );
+ }, B.origin);
+
+ await page.goto("/ask");
+ // Origin B is fetched meanwhile — every feed of it answered...
+ await expect
+ .poll(() => mocks.b.requests)
+ .toEqual(
+ expect.arrayContaining([
+ "/summaries/manifest.json",
+ "/summaries/page-0000.json",
+ "/subs/manifest.json",
+ "/posts/manifest.json",
+ ]),
+ );
+ await expect.poll(() => heldList.length).toBeGreaterThan(0);
+ // ...but it is not the hub: no question may ground in it alone. Give a
+ // ready provider time to show itself, then check it did not.
+ await page.waitForTimeout(1_000);
+ await expect(page.getByPlaceholder("Loading transcripts…")).toBeVisible();
+ await expect(page.getByTestId("ask-blocked")).toHaveCount(0);
+ await expect(page.getByTestId("ask-scope")).toHaveCount(0);
+
+ // The hub's list arrives: Origin A is fetched, and the chat is ready over
+ // both.
+ for (const r of heldList.splice(0)) {
+ await fulfillJson(r, [
+ { siteId: "origin0", siteTitle: A.title, siteUrl: A.origin, pwa: false, contract: 1 },
+ ]);
+ }
+ await expect
+ .poll(() => mocks.a.requests)
+ .toContain("/summaries/page-0000.json");
+ await expect(
+ page.getByPlaceholder(/^Ask about the transcripts/),
+ ).toBeVisible();
+ const both = ASK_SCOPE_LINE(2, 2);
+ if (both !== null) {
+ await expect(page.getByTestId("ask-scope")).toHaveText(copyText(both));
+ }
});
test("a subs manifest that cannot be read is retried once and never fails its archive", async ({
@@ -425,7 +531,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
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" }),
+ chip(page, B).getByRole("button", { name: "Retry Origin B", exact: true }),
).toHaveCount(0);
await expect(status(page)).toHaveCount(0);
// Ready waits for the retry to settle, so the count is final here.
@@ -434,6 +540,80 @@ test.describe("hub federated search — scope, per-archive state, attribution",
).toBe(2);
});
+ test("a member's missing live chat is noted on its chip, and its Retry refetches the live chat alone", async ({
+ page,
+ }) => {
+ const mocks = await setup(page);
+ mocks.b.subs = "error";
+ await page.goto("/");
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+
+ // The note, on Origin B's chip only, with its own Retry.
+ const note = chip(page, B).getByTestId("hub-scope-live-chat");
+ await expect(note).toHaveText(LIVE_CHAT_MISSING);
+ await expect(chip(page, A).getByTestId("hub-scope-live-chat")).toHaveCount(0);
+ const retryChat = chip(page, B).getByRole("button", {
+ name: "Retry live chat from Origin B",
+ exact: true,
+ });
+ await expect(retryChat).toBeEnabled();
+
+ // Retry, answered only when the test says: while it is in flight Origin B
+ // stays ready and its video stays in the results, the note stays with its
+ // Retry disabled, and nothing but the live chat is asked for again.
+ mocks.b.subs = "hold";
+ const asked = mocks.b.requests.length;
+ await retryChat.click();
+ await expect.poll(() => mocks.b.heldSubs.length).toBe(1);
+ await expect(retryChat).toBeDisabled();
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(note).toHaveText(LIVE_CHAT_MISSING);
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ await expect(resultFrom(page, A)).toHaveCount(1);
+ expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]);
+
+ // It answers: the note and its Retry go; the archive never left.
+ for (const r of mocks.b.heldSubs.splice(0)) await fulfillJson(r, SUBS_OK);
+ await expect(note).toHaveCount(0);
+ await expect(retryChat).toHaveCount(0);
+ await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
+ await expect(resultFrom(page, B)).toHaveCount(1);
+ expect(mocks.b.requests.slice(asked)).toEqual(["/subs/manifest.json"]);
+ });
+
+ test("/ask is not ready while it refetches a live chat that failed on the front page", async ({
+ page,
+ }) => {
+ // Origin B's live chat failed on the front page: its archive is ready
+ // there, noted as missing its live chat.
+ const mocks = await setup(page);
+ mocks.b.subs = "error";
+ await page.goto("/");
+ await expect(chip(page, B).getByTestId("hub-scope-live-chat")).toHaveText(
+ LIVE_CHAT_MISSING,
+ );
+
+ // To /ask in the same page life (the same query client): its own mount
+ // asks for the failed live chat again. Held, /ask is not ready — a question
+ // now would miss live chat that may be about to arrive.
+ mocks.b.subs = "hold";
+ await page
+ .getByRole("banner")
+ .getByRole("link", { name: "Ask AI", exact: true })
+ .click();
+ await page.waitForURL(/\/ask\/?$/);
+ await expect.poll(() => mocks.b.heldSubs.length).toBe(1);
+ await page.waitForTimeout(1_000);
+ await expect(page.getByPlaceholder("Loading transcripts…")).toBeVisible();
+
+ // It answers: /ask is ready, over both archives and B's live chat.
+ for (const r of mocks.b.heldSubs.splice(0)) await fulfillJson(r, SUBS_OK);
+ await expect(
+ page.getByPlaceholder(/^Ask about the transcripts/),
+ ).toBeVisible();
+ });
+
test("/ask with every archive switched off says so and cannot ask; with one back on, it asks", async ({
page,
}) => {
diff --git a/mcp/README.md b/mcp/README.md
@@ -522,9 +522,16 @@ Logs go to stderr; stdout is the MCP JSON-RPC channel.
```sh
claude mcp add rekietalyzer \
--env TRANSCRIPT_SITE_URL=https://rekietalyzer.pages.dev \
+ --env ARCHILYZER_EDITOR_URL=http://localhost:3001 \
+ --env WORKER_TOKEN=… \
-- pnpm -C /ABS/PATH/TO/yt-dlp-transcript-browser --filter yt-dlp-transcript-mcp exec tsx src/index.ts
```
+The two editor lines are optional: they let `fetch_clip` ask a local editor for
+clip media (`WORKER_TOKEN` is the editor's own, from `editor/.env`; the URL
+defaults to `http://localhost:3001`). Leave them out and every other tool works
+the same; `fetch_clip` then says it has no editor and fetches nothing.
+
**The `/sweep` and `/ask` commands.** `.claude/commands/{sweep,ask}.md` in this
repo call `mcp__archilyzer__sweep_plan` / `mcp__archilyzer__ask_plan` — the tool
name embeds the MCP server name **as you registered it**, so if you used another
@@ -544,11 +551,16 @@ files to match. `foo:bar` namespacing is plugin-only, so what you type stays
"--filter", "yt-dlp-transcript-mcp",
"exec", "tsx", "src/index.ts"
],
- "env": { "TRANSCRIPT_SITE_URL": "https://rekietalyzer.pages.dev" }
+ "env": {
+ "TRANSCRIPT_SITE_URL": "https://rekietalyzer.pages.dev",
+ "ARCHILYZER_EDITOR_URL": "http://localhost:3001",
+ "WORKER_TOKEN": "…"
+ }
}
}
}
```
Swap `TRANSCRIPT_SITE_URL` for `TRANSCRIPT_HUB_URL` to federate a whole hub, or
-`TRANSCRIPT_LOCAL_DIR` to read a local build.
+`TRANSCRIPT_LOCAL_DIR` to read a local build. `ARCHILYZER_EDITOR_URL` and
+`WORKER_TOKEN` are optional, for `fetch_clip` only, as above.
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -6698,6 +6698,21 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
the archive is `ready`, its videos are searched, only its live chat is missing, and nothing says
so (a new low). Official members send CORS on their 404s (checked live on
`https://jeralyzer.pages.dev/subs/<missing>.json`).
+ **AMENDED 2026-09-28 (release 11 O1; `release-11.md`, "Slice O1, as shipped"): the rule keys on
+ `errorUpdateCount`, and the low is closed.** "Has errored" is `subs.data === undefined &&
+ subs.errorUpdateCount > 0` (`SearchDataContext.tsx:461`), not `isError`. TanStack (query-core
+ 5.100.5, `fetchState`) puts an errored query with no data back to `pending` / `error: null` on
+ refetch but keeps the count, and the count moves only on the final `error` dispatch (after
+ `retry: 1`). Settled is `data !== undefined || (errored && (progressive || !isFetching))`
+ (`:462-464`): the progressive front page stays ready through a refetch, while `/ask` waits for one.
+ The count survives in the shared query client into `/ask`'s own mount (`retryOnMount`), a scope
+ toggle off→on, or a whole-archive Retry. A ready archive whose subs errored carries
+ `liveChatMissing: {retrying, error?}` (`:477`). Its scope chip shows `LIVE_CHAT_MISSING`
+ (`export/app/ask/hubScopeCopy.ts:24`, a DRAFT) in `data-testid="hub-scope-live-chat"`, with a Retry
+ (`Retry live chat from <title>`, `HubScope.tsx:106-115`). The Retry calls
+ `federation.retryLiveChat(origin)`, which refetches `["subs-manifest", origin]` exactly (`:799-802`).
+ `resetQueries` would zero the count; nothing in `common/` or `export/` calls it, and `gcTime` is
+ Infinity.
- **An empty scope is never ready unless the provider is `progressive`**
(`SearchDataContext.tsx:456-466`: `allSettled = (progressive || scoped.length > 0) && …`).
`MultiSiteDataProvider` has two users: the hub's front page (`HubHome.tsx:40`, `progressive`:
@@ -6709,6 +6724,22 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
forever on `/ask` (a new low). Both surfaces take their list from `useFederatedSites()`
(`export/app/components/hub/useHubSites.ts:49`), so an archive off on the front page is not fetched
on `/ask`.
+ **AMENDED 2026-09-28 (release 11 O1): the zero-archive low is closed, and `/ask` waits for the
+ hub's list.**
+ - `siteRegistry.ts` has `builtinsLoaded` (`:76`; `getBuiltinsLoaded` `:106`; `useRegistry()` returns
+ it). `loadBuiltins` (`:195`, exported) sets it once `/hub-sites.json` has answered in any way — a
+ list, `[]`, a 404, a 500, a network error or bad JSON — in the same `emit` that adds the
+ built-ins. It is still never retried.
+ - `useHubSites().listed = builtinsLoaded && summary settled` (`useHubSites.ts:36`) is passed
+ through `useFederatedSites` to `MultiSiteDataProvider`'s `listed` prop (default `true`,
+ `SearchDataContext.tsx:310`), where `allSettled = listed && …` (`:527`). `federation.listed`
+ carries it. Only `AskHub` passes it (`AskHub.tsx:48`); the progressive front page is unchanged.
+ - Once `listed`, `AskChat` (`:40`) blocks the composer with `NO_ARCHIVES_ON_HUB` (an empty list) or
+ `NO_ARCHIVES_IN_SCOPE` (every chip off). Before `listed`, it reads "Loading transcripts…".
+ - `AskScope` (`AskHub.tsx:49`) renders `ASK_SCOPE_LINE(inScope, total)` in
+ `data-testid="ask-scope"` once `listed` with at least one archive in scope. `null` hides it.
+ - `NO_ARCHIVES_ON_HUB`, `LIVE_CHAT_MISSING` and `ASK_SCOPE_LINE` (`hubScopeCopy.ts:16/24/33`) are
+ DRAFTs awaiting the operator's ruling. `LinkedCopy` renders a line's one [bracketed] link to `/`.
- **The official instances' order is the homepage's key and tiebreak.** The homepage sorts its
public sites by `transcribed.total` descending, then `siteTitle.localeCompare`
(`common/lib/homepageSummary.ts:437-441`); `hub-summary.json` keeps that order, and
diff --git a/plans/release-11.md b/plans/release-11.md
@@ -663,6 +663,279 @@ Gates on the merged tree, from the worktree root (`o6-gateR.log`, `o6-e2e-editor
ENVIRONMENT.md regenerated fails exactly the mention test (1); an undeclared
`ARCHILYZER_NEW_KNOB` in the entrypoint fails 1; all-absent ids not refused fails 1.
+### Slice O1, as shipped — hub lows and the DRAFT copy (2026-09-28)
+
+Release 10's hub lows (`release-10.md`, "Slice L1, as shipped", both "Found and left" lists) and
+STATE's x.com icon low, with the three lines that need words shipped as DRAFT constants for a
+morning ruling. Branch `r11/hub-lows` off `main` `2162db92`, merged up to `baaa4b47` (O4) before
+the first commit; worktree `/home/user/Projects/r11-hub-lows` (port block #4). One Opus
+implementer. Scratch files `o1-*` in the job's `tmp/overnight`.
+
+**What shipped.**
+1. **A "built-ins loaded" flag** (`common/components/siteRegistry.ts`). `builtinsLoaded` turns true
+ once `/hub-sites.json` has been answered — a list, `[]`, a 404, a 500, a network error, bad JSON
+ or a non-list. It is set in the same update that adds the built-ins (one `emit`), so no reader
+ sees "loaded" over a list that lacks them. `useRegistry()` returns it, and `getBuiltinsLoaded()`
+ reads it outside React. `loadBuiltins` is exported, and so are the store's `getRegisteredSites` /
+ `subscribeRegistry`. The one-shot guard is now its own `builtinsRequested`: before, a flag named
+ `builtinsLoaded` was set at the START of the fetch. The file is still never retried.
+2. **`/ask` counts as ready only once the hub's list is in.** `useHubSites()` returns
+ `listed = builtinsLoaded && summary settled`, and `useFederatedSites()` passes it through.
+ `MultiSiteDataProvider` takes a `listed` prop (default `true`), and
+ `allSettled = listed && …`. `federation` carries `listed`. `AskHub` passes it; the front page
+ does not, so it is unchanged. The archives a browser added are read from storage at once, and
+ `/hub-sites.json`'s arrive a moment later. Before this, a question sent in between grounded in
+ the added archives alone. Their fetches still start at once; only readiness waits.
+3. **Three DRAFT copy lines** (`export/app/ask/hubScopeCopy.ts`), each marked
+ `// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling`:
+ - **`NO_ARCHIVES_ON_HUB`** (`:16`). On `/ask`, once the list is in and it is empty (a hub whose
+ `hub-sites.json` is `[]` or failed, with none added here), the composer shows it in
+ `data-testid="ask-blocked"` (`role="status"`), the box and Ask disabled. It replaces
+ "Loading transcripts…" forever. Its [bracketed] words link to `/`, where the add form is.
+ - **`LIVE_CHAT_MISSING`** (`:24`). The note on a scope chip (`data-testid="hub-scope-live-chat"`)
+ for a READY archive whose subs manifest settled in error. Beside it is a Retry, accessible name
+ `Retry live chat from <title>`: `federation.retryLiveChat(origin)` refetches
+ `["subs-manifest", origin]` exactly, and nothing else. The Retry is disabled while it runs.
+ - **`ASK_SCOPE_LINE`** (`:33`). A function of `(inScope, total)` so the plural and the words are
+ one editable constant. `AskScope` renders it (`data-testid="ask-scope"`) between `/ask`'s
+ header and the chat, once the list is in, whenever at least one archive is in scope. Returning
+ `null` hides it: e.g. `inScope === total ? null : …` shows it only when some archives are off.
+ Its [bracketed] words link to `/`.
+ `NO_ARCHIVES_IN_SCOPE` (L1's draft, still unruled) also waits for the list now, so it no longer
+ flashes while the built-ins arrive. `LinkedCopy` (`export/app/ask/LinkedCopy.tsx`) is the one
+ renderer of a copy line with its link. It replaces AskChat's inline split.
+4. **The archive stays ready through a live-chat Retry.** TanStack puts an errored query that has
+ no data back to `pending` while it refetches (`fetchState`, query-core 5.100.5). So
+ `isError && !isFetching`, L1's rule, would have taken the archive and its videos out of the
+ search for the length of the Retry. The subs rule now keys on `errorUpdateCount > 0` with no
+ data: the count moves only once the query's own retry is spent, and it survives a refetch.
+ `FederatedSiteState.liveChatMissing = {retrying, error?}` rides in `statusKey`.
+5. **`normalizeSocialSvg` themes every fill colour** (`common/lib/settingsSchema.ts`). *(Narrowed by
+ review fix 1 below: only a single-colour icon is themed; a multi-colour one keeps its colours.)*
+ - **What changes.** Every `fill` attribute, and every `fill:` in a `style`, becomes
+ `currentColor`: the root's (a root colour too, not only an absent one) and every child tag's.
+ The exceptions are `none`, `transparent`, `currentColor`, `inherit` and `url(…)` paint (with or
+ without a fallback).
+ - **What is kept.** The tags inside a `<mask>` (white and black there are coverage, not colour),
+ `fill-rule` / `fill-opacity`, strokes and gradient stops.
+ - **The operator's icons.** x.com: the root's `fill="none"` stays, and the path's `white` becomes
+ `currentColor`. Kiwi Farms: the gradient drop and its stops are unchanged, and the `#414042`
+ dot follows the footer's colour.
+ - The function is idempotent.
+6. **`mcp/README.md`.** The `claude mcp add` and `mcp.json` examples carry
+ `ARCHILYZER_EDITOR_URL=http://localhost:3001` and `WORKER_TOKEN=…`, marked optional for
+ `fetch_clip` in AGENTS.md's words (the URL's default and the no-editor answer added). mcp has no
+ changelog.
+
+| sha | what |
+|---|---|
+| `0a2ba7b9` | `common:` siteRegistry `builtinsLoaded` (+ `getBuiltinsLoaded`, exported `loadBuiltins`), set in the same update as the built-ins; +8 unit tests |
+| `615563d1` | `common: normalizeSocialSvg` themes every fill colour, children's too; `none` / `url()` / mask fills kept; +8 unit tests |
+| `2f9b5ab9` | `hub:` `listed` through `useHubSites` → `MultiSiteDataProvider` → `federation`; `liveChatMissing` + `retryLiveChat` (the `errorUpdateCount` rule); the three DRAFT constants; `AskScope`, `LinkedCopy`; the chip note; e2e `ask.spec` (+3), `federated-search.spec` (+2, and the scope line in the chips test) |
+| `2ef9e745` | `mcp:` README — the two `fetch_clip` env lines in both examples |
+| `db555285` | `export: [Unreleased]` above `[0.9.4]` (five bullets) |
+| _this_ | `plans:` this record |
+
+**Gates** (logs `o1-*.log`):
+- **tsc:** clean (`o1-tsc-2.log`, 34 s, on the committed tree). `0a2ba7b9` and `615563d1` are
+ additive subsets of it, with no type dependency on the rest.
+- **Unit and script tests** (`o1-units-1.log`):
+ - common **2,057/2,057** (2,041 + 16);
+ - editor unit **85/85**;
+ - `test:scripts` **175 + 1 skipped**;
+ - mcp **269/269**.
+- **Builds** (`o1-run-1.log`), with `export/public` seeded per path (17 links, none dangling):
+ - export site ok (24 s), `data-accent="signal"`;
+ - export hub ok (24 s), `dark data-base="dark"`, `out/ask/index.html` present.
+ - Editor and homepage builds were not run: neither app changed. The editor's two save paths call
+ `normalizeSocialSvg`, and its output for their e2e inputs is byte-identical (unit test 4).
+- **e2e** on `db555285` (`o1-run-1.log`; no wait in the queue):
+ - `e2e:hub` in full: **31 passed** (26 + 5 new), 1.3 min;
+ - export `site-branding` + `ask-chat` + `ask-workspace`: **36 passed**, 1.9 min. The footer's
+ social row is `site-branding`'s. No export spec reads the icons' colour, and the fixture's icon
+ is already `currentColor`.
+ - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`, the links in place, no swap): **3 passed**, 36.7 s.
+- **The primary's `export/public`:** all **380 files**' path, size, mtime and md5 were identical
+ before and after the three runs (`o1-primary-before-1.txt` vs `-after-1.txt`, `diff` empty).
+ After 2origin the worktree held its own `hub-sites.json`, `sw.js`, `robots.txt`, `_headers`,
+ `corpus.json` and `llms.txt`. The seed relinked them.
+- **Screens** (`o1-shots/`, from a temporary spec, deleted after): the chip note and its Retry, the
+ scope line under `/ask`'s header, and the zero-archive line under the composer.
+- **Numbers tool:** none. No `settings.json`, `site.json` or `config.json` key changed.
+
+**They bite:**
+- **Unit, against `baaa4b47`'s source** (a scratch copy of the old `common/`, `o1-bite-units.log`):
+ - `siteRegistry.test.ts` fails to load (no `getBuiltinsLoaded`).
+ - `normalizeSocialSvg.test.ts` **4 of 8 fail**: x.com, Kiwi Farms, the colour spellings and the
+ mask.
+ - The other four pin behaviour the old code already had: unchanged icons, the bare icon's exact
+ output, idempotency and the refusals. They are guards and pass on it by design.
+- **Mutations of the new code:**
+ - the flag set at the start of `loadBuiltins`: 7 of 8 fail;
+ - the flag set only when the file listed something (the old early returns): 6 of 8;
+ - the flag announced in its own update before the built-ins join: 7 of 8;
+ - `none` themed too: the x.com and "unchanged" tests fail;
+ - masks not skipped: the mask test fails.
+- **e2e, against `baaa4b47`'s seven source files,** with the new specs and `hubScopeCopy.ts` kept
+ (`o1-bite-e2e.log`, restored by a trap, tree clean after): **6 failed, 10 passed.** The six are
+ every new-behaviour test:
+ - the three zero-archive tests: no `ask-blocked` line;
+ - the chips test: no `ask-scope` line;
+ - the listed test: `Loading transcripts…` gone, because the chat was ready over the added archive
+ alone;
+ - the live-chat test: no note.
+
+**Found and left**
+- **The icon fix applies on save.** `normalizeSocialSvg` runs when a link is saved, not when the
+ footer renders. The stored x.com icon in `settings.json` keeps `fill="white"` until the Settings
+ form is saved once. Any save re-normalizes every link, with no re-paste. Then every site and the
+ hub need a rebuild. A render-side pass (as `sizeSocialSvg` does for sizes) would fix stored
+ icons with no save, but `Footer.tsx` and `sizeSocialSvg` are outside this slice.
+ *(Corrected by the review: "save" is ANY `settings.json` write, and the homepage needs the
+ rebuild too. See the review fixes' rollout note below. The review judged a render-side pass
+ unneeded: every stored icon is in the global `settings.json`.)*
+- **The Kiwi Farms dot changes colour.** It was a fixed `#414042`, barely visible on Dark, and now
+ follows the footer's colour. The green gradient drop is unchanged.
+- **Strokes are not themed.** An icon drawn with `stroke="white"` would still vanish. Neither of the
+ operator's icons is stroked.
+- **A failed `/hub-sites.json` reads as "no archives yet"**, the same as an empty one, and the
+ registry still never retries: a reload asks again.
+- **The front page does not wait for the list.** It is progressive and does not take `listed`, so
+ it still settles for the instant before `hub-sites.json` arrives. This is pre-existing, and its
+ chips show the loading.
+- **A live-chat Retry that fails again brings the note back,** with its Retry enabled
+ (`errorUpdateCount` moves, `isFetching` clears). No e2e covers that second failure. TanStack
+ clears `error` during the refetch, so the Retry's title says only "Try again" while it runs. The
+ button is disabled then.
+- **FACTS is stale after merge** (release 10 facts, "L1 — hub lows"): "nothing says so (a new low)"
+ (the subs rule) and "A hub with zero archives therefore reads 'Loading transcripts…' forever"
+ are both closed here, and the subs rule's anchor now keys on `errorUpdateCount`. Not edited here
+ (a shared file). *(Done in the review round, `ac9c299b`.)*
+
+`[Unreleased]` bullets: `export/CHANGELOG.md` (five: `/ask` with no archives, its wait for the
+hub's list, its scope line, the chip's missing live chat, the social icon). `editor/CHANGELOG.md`
+and `homepage/CHANGELOG.md` are unchanged, because neither app changed.
+
+**DRAFT copy for the morning ruling** (`export/app/ask/hubScopeCopy.ts`):
+- `:16` `NO_ARCHIVES_ON_HUB` = "This hub has no archives yet. Add one on the [hub's front page] to ask."
+- `:24` `LIVE_CHAT_MISSING` = "live chat didn't load"
+- `:33` `ASK_SCOPE_LINE(n, m)` = "Searching {n} of {m} archive(s) — [change on the front page]." (shown
+ whenever at least one archive is in scope, all included)
+- Still owed from release 10: `:7` `NO_ARCHIVES_IN_SCOPE` = "No archives selected. Choose some on the
+ [hub's front page] to ask."
+
+**Review fixes** (review SHIP AFTER FIXES, `o1-review.md`; probes `o1-rv*`). First `git merge main`
+at `cb9d02b2` (O4, O3 and O6 checkpoint A), giving `c7b42f23`. The two expected joins: one
+`[Unreleased]` in `export/CHANGELOG.md` (O3's bullet, then O1's five) and the records O4, O3, O6,
+then O1. No code conflicts, no dependency change (script lines only). tsc was clean on the merge
+(`o1-tsc-3.log`).
+
+1. **Should-fix: `normalizeSocialSvg` flattened every multi-colour icon, and wrote the result back**
+ (`69d08bf6`).
+ - **The failure.** YouTube's official mark (a `#f00` rounded rectangle and a `#fff` triangle)
+ came out as a blank rounded rectangle, and a dark disc with a white glyph as a plain disc.
+ `writeSettings` re-normalizes every link on EVERY `settings.json` write (`common/lib/settings.ts:247`,
+ `validatedSocialLinks`), not only the Settings form. So the pasted original would be destroyed
+ by the first write of any kind.
+ - **The rule now.** The solid colours of the root and every child outside a `<mask>` are counted
+ together (`fill` attributes and style `fill:`; `none` / `transparent` / `inherit` / `url()` never
+ count). Spellings of one colour count once: `paintKey` folds `#FFF`, `#ffffff`, `white` and
+ `rgb(255, 255, 255)`. With at most ONE colour, every one becomes `currentColor`, as before.
+ With two or more, every colour stays as pasted: the pre-O1 behaviour, and such an icon carries
+ its own contrast. The root still gains `fill="currentColor"` when it has none, and `aria-hidden`.
+ - **Unchanged output:** x.com (one colour, white) and Kiwi Farms (gradient + one grey) come out
+ exactly as before, and all eight earlier tests stand (the spellings test now uses one colour
+ in five spellings).
+ - **Idempotent:** a themed icon has no solid colour left, and an untouched one counts the same
+ colours again.
+2. **Edge cases** (`69d08bf6`, the same function).
+ - **Animation tags are passed over.** `<animate…>`, `<animateTransform>`, `<animateMotion>` and
+ `<set>` are never read or changed, because their `fill="freeze"` / `fill="remove"` is timing.
+ Counted, `freeze` would also have made a one-colour icon "two colours".
+ - **A self-closing `<mask … />` is matched first** (`<mask\b[^>]*\/>`). The lazy block form no
+ longer swallows everything after it up to the next `</mask>`.
+ - **Recorded as left:**
+ - a `<pattern>`'s children are themed (a single-colour icon's pattern would flatten; a
+ two-colour one is untouched);
+ - class fills in a `<style>` block (`.st0{fill:#FFF}`, Illustrator exports) are neither counted
+ nor themed;
+ - an unquoted `fill=white`, and a `>` inside an attribute value, are skipped.
+3. **Low 1: `/ask` waits for a live-chat refetch in flight** (`a3e7c616`, the reviewer's one-liner):
+ `subsSettled = data !== undefined || (subsErrored && (progressive || !subs.isFetching))`.
+ - **What it changes.** `errorUpdateCount` lives in the shared query client, so it outlives the
+ front page. It survives into refetches the front page never asked for: `/ask`'s own mount (an
+ errored query with no data is refetched when a new observer mounts), a scope toggle off and
+ on, and a whole-archive Retry.
+ - **Before**, `/ask` counted such an archive ready during that refetch, and a question could miss
+ live chat about to arrive.
+ - **Now** `/ask`, which is not progressive, counts it loading until the refetch settles. The
+ progressive front page is unchanged: it stays ready through its own live-chat Retry, which the
+ earlier e2e pins.
+ - **The new e2e** (`federated-search.spec`, "/ask is not ready while it refetches a live chat that
+ failed on the front page"): subs fail on `/`, then a client-side move to `/ask` by the header's
+ Ask AI link. The mount's refetch is held, and the page reads "Loading transcripts…". On release
+ it is ready.
+4. **FACTS** (`ac9c299b`). Release 10's "L1 — hub lows" gets two dated amendments (2026-09-28,
+ release 11 O1):
+ - the subs rule on `errorUpdateCount` and the `/ask` wait, with its anchors and the
+ `resetQueries` caveat;
+ - the zero-archive low closed: `builtinsLoaded`, `listed`, the blocked lines, `AskScope` and the
+ DRAFT constants anchored.
+5. **Changelog** (`5477b43f`):
+ - the social icon bullet now says "a single-colour icon", and that a multi-colour one (YouTube's)
+ keeps its colours;
+ - the live-chat bullet adds that Ask AI waits while it asks for a missing live chat again.
+
+| sha | what |
+|---|---|
+| `c7b42f23` | merge `main` `cb9d02b2`: one `[Unreleased]`, the records O4, O3, O6, O1 |
+| `69d08bf6` | `common: normalizeSocialSvg` themes only a single-colour icon; animation tags passed over; self-closing `<mask/>`; +5 unit tests (13) |
+| `a3e7c616` | `hub:` `/ask` waits for a live-chat refetch in flight (`progressive \|\| !isFetching`); +1 e2e |
+| `5477b43f` | `export: [Unreleased]`: the single-colour icon; Ask AI's wait |
+| `ac9c299b` | `plans:` FACTS, release 10 "L1 — hub lows" amended |
+| _this_ | `plans:` these review fixes |
+
+**Gates on `ac9c299b`**
+- **tsc:** clean (`o1-tsc-4.log`, 62 s).
+- **Unit tests** (`o1-units-2.log`):
+ - common **2,102/2,102** (main `cb9d02b2`'s, plus O1's 21: 8 registry + 13 SVG);
+ - editor unit **85/85**;
+ - `test:scripts` **175 + 1 skipped**;
+ - mcp **269/269**.
+- **e2e** (`o1-run-2.log`):
+ - `e2e:hub` in full: **32 passed** (31 + 1 new), 1.2 min;
+ - export `site-branding` + `ask-chat` + `ask-workspace`: **36 passed**, 1.7 min (43 s in the
+ queue).
+ - The primary's `export/public` was unchanged: all 380 files' size, mtime and md5
+ (`o1-primary-before-2.txt` vs `-after-2.txt`).
+ - Builds and `e2e:2origin` were not re-run: they are outside this round's gate list, and tsc
+ covers the two changed modules.
+
+**They bite:**
+- **Unit** (`o1-bite2-units.log`, a scratch copy of `HEAD`'s `common/`):
+ - Against O1's first normalizer (`c7b42f23`'s `settingsSchema.ts`), **5 of 13 fail**: all five
+ new tests (YouTube, the badge, root-vs-child colours, the animation tags, the self-closing mask).
+ - Each one-line mutation of the new code fails exactly its own test:
+ - no single-colour gate: YouTube, the badge and root-vs-child;
+ - animation tags not passed over: the animation test;
+ - no self-closing alternative: the mask test;
+ - spellings not folded: the spellings test.
+- **e2e, one-line mutations of the subs rule** (`o1-bite2-e2e.log`, restored by a trap, tree clean
+ after). This answers the review's nit: the first round's whole-source bite never reached the
+ mid-Retry asserts.
+ - **Release 10's `isSuccess || (isError && !isFetching)`:** the live-chat test fails mid-Retry.
+ The Retry vanished (`toBeDisabled`: element not found) because the archive left `ready` while
+ the subs were held.
+ - **O1's first `data || errored` (no `/ask` wait):** the new `/ask` test fails. "Loading
+ transcripts…" is not visible, because `/ask` was ready during its own refetch.
+
+**Rollout note (owed): the social icons.** A settings write re-normalizes every stored social link.
+That means ANY write through `writeSettings`, not only the Settings form's Save, so the first one
+after the :3001 restart rewrites the stored x.com icon. Then rebuild every site, the hub AND the
+homepage: the homepage's footer uses the same `settings.socialLinks`
+(`homepage/app/components/Footer.tsx:20`; no `homepage.json` or `site.json` overrides them).
+Whether `homepage/CHANGELOG.md` gets a bullet is the parent's call (O2 owns the homepage).
+
## Rollout
Nothing is rolled out tonight. The morning runbook lists what is owed: the :3001 editor restart,