commit f83fcb88aca738ebe9c75dfe98ba6a99d686b297
parent 695cec67147f4d3b004dd0dcfc794dd0843cc770
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 18:48:01 -0400
export: the hub in the homepage's register — a plain H1, "Official instances" with the homepage's figures per card (hub-summary.json), "Archives you added", the live line reworded; specs follow the new H1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
12 files changed, 376 insertions(+), 102 deletions(-)
diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts
@@ -224,7 +224,7 @@ export type ValidateResult =
// add-flow states.
const BAD_URL = "Enter a full site URL, like https://archive.example.com.";
const MIXED = "That site is served over http, so a secure hub can't read it.";
-const ALREADY = "That archive is already on your shelf.";
+const ALREADY = "That archive is already on this hub.";
const UNREADABLE =
"Couldn't read that site — it may not be an Archilyzer archive, or it isn't sharing its data.";
const WRONG_CONTRACT =
diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx
@@ -37,7 +37,7 @@ export default function AskPage() {
<Link href="/use-with-ai" className="text-brand hover:underline">
Use with AI
</Link>{" "}
- for other ways to use this archive.
+ for other ways to use {isHub ? "every archive on this hub" : "this archive"}.
</p>
</header>
diff --git a/export/app/components/hub/AddArchive.tsx b/export/app/components/hub/AddArchive.tsx
@@ -1,10 +1,11 @@
"use client";
-// The "add a volume to the shelf" control: paste an archive URL, it validates
-// against the federation contract, and on success the archive slides onto the
-// shelf. Written as direction — validating → success, or a plain reason it
-// couldn't be read. Self-contained: owns its input + status and commits to the
-// shared registry.
+// The add-an-archive control: paste an archive URL, it validates against the
+// federation contract, and on success the archive joins "Archives you added".
+// Written as direction — validating → success, or a plain reason it couldn't
+// be read. Self-contained: owns its input + status and commits to the shared
+// registry. The button is the family's CTA — ink on ground, not a coloured
+// fill (tokens.css, the archilyzer block).
import { useState } from "react";
import {
@@ -65,7 +66,7 @@ export default function AddArchive() {
<button
type="submit"
disabled={busy || !url.trim()}
- className="rounded-md bg-brand px-4 py-2 text-sm font-medium text-brand-ink transition-colors hover:bg-brand-strong disabled:opacity-50"
+ className="rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors hover:bg-brand hover:text-brand-ink disabled:opacity-50 disabled:hover:bg-foreground disabled:hover:text-background"
>
{busy ? "Validating…" : "Add"}
</button>
diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx
@@ -1,11 +1,22 @@
"use client";
-// The hub's hero: a shelf of archives, not a KPI wall. Each federated archive is
-// a first-class, identity-bearing spine in its OWN accent — the registry itself
-// is the thing you curate, so it leads. Adding a site slides a new volume onto
-// the shelf. Chrome stays neutral/brass so the per-site accents are the only
-// color; the load stagger (globals.css .shelf-spine) is the only motion and
-// respects reduced-motion.
+// The hub's front: what it is, the official instances it searches, the
+// archives a visitor added, and the form to add another — in the homepage's
+// register (homepage/app/page.tsx): short declarative sentences, no eyebrow
+// over a band, no subtitle under a heading, and each heading after the H1
+// names what is under it.
+//
+// Two kinds of number, deliberately kept apart. An official card shows the
+// homepage's own figures for that archive, composed at hub build time
+// (/hub-summary.json, useHubSummary) — the same numbers the homepage prints,
+// omitted one by one when the file lacks them. An added archive has no such
+// 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 (tokens.css, the archilyzer family:
+// the tool has no colour, the archives do). The homepage stripes its cards by
+// chart series instead, because there each card is also a chart legend; the
+// hub has no chart, so it keeps the archive's identity.
import { X } from "lucide-react";
import { Badge } from "yt-dlp-transcript-common/components/ui/badge";
@@ -13,107 +24,195 @@ import {
useRegistry,
type RegisteredSite,
} from "yt-dlp-transcript-common/components/siteRegistry";
+import {
+ hubSummarySiteFor,
+ type HubSummarySite,
+} from "yt-dlp-transcript-common/lib/hubSummary";
import AddArchive from "./AddArchive";
+import { useHubSummary } from "./useHubSummary";
-// Fall back to the family brass when a site declares no accent.
+// Fall back to the family's signal colour when a site declares no accent.
const FALLBACK_ACCENT = "var(--brand)";
-function Spine({
+function Figure({ value, unit }: { value: number | undefined; unit: string }) {
+ if (value === undefined) return null;
+ return (
+ <div className="flex min-w-0 flex-col-reverse justify-end gap-1">
+ <dt className="text-xs text-faint">{unit}</dt>
+ <dd className="text-[0.9375rem] tabular-nums text-foreground">
+ {value.toLocaleString()}
+ </dd>
+ </div>
+ );
+}
+
+function ArchiveCard({
site,
+ figures,
index,
onRemove,
}: {
site: RegisteredSite;
+ // Official: the build-time figures (null until/unless the summary loads).
+ // Added: undefined — the card shows the descriptor's channel count only.
+ figures?: HubSummarySite | null;
index: number;
- onRemove: (origin: string) => void;
+ onRemove?: (origin: string) => void;
}) {
- const accent = site.accent || FALLBACK_ACCENT;
- const member = site.kind === "builtin";
+ const accent = site.accent || figures?.accent || FALLBACK_ACCENT;
+ const href = site.siteUrl || site.origin;
+ const description = figures?.siteDescription;
+ const channels =
+ figures === undefined ? site.channelCount : figures?.channels;
+ const anyFigure =
+ channels !== undefined ||
+ figures?.recordings !== undefined ||
+ figures?.transcripts !== undefined ||
+ figures?.hoursArchived !== undefined;
return (
<li
data-testid="shelf-spine"
- className="shelf-spine group relative flex w-full min-w-0 max-w-full flex-col gap-2 rounded-md border border-border bg-card/60 py-3 pl-4 pr-3 sm:w-auto sm:min-w-52 sm:max-w-64"
+ className="shelf-spine group relative flex min-w-0 flex-col gap-4 border-r border-b border-border bg-surface p-5 pl-6"
style={{ animationDelay: `${Math.min(index, 8) * 45}ms` }}
>
- {/* The spine: a thick accent edge in the site's own color. */}
<span
aria-hidden="true"
- className="absolute inset-y-1.5 left-0 w-1 rounded-full"
+ className="absolute inset-y-0 left-0 w-[3px]"
style={{ background: accent }}
/>
- <div className="flex items-start justify-between gap-2">
- <span className="font-display text-base font-semibold leading-tight tracking-tight text-foreground">
- {site.siteTitle}
- </span>
- {site.kind === "external" && (
- <button
- type="button"
- onClick={() => onRemove(site.origin)}
- aria-label={`Remove ${site.siteTitle}`}
- // Reveal-on-hover is not an affordance on a touch screen: a device
- // with no hover shows it always. `hover:` never gates a function.
- className="-mr-1 -mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
- >
- <X className="size-3.5" aria-hidden="true" />
- </button>
+ <div className="flex flex-col gap-1.5">
+ <div className="flex items-start justify-between gap-2">
+ <h3 className="font-display text-lg font-semibold text-foreground">
+ <a
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-border-strong"
+ >
+ {site.siteTitle}
+ <span aria-hidden="true" className="ml-1.5 text-sm text-faint">
+ ↗
+ </span>
+ </a>
+ </h3>
+ {onRemove && (
+ <button
+ type="button"
+ onClick={() => onRemove(site.origin)}
+ aria-label={`Remove ${site.siteTitle}`}
+ // Reveal-on-hover is not an affordance on a touch screen: a device
+ // with no hover shows it always. `hover:` never gates a function.
+ className="-mr-1 mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
+ >
+ <X className="size-3.5" aria-hidden="true" />
+ </button>
+ )}
+ </div>
+ {description && (
+ <p className="text-sm leading-relaxed text-muted-foreground">
+ {description}
+ </p>
)}
</div>
- {typeof site.channelCount === "number" && (
- <span className="font-mono text-[0.7rem] uppercase tracking-[0.12em] text-muted-foreground">
- {site.channelCount} channel{site.channelCount === 1 ? "" : "s"}
- </span>
- )}
- <div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
- <Badge variant={member ? "secondary" : "outline"}>
- {member ? "Member" : "Added"}
- </Badge>
- <Badge variant={site.pwa ? "info" : "ghost"}>
- {site.pwa ? "Installable" : "Data-only"}
- </Badge>
+ <div className="mt-auto flex flex-col gap-3">
+ {anyFigure && (
+ <dl className="grid grid-cols-4 gap-3">
+ <Figure value={channels} unit="channels" />
+ <Figure value={figures?.recordings} unit="recordings" />
+ <Figure value={figures?.transcripts} unit="transcripts" />
+ <Figure value={figures?.hoursArchived} unit="hours" />
+ </dl>
+ )}
+ <div>
+ <Badge variant={site.pwa ? "info" : "ghost"}>
+ {site.pwa ? "Installable" : "Data-only"}
+ </Badge>
+ </div>
</div>
</li>
);
}
+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 hours = summary?.official?.hoursArchived;
return (
- <section className="flex flex-col gap-4">
- <div className="flex flex-col gap-1">
- <span className="font-mono text-xs uppercase tracking-[0.16em] text-muted-foreground">
- The shelf
- </span>
- <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">
- Your archives
+ <section className="flex flex-col gap-10 pb-4">
+ <div className="flex flex-col gap-4">
+ <h1 className="font-display text-[1.75rem] font-semibold leading-[1.12] tracking-[-0.01em] text-foreground sm:text-[2.25rem]">
+ {hours ? (
+ <>
+ <span className="font-medium tabular-nums">
+ {hours.toLocaleString()}
+ </span>{" "}
+ hours of speech from every official archive, searchable at once.
+ </>
+ ) : (
+ <>Every official archive, searchable at once.</>
+ )}
</h1>
- <p className="max-w-prose text-sm text-muted-foreground">
- Every archive you add is searchable together below. Paste a site's
- URL to shelve it.
+ <p className="max-w-2xl text-[1.0625rem] leading-relaxed text-muted-foreground">
+ One search runs across all of them, in your browser. Any Archilyzer
+ archive can join it: add its address below.
</p>
</div>
- <AddArchive />
-
- {sites.length === 0 ? (
- <div className="rounded-lg border border-dashed border-border/70 px-5 py-8 text-center">
- <p className="text-sm text-muted-foreground">
- The shelf is empty. Add an archive above to start searching across
- it.
+ {official.length > 0 ? (
+ <div>
+ <h2 className="font-display text-2xl font-semibold text-foreground">
+ Official instances
+ </h2>
+ <p className="mt-2 mb-6 max-w-xl leading-relaxed text-muted-foreground">
+ The archives I run. Anyone can run their own.
</p>
+ <ul className={GRID}>
+ {official.map((site, i) => (
+ <ArchiveCard
+ key={site.origin || site.siteId}
+ site={site}
+ figures={hubSummarySiteFor(summary, site)}
+ index={i}
+ />
+ ))}
+ </ul>
</div>
) : (
- <ul className="flex list-none flex-wrap gap-3 border-b-2 border-border/70 pb-4">
- {sites.map((site, i) => (
- <Spine
- key={site.origin || site.siteId}
- site={site}
- index={i}
- onRemove={removeSite}
- />
- ))}
- </ul>
+ <p className="max-w-xl leading-relaxed text-muted-foreground">
+ This hub has no official instances. Add an archive to search it.
+ </p>
)}
+
+ <div>
+ {added.length > 0 && (
+ <>
+ <h2 className="font-display text-2xl font-semibold text-foreground">
+ Archives you added
+ </h2>
+ <ul className={`${GRID} mt-6 mb-6`}>
+ {added.map((site, i) => (
+ <ArchiveCard
+ key={site.origin || site.siteId}
+ site={site}
+ index={i}
+ onRemove={removeSite}
+ />
+ ))}
+ </ul>
+ </>
+ )}
+ <p className="mb-3 max-w-xl leading-relaxed text-muted-foreground">
+ Add an archive by its address. It stays in this browser until you
+ remove it.
+ </p>
+ <AddArchive />
+ </div>
</section>
);
}
diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx
@@ -1,7 +1,8 @@
"use client";
-// The hub home: the archive shelf (hero + registry) above one unified search
-// across every shelved archive. The shelf/add-flow live in ArchiveShelf; this
+// The hub home: the archives (official instances, the visitor's added ones,
+// the add form) above one unified search across all of them. The cards and
+// add-flow live in ArchiveShelf; this
// component wires the registry into the multi-origin data source so the shared
// TranscriptSearch renders one merged, origin-qualified view.
diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx
@@ -1,15 +1,22 @@
"use client";
-// A demoted stats line, not a KPI wall: the shelf is the hero, so the totals sit
-// quietly beneath it as a single mono strip. Numbers are computed from the LIVE
-// merged data across the shelved archives (not a build-time summary), so they
-// always reflect exactly what's federated right now.
+// One line under the shelf: what this browser is searching right now. These
+// numbers are the LIVE federation — computed from the merged data of every
+// archive on the hub, the visitor's added ones included — so they are not the
+// cards' numbers and are not meant to match them. The official cards show the
+// build-time figures the homepage prints (hub-summary.json); this line shows
+// what actually loaded.
import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext";
import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
-function fmt(n: number): string {
- return n.toLocaleString();
+function part(n: number, one: string, many: string) {
+ return (
+ <>
+ <span className="tabular-nums text-foreground">{n.toLocaleString()}</span>{" "}
+ {n === 1 ? one : many}
+ </>
+ );
}
export default function HubStats() {
@@ -19,20 +26,13 @@ export default function HubStats() {
if (sites.length === 0) return null;
- const parts: [number, string][] = [
- [sites.length, sites.length === 1 ? "archive" : "archives"],
- [channels.length, channels.length === 1 ? "channel" : "channels"],
- [transcripts, transcripts === 1 ? "transcript" : "transcripts"],
- ];
-
return (
- <p className="font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground">
- {parts.map(([n, label], i) => (
- <span key={label}>
- {i > 0 && <span className="mx-2 text-muted-foreground/40">·</span>}
- <span className="text-foreground tabular-nums">{fmt(n)}</span> {label}
- </span>
- ))}
+ <p className="text-sm text-muted-foreground">
+ Searching {part(sites.length, "archive", "archives")}
+ <span className="mx-2 text-muted-foreground/40">·</span>
+ {part(channels.length, "channel", "channels")}
+ <span className="mx-2 text-muted-foreground/40">·</span>
+ {part(transcripts, "transcript", "transcripts")} right now.
</p>
);
}
diff --git a/export/app/components/hub/useHubSummary.ts b/export/app/components/hub/useHubSummary.ts
@@ -0,0 +1,34 @@
+"use client";
+
+// The official instances' numbers, composed at hub build time
+// (common/bin/compose-hub.ts -> /hub-summary.json) from the same summary the
+// 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.
+
+import { useQuery } from "@tanstack/react-query";
+import {
+ HUB_SUMMARY_FILE,
+ parseHubSummary,
+ type HubSummary,
+} from "yt-dlp-transcript-common/lib/hubSummary";
+
+async function fetchHubSummary(): Promise<HubSummary | null> {
+ try {
+ const res = await fetch(`/${HUB_SUMMARY_FILE}`);
+ if (!res.ok) return null;
+ return parseHubSummary(await res.json());
+ } catch {
+ return null;
+ }
+}
+
+export function useHubSummary(): HubSummary | null {
+ const { data } = useQuery({
+ queryKey: ["hub-summary"],
+ queryFn: fetchHubSummary,
+ staleTime: Infinity,
+ retry: false,
+ });
+ return data ?? null;
+}
diff --git a/export/e2e-2origin/federation.spec.ts b/export/e2e-2origin/federation.spec.ts
@@ -19,7 +19,7 @@ test.describe("real two-origin federation (CORS + service worker)", () => {
}) => {
await page.goto("/");
await expect(
- page.getByRole("heading", { name: "Your archives" }),
+ page.getByRole("heading", { level: 1, name: /searchable at once\./ }),
).toBeVisible();
await addArchive(page, ORIGIN_B);
// The spine's title came from Origin B's /site.json, fetched cross-origin —
diff --git a/export/e2e-hub/ask-grounding.spec.ts b/export/e2e-hub/ask-grounding.spec.ts
@@ -124,7 +124,7 @@ test("hub: a search run on / still grounds the chat after the per-card Ask", asy
await stubBuiltins(page);
await mockOriginB(page);
await page.goto("/");
- await expect(page.getByRole("heading", { name: "Your archives" })).toBeVisible();
+ await expect(page.getByRole("heading", { level: 1, name: /searchable at once\./ })).toBeVisible();
await addArchive(page, ORIGIN_B);
await expect(
page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }),
diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts
@@ -10,7 +10,7 @@ import { expect, test, type Page } from "@playwright/test";
// This spec is the cheap guard on the rendered page; the build itself is the
// 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 an empty shelf,
+// 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) =>
diff --git a/export/e2e-hub/federation.spec.ts b/export/e2e-hub/federation.spec.ts
@@ -122,7 +122,7 @@ test.describe("hub federation — cross-origin browse + search", () => {
await stubBuiltins(page);
await page.goto("/");
await expect(
- page.getByRole("heading", { name: "Your archives" }),
+ page.getByRole("heading", { level: 1, name: /searchable at once\./ }),
).toBeVisible();
});
@@ -139,13 +139,20 @@ test.describe("hub federation — cross-origin browse + search", () => {
await expect(builtWith).toHaveAttribute("href", PROJECT_URL);
});
- test("adds an archive by URL and shows it on the shelf", async ({ page }) => {
+ test("adds an archive by URL and shows it under Archives you added", async ({ page }) => {
await mockOriginB(page);
await addArchive(page, ORIGIN_B);
// Spine carries the descriptor's siteTitle → the site was read cross-origin.
await expect(
page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }),
).toBeVisible();
+ // An added archive gets its own section, and its own Remove control.
+ await expect(
+ page.getByRole("heading", { level: 2, name: "Archives you added" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Remove Origin B" }),
+ ).toBeAttached();
});
test("searches across the added origin and returns origin-qualified hits", async ({
diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts
@@ -0,0 +1,132 @@
+import { expect, test, type Page, type Route } from "@playwright/test";
+
+// The hub's "Official instances": one card per built-in member
+// (hub-sites.json), carrying the homepage's own figures for it when the hub was
+// composed with them (hub-summary.json, written by compose-hub from the same
+// buildHomepageSummary call the homepage makes). The summary is OPTIONAL — an
+// older build or a hub composed with no index ships none — and then the card
+// renders with no figures, never an error.
+
+// A member origin that is never really served: its data is 404 (with CORS so
+// the browser reports a clean 404, not a blocked read). The cards come from
+// hub-sites.json + hub-summary.json alone, so nothing here needs its data.
+const MEMBER = "http://localhost:4598";
+
+async function fulfillJson(route: Route, body: unknown) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify(body),
+ });
+}
+
+async function stubMember(page: Page) {
+ await page.route("**/hub-sites.json", (r) =>
+ fulfillJson(r, [
+ {
+ siteId: "member",
+ siteTitle: "Member Archive",
+ siteUrl: MEMBER,
+ pwa: false,
+ contract: 1,
+ },
+ ]),
+ );
+ await page.route(`${MEMBER}/**`, (r) =>
+ r.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" }, body: "" }),
+ );
+}
+
+const SUMMARY = {
+ version: 1,
+ generatedAt: "2026-09-25T00:00:00.000Z",
+ official: {
+ sites: 1,
+ channels: 12,
+ recordings: 3456,
+ transcripts: 2345,
+ hoursArchived: 7890,
+ gone: 3,
+ },
+ sites: [
+ {
+ siteId: "member",
+ siteTitle: "Member Archive",
+ siteDescription: "Every word the member said.",
+ siteUrl: MEMBER,
+ channels: 12,
+ recordings: 3456,
+ transcripts: 2345,
+ hoursArchived: 7890,
+ gone: 3,
+ },
+ ],
+};
+
+test.describe("hub official instances", () => {
+ test("with hub-summary.json, an official card shows its four figures", async ({
+ page,
+ }) => {
+ await stubMember(page);
+ await page.route("**/hub-summary.json", (r) => fulfillJson(r, SUMMARY));
+ await page.goto("/");
+
+ await expect(
+ page.getByRole("heading", {
+ level: 1,
+ name: "7,890 hours of speech from every official archive, searchable at once.",
+ }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { level: 2, name: "Official instances" }),
+ ).toBeVisible();
+
+ const card = page
+ .getByTestId("shelf-spine")
+ .filter({ hasText: "Member Archive" });
+ await expect(card).toBeVisible();
+ await expect(
+ card.getByRole("link", { name: /Member Archive/ }),
+ ).toHaveAttribute("target", "_blank");
+ await expect(card).toContainText("Every word the member said.");
+ for (const [value, unit] of [
+ ["12", "channels"],
+ ["3,456", "recordings"],
+ ["2,345", "transcripts"],
+ ["7,890", "hours"],
+ ]) {
+ await expect(card.locator("dd", { hasText: value })).toBeVisible();
+ await expect(card.locator("dt", { hasText: unit })).toBeVisible();
+ }
+ // A built-in member is not the visitor's to remove, and there is no
+ // "Archives you added" section until they add one.
+ await expect(card.getByRole("button", { name: /^Remove/ })).toHaveCount(0);
+ await expect(
+ page.getByRole("heading", { name: "Archives you added" }),
+ ).toHaveCount(0);
+ });
+
+ test("without hub-summary.json, the card renders with no figures", async ({
+ page,
+ }) => {
+ await stubMember(page);
+ await page.route("**/hub-summary.json", (r) =>
+ r.fulfill({ status: 404, body: "" }),
+ );
+ await page.goto("/");
+
+ await expect(
+ page.getByRole("heading", {
+ level: 1,
+ name: "Every official archive, searchable at once.",
+ }),
+ ).toBeVisible();
+ const card = page
+ .getByTestId("shelf-spine")
+ .filter({ hasText: "Member Archive" });
+ await expect(card).toBeVisible();
+ await expect(card.getByText("Data-only")).toBeVisible();
+ await expect(card.locator("dl")).toHaveCount(0);
+ });
+});