Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mexport/app/ask/AskHub.tsx | 11++++++-----
Mexport/app/components/hub/ArchiveShelf.tsx | 66++++++++++++++++++++++++++++--------------------------------------
Mexport/app/components/hub/HubHome.tsx | 31+++++++------------------------
Mexport/app/components/hub/HubStats.tsx | 13++++++-------
Aexport/app/components/hub/useHubSites.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/hub/useHubSummary.ts | 13++++++++++---
Mexport/e2e-hub/federated-search.spec.ts | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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 }); + }); });