commit bb9e2de80439b6b207fac458f6643c51f30ca73a
parent 3ccdc7280d42a9eb8aa97516838584af35c0a7f4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 2 Jul 2026 09:39:14 -0400
Phase 6b: the archive shelf — designed hub hero + provenance chrome
Replace HubHome's plain shelf with the designed hero (per the plan's "a shelf of
archives, not a KPI wall"):
- ArchiveShelf.tsx: the registry IS the hero. Each federated archive is a spine
bearing its OWN accent as a thick colored edge; chrome stays neutral/brass so
the accents are the only color. Badge vocabulary encodes real state — Member
vs Added (kind), Installable vs Data-only (pwa). Empty shelf invites the first
add instead of showing zeros. The load stagger (globals.css .shelf-spine) is
the only motion and yields to reduced-motion.
- AddArchive.tsx: the "add a volume" flow, written as direction — Validating… →
"Added <title>", or a plain reason it couldn't be read (noValidate so
validateSite is the single gate). Consistent verb: Add → Added.
- HubHome slims to ArchiveShelf + MultiSiteDataProvider → TranscriptSearch.
- siteRegistry: RegisteredSite gains channelCount (from the descriptor) for the
spine's mono count label.
- Gate the site-mode FAMILY chrome off the hub: the header sibling switcher and
the footer related-sites list are build-time family navigation, irrelevant on
a hub whose family is the runtime shelf.
Hub e2e green (4 passing; shelf-spine testid added). Site mode untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 256 insertions(+), 110 deletions(-)
diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts
@@ -51,6 +51,9 @@ export type RegisteredSite = {
kind: "builtin" | "external";
// Contract version the descriptor declared (already validated to match).
contract: number;
+ // How many channels the archive exposes (from its descriptor), when known —
+ // shown on the shelf spine. Absent for built-ins seeded from hub-sites.json.
+ channelCount?: number;
// ms epoch when added; 0 for builtins (loaded at boot, order-stable).
addedAt: number;
};
@@ -143,6 +146,7 @@ function coerceStored(raw: unknown): RegisteredSite | null {
pwa: r.pwa === true,
kind: "external",
contract: typeof r.contract === "number" ? r.contract : 0,
+ ...(typeof r.channelCount === "number" ? { channelCount: r.channelCount } : {}),
addedAt: typeof r.addedAt === "number" ? r.addedAt : 0,
};
}
@@ -303,6 +307,7 @@ export async function validateSite(input: string): Promise<ValidateResult> {
pwa: descriptor.pwa === true,
kind: "external",
contract: descriptor.contract,
+ channelCount: descriptor.channels.length,
addedAt: Date.now(),
};
return { ok: true, site };
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -8,14 +8,17 @@ import {
resolveSocialLinks,
} from "yt-dlp-transcript-common/lib/site";
import { currentSite } from "../lib/site";
+import { instanceMode } from "../lib/mode";
export default function Footer() {
const site = currentSite();
const socialLinks = resolveSocialLinks(site, getSettings());
// The whole pool is present at build time, so siblings (other sites with a
// siteUrl) resolve here; single-site installs get an empty list and the
- // related-sites block is omitted entirely.
- const related = resolveRelatedSites(site, listSites());
+ // related-sites block is omitted entirely. On the hub this build-time family
+ // list is irrelevant (its "family" is the runtime shelf), so it's omitted.
+ const related =
+ instanceMode() === "site" ? resolveRelatedSites(site, listSites()) : [];
return (
<footer className="mt-auto border-t border-border bg-muted/30">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4 text-sm text-muted-foreground">
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -24,12 +24,15 @@ export default function Header() {
// family default (resolveHubUrl). Only a plain site shows the backlink — the
// hub itself never links to itself — and never when the parent is this very
// deployment.
+ const isSite = instanceMode() === "site";
const resolvedHub = resolveHubUrl(site, settings);
const hubUrl =
- instanceMode() === "site" && resolvedHub && resolvedHub !== site.siteUrl
+ isSite && resolvedHub && resolvedHub !== site.siteUrl
? resolvedHub
: undefined;
- const related = resolveRelatedSites(site, listSites());
+ // The sibling switcher is build-time family navigation — it belongs on a
+ // single site, not on the hub (whose "family" is the runtime shelf).
+ const related = isSite ? resolveRelatedSites(site, listSites()) : [];
return (
<header className="sticky top-0 z-20 border-b border-border bg-background/80 backdrop-blur-md">
diff --git a/export/app/components/hub/AddArchive.tsx b/export/app/components/hub/AddArchive.tsx
@@ -0,0 +1,85 @@
+"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.
+
+import { useState } from "react";
+import {
+ useRegistry,
+ validateSite,
+} from "yt-dlp-transcript-common/components/siteRegistry";
+
+type AddState =
+ | { status: "idle" }
+ | { status: "validating" }
+ | { status: "added"; title: string }
+ | { status: "error"; message: string };
+
+export default function AddArchive() {
+ const { addSite } = useRegistry();
+ const [url, setUrl] = useState("");
+ const [state, setState] = useState<AddState>({ status: "idle" });
+
+ async function submit() {
+ if (state.status === "validating" || !url.trim()) return;
+ setState({ status: "validating" });
+ const result = await validateSite(url);
+ if (result.ok) {
+ addSite(result.site);
+ setUrl("");
+ setState({ status: "added", title: result.site.siteTitle });
+ } else {
+ setState({ status: "error", message: result.reason });
+ }
+ }
+
+ const busy = state.status === "validating";
+
+ return (
+ <div className="flex flex-col gap-1.5">
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ void submit();
+ }}
+ noValidate
+ className="flex flex-wrap items-center gap-2"
+ >
+ <input
+ type="url"
+ inputMode="url"
+ placeholder="https://archive.example.com"
+ value={url}
+ onChange={(e) => {
+ setUrl(e.target.value);
+ if (state.status !== "idle" && state.status !== "validating") {
+ setState({ status: "idle" });
+ }
+ }}
+ className="flex-1 min-w-64 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
+ aria-label="Archive URL"
+ />
+ <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"
+ >
+ {busy ? "Validating…" : "Add"}
+ </button>
+ </form>
+ {state.status === "error" && (
+ <p className="text-xs text-destructive" role="alert">
+ {state.message}
+ </p>
+ )}
+ {state.status === "added" && (
+ <p className="text-xs text-success" role="status">
+ Added {state.title}.
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx
@@ -0,0 +1,117 @@
+"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.
+
+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 AddArchive from "./AddArchive";
+
+// Fall back to the family brass when a site declares no accent.
+const FALLBACK_ACCENT = "var(--brand)";
+
+function Spine({
+ site,
+ index,
+ onRemove,
+}: {
+ site: RegisteredSite;
+ index: number;
+ onRemove: (origin: string) => void;
+}) {
+ const accent = site.accent || FALLBACK_ACCENT;
+ const member = site.kind === "builtin";
+ return (
+ <li
+ data-testid="shelf-spine"
+ className="shelf-spine group relative flex min-w-52 max-w-64 flex-col gap-2 rounded-md border border-border bg-card/60 py-3 pl-4 pr-3"
+ 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"
+ 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}`}
+ className="-mr-1 -mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
+ >
+ <X className="size-3.5" aria-hidden="true" />
+ </button>
+ )}
+ </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>
+ </li>
+ );
+}
+
+export default function ArchiveShelf() {
+ const { sites, removeSite } = useRegistry();
+
+ 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
+ </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>
+ </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.
+ </p>
+ </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>
+ )}
+ </section>
+ );
+}
diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx
@@ -1,11 +1,11 @@
"use client";
-// The hub home: a shelf of federated archives above one unified search across
-// all of them. This is the functional Phase-6 shell (add-by-URL + registry list
-// + merged search); the designed archive-shelf hero with per-site accent spines
-// and the badge vocabulary lands in the hub-frontend phase.
+// The hub home: the archive shelf (hero + registry) above one unified search
+// across every shelved archive. The shelf/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.
-import { useMemo, useState } from "react";
+import { useMemo } from "react";
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
@@ -13,23 +13,13 @@ import {
MultiSiteDataProvider,
type FederatedSite,
} from "yt-dlp-transcript-common/components/SearchDataContext";
-import {
- useRegistry,
- validateSite,
-} from "yt-dlp-transcript-common/components/siteRegistry";
-
-type AddState =
- | { status: "idle" }
- | { status: "validating" }
- | { status: "error"; message: string };
+import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
+import ArchiveShelf from "./ArchiveShelf";
export default function HubHome() {
- const { sites, addSite, removeSite } = useRegistry();
- const [url, setUrl] = useState("");
- const [add, setAdd] = useState<AddState>({ status: "idle" });
+ const { sites } = useRegistry();
- // The provider merges the hub's own same-origin pool (origin "") plus every
- // registered origin. Accent rides along for provenance in the UI.
+ // Carry each site's accent through to the merged search for provenance.
const federated = useMemo<FederatedSite[]>(
() =>
sites.map((s) => ({
@@ -40,93 +30,10 @@ export default function HubHome() {
[sites],
);
- async function onAdd() {
- if (add.status === "validating") return;
- setAdd({ status: "validating" });
- const result = await validateSite(url);
- if (result.ok) {
- addSite(result.site);
- setUrl("");
- setAdd({ status: "idle" });
- } else {
- setAdd({ status: "error", message: result.reason });
- }
- }
-
return (
<PlayerProvider>
<div className="flex flex-col gap-6">
- <section className="flex flex-col gap-3">
- <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">
- Your archives
- </h1>
- <form
- onSubmit={(e) => {
- e.preventDefault();
- void onAdd();
- }}
- noValidate
- className="flex flex-wrap items-center gap-2"
- >
- <input
- type="url"
- inputMode="url"
- placeholder="Add an archive — https://archive.example.com"
- value={url}
- onChange={(e) => {
- setUrl(e.target.value);
- if (add.status === "error") setAdd({ status: "idle" });
- }}
- className="flex-1 min-w-64 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-2 focus-visible:outline-brand"
- aria-label="Archive URL"
- />
- <button
- type="submit"
- disabled={add.status === "validating" || !url.trim()}
- className="rounded-md bg-brand px-4 py-2 text-sm font-medium text-brand-ink disabled:opacity-50"
- >
- {add.status === "validating" ? "Validating…" : "Add"}
- </button>
- </form>
- {add.status === "error" && (
- <p className="text-sm text-destructive" role="alert">
- {add.message}
- </p>
- )}
- {sites.length === 0 ? (
- <p className="text-sm text-muted-foreground">
- No archives yet. Add one above to start searching across it.
- </p>
- ) : (
- <ul className="flex flex-wrap gap-2 list-none">
- {sites.map((s) => (
- <li
- key={s.origin || s.siteId}
- className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
- style={
- s.accent
- ? { borderLeft: `3px solid ${s.accent}` }
- : undefined
- }
- >
- <span className="font-medium text-foreground">
- {s.siteTitle}
- </span>
- {s.kind === "external" && (
- <button
- type="button"
- onClick={() => removeSite(s.origin)}
- className="text-muted-foreground hover:text-destructive transition-colors"
- aria-label={`Remove ${s.siteTitle}`}
- >
- ×
- </button>
- )}
- </li>
- ))}
- </ul>
- )}
- </section>
+ <ArchiveShelf />
<MultiSiteDataProvider sites={federated}>
<TranscriptSearch />
</MultiSiteDataProvider>
diff --git a/export/app/globals.css b/export/app/globals.css
@@ -12,3 +12,25 @@ body {
color: var(--foreground);
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
}
+
+/* Archive shelf: a restrained load stagger — the spines rise onto the shelf in
+ sequence (per-spine delay set inline). Motion is the only animation on the
+ hub, and it yields entirely to a reduced-motion preference. */
+@keyframes shelf-rise {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+.shelf-spine {
+ animation: shelf-rise 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+@media (prefers-reduced-motion: reduce) {
+ .shelf-spine {
+ animation: none;
+ }
+}
diff --git a/export/e2e-hub/federation.spec.ts b/export/e2e-hub/federation.spec.ts
@@ -128,8 +128,10 @@ test.describe("hub federation — cross-origin browse + search", () => {
test("adds an archive by URL and shows it on the shelf", async ({ page }) => {
await mockOriginB(page);
await addArchive(page, ORIGIN_B);
- // Chip carries the descriptor's siteTitle → the site was read cross-origin.
- await expect(page.getByText("Origin B")).toBeVisible();
+ // Spine carries the descriptor's siteTitle → the site was read cross-origin.
+ await expect(
+ page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }),
+ ).toBeVisible();
});
test("searches across the added origin and returns origin-qualified hits", async ({
@@ -137,7 +139,9 @@ test.describe("hub federation — cross-origin browse + search", () => {
}) => {
await mockOriginB(page);
await addArchive(page, ORIGIN_B);
- await expect(page.getByText("Origin B")).toBeVisible();
+ await expect(
+ page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }),
+ ).toBeVisible();
// Wait for the merged search UI to hydrate, then metadata-search "bravo".
await page.getByTestId("query-builder").waitFor();