commit f28c393d592ed10b3a60c63d0613fb9f374edf1d
parent 79ca2b0f4b0d5309ee6b0bb1385ccf9efc6405ad
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 02:29:23 -0400
hub: one order and one colour for the official instances — the homepage's
The hub listed its official cards in hub-sites.json's order (alphabetical)
and the homepage by transcripts, so the same colours landed on different
rows; and an official site with no accent drew a card stripe but no chip dot
or result stripe. useHubSites (export/app/components/hub) is now the one list
every hub surface reads — the shelf's cards, the scope chips, the search's
result stripes, chip dots and channel groups, and /ask: official instances in
hub-summary.json's order, each in its own accent or seriesColor() at its
summary index (officialInstances), then the added archives. It lists the
official ones once the summary has settled, so they never reorder a moment
later; HubStats counts the provider's list. e2e: summary B-then-A over
hub-sites.json A-then-B gives cards and chips B, A, and each archive the same
series colour on its card, chip dot and result edge.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 199 insertions(+), 82 deletions(-)
diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx
@@ -2,7 +2,8 @@
// 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.
+// across every shelved archive. Mirrors HubHome's MultiSiteDataProvider wiring,
+// from the same list (useHubSites): the same order, the same colour per archive.
import { useMemo } from "react";
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
@@ -13,7 +14,7 @@ import {
type FederatedSite,
} 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 { useHubSites } from "../components/hub/useHubSites";
import AskChat from "./AskChat";
// `transcriptDownloads` comes from the server parent's currentSite() (a client
@@ -24,15 +25,15 @@ export default function AskHub({
}: {
transcriptDownloads?: boolean;
}) {
- const { sites } = useRegistry();
+ const { all } = useHubSites();
const federated = useMemo<FederatedSite[]>(
() =>
- sites.map((s) => ({
+ all.map((s) => ({
origin: s.origin,
siteTitle: s.siteTitle,
accent: s.accent,
})),
- [sites],
+ [all],
);
// The whole provider stack, in the order SiteWorkspace mounts it for a single
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,15 @@ 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,
});
- 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
@@ -8,7 +8,10 @@ import { expect, test, type Page, type Route } from "@playwright/test";
// 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 not retried).
+// answer (a 404 is not retried); 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.
const ORIGIN_A = "http://localhost:4598";
const ORIGIN_B = "http://localhost:4599";
@@ -105,12 +108,19 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> {
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,
@@ -119,7 +129,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);
@@ -293,4 +314,54 @@ test.describe("hub federated search — scope, per-archive state, attribution",
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 });
+ });
});