commit 189bb8a3ed05cbfb446d29a0ac3a6d9e146d4757
parent 7f1b762f28b77c2a6cdb2738d1c06e417a80c559
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 16:18:11 -0400
homepage: / rethought — claim, chart, family numbers, official instances, one paragraph of what it does
Drops the recent-acquisitions rail (component kept), How it works, What this
isn't and the eyebrows. The stats strip and the cards read the summary's v5
official / per-site numbers and hide without them; cards stripe with
seriesColor(i) to match the chart.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 209 insertions(+), 254 deletions(-)
diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx
@@ -1,70 +1,69 @@
import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageSummary";
import { seriesColor } from "yt-dlp-transcript-common/lib/homepageChart";
-// THE ONLY POLYCHROME MOMENT ON THE PAGE.
+// The official instances: one card per public archive, its title linking out,
+// its own numbers underneath. The stripe is seriesColor(i), the same index the
+// growth chart colours that instance's layer with, so a card is its own legend.
//
-// The chrome of this family spends no colour at all. Here it does, and the
-// colour carries the argument: the tool is achromatic, the archives are not. A
-// visitor should be able to see at a glance that these are distinct
-// publications, not one product's sections.
+// A site's `accent` is deliberately NOT used here any more: the chart cannot
+// wear a per-site brand hex (it would break the validated order and dark mode),
+// and a card whose stripe disagrees with its layer is worse than no stripe.
//
-// WHY NOT SiteGrid. SiteGrid is a `"use client"` component whose props ARE chart
-// state — metric, breakdown, a hidden-set and a toggle callback — because on
-// /stats/ its cards double as the chart legend. There is no chart here, so
-// reusing it would mean inventing chart state and shipping a client bundle to
-// render a static list. This is the same data, rendered flat and server-side.
-//
-// COLOUR SOURCE, in order: the site's own `accent` from its config, when it has
-// one. Otherwise the colour that site already wears in the dashboard's charts —
-// which is a legend assignment, deterministic per site, and not a brand claim
-// being invented on the site's behalf.
+// Numbers are v5 fields; a pre-v5 summary lacks them and the card shows only
+// the transcript count it always had.
+
+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-[var(--faint)]">{unit}</dt>
+ <dd className="tabular text-[0.9375rem] text-[var(--foreground)]">
+ {value.toLocaleString()}
+ </dd>
+ </div>
+ );
+}
+
export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) {
if (sites.length === 0) return null;
return (
- // Hairline geometry without phantom cells: each card draws its own right
- // and bottom rule and the list draws the top and left, so a row that
- // doesn't divide evenly leaves empty SPACE rather than an empty filled
- // cell. (A `gap-px` grid over a coloured background paints the gaps of the
- // missing cells too, which reads as a broken card.)
<ul className="grid grid-cols-1 border-t border-l border-[var(--border)] sm:grid-cols-2 lg:grid-cols-3 list-none">
- {sites.map((site, i) => {
- const color = site.accent ?? seriesColor(i);
- return (
- <li
- key={site.siteId}
- className="border-r border-b border-[var(--border)] bg-[var(--surface)]"
- >
- <a
- href={site.siteUrl}
- target="_blank"
- rel="noopener noreferrer"
- className="group flex h-full flex-col gap-3 p-5 transition-colors hover:bg-[var(--panel-2)]"
- >
- <div className="flex items-center gap-3">
- <span
- aria-hidden="true"
- className="h-5 w-[3px] shrink-0"
- style={{ backgroundColor: color }}
- />
- <h3 className="font-display text-base font-semibold text-[var(--foreground)]">
- {site.siteTitle}
- </h3>
- </div>
- {site.siteDescription && (
- <p className="line-clamp-2 text-sm leading-relaxed text-[var(--muted-foreground)]">
- {site.siteDescription}
- </p>
- )}
- <div className="mt-auto flex items-baseline gap-2 pt-2">
- <span className="tabular text-lg text-[var(--foreground)]">
- {site.transcribed.total.toLocaleString()}
- </span>
- <span className="label-machine">transcripts</span>
- </div>
- </a>
- </li>
- );
- })}
+ {sites.map((site, i) => (
+ <li
+ key={site.siteId}
+ className="relative flex flex-col gap-4 border-r border-b border-[var(--border)] bg-[var(--surface)] p-5 pl-6"
+ >
+ <span
+ aria-hidden="true"
+ className="absolute left-0 top-0 h-full w-[3px]"
+ style={{ backgroundColor: seriesColor(i) }}
+ />
+ <div className="flex flex-col gap-1.5">
+ <h3 className="font-display text-lg font-semibold text-[var(--foreground)]">
+ <a
+ href={site.siteUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-[var(--border-strong)]"
+ >
+ {site.siteTitle}
+ <span aria-hidden="true" className="ml-1.5 text-sm text-[var(--faint)]">↗</span>
+ </a>
+ </h3>
+ {site.siteDescription && (
+ <p className="text-sm leading-relaxed text-[var(--muted-foreground)]">
+ {site.siteDescription}
+ </p>
+ )}
+ </div>
+ <dl className="mt-auto grid grid-cols-4 gap-3">
+ <Figure value={site.channels} unit="channels" />
+ <Figure value={site.recordings} unit="recordings" />
+ <Figure value={site.transcribed.total} unit="transcripts" />
+ <Figure value={site.hoursArchived} unit="hours" />
+ </dl>
+ </li>
+ ))}
</ul>
);
}
diff --git a/homepage/app/components/FamilyStats.tsx b/homepage/app/components/FamilyStats.tsx
@@ -0,0 +1,69 @@
+import type { HomepageOfficialTotals } from "yt-dlp-transcript-common/lib/homepageSummary";
+
+// The family's numbers as one readout strip: hairline cells, a figure and a
+// plain label each. Everything is the sum over the official instances (the
+// summary's `official`), which is what the caption claims.
+//
+// The "gone" cell is the one that carries the project's argument, so it is the
+// one that gets the width: a full row on a phone, two cells on a wide screen.
+
+export function FamilyStats({
+ official,
+ generatedAt,
+}: {
+ official: HomepageOfficialTotals;
+ generatedAt: string | null;
+}) {
+ const cells = [
+ { value: official.hoursArchived, label: "Hours of speech" },
+ { value: official.transcripts, label: "Transcripts" },
+ { value: official.recordings, label: "Recordings" },
+ { value: official.channels, label: "Channels" },
+ { value: official.sites, label: "Official instances" },
+ ];
+ const built = generatedAt ? new Date(generatedAt) : null;
+ const builtOk = built && !Number.isNaN(built.getTime());
+ return (
+ <div className="flex flex-col gap-3">
+ <dl className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 border-t border-l border-[var(--border)]">
+ {cells.map((c, i) => (
+ <div
+ key={c.label}
+ // Five cells in a two-column phone grid: the fifth takes the row.
+ className={`flex flex-col-reverse justify-end gap-2 border-r border-b border-[var(--border)] px-4 py-5 ${
+ i === cells.length - 1 ? "col-span-2 sm:col-span-1" : ""
+ }`}
+ >
+ <dt className="text-sm text-[var(--muted-foreground)]">{c.label}</dt>
+ <dd className="tabular text-[1.5rem] sm:text-[1.75rem] font-medium leading-none tracking-[-0.02em] text-[var(--foreground)]">
+ {c.value.toLocaleString()}
+ </dd>
+ </div>
+ ))}
+ {official.gone > 0 && (
+ <div className="col-span-2 flex flex-col-reverse justify-end gap-2 border-r border-b border-[var(--border)] px-4 py-5 sm:col-span-1 lg:col-span-2">
+ <dt className="text-sm text-[var(--muted-foreground)]">
+ Recordings gone at the source, still here
+ </dt>
+ <dd className="tabular text-[1.5rem] sm:text-[1.75rem] font-medium leading-none tracking-[-0.02em] text-[var(--state-gone)]">
+ {official.gone.toLocaleString()}
+ </dd>
+ </div>
+ )}
+ </dl>
+ {builtOk && (
+ <p className="text-sm text-[var(--faint)]">
+ Across the official instances · index built{" "}
+ <time dateTime={built.toISOString().slice(0, 10)}>
+ {built.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ timeZone: "UTC",
+ })}
+ </time>
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx
@@ -1,48 +1,29 @@
import Link from "next/link";
-import { RecentAdditions } from "./components/RecentAdditions";
-import { KpiHeader } from "./components/KpiHeader";
+import { ArchiveGrowthChart } from "./components/ArchiveGrowthChart";
+import { FamilyStats } from "./components/FamilyStats";
import { ArchiveCards } from "./components/ArchiveCards";
import { loadSummary } from "./lib/summary";
import { currentHomepage } from "./lib/homepage";
-// The project's front page. Its ONE job: make a visitor understand what
-// Archilyzer is and download it.
+// The project's front page. It says what Archilyzer is, shows what it has
+// built, and offers the download.
//
-// Every number on this page is computed at build time from the operator's own
-// corpus and gated on that corpus existing — see loadSummary(). There are no
-// hardcoded figures, because they move: the public-archive count went from three
-// to four during this page's own construction. A build without data drops the
-// numeric bands entirely rather than printing zeroes.
+// Every number is computed at build time from the operator's corpus (the
+// summary's `official` block and `monthly` series) and hidden when absent — see
+// loadSummary(). A build with no data renders the words and the links only.
+//
+// Copy rules, kept deliberately: short declarative sentences, no eyebrow over
+// every band, no subtitles under the headings. The page has two headings after
+// the H1 and both name what is under them.
const CONTAINER = "w-full max-w-6xl mx-auto px-5 sm:px-6";
-function Band({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
- <section className={`border-t border-[var(--border)] ${className ?? ""}`}>
- <div className={`${CONTAINER} py-14 sm:py-20`}>{children}</div>
- </section>
- );
-}
-
-function Eyebrow({ children }: { children: React.ReactNode }) {
- return <h2 className="label-machine mb-8">{children}</h2>;
-}
-
export default function Home() {
const summary = loadSummary();
- const hours = summary?.totals.hoursArchived ?? null;
- const gone = summary?.availability?.byState.deleted ?? 0;
- const counted = summary?.availability?.counted ?? 0;
- // The operator's hub — one search over every published archive — is a
- // separate app on its own Pages project; homepage.json's siteUrl is its
- // address (the same field the hub's own build reads). No URL, no link: a
- // source-only build has no hub to point at.
+ const official = summary?.official ?? null;
+ const hours = official?.hoursArchived ?? summary?.totals.hoursArchived ?? null;
+ const sites = summary?.sites ?? [];
+ const months = summary?.monthly ?? [];
// Operator decision 2026-09-25: do not link the hub from the homepage until it
// is more polished. The URL stays in homepage.json (the hub's own build reads
// it); only the hero button is withheld. Flip this to true to show it again.
@@ -51,14 +32,11 @@ export default function Home() {
return (
<>
- {/* ── Hero ─────────────────────────────────────────────────────────── */}
- <section className={`${CONTAINER} pt-14 pb-16 sm:pt-20 sm:pb-24`}>
- <div className="flex flex-col gap-8">
- {/* Sentence case, and nowhere near poster scale. The earlier version
- was set in bold expanded caps at 4.75rem, which read as shouting —
- the wrong register for a tool whose argument is composure. */}
+ {/* ── Hero: the claim, the two ways in, then the evidence ───────────── */}
+ <section className={`${CONTAINER} pt-12 pb-14 sm:pt-16 sm:pb-20`}>
+ <div className="flex flex-col gap-6">
<h1
- className="reveal font-display text-[1.9rem] leading-[1.1] font-semibold tracking-[-0.01em] text-[var(--foreground)] sm:text-[2.6rem] lg:text-[3rem] max-w-3xl"
+ className="reveal font-display text-[1.75rem] leading-[1.12] font-semibold tracking-[-0.01em] text-[var(--foreground)] sm:text-[2.25rem] lg:text-[2.625rem]"
style={{ "--d": "60ms" } as React.CSSProperties}
>
{hours ? (
@@ -74,16 +52,16 @@ export default function Home() {
</h1>
<p
- className="reveal max-w-xl text-[1.0625rem] leading-relaxed text-[var(--muted-foreground)]"
- style={{ "--d": "160ms" } as React.CSSProperties}
+ className="reveal max-w-2xl text-[1.0625rem] leading-relaxed text-[var(--muted-foreground)]"
+ style={{ "--d": "140ms" } as React.CSSProperties}
>
Download a channel’s back catalogue, transcribe it on your own
- machine, publish a static site you host yourself.
+ hardware, publish a static site you host yourself.
</p>
<div
className="reveal flex flex-wrap items-center gap-x-8 gap-y-4"
- style={{ "--d": "240ms" } as React.CSSProperties}
+ style={{ "--d": "200ms" } as React.CSSProperties}
>
<Link
href="/downloads/"
@@ -107,167 +85,76 @@ export default function Home() {
)}
</div>
- {/* The rail: real acquisitions, real states. The signature object. */}
- <div
- className="reveal mt-6"
- style={{ "--d": "320ms" } as React.CSSProperties}
- >
- <RecentAdditions items={summary?.recent ?? []} />
- </div>
+ {months.length > 0 && sites.length > 0 && (
+ <div
+ className="reveal mt-6"
+ style={{ "--d": "280ms" } as React.CSSProperties}
+ >
+ <ArchiveGrowthChart months={months} sites={sites} />
+ </div>
+ )}
</div>
</section>
- {/* ── The numbers, only when there are numbers ─────────────────────── */}
- {summary && (
- <Band>
- <Eyebrow>This deployment, at last build</Eyebrow>
- <KpiHeader summary={summary} />
- </Band>
+ {/* ── The family's numbers ─────────────────────────────────────────── */}
+ {official && official.sites > 0 && (
+ <section className={`${CONTAINER} pb-14 sm:pb-20`}>
+ <FamilyStats
+ official={official}
+ generatedAt={summary?.generatedAt ?? null}
+ />
+ </section>
)}
- {/* ── What it does ─────────────────────────────────────────────────── */}
- <Band>
- <Eyebrow>What it does</Eyebrow>
- <div className="grid grid-cols-1 gap-px bg-[var(--border)] border border-[var(--border)] md:grid-cols-3">
- {[
- {
- title: "Archive",
- body: "The whole back catalogue, then new uploads on a schedule. Captions where the platform has them, local transcription where it doesn't.",
- },
- {
- title: "Publish",
- body: "Plain HTML and JSON — no database, nothing to keep running. One corpus can publish several sites without duplicating data.",
- },
- {
- title: "Search & ask",
- body: "Search every transcript at once, land on the exact second. A machine-readable index ships with it, so an LLM can navigate the archive too.",
- },
- ].map((panel) => (
- <div
- key={panel.title}
- className="bg-[var(--surface)] p-6 flex flex-col gap-2"
- >
- <h3 className="font-display text-base font-semibold text-[var(--foreground)]">
- {panel.title}
- </h3>
- <p className="text-sm leading-[1.65] text-[var(--muted-foreground)]">
- {panel.body}
- </p>
- </div>
- ))}
- </div>
- </Band>
+ {/* ── Official instances ───────────────────────────────────────────── */}
+ {sites.length > 0 && (
+ <section className="border-t border-[var(--border)]">
+ <div className={`${CONTAINER} py-14 sm:py-20`}>
+ <h2 className="font-display text-2xl font-semibold text-[var(--foreground)]">
+ Official instances
+ </h2>
+ <p className="mt-2 mb-8 max-w-xl text-[var(--muted-foreground)] leading-relaxed">
+ The archives I run. Anyone can run their own.
+ </p>
+ <ArchiveCards sites={sites} />
+ </div>
+ </section>
+ )}
- {/* ── The proof point ──────────────────────────────────────────────── */}
- {gone > 0 && (
- <Band>
- <div className="flex flex-col gap-4 max-w-2xl">
- <p className="font-display text-xl sm:text-2xl font-semibold leading-[1.25] text-[var(--foreground)]">
- <span className="tabular font-medium text-[var(--state-gone)]">
- {gone.toLocaleString()}
- </span>{" "}
- recordings here no longer exist where they came from.
+ {/* ── What it does ─────────────────────────────────────────────────── */}
+ {/* One paragraph, not three titled panels: without titles, three
+ one-sentence items are just a paragraph with bullets. */}
+ <section className="border-t border-[var(--border)]">
+ <div className={`${CONTAINER} py-14 sm:py-20`}>
+ <h2 className="font-display text-2xl font-semibold text-[var(--foreground)]">
+ What it does
+ </h2>
+ <div className="mt-6 flex max-w-2xl flex-col gap-4 text-[1.0625rem] leading-relaxed text-[var(--muted-foreground)]">
+ <p>
+ Archilyzer downloads a channel’s videos and keeps up with
+ new uploads. It uses the platform’s captions where they
+ exist and transcribes the audio on your machine where they
+ don’t.
</p>
- <p className="text-[var(--muted-foreground)] leading-relaxed">
- Re-checked and found gone. The transcripts are still here, still
- searchable, still citable to the second.
+ <p>
+ The result is a static site of plain HTML and JSON that any file
+ host can serve. Search covers every transcript and opens the video
+ at the second the words were said.
</p>
- <p className="text-sm text-[var(--faint)] leading-relaxed">
- The other {(counted - gone).toLocaleString()} are simply not known
- to be gone — nothing re-checks a corpus this size continuously. A
- floor, not a guarantee.
+ <p>
+ It is a program you run, not a service. There is no account and
+ nothing phones home.{" "}
+ <Link
+ href="/docs/"
+ className="text-[var(--foreground)] underline decoration-[var(--border-strong)] underline-offset-4 hover:decoration-[var(--foreground)]"
+ >
+ The docs
+ </Link>{" "}
+ cover setup and deployment.
</p>
</div>
- </Band>
- )}
-
- {/* ── How it works ─────────────────────────────────────────────────── */}
- <Band>
- <Eyebrow>How it works</Eyebrow>
- <ol className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4 list-none">
- {[
- {
- n: "01",
- t: "Pick channels",
- d: "Add a URL. Captions as published, or audio transcribed here.",
- },
- {
- n: "02",
- t: "Transcribe",
- d: "whisper.cpp or another local backend. Nothing is uploaded.",
- },
- {
- n: "03",
- t: "Compose",
- d: "A paginated index and a static site, rebuilt incrementally.",
- },
- {
- n: "04",
- t: "Ship",
- d: "Deploy anywhere that serves files. A free tier is enough.",
- },
- ].map((step) => (
- <li key={step.n} className="flex flex-col gap-1.5">
- <span className="tabular text-xs text-[var(--faint)]">
- {step.n}
- </span>
- <h3 className="font-display text-base font-semibold text-[var(--foreground)]">
- {step.t}
- </h3>
- <p className="text-sm leading-[1.65] text-[var(--muted-foreground)]">
- {step.d}
- </p>
- </li>
- ))}
- </ol>
- </Band>
-
- {/* ── What this isn't. The highest-trust block on the page, and the
- cheapest: it is all simply true. ──────────────────────────────── */}
- <Band>
- <Eyebrow>What this isn’t</Eyebrow>
- <ul className="grid grid-cols-1 gap-x-10 gap-y-6 sm:grid-cols-2 list-none max-w-4xl">
- {[
- [
- "Not a hosted service",
- "Nothing to sign up for. Your machine, your hosting.",
- ],
- [
- "No public repository",
- "A dated snapshot tarball. Nothing to pull.",
- ],
- [
- "You supply the tools",
- "yt-dlp, ffmpeg and a transcription backend are yours to install.",
- ],
- [
- "Transcription is slow",
- "Bound by your GPU. A large catalogue is days of machine time.",
- ],
- ].map(([title, body]) => (
- <li key={title} className="flex flex-col gap-1">
- <h3 className="text-sm font-semibold text-[var(--foreground)]">
- {title}
- </h3>
- <p className="text-sm leading-[1.65] text-[var(--muted-foreground)]">
- {body}
- </p>
- </li>
- ))}
- </ul>
- </Band>
-
- {/* ── The reference deployment ─────────────────────────────────────── */}
- {summary && summary.sites.length > 0 && (
- <Band>
- <Eyebrow>The reference deployment</Eyebrow>
- <p className="mb-8 max-w-xl text-[var(--muted-foreground)] leading-relaxed">
- Public archives built from one corpus on one machine — the output in
- use.
- </p>
- <ArchiveCards sites={summary.sites} />
- </Band>
- )}
+ </div>
+ </section>
</>
);
}