Archilyzer · Source

archilyzer

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

commit 556dd6e71302e64ed7fda62410df421fc7ee8f10
parent 933a03b1449d1fd046aeea223335536ce6f3e63a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 16:32:21 -0400

Merge one-core/r8-homepage — release 8 slice H: the homepage rethought (plain copy, monthly chart by instance, family stats, Official instances; deploy homepage --preview)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/bin/archilyzer.ts | 13++++++++++---
Acommon/lib/homepageSummary.test.ts | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/homepageSummary.ts | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/publish/build.test.ts | 25+++++++++++++++++++++++++
Mcommon/publish/build.ts | 37++++++++++++++++++++++++++++++++-----
Meditor/CHANGELOG.md | 1+
Mhomepage/app/components/ArchiveCards.tsx | 115+++++++++++++++++++++++++++++++++++++++----------------------------------------
Ahomepage/app/components/ArchiveGrowthChart.tsx | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahomepage/app/components/FamilyStats.tsx | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/globals.css | 9+++++++++
Mhomepage/app/page.tsx | 279++++++++++++++++++++++++-------------------------------------------------------
Mhomepage/e2e/marketing.spec.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mplans/release-8.md | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 1006 insertions(+), 295 deletions(-)

diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -158,10 +158,17 @@ export const COMMANDS: Command[] = [ }, { path: ["deploy", "homepage"], - usage: "ship homepage/out to the Pages project archilyzer (production branch main)", - run: async () => { + usage: + "[--preview <branch>] ship homepage/out to the Pages project archilyzer (production branch main)", + flags: { preview: "string" }, + run: async ({ flags }) => { const { deployHomepage } = await import("../publish/build"); - return refusalsExit(() => deployHomepage({ signal: interrupted() })); + return refusalsExit(() => + deployHomepage({ + signal: interrupted(), + previewBranch: typeof flags.preview === "string" ? flags.preview : undefined, + }), + ); }, }, { diff --git a/common/lib/homepageSummary.test.ts b/common/lib/homepageSummary.test.ts @@ -0,0 +1,146 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildHomepageSummary, HOMEPAGE_SUMMARY_VERSION } from "./homepageSummary"; +import type { VideoStat } from "./stats"; +import type { Site } from "./site"; + +// The v5 additions: `monthly` (transcripts by UPLOAD month, stacked by primary +// public site, full history, zero-filled), per-site numbers, and `official` +// (the sum over the public sites, not the instance-wide totals). +// +// Limits, pinned here rather than discovered: `monthly` is NOT capped — it runs +// from the earliest upload month to the last COMPLETE month, however old. The +// build month is never emitted (it is partial). A transcript whose upload date +// is missing, malformed, in the build month or later is not placed in any +// month; `monthlyUnplaced` counts it. Channels exposed by no +// public site (pool-only, or a site without a siteUrl) are outside `monthly`, +// the site numbers and `official`, but still inside `totals`. + +const NOW = new Date("2026-03-15T12:00:00Z"); + +function stat(p: Partial<VideoStat> & { channelSlug: string; id: string }): VideoStat { + return { + slug: `${p.channelSlug}/${p.id}`, + channel: p.channelSlug.toUpperCase(), + title: p.id, + platform: "youtube", + uploadDate: "20260101", + downloadedDate: "20260301", + transcribedDate: "20260302", + timestamp: null, + duration: 1800, + viewCount: null, + likeCount: null, + commentCount: null, + channelFollowerCount: null, + categories: [], + tags: [], + language: null, + isLivestream: false, + mediaType: "video", + status: "available", + hasTranscript: true, + cueCount: null, + coverage: null, + ...p, + } as VideoStat; +} + +function site(siteId: string, slugs: string[], siteUrl?: string): Site { + return { + siteId, + siteTitle: siteId.toUpperCase(), + siteDescription: `${siteId} desc`, + channels: slugs.map((slug) => ({ slug })), + ...(siteUrl ? { siteUrl } : {}), + } as unknown as Site; +} + +const SITES = [ + site("alpha", ["a1", "a2"], "https://alpha.example"), + site("beta", ["b1"], "https://beta.example"), + site("private", ["p1"]), // no siteUrl: not an official instance +]; +const CHANNEL_SITES = { a1: ["alpha"], a2: ["alpha"], b1: ["beta"], p1: ["private"] }; + +const STATS: VideoStat[] = [ + stat({ channelSlug: "a1", id: "x1", uploadDate: "20251101", duration: 3600 }), + stat({ channelSlug: "a1", id: "x2", uploadDate: "20251101", duration: 3600, status: "deleted" }), + stat({ channelSlug: "a2", id: "x3", uploadDate: "20260203", duration: 1800 }), + // downloaded, not transcribed: a recording, not a transcript, not placed + stat({ channelSlug: "a2", id: "x4", hasTranscript: false, transcribedDate: null }), + stat({ channelSlug: "b1", id: "y1", uploadDate: "20260115", duration: 7200 }), + // unplaceable upload dates: missing, malformed, future + stat({ channelSlug: "b1", id: "y2", uploadDate: "" }), + stat({ channelSlug: "b1", id: "y3", uploadDate: "2026-01" }), + stat({ channelSlug: "b1", id: "y4", uploadDate: "20260501" }), + // uploaded in the build month (2026-03): partial, so not placed + stat({ channelSlug: "b1", id: "y5", uploadDate: "20260310" }), + // pool-only (no public site): counted in totals, nowhere else + stat({ channelSlug: "p1", id: "z1", uploadDate: "20200101", status: "deleted" }), +]; + +test("monthly: upload months, zero-filled from first upload to the build month, every site keyed", () => { + const s = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW); + assert.equal(s.version, HOMEPAGE_SUMMARY_VERSION); + assert.deepEqual( + s.monthly!.map((m) => m.month), + ["2025-11", "2025-12", "2026-01", "2026-02"], + ); + assert.deepEqual(s.monthly![0].bySite, { alpha: 2, beta: 0 }); + assert.deepEqual(s.monthly![1].bySite, { alpha: 0, beta: 0 }); + assert.deepEqual(s.monthly![2].bySite, { alpha: 0, beta: 1 }); + assert.deepEqual(s.monthly![3].bySite, { alpha: 1, beta: 0 }); + // A month equal to the generation month is not emitted. + assert.ok(!s.monthly!.some((m) => m.month === "2026-03")); + // Placed + unplaced = every transcribed public-universe record. + const placed = s.monthly!.reduce((a, m) => a + m.bySite.alpha + m.bySite.beta, 0); + assert.equal(s.monthlyUnplaced, 4); + assert.equal(placed + s.monthlyUnplaced!, s.official!.transcripts); +}); + +test("per-site numbers and official totals sum over the public sites only", () => { + const s = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW); + const alpha = s.sites.find((x) => x.siteId === "alpha")!; + const beta = s.sites.find((x) => x.siteId === "beta")!; + assert.equal(s.sites.length, 2); + assert.deepEqual( + [alpha.channels, alpha.recordings, alpha.transcribed.total, alpha.hoursArchived, alpha.gone], + [2, 4, 3, 3, 1], // 3600 + 3600 + 1800 s = 2.5 h -> 3 + ); + assert.deepEqual( + [beta.channels, beta.recordings, beta.transcribed.total, beta.hoursArchived, beta.gone], + [1, 5, 5, 4, 0], // 7200 + 4 x 1800 s = 4 h + ); + // Hours are rounded once over the summed seconds, not per site. + assert.deepEqual(s.official, { + sites: 2, + channels: 3, + recordings: 9, + transcripts: 8, + hoursArchived: 7, // 23400 s = 6.5 h -> 7 + gone: 1, + }); + // The instance-wide totals still count the pool-only channel. + assert.equal(s.totals.transcripts, 9); + assert.equal(s.availability!.byState.deleted, 2); +}); + +test("no transcribed public content: monthly is empty, not a run of zeroes", () => { + const s = buildHomepageSummary(STATS.filter((x) => x.id === "z1"), CHANNEL_SITES, SITES, NOW); + assert.deepEqual(s.monthly, []); + assert.equal(s.monthlyUnplaced, 0); + assert.equal(s.official!.sites, 0); +}); + +test("the build month is never emitted, even in January (the year rolls back)", () => { + const s = buildHomepageSummary( + [stat({ channelSlug: "a1", id: "j1", uploadDate: "20251215" }), + stat({ channelSlug: "a1", id: "j2", uploadDate: "20260105" })], + CHANNEL_SITES, + SITES, + new Date("2026-01-20T00:00:00Z"), + ); + assert.deepEqual(s.monthly!.map((m) => m.month), ["2025-12"]); + assert.equal(s.monthlyUnplaced, 1); +}); diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts @@ -22,7 +22,11 @@ import { VIDEO_STATES, type VideoState } from "./availability"; // on the type, because nothing gates on this number — it is a provenance marker, // not a compatibility check — and a summary written by an older build must keep // deserializing. Readers must treat their absence as "unknown", never as zero. -export const HOMEPAGE_SUMMARY_VERSION = 4; +// +// v5 adds `monthly`, `official` and the per-site `channels` / `recordings` / +// `hoursArchived` / `gone` numbers — all additive, all optional on the type for +// the same reason: a v4 summary on disk must still render (the numbers hide). +export const HOMEPAGE_SUMMARY_VERSION = 5; // Day buckets are capped to this many trailing days so the embedded summary stays // small regardless of archive age (daily detail is only useful recently). @@ -64,6 +68,13 @@ export type HomepageSummarySite = { siteUrl: string; // public sites only — always present transcribed: SiteMetricStat; downloaded: SiteMetricStat; + // v5 per-site numbers, over the channels this site is the PRIMARY public site + // for (the same attribution as `transcribed`/`downloaded`, so the cards sum to + // `official`). Optional: absent from pre-v5 summaries. + channels?: number; // channels with at least one transcript + recordings?: number; // records with a download date (matches totals.downloads) + hoursArchived?: number; // transcribed-video durations, rounded to the hour + gone?: number; // records re-checked and found deleted at the source // The site's own brand accent ("#rrggbb"), when it defines one. Absent for a // site that hasn't set one — callers fall back to the chart palette rather // than inventing a brand colour. Added in v4. @@ -105,6 +116,30 @@ export type HomepageAvailability = { counted: number; }; +// One calendar month of the archive's BACK CATALOGUE: transcribed recordings in +// the public universe, keyed by the month the creator UPLOADED them (not the +// month we transcribed them — that history is only as old as the tool, while +// upload month shows how far back each archive reaches). Values are keyed by +// siteId and attributed to the primary public site, so a month's values sum +// to that month's transcripts with no double counting. +export type HomepageMonth = { + month: string; // "YYYY-MM" + bySite: Record<string, number>; +}; + +// The family's own numbers: the sum over `sites` (public sites with activity), +// NOT the instance-wide `totals`, which also count pool-only channels and +// sites without a public URL. Copy that says "across the official instances" +// reads this. +export type HomepageOfficialTotals = { + sites: number; + channels: number; + recordings: number; + transcripts: number; + hoursArchived: number; + gone: number; +}; + export type HomepageSummary = { version: number; generatedAt: string; // ISO timestamp @@ -128,6 +163,17 @@ export type HomepageSummary = { recent: HomepageRecentItem[]; // Instance-wide state census. Optional — absent from pre-v4 summaries. availability?: HomepageAvailability; + // v5. Full history of COMPLETE months: one entry per month from the earliest + // upload month in the public universe to the month BEFORE the build month, + // zero-filled between. The build month itself is never emitted — it is + // partial, and charted it reads as a collapse. Every site in `sites` has a + // key in every entry (zero included). Empty when there is nothing to place. + // Transcripts whose upload date is missing, malformed, in the build month or + // later are not placed; `monthlyUnplaced` counts them so nothing is dropped + // silently. + monthly?: HomepageMonth[]; + monthlyUnplaced?: number; + official?: HomepageOfficialTotals; }; const RECENT_LIMIT = 24; @@ -139,6 +185,23 @@ function monthOf(date: string | null): string | null { return `${date.slice(0, 4)}-${date.slice(4, 6)}`; } +// An upload date "YYYYMMDD" -> "YYYY-MM", or null when it is not a plausible +// date (missing, short, non-numeric, month outside 1..12). +function uploadMonthOf(date: string | null | undefined): string | null { + if (!date || !/^\d{8}$/.test(date)) return null; + const m = +date.slice(4, 6); + if (m < 1 || m > 12 || +date.slice(0, 4) < 1900) return null; + return `${date.slice(0, 4)}-${date.slice(4, 6)}`; +} + +// "YYYY-MM" -> the calendar month before it. +function previousMonth(ym: string): string { + const [y, m] = ym.split("-").map(Number); + return m === 1 + ? `${String(y - 1).padStart(4, "0")}-12` + : `${String(y).padStart(4, "0")}-${String(m - 1).padStart(2, "0")}`; +} + function monthRange(start: string, end: string): string[] { const out: string[] = []; let [y, m] = start.split("-").map(Number); @@ -284,6 +347,17 @@ export function buildHomepageSummary( let transcribedThisMonth = 0; let downloadedThisMonth = 0; const channelSet = new Set<string>(); + // Per public site: channels with a transcript, recordings, seconds, gone. + type SiteAcc = { channels: Set<string>; recordings: number; seconds: number; gone: number }; + const siteAcc = new Map<string, SiteAcc>(); + const accOf = (id: string): SiteAcc => { + let a = siteAcc.get(id); + if (!a) siteAcc.set(id, (a = { channels: new Set(), recordings: 0, seconds: 0, gone: 0 })); + return a; + }; + // Upload-month placement of transcribed public-universe records. + const uploadItems: { month: string; siteId: string }[] = []; + let monthlyUnplaced = 0; for (const s of stats) { const hasTx = s.hasTranscript && !!s.transcribedDate; @@ -300,7 +374,15 @@ export function buildHomepageSummary( const siteId = primarySiteOf.get(s.channelSlug); if (!siteId) continue; // not on any public site channelName.set(s.channelSlug, s.channel); + const acc = accOf(siteId); + if (s.downloadedDate) acc.recordings += 1; + if (s.status === "deleted") acc.gone += 1; if (hasTx) { + acc.channels.add(s.channelSlug); + acc.seconds += s.duration > 0 ? s.duration : 0; + const um = uploadMonthOf(s.uploadDate); + if (um && um < nowMonth) uploadItems.push({ month: um, siteId }); + else monthlyUnplaced += 1; transcribedItems.push({ date: s.transcribedDate as string, channelSlug: s.channelSlug, siteId }); } if (s.downloadedDate) { @@ -326,6 +408,10 @@ export function buildHomepageSummary( siteUrl: s.siteUrl, transcribed: siteStatFrom(series.transcribed.month, s.siteId, nowMonth), downloaded: siteStatFrom(series.downloaded.month, s.siteId, nowMonth), + channels: siteAcc.get(s.siteId)?.channels.size ?? 0, + recordings: siteAcc.get(s.siteId)?.recordings ?? 0, + hoursArchived: Math.round((siteAcc.get(s.siteId)?.seconds ?? 0) / 3600), + gone: siteAcc.get(s.siteId)?.gone ?? 0, ...(s.accent ? { accent: s.accent } : {}), })) // Keep a public site only if it has any activity in either metric. @@ -379,6 +465,39 @@ export function buildHomepageSummary( counted: stats.length, }; + // Monthly back-catalogue series, keyed by the sites that survived the + // activity filter (a site with no activity has no transcripts to place). + const keptIds = summarySites.map((s) => s.siteId); + let firstMonth: string | null = null; + for (const it of uploadItems) { + if (firstMonth === null || it.month < firstMonth) firstMonth = it.month; + } + const lastComplete = previousMonth(nowMonth); + const monthly: HomepageMonth[] = firstMonth + ? monthRange(firstMonth, lastComplete).map((month) => ({ + month, + bySite: Object.fromEntries(keptIds.map((id) => [id, 0])), + })) + : []; + const monthIndex = new Map(monthly.map((m, i) => [m.month, i])); + for (const it of uploadItems) { + const i = monthIndex.get(it.month); + if (i === undefined) continue; // unreachable: every item's month is in range + monthly[i].bySite[it.siteId] = (monthly[i].bySite[it.siteId] ?? 0) + 1; + } + + // The family's own totals — hours summed in seconds, then rounded once. + let officialSeconds = 0; + for (const id of keptIds) officialSeconds += siteAcc.get(id)?.seconds ?? 0; + const official: HomepageOfficialTotals = { + sites: summarySites.length, + channels: summarySites.reduce((a, s) => a + (s.channels ?? 0), 0), + recordings: summarySites.reduce((a, s) => a + (s.recordings ?? 0), 0), + transcripts: summarySites.reduce((a, s) => a + s.transcribed.total, 0), + hoursArchived: Math.round(officialSeconds / 3600), + gone: summarySites.reduce((a, s) => a + (s.gone ?? 0), 0), + }; + return { version: HOMEPAGE_SUMMARY_VERSION, generatedAt: now.toISOString(), @@ -396,5 +515,8 @@ export function buildHomepageSummary( sites: summarySites, recent, availability, + monthly, + monthlyUnplaced, + official, }; } diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -6,6 +6,8 @@ import { buildHubSteps, buildSiteSteps, hubProjectProblem, + homepageDeployArgs, + deployHomepage, dockerSiteOutDir, dockerSiteStagingDir, resolveOutDir, @@ -126,3 +128,26 @@ test("hubProjectProblem refuses a missing project and the homepage's", () => { assert.match(hubProjectProblem("archilyzer")!, /homepage's — set the hub's own project/); assert.equal(hubProjectProblem("archilyzer-hub"), null); }); + +test("homepageDeployArgs: production is branch main; a preview is its own branch and never main", () => { + const base = ["wrangler", "pages", "deploy", "/repo/homepage/out", "--project-name", "archilyzer"]; + assert.deepEqual(homepageDeployArgs("/repo/homepage/out"), [...base, "--branch", "main"]); + assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " "), [...base, "--branch", "main"]); + assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " r8-home "), [...base, "--branch", "r8-home"]); +}); + +test("deployHomepage refuses a bad preview branch before it looks for a build", async () => { + const noBuild = { monorepoRoot: "/nonexistent-repo" } as Paths; + await assert.rejects( + deployHomepage({ paths: noBuild, previewBranch: "main" }), + /"main" is the production branch/, + ); + await assert.rejects( + deployHomepage({ paths: noBuild, previewBranch: "Not_Valid" }), + /not a valid preview branch name/, + ); + await assert.rejects( + deployHomepage({ paths: noBuild, previewBranch: "r8-home" }), + /homepage\/out holds no build/, + ); +}); diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -974,21 +974,48 @@ export async function buildHomepage(opts: PublishOpts = {}): Promise<number> { } /** - * Deploy homepage/out to the homepage's constant project, to its production - * branch `main` — exactly what homepage/package.json's hardcoded `deploy` line - * ran. THROWS on failure or when there is no build. + * The wrangler argv (after `pnpm dlx`) for a homepage deploy of `outDir`: the + * production branch `main` — what homepage/package.json's hardcoded `deploy` + * line ran — or, with `previewBranch`, that preview branch and no `main`. + * Pure, so the test pins exactly what deployHomepage runs. */ -export async function deployHomepage(opts: PublishOpts = {}): Promise<void> { +export function homepageDeployArgs(outDir: string, previewBranch?: string): string[] { + const branch = previewBranch?.trim() || undefined; + return [ + ...pagesDeployArgs({ outDir, project: HOMEPAGE_PAGES_PROJECT, previewBranch: branch }), + ...homepageProductionArgs(branch), + ]; +} + +function homepageProductionArgs(previewBranch: string | undefined): string[] { + return previewBranch ? [] : ["--branch", "main"]; +} + +/** + * Deploy homepage/out to the homepage's constant project: production (branch + * `main`), or a PREVIEW with `previewBranch` — the same refusals and alias line + * as deployHub. THROWS on failure or when there is no build. + */ +export async function deployHomepage( + opts: PublishOpts & { previewBranch?: string } = {}, +): Promise<void> { const { paths, onLog, signal } = resolved(opts); + if (opts.previewBranch !== undefined) { + const problem = previewBranchProblem(opts.previewBranch); + if (problem) throw new Error(problem); + } + const branch = opts.previewBranch?.trim() || undefined; const outDir = path.join(homepageDir(paths), "out"); if (!existsSync(path.join(outDir, "index.html"))) { throw new Error("homepage/out holds no build — run archilyzer build homepage first"); } + if (branch) onLog(`=== Deploy homepage (preview "${branch}") ===\n`); const code = await runPagesDeployIntoLog(onLog, signal, { outDir, project: HOMEPAGE_PAGES_PROJECT, cwd: homepageDir(paths), - extraArgs: ["--branch", "main"], + previewBranch: branch, + extraArgs: homepageProductionArgs(branch), }); if (signal.aborted) return; if (code !== 0) throw new Error(`Homepage deploy failed (exit ${code}).`); diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The project homepage is rewritten, and it can be deployed as a preview.** The front page of `archilyzer.pages.dev` now opens on one line and a chart. The chart shows the official instances' transcripts by the month each video was published, from 2009 to now, stacked by instance. It is drawn when the site is built, so no chart script loads. Under it is one strip of numbers for the official instances: hours of speech, transcripts, recordings, channels, instances, and recordings gone at the source but still here. The **Official instances** section has a card for each public site, with its channels, recordings, transcripts and hours. **What it does** is now three short paragraphs. The recent-acquisitions list, "How it works" and "What this isn't" are gone from the page. The homepage summary (`homepage/public/homepage-summary.json`) is version 5. It adds `monthly`, `official` and per-site numbers and removes nothing, so `/stats` is unchanged. `archilyzer deploy homepage --preview <branch>` deploys a preview the way `deploy hub --preview` does. Without the flag it still deploys to production (`main`). - **A channel's video list shows titles, and you can search by them.** On `/channels/<slug>/videos`, each row now shows the video's title, with its id in smaller type underneath. Search matches the title or the id, ignoring case. The title comes from the transcript index for transcribed videos, from the channel's metadata scan for videos that were listed but never downloaded, and otherwise from the video's `metadata.info.json`. A video none of these name shows its id, as before. Each row's selection checkbox and link are named by the id as before, and the order is unchanged (by id). - **An undownloaded video's page shows its title and details.** The video page used to show a bare id for any video without a `metadata.info.json`. If the channel's metadata scan has read the video, the page now shows its title, upload date and duration from the scan, marked *from the listing scan — not downloaded*. Any video page with a description, from either source, has a **Description** section, collapsed by default. - **A rate-limit cooldown the download runner recorded is no longer undone by another lane a few minutes later.** The four automatic runners (download, transcription, digest, backfill) are meant to share one copy of `.auto-queue/state.json`, because every save writes the whole file. At startup all four read it at the same moment, and each kept its own copy. After that, whichever runner saved next wrote its startup copy over the others' progress. On 2026-09-25 that silently put a YouTube cooldown back from 13 failures to 12 and dropped a six-hour hold on one rate-limited video, 13 minutes after the download runner had saved them. The runners now wait on one read at startup and share one copy, so a save by any lane keeps every lane's latest state. A cooldown that a manual Sync, a metadata scan or the video page records is no longer erased by a runner's save either. It used to go to the file only, so the next save of any lane wrote over it, and nothing brought it back while the download runner was paused or stopped. It is now written into the copy the runners share. Nothing changes in the file format. 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/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx @@ -0,0 +1,261 @@ +import type { + HomepageMonth, + HomepageSummarySite, +} from "yt-dlp-transcript-common/lib/homepageSummary"; +import { + monthLabel, + seriesColor, +} from "yt-dlp-transcript-common/lib/homepageChart"; + +// The front page's showpiece: every official instance's back catalogue as +// stacked strata, one month per step, from the oldest upload to this month. +// +// BUILD-TIME ONLY. A server component that renders inline SVG from the summary; +// no chart library and no client JS ships on `/`. The hover layer is native: +// each month is a transparent column carrying a <title>, so a pointer gets a +// tooltip and the column tints, with nothing to hydrate. +// +// GEOMETRY. The plot is an SVG with preserveAspectRatio="none" inside a box of +// fixed CSS height, so it fills any width without a layout shift. Nothing in the +// SVG is text (text would stretch); the axis labels are HTML positioned in +// percent over the same box. Strokes use non-scaling-stroke so a hairline stays +// a hairline at every width. +// +// WHY AN AREA, not bars. ~200 months across ~1,100 px is ~5 px a month; a bar +// with the 2 px gap the marks spec asks for would be half gap. At this density +// the stack reads as strata, which is what it is. +// +// COLOUR follows the instance, never its rank on this chart: seriesColor(i) with +// i = the site's position in `sites`, the same index the instance cards use. +// The STACK order is different on purpose: the family palette's first two +// slots (teal, blue) are too close to share an edge, so the layers are +// interleaved (0, 2, 1, 4, 3) to keep every touching pair distinguishable. + +const W = 1000; +const H = 300; +const STACK_ORDER = [0, 2, 1, 4, 3]; + +function stackOrder(n: number): number[] { + const head = STACK_ORDER.filter((i) => i < n); + const tail = Array.from({ length: Math.max(0, n - 5) }, (_, k) => k + 5); + return [...head, ...tail]; +} + +// A clean tick step giving three or four gridlines under `max`. +function niceStep(max: number): number { + const raw = max / 3.5; + const pow = 10 ** Math.floor(Math.log10(raw)); + for (const m of [1, 2, 2.5, 5, 10]) { + if (m * pow >= raw) return m * pow; + } + return 10 * pow; +} + +export function ArchiveGrowthChart({ + months, + sites, +}: { + months: HomepageMonth[]; + sites: HomepageSummarySite[]; +}) { + const n = months.length; + if (n === 0 || sites.length === 0) return null; + + const totals = months.map((m) => + sites.reduce((a, s) => a + (m.bySite[s.siteId] ?? 0), 0), + ); + const peak = Math.max(...totals); + if (peak === 0) return null; + const step = niceStep(peak); + const yMax = Math.ceil(peak / step) * step; + const ticks: number[] = []; + for (let t = step; t <= yMax; t += step) ticks.push(t); + + const x = (i: number) => (n === 1 ? W / 2 : (i / (n - 1)) * W); + const y = (v: number) => H - (v / yMax) * H; + const r = (v: number) => Math.round(v * 10) / 10; + + // Layers bottom-up, each with its lower and upper edge per month. + const order = stackOrder(sites.length); + const base = new Array<number>(n).fill(0); + const layers = order.map((si) => { + const site = sites[si]; + const lo = base.slice(); + const hi = months.map((m, i) => (base[i] += m.bySite[site.siteId] ?? 0)); + const top = hi.map((v, i) => `${r(x(i))},${r(y(v))}`); + const bottom = lo.map((v, i) => `${r(x(i))},${r(y(v))}`).reverse(); + return { + site, + color: seriesColor(si), + area: `M${top.join("L")}L${bottom.join("L")}Z`, + edge: `M${top.join("L")}`, + }; + }); + + // Year ticks at each January. Every second year from sm up, every fourth on a + // phone; the ends are skipped so a label never hangs off the plot. + const years = months + .map((m, i) => ({ year: Number(m.month.slice(0, 4)), i, jan: m.month.endsWith("-01") })) + .filter((t) => t.jan && x(t.i) / W > 0.03 && x(t.i) / W < 0.97); + + const first = monthLabel(months[0].month); + const last = monthLabel(months[n - 1].month); + const peakIdx = totals.indexOf(peak); + const all = totals.reduce((a, b) => a + b, 0); + const label = + `Stacked area chart of ${all.toLocaleString()} transcripts by the month each ` + + `video was published, ${first} to ${last}, across ${sites.length} official ` + + `instances. The busiest month is ${monthLabel(months[peakIdx].month)}, ` + + `with ${peak.toLocaleString()}.`; + + // Per-year table for anyone who wants the numbers rather than the shape. + const byYear = new Map<string, Record<string, number>>(); + for (const m of months) { + const row = byYear.get(m.month.slice(0, 4)) ?? {}; + for (const s of sites) row[s.siteId] = (row[s.siteId] ?? 0) + (m.bySite[s.siteId] ?? 0); + byYear.set(m.month.slice(0, 4), row); + } + + return ( + <figure className="flex flex-col gap-4"> + <ul className="flex flex-wrap gap-x-5 gap-y-2 list-none" aria-hidden="true"> + {sites.map((s, i) => ( + <li key={s.siteId} className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]"> + <span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: seriesColor(i) }} /> + {s.siteTitle} + </li> + ))} + </ul> + + <div className="relative mt-4 pb-7"> + <div + role="img" + aria-label={label} + className="relative h-[200px] sm:h-[260px] lg:h-[300px] border-b border-[var(--border-strong)]" + > + <svg + viewBox={`0 0 ${W} ${H}`} + preserveAspectRatio="none" + className="absolute inset-0 h-full w-full overflow-visible" + aria-hidden="true" + > + {ticks.map((t) => ( + <line + key={t} + x1={0} + x2={W} + y1={y(t)} + y2={y(t)} + stroke="var(--chart-grid)" + strokeWidth={1} + vectorEffect="non-scaling-stroke" + /> + ))} + {layers.map((l) => ( + <path key={l.site.siteId} d={l.area} fill={l.color} /> + ))} + {/* The surface gap: each layer's upper edge is drawn in the page + colour, so touching strata separate by negative space. */} + {layers.map((l) => ( + <path + key={`${l.site.siteId}-edge`} + d={l.edge} + fill="none" + stroke="var(--background)" + strokeWidth={1.25} + strokeLinejoin="round" + vectorEffect="non-scaling-stroke" + /> + ))} + {months.map((m, i) => { + const w = W / n; + const parts = sites + .map((s) => [s.siteTitle, m.bySite[s.siteId] ?? 0] as const) + .filter(([, v]) => v > 0) + .map(([t, v]) => `${t} ${v.toLocaleString()}`); + return ( + <rect + key={m.month} + className="growth-hit" + x={Math.max(0, x(i) - w / 2)} + y={0} + width={w} + height={H} + > + <title> + {`${monthLabel(m.month)}: ${totals[i].toLocaleString()} transcripts` + + (parts.length ? `\n${parts.join("\n")}` : "")} + </title> + </rect> + ); + })} + </svg> + + {/* Value scale: the number sits on its gridline, left edge. */} + {ticks.map((t) => ( + <span + key={t} + aria-hidden="true" + className="tabular pointer-events-none absolute left-0 -translate-y-full pb-1 text-[0.6875rem] leading-none text-[var(--chart-axis)]" + style={{ top: `${(1 - t / yMax) * 100}%` }} + > + {t.toLocaleString()} + </span> + ))} + </div> + + {years.map((t) => ( + <span + key={t.year} + aria-hidden="true" + className={`tabular absolute bottom-0 -translate-x-1/2 text-[0.6875rem] leading-none text-[var(--chart-axis)] ${ + t.year % 4 === 0 ? "" : t.year % 2 === 0 ? "hidden sm:block" : "hidden" + }`} + style={{ left: `${(x(t.i) / W) * 100}%` }} + > + {t.year} + </span> + ))} + </div> + + <figcaption className="text-sm text-[var(--muted-foreground)]"> + Transcripts by the month each video was published, all official instances. + </figcaption> + <details className="text-sm"> + <summary className="cursor-pointer text-[var(--muted-foreground)] underline decoration-[var(--border-strong)] underline-offset-4 hover:text-[var(--foreground)]"> + Numbers by year + </summary> + <div className="mt-3 max-w-full overflow-x-auto"> + <table className="tabular w-full text-xs"> + <thead> + <tr className="text-[var(--faint)]"> + <th className="py-1 pr-4 text-left font-normal">Year</th> + {sites.map((s) => ( + <th key={s.siteId} className="py-1 pr-4 text-right font-normal"> + {s.siteTitle} + </th> + ))} + <th className="py-1 text-right font-normal">Total</th> + </tr> + </thead> + <tbody> + {[...byYear.entries()].map(([yr, row]) => ( + <tr key={yr} className="border-t border-[var(--border)] text-[var(--foreground)]"> + <td className="py-1 pr-4">{yr}</td> + {sites.map((s) => ( + <td key={s.siteId} className="py-1 pr-4 text-right"> + {(row[s.siteId] ?? 0).toLocaleString()} + </td> + ))} + <td className="py-1 text-right"> + {Object.values(row).reduce((a, b) => a + b, 0).toLocaleString()} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </details> + </figure> + ); +} 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/globals.css b/homepage/app/globals.css @@ -95,3 +95,12 @@ body { .doc-measure { max-width: 68ch; } + +/* The growth chart's hover layer: one transparent column per month, tinted + under the pointer. Native <title> tooltips, no client JS. */ +.growth-hit { + fill: transparent; +} +.growth-hit:hover { + fill: var(--chart-grid); +} 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&rsquo;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&rsquo;s videos and keeps up with + new uploads. It uses the platform&rsquo;s captions where they + exist and transcribes the audio on your machine where they + don&rsquo;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&rsquo;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> </> ); } diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts @@ -1,10 +1,12 @@ +import fs from "node:fs"; +import path from "node:path"; import { test, expect } from "@playwright/test"; // The home page's job is to say what Archilyzer is and offer the download. -// Everything asserted here is data-INDEPENDENT by construction: no count, no -// site name, no headline number. The numeric bands are gated on corpus data, so -// pinning one would make this suite fail on a source-only build — which is -// exactly the build this page has to work on. +// Nothing asserted here names a count, a site or a headline number. The numeric +// parts are gated on corpus data, so the specs that touch them read the summary +// on disk and branch on it — a source-only build is the build this page has to +// work on. test.beforeEach(async ({ page }) => { await page.goto("/"); @@ -23,7 +25,7 @@ test("says what it is and offers the source", async ({ page }) => { }); // The hub is a separate app on its own Pages project, linked from the hero -// when homepage.json names its URL. Data-dependent like the rail below, so the +// when homepage.json names its URL. Data-dependent like the numbers below, so the // assertion is conditional the same way: absent is a legal state (a // source-only build has no hub), but PRESENT means an absolute link off-site — // never a relative path into this site, which has no search. @@ -34,19 +36,6 @@ test("the hub link, when there is one, leaves for the hub", async ({ page }) => await expect(hub).toHaveAttribute("href", /^https?:\/\/[^/]/); }); -test("the trust block states what it isn't", async ({ page }) => { - // The cheapest, highest-value block on the page — every claim in it is - // simply true, and each one pre-empts a wrong assumption. - for (const claim of [ - "Not a hosted service", - "No public repository", - "You supply the tools", - "Transcription is slow", - ]) { - await expect(page.getByText(claim, { exact: true })).toBeVisible(); - } -}); - test("every nav destination resolves", async ({ page }) => { // The nav must never point at a 404, including on a build with no corpus // data — which is why /stats/ always exists and degrades in place. @@ -61,22 +50,66 @@ test("every nav destination resolves", async ({ page }) => { } }); -test("the rail never invents a recording", async ({ page }) => { - // Absent data must produce an empty state, not filler. An archiving tool - // that fabricates transcript rows on its own marketing page has undercut - // itself before a visitor has read a sentence. - const rail = page.getByText("Recent acquisitions"); - const empty = page.getByText(/shipped without corpus data/i); - const hasRail = await rail.isVisible().catch(() => false); - if (!hasRail) { - await expect(empty).toBeVisible(); +// The numeric parts of the page are gated on the build-time summary, so each +// assertion reads the summary on disk and expects exactly what it implies: the +// part present with data, absent without. A source-only build (no summary) is +// a legal state and must render none of them rather than zeroes. +function readSummary(): { + monthly?: unknown[]; + sites?: unknown[]; + official?: { sites: number }; + generatedAt?: string; +} | null { + try { + const file = path.resolve( + test.info().project.testDir, + "..", + "public", + "homepage-summary.json", + ); + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return null; + } +} + +test("the growth chart renders from the summary, or not at all", async ({ + page, +}) => { + const summary = readSummary(); + const chart = page.getByRole("img", { name: /transcripts by the month/i }); + if (!summary?.monthly?.length || !summary.sites?.length) { + await expect(chart).toHaveCount(0); + return; + } + await expect(chart).toBeVisible(); + await expect( + page.getByText(/all official instances\.$/i), + ).toBeVisible(); +}); + +test("official instances link out, and the numbers carry their build date", async ({ + page, +}) => { + const summary = readSummary(); + const heading = page.getByRole("heading", { name: "Official instances" }); + if (!summary?.sites?.length) { + await expect(heading).toHaveCount(0); return; } - // With data: every row carries a real date in the log's own format. - const times = page.locator("time[datetime]"); - await expect(times.first()).toBeVisible(); - for (const text of await times.allTextContents()) { - expect(text.trim()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + await expect(heading).toBeVisible(); + const section = page.locator("section").filter({ has: heading }); + const links = section.getByRole("link"); + expect(await links.count()).toBeGreaterThanOrEqual(1); + for (const href of await links.evaluateAll((els) => + els.map((e) => e.getAttribute("href") ?? ""), + )) { + expect(href).toMatch(/^https?:\/\/[^/]/); + } + if (summary.official && summary.official.sites > 0) { + const time = page.locator("time[datetime]"); + await expect(time).toHaveCount(1); + expect(await time.getAttribute("datetime")).toMatch(/^\d{4}-\d{2}-\d{2}$/); } }); diff --git a/plans/release-8.md b/plans/release-8.md @@ -179,6 +179,131 @@ format is unchanged. persist its old object to the same fixture file until it notices its job is gone. That is a cross-spec clobber, not a two-lanes-in-one-process problem, and this slice did not introduce it. +### Slice H, as shipped — the homepage, rethought (2026-09-25) + +Branch `one-core/r8-homepage` off `main` `07a56106`. **The operator's direction (2026-09-25):** "take a +look at the homepage and try out a complete refresh, rethink it from the ground up. Primarily describe +the project, and any of our existing sites should be regarded as the instances that belong to me, maybe +called something like 'Official instances'." The page had "a bit of 'clunky AI' writing where it could +be more straight to the point and have less subtitles"; "The whole 'What this isn't' section seems kind +of bad"; "narrow it down to minimal most impactful copy and show some cool stats about the official +instances"; and "The 'recent acquisitions' list doesn't feel like it should be the above-the-fold +showpiece, I think a chart there would be a cooler visual." The hub link stays withheld +(`HUB_LINK_ENABLED = false`). + +**The data.** `buildHomepageSummary` is v5, all additive and optional on the type so a v4 summary on +disk still renders (its numbers hide): +- `monthly: {month, bySite}[]` counts transcribed public-universe recordings by **upload month** + (`VideoStat.uploadDate`), attributed to the primary public site. It is full history, from the + earliest upload month to the last complete month (the build month is never emitted), zero-filled, and every site is keyed in every month. + Upload month rather than `transcribedDate` because the transcription history is six months old + (2026-04 to 2026-09, 25,288 of it in May) and would chart the tool's own backlog. Upload month + shows how far back each archive reaches: 204 months, 2009-09 to 2026-08. +- `monthlyUnplaced` counts the transcripts with a missing, malformed, build-month or future upload date, so + nothing is dropped silently (106 on the current corpus, all from the build month). +- Each site gains `channels` (with a transcript), `recordings` (with a download date), `hoursArchived` + and `gone` (status `deleted`). +- `official` sums them over the public sites. It is not `totals`, which also counts pool-only + channels and the URL-less Jasolyzer: 49,767 official transcripts against 49,769 in total. + +**The page, top to bottom, with its final copy:** +1. **H1** `{official.hoursArchived} hours of speech, searchable to the second.` (no data: + `Every word a channel said, searchable to the second.`). It is one line from `lg` up. + **Subhead:** "Download a channel's back catalogue, transcribe it on your own hardware, publish a + static site you host yourself." The CTAs are unchanged: `Download the source` → `/downloads/`, + `Read the setup guide →` → `/docs/install/`, and the hub button behind the switch. +2. **The chart** (`ArchiveGrowthChart.tsx`), in the hero above the fold at 1280×900. It has a + legend row, then stacked strata, then the caption "Transcripts by the month each video was + published, all official instances." and a `Numbers by year` details table. +3. **The stats strip** (`FamilyStats.tsx`): Hours of speech · Transcripts · Recordings · Channels · + Official instances · **Recordings gone at the source, still here**. The last cell is double + width, in `--state-gone`. The caption is `Across the official instances · index built <time>`. +4. **Official instances**, with the standfirst "The archives I run. Anyone can run their own." It + has five cards: title linking out (↗), description, and channels · recordings · transcripts · + hours. The stripe is `seriesColor(i)`, the chart's colour for that instance. +5. **What it does**, as three short paragraphs. Without subtitles, three one-sentence items are a + paragraph with bullets in front: + "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." / "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." / "It is a program you run, + not a service. There is no account and nothing phones home. The docs cover setup and deployment." + (links `/docs/`). + +**Dropped from `/`:** the recent-acquisitions rail (`RecentAdditions.tsx` is kept and `recent` stays +in the summary), "How it works", "What this isn't", the proof-point band (its number is now the strip's +gone cell), the "This deployment, at last build" eyebrow and every other eyebrow. + +**The chart.** It is a server component and renders inline SVG at build time. It ships no client JS +and no chart library. The plot is an SVG with `preserveAspectRatio="none"` in a fixed-height box +(200/260/300 px), which avoids layout shift at any width. The hairlines are `non-scaling-stroke`, and +the axis labels are HTML positioned in percent, so no text stretches. Year ticks run every 4 years on +a phone and every 2 from `sm`. The value scale sits on its gridlines. Hover is native: one transparent +column per month with a `<title>` (month, total and per instance) and a CSS tint. The chart has +`role="img"` with a sentence label (total, span, instance count and busiest month). **Area, not bars:** +204 months over ~1,100 px is ~5 px a month, and the dataviz spec's 2 px gap would be half of each bar. +The palette check (`validate_palette.js`) on the family's `--chart-1..5` FAILS normal-vision separation +for the adjacent teal/blue pair (ΔE 12.4 light, 9.0 dark). The **stack order is therefore interleaved +(0, 2, 1, 4, 3)**, and every touching pair then passes (worst 21.5 light / 22.5 dark, CVD ≥ 10.2). The +colours still follow the instance. Cards no longer use a site's `accent`, so a stripe cannot disagree +with its layer (no site sets one today). + +**`deploy homepage [--preview <branch>]`.** `deployHomepage` takes `previewBranch` the way `deployHub` +does. `previewBranchProblem` refuses `main`/`production` and bad names before the build check. A preview +deploys with `--branch <name>` and no `--branch main`, and prints the `previewAliasUrl` line. +`homepageDeployArgs(outDir, previewBranch?)` is the pure argv. **No deploy was run.** + +| sha | what | +|---|---| +| `6fd98789` | `homepageSummary.ts` v5: `monthly`, `monthlyUnplaced`, per-site `channels`/`recordings`/`hoursArchived`/`gone`, `official`. New `homepageSummary.test.ts` (+3): upload-month placement, zero fill and every site keyed; unplaceable dates counted; per-site and official sums exclude a URL-less site that `totals` still counts; hours rounded once over summed seconds; empty `monthly` when there is nothing to place | +| `1f93fd10` | `ArchiveGrowthChart.tsx` and the `.growth-hit` hover rule in `globals.css` | +| `921f534b` | `page.tsx` rewritten. `FamilyStats.tsx` is new. `ArchiveCards.tsx` gets per-site numbers and a `seriesColor(i)` stripe, and the title becomes the link | +| `8aa17ccb` | `marketing.spec.ts`: "What this isn't" and the rail are replaced by the chart (`role=img`, present iff the summary on disk has `monthly`) and `Official instances` (≥ 1 absolute link iff sites) with the strip's `time[datetime]`. H1 regex, CTA names, the conditional hub link, nav and 404 are kept | +| `cde68392` | (review fix) `monthly` ends at the last COMPLETE month: the month holding `generatedAt` is never emitted, because charted it read as a collapse (106 so far in 2026-09). Its transcripts join `monthlyUnplaced` (106 on the live data), so placed + unplaced still equals `official.transcripts`. `homepageSummary.test.ts` +1 (a build month in January rolls back to December) and the fixture gains a build-month upload that is not emitted. The chart and the `Numbers by year` table read the same series, so the 2026 row now stops at August. The caption is unchanged. It says "by the month each video was published", which is still true | +| `7c01ea86` | `deployHomepage({previewBranch})`, `homepageDeployArgs` and the CLI row. `build.test.ts` +2 (argv for production, blank and preview; refusal order) | + +**Re-gate after the review fix**, on `cde68392` (`h-regate.log`): tsc clean. Common tests **1816/1816** +(1815 + 1). `next build` ok. Homepage e2e **22 passed, 0 failed, 36 s** (`h-e2e2.log`), with no queue +wait. `h-home-desktop-dark.png` was re-shot: the strata end at 2026-08 with no drop to zero, and there +is no overflow at 1280 or 390. + +**Gates**, on `7c01ea86`: tsc clean. Common tests **1815/1815** = 1810 + 5. `pnpm --filter homepage +exec next build` ok, 15 static pages (`out/index.html` is 271 KB, 38 KB gzipped, against 94 KB before; +most of the growth is the chart's paths and 205 hover columns, repeated in the RSC payload). The +homepage e2e (`node scripts/worktree.mjs run -- pnpm --filter homepage run e2e`, `h-e2e.log`) passed +**22, failed 0, in 35 s**, with no queue wait; its 5 specs were run against the copied v5 summary. The +marketing spec also passed with the summary moved away (no-data path: no chart, no heading, the +fallback H1). Screenshots at 1280 and 390 wide in light and dark were checked by eye: no horizontal +overflow (scrollWidth equals the viewport in all four), the chart legible, nothing clipped. The +editor/export builds, editor unit, test:scripts and mcp were not run, because the slice touches no file +they build or test (`common/publish/build.ts` is covered by the common suite and tsc). + +For a realistic render, the worktree's `homepage/public/{channel-sites.json,stats/}` were copied from +the primary. `homepage-summary.json` was rebuilt offline by `buildHomepageSummary` over the primary's +stats pages (`$T/h-mksummary.ts`) and read no corpus. On the current data, official is 5 instances, +63 channels, 75,785 recordings, 49,767 transcripts, 60,840 h and 480 gone. + +**Numbers: none.** No settings, site or channel key changed. The summary's format is additive. + +**Pending, the parent's:** `archilyzer build homepage`, then the preview (`deploy homepage --preview +<branch>`) and production deploys, from the primary. The live summary becomes v5 only when compose +runs there. + +**Found and left.** +- **The partial build month is trimmed (review fix above). August still lags.** The last complete month, + 2026-08, has 419 against ~600 a month through 2025 and 778 in July, because archiving trails recent + uploads. It stays in the series: the rule is to trim one month, and August is complete as a calendar + month. +- **The chart cannot show when things were archived.** `transcribedDate` has six months of history + and a 25k May spike. The summary's `series.transcribed.month` still carries it for `/stats`. +- **`--chart-3` is `--state-gone` in the archilyzer family** (Bonnellyzer's layer is the same rust as + the gone figure). The family chroma floor also fails the validator (teal and ochre read greyish). + Both are in `common/styles/tokens.css`, outside this slice. +- **`/stats` logs `Received NaN for the width attribute`** once during `stats.spec.ts` (the spec + passes). It comes from the `/stats` client charts, not this page, and was not investigated. +- `KpiHeader.tsx` and `RecentAdditions.tsx` are unchanged. `/stats` still uses `KpiHeader`. The rail is + unused on `/` but kept, per the brief. + ## Rollout 2026-09-25 (late afternoon) — `0e72ef73` live on :3001 (second restart of the day) The second editor restart of the day. Release 7 (`bb3dbb4c`) had gone live at 13:11. Two things