Archilyzer · Source

archilyzer

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

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:
Mcommon/components/siteRegistry.ts | 5+++++
Mexport/app/components/Footer.tsx | 7+++++--
Mexport/app/components/Header.tsx | 7+++++--
Aexport/app/components/hub/AddArchive.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/hub/ArchiveShelf.tsx | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/hub/HubHome.tsx | 113+++++++------------------------------------------------------------------------
Mexport/app/globals.css | 22++++++++++++++++++++++
Mexport/e2e-hub/federation.spec.ts | 10+++++++---
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&apos;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();