Archilyzer · Source

archilyzer

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

commit 23c01dcc6d2539e2f95d611e82a6b4320bbca0a2
parent c793261ab7c490a9947730e3fef65da35a6387a3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 20:17:23 -0400

Merge one-core/c1b-hub-copy — release 9 slice C1b: Official Instances in Title Case, the sentence under it and the card descriptions gone (hub + homepage), a bare Ko-fi link in the homepage and hub footers

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

Diffstat:
Mcommon/lib/project.ts | 6++++++
Meditor/CHANGELOG.md | 1+
Mexport/app/components/Footer.tsx | 14++++++++++++++
Mexport/app/components/hub/AddArchive.tsx | 2+-
Mexport/app/components/hub/ArchiveShelf.tsx | 72+++++++++++++++++++++++++++++++-----------------------------------------
Mexport/e2e-hub/official-instances.spec.ts | 34++++++++++++++++++++++++++++++----
Mexport/e2e/site-branding.spec.ts | 11++++++++++-
Mhomepage/app/components/ArchiveCards.tsx | 33++++++++++++++-------------------
Mhomepage/app/components/Footer.tsx | 22+++++++++++++++++-----
Mhomepage/app/page.tsx | 16+++++++---------
Mhomepage/e2e/marketing.spec.ts | 26+++++++++++++++++++++++++-
Mplans/release-9.md | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 243 insertions(+), 81 deletions(-)

diff --git a/common/lib/project.ts b/common/lib/project.ts @@ -26,6 +26,12 @@ export const PROJECT_NAME = "Archilyzer"; // valid. Only then is changing this string a cosmetic follow-up. export const PROJECT_URL = "https://archilyzer.pages.dev"; +// The project's Ko-fi page. Rendered as a bare link (visible text "Ko-fi", no +// copy around it) on the project's OWN surfaces only: the homepage footer and +// the hub's footer. A site build never shows it — an archive someone else +// hosts must not carry this project's tip jar. +export const KOFI_URL = "https://ko-fi.com/archilyzer"; + export const PROJECT_TAGLINE = "Self-hosted, searchable video-transcript archives."; // Where a visitor gets the source. A dated snapshot tarball — there is no diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The hub and the homepage drop their subtitles, and both carry a Ko-fi link.** The headings are in Title Case on both pages: **Official Instances**, **Archives You Added** (hub) and **What It Does** (homepage). The line "The archives I run. Anyone can run their own." is gone from both, and the official-instance cards no longer show the site's description under its name; the four figures stay. The footer of the homepage and of the hub has a plain link reading "Ko-fi" to `https://ko-fi.com/archilyzer`. A published site's footer does not: an archive someone else hosts never carries it. Needs a rebuild and deploy of the hub and the homepage. - **A job that waited in a queue no longer ends `failed` after doing its work.** A sync, download or other job queued behind another on the same platform ran its final page refresh outside any request, where Next refuses it, so the job read `failed` and `pnpm ops … --wait` exited 1 even though the work was done (the teamrcn sync on 2026-09-25). The refresh is now skipped there with one warning in the server log; the pages re-read disk on their next load anyway. - **Downloads no longer sleep after a video the download filter declined.** The "sleep between downloads" (30 s by default) ran after every video, including each one the channel's download filter declined before fetching anything. A filtered channel's download-missing slept 193 times for 14 downloads on 2026-09-25. It still sleeps after every real fetch and after every failure, per-video ones included. - **YouTube requests are paced at one per second.** Every yt-dlp run against YouTube now carries `--sleep-requests 1`, as Rumble's already did: the 429 investigation found YouTube had no request-level pacing at all. A channel's own `ytdlpExtraArgs` still wins, because it comes after. diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -8,6 +8,7 @@ import { resolveSocialLinks, } from "yt-dlp-transcript-common/lib/site"; import { + KOFI_URL, PROJECT_NAME, PROJECT_URL, } from "yt-dlp-transcript-common/lib/project"; @@ -88,6 +89,19 @@ export default function Footer() { {PROJECT_NAME} </a> </span> + {/* Ko-fi: a bare link, no copy — and on the HUB only. The hub is the + project's own deployment; a site build is an archive whoever runs + it hosts, and must not carry this project's tip jar. */} + {instanceMode() === "hub" && ( + <a + href={KOFI_URL} + target="_blank" + rel="noopener noreferrer" + className="text-xs underline underline-offset-2 hover:text-foreground transition-colors" + > + Ko-fi + </a> + )} {socialLinks.length > 0 && ( <ul className="flex items-center gap-3 list-none"> {socialLinks.map((link, i) => ( diff --git a/export/app/components/hub/AddArchive.tsx b/export/app/components/hub/AddArchive.tsx @@ -1,7 +1,7 @@ "use client"; // The add-an-archive control: paste an archive URL, it validates against the -// federation contract, and on success the archive joins "Archives you added". +// federation contract, and on success the archive joins "Archives You Added". // Written as direction — validating → success, or a plain reason it couldn't // be read. Self-contained: owns its input + status and commits to the shared // registry. The button is the family's CTA — ink on ground, not a coloured diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx @@ -3,8 +3,9 @@ // The hub's front: what it is, the official instances it searches, the // archives a visitor added, and the form to add another — in the homepage's // register (homepage/app/page.tsx): short declarative sentences, no eyebrow -// over a band, no subtitle under a heading, and each heading after the H1 -// names what is under it. +// over a band, no subtitle under a heading (nor under a card's title: the +// site's description is not shown), Title Case headings, and each heading +// after the H1 names what is under it. // // Two kinds of number, deliberately kept apart. An official card shows the // homepage's own figures for that archive, composed at hub build time @@ -67,7 +68,6 @@ function ArchiveCard({ }) { const accent = site.accent || figures?.accent || fallbackAccent; const href = site.siteUrl || site.origin; - const description = figures?.siteDescription; const channels = figures === undefined ? site.channelCount : figures?.channels; const anyFigure = @@ -86,38 +86,31 @@ function ArchiveCard({ className="absolute inset-y-0 left-0 w-[3px]" style={{ background: accent }} /> - <div className="flex flex-col gap-1.5"> - <div className="flex items-start justify-between gap-2"> - <h3 className="font-display text-lg font-semibold text-foreground"> - <a - href={href} - target="_blank" - rel="noopener noreferrer" - className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-border-strong" - > - {site.siteTitle} - <span aria-hidden="true" className="ml-1.5 text-sm text-faint"> - ↗ - </span> - </a> - </h3> - {onRemove && ( - <button - type="button" - onClick={() => onRemove(site.origin)} - aria-label={`Remove ${site.siteTitle}`} - // Reveal-on-hover is not an affordance on a touch screen: a device - // with no hover shows it always. `hover:` never gates a function. - className="-mr-1 mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100" - > - <X className="size-3.5" aria-hidden="true" /> - </button> - )} - </div> - {description && ( - <p className="text-sm leading-relaxed text-muted-foreground"> - {description} - </p> + <div className="flex items-start justify-between gap-2"> + <h3 className="font-display text-lg font-semibold text-foreground"> + <a + href={href} + target="_blank" + rel="noopener noreferrer" + className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-border-strong" + > + {site.siteTitle} + <span aria-hidden="true" className="ml-1.5 text-sm text-faint"> + ↗ + </span> + </a> + </h3> + {onRemove && ( + <button + type="button" + onClick={() => onRemove(site.origin)} + aria-label={`Remove ${site.siteTitle}`} + // Reveal-on-hover is not an affordance on a touch screen: a device + // with no hover shows it always. `hover:` never gates a function. + className="-mr-1 mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100" + > + <X className="size-3.5" aria-hidden="true" /> + </button> )} </div> <div className="mt-auto flex flex-col gap-3"> @@ -177,12 +170,9 @@ export default function ArchiveShelf() { {official.length > 0 ? ( <div> - <h2 className="font-display text-2xl font-semibold text-foreground"> - Official instances + <h2 className="mb-6 font-display text-2xl font-semibold text-foreground"> + Official Instances </h2> - <p className="mt-2 mb-6 max-w-xl leading-relaxed text-muted-foreground"> - The archives I run. Anyone can run their own. - </p> <ul className={GRID}> {official.map((site, i) => { const figures = hubSummarySiteFor(summary, site); @@ -209,7 +199,7 @@ export default function ArchiveShelf() { {added.length > 0 && ( <> <h2 className="font-display text-2xl font-semibold text-foreground"> - Archives you added + Archives You Added </h2> <ul className={`${GRID} mt-6 mb-6`}> {added.map((site, i) => ( diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"; +import { KOFI_URL } from "../../common/lib/project"; -// The hub's "Official instances": one card per built-in member +// The hub's "Official Instances": one card per built-in member // (hub-sites.json), carrying the homepage's own figures for it when the hub was // composed with them (hub-summary.json, written by compose-hub from the same // buildHomepageSummary call the homepage makes). The summary is OPTIONAL — an @@ -79,8 +80,15 @@ test.describe("hub official instances", () => { }), ).toBeVisible(); await expect( - page.getByRole("heading", { level: 2, name: "Official instances" }), + page.getByRole("heading", { + level: 2, + name: "Official Instances", + exact: true, + }), ).toBeVisible(); + // No subtitle under the heading (operator ruling 2026-09-25: the title + // implies it). + await expect(page.getByText("The archives I run")).toHaveCount(0); const card = page .getByTestId("shelf-spine") @@ -89,7 +97,9 @@ test.describe("hub official instances", () => { await expect( card.getByRole("link", { name: /Member Archive/ }), ).toHaveAttribute("target", "_blank"); - await expect(card).toContainText("Every word the member said."); + // The summary carries a description, and the card does not show it: no + // subtitle unless needed. The four figures carry the card. + await expect(card).not.toContainText("Every word the member said."); for (const [value, unit] of [ ["12", "channels"], ["3,456", "recordings"], @@ -103,10 +113,26 @@ test.describe("hub official instances", () => { // "Archives you added" section until they add one. await expect(card.getByRole("button", { name: /^Remove/ })).toHaveCount(0); await expect( - page.getByRole("heading", { name: "Archives you added" }), + page.getByRole("heading", { name: "Archives You Added" }), ).toHaveCount(0); }); + test("the hub footer carries a bare Ko-fi link", async ({ page }) => { + await stubMember(page); + await page.route("**/hub-summary.json", (r) => + r.fulfill({ status: 404, body: "" }), + ); + await page.goto("/"); + const kofi = page + .locator("footer") + .getByRole("link", { name: "Ko-fi", exact: true }); + await expect(kofi).toHaveCount(1); + await expect(kofi).toHaveAttribute("href", KOFI_URL); + await expect(kofi).toHaveAttribute("target", "_blank"); + await expect(kofi).toHaveAttribute("rel", "noopener noreferrer"); + await expect(kofi).toHaveText("Ko-fi"); + }); + test("without hub-summary.json, the card renders with no figures", async ({ page, }) => { diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { PROJECT_URL } from "../../common/lib/project"; +import { KOFI_URL, PROJECT_URL } from "../../common/lib/project"; import { installRoutes } from "./helpers"; // The export app is built per-site; its SSR branding (document title, header, @@ -45,6 +45,15 @@ test("footer credits the project, ungated and in the same tab", async ({ await expect(footer).toContainText("Built with Archilyzer"); }); +test("a site build carries no Ko-fi link", async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + // The project's Ko-fi is on the hub and the homepage only: an archive + // someone else hosts must not carry this project's tip jar. + await expect(page.getByRole("link", { name: "Ko-fi" })).toHaveCount(0); + await expect(page.locator(`a[href="${KOFI_URL}"]`)).toHaveCount(0); +}); + test("the Downloads eyebrow appears only when something is behind it", async ({ page, }) => { diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx @@ -2,7 +2,9 @@ import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageS import { seriesColor } from "yt-dlp-transcript-common/lib/homepageChart"; // 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 +// its own numbers underneath. No description line under the title — the +// page's copy rule is no subtitles unless needed, and the name plus the +// numbers say what the archive is. The stripe is seriesColor(i), the same index the // growth chart colours that instance's layer with, so a card is its own legend. // // A site's `accent` is deliberately NOT used here any more: the chart cannot @@ -38,24 +40,17 @@ export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) { 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> + <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> <dl className="mt-auto grid grid-cols-4 gap-3"> <Figure value={site.channels} unit="channels" /> <Figure value={site.recordings} unit="recordings" /> diff --git a/homepage/app/components/Footer.tsx b/homepage/app/components/Footer.tsx @@ -5,6 +5,7 @@ import { } from "yt-dlp-transcript-common/lib/settings"; import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage"; import { + KOFI_URL, PROJECT_NAME, PROJECT_TAGLINE, } from "yt-dlp-transcript-common/lib/project"; @@ -49,9 +50,12 @@ export default function Footer() { </ul> </nav> - {socialLinks.length > 0 && ( - <div className="flex flex-col gap-3"> - <span className="label-machine">Elsewhere</span> + {/* "Elsewhere": the operator's social accounts, then the project's + Ko-fi — a bare link, no copy. Ko-fi is always present, so the + column always renders; the icon row only when links are set. */} + <div className="flex flex-col gap-3"> + <span className="label-machine">Elsewhere</span> + {socialLinks.length > 0 && ( <ul className="flex items-center gap-4 list-none"> {socialLinks.map((link, i) => ( <li key={`${link.url}-${i}`}> @@ -67,8 +71,16 @@ export default function Footer() { </li> ))} </ul> - </div> - )} + )} + <a + href={KOFI_URL} + target="_blank" + rel="noopener noreferrer" + className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" + > + Ko-fi + </a> + </div> </div> </footer> ); diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx @@ -13,7 +13,8 @@ import { currentHomepage } from "./lib/homepage"; // 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 +// every band, no subtitles under the headings or the cards, headings in Title +// Case (the hub shelf matches). 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"; @@ -106,28 +107,25 @@ export default function Home() { </section> )} - {/* ── Official instances ───────────────────────────────────────────── */} + {/* ── 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 className="mb-8 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> )} - {/* ── What it does ─────────────────────────────────────────────────── */} + {/* ── What It Does ─────────────────────────────────────────────────── */} {/* Three short paragraphs, not three titled panels: without titles, three one-sentence items read as prose, not as a feature grid. */} <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 + 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> diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { test, expect } from "@playwright/test"; +import { KOFI_URL } from "../../common/lib/project"; // The home page's job is to say what Archilyzer is and offer the download. // Nothing asserted here names a count, a site or a headline number. The numeric @@ -92,12 +93,18 @@ test("official instances link out, and the numbers carry their build date", asyn page, }) => { const summary = readSummary(); - const heading = page.getByRole("heading", { name: "Official instances" }); + const heading = page.getByRole("heading", { + level: 2, + name: "Official Instances", + exact: true, + }); if (!summary?.sites?.length) { await expect(heading).toHaveCount(0); return; } await expect(heading).toBeVisible(); + // No subtitle under the heading, and no description under a card's title. + await expect(page.getByText("The archives I run")).toHaveCount(0); const section = page.locator("section").filter({ has: heading }); const links = section.getByRole("link"); expect(await links.count()).toBeGreaterThanOrEqual(1); @@ -113,6 +120,23 @@ test("official instances link out, and the numbers carry their build date", asyn } }); +test("the headings after the H1 are in Title Case", async ({ page }) => { + await expect( + page.getByRole("heading", { level: 2, name: "What It Does", exact: true }), + ).toBeVisible(); +}); + +test("the footer carries a bare Ko-fi link", async ({ page }) => { + const kofi = page + .locator("footer") + .getByRole("link", { name: "Ko-fi", exact: true }); + await expect(kofi).toHaveCount(1); + await expect(kofi).toHaveAttribute("href", KOFI_URL); + await expect(kofi).toHaveAttribute("target", "_blank"); + await expect(kofi).toHaveAttribute("rel", "noopener noreferrer"); + await expect(kofi).toHaveText("Ko-fi"); +}); + test("branded 404 for an unknown path", async ({ page }) => { await page.goto("/no-such-page/"); await expect(page.getByRole("heading", { name: /no such page/i })).toBeVisible(); diff --git a/plans/release-9.md b/plans/release-9.md @@ -376,6 +376,93 @@ Bonnellyzer 2, Hasanalyzer 3, Rekietalyzer 0 — identical before and after on t left as a fetch so the e2e can stub it. - The chart-3 change repaints the homepage's third instance too; it needs a homepage rebuild to show. +### Slice C1b, as shipped — copy rulings + Ko-fi link (2026-09-25) + +Branch `one-core/c1b-hub-copy` off `main` `6820bd21`. **The operator's rulings (2026-09-25), amending +C1:** Title Case on the h2 headings of the hub shelf and the homepage, so the two pages share one style; +no subtitle where the title implies it ("The archives I run. Anyone can run their own." removed on +both); no per-site description on the official-instance cards (hub and homepage), the four figures +stay; a bare `Ko-fi` link, no copy, on the project's own surfaces only. The hub's H1 and its paragraph +and the homepage's hero paragraph stay (they are not subtitles). C2's files (`SearchDataContext`, +`SearchResults`, `SearchSessionContext`, `siteRegistry`, `HubHome`, `HubStats`, the `ask-grounding` / +`federation` / `ask` hub specs) were not touched. + +**Copy, final:** +- Hub (`ArchiveShelf.tsx`): h2 **Official Instances** (no paragraph under it; the grid's top margin + moved onto the h2), h2 **Archives You Added**. The card is title (+ Remove on an added archive), then + the figures and the `Installable`/`Data-only` badge; `siteDescription` is no longer read. + `AddArchive.tsx`'s comment names the new heading. +- Homepage (`page.tsx`, `ArchiveCards.tsx`): h2 **Official Instances** (no paragraph), h2 **What It + Does**; the card is title then figures. The page's copy-rule comment now says so. +- `FamilyStats`' cell label "Official instances" is a stat label, not a heading, and stays in the + strip's sentence case (like "Hours of speech"); not in the ruling. + +**Ko-fi.** `KOFI_URL = "https://ko-fi.com/archilyzer"` beside `PROJECT_URL` in `common/lib/project.ts`. +Export footer (`export/app/components/Footer.tsx`): `<a target="_blank" rel="noopener noreferrer">Ko-fi</a>` +in the "Built with Archilyzer" row, rendered **only when `instanceMode() === "hub"`** — a site build +shows nothing. Homepage footer (`homepage/app/components/Footer.tsx`): the homepage has no "Built with" +row, so the link sits in the **Elsewhere** column under the social icons; that column now always +renders (it was gated on social links), the icon row only when links are set. + +**Layout without the description** (built hub served statically with the primary's `hub-sites.json` + +`hub-summary.json` stubbed in; built homepage with the primary's `homepage/public` data), 1280 and 390, +dark: the cards shorten by the description line; the figures stay aligned across a row (`mt-auto` on +the figure block unchanged), and at 390 each card is title, figures, badge with no gap. Shots: +`$T/c1b-hub-1280.png`, `$T/c1b-hub-390.png`, `$T/c1b-homepage-1280.png`, `$T/c1b-homepage-390.png`. + +**Changed locators:** `e2e-hub/official-instances.spec.ts` — `heading {level: 2, name: "Official +instances"}` → `{level: 2, name: "Official Instances", exact: true}`; the card's `toContainText("Every +word the member said.")` → `not.toContainText(…)` (the stub still carries a description, so the +absence is proved); `getByText("The archives I run")` count 0; `heading "Archives you added"` → +`"Archives You Added"`; new test "the hub footer carries a bare Ko-fi link" (href `KOFI_URL`, +`_blank`, `noopener noreferrer`, text exactly `Ko-fi`). `homepage/e2e/marketing.spec.ts` — `heading +"Official instances"` → `{level: 2, name: "Official Instances", exact: true}` + "The archives I run" +count 0; new "the headings after the H1 are in Title Case" (`What It Does`, exact) and "the footer +carries a bare Ko-fi link". `export/e2e/site-branding.spec.ts` — new "a site build carries no Ko-fi +link" (no link named Ko-fi, no `a[href=KOFI_URL]`). **Left unchanged deliberately:** +`e2e-hub/federation.spec.ts:151` `heading {level: 2, name: "Archives you added"}` (C2's file) — a +Playwright role name without `exact` is a case-insensitive substring, so it matches "Archives You +Added" and passes (it did: e2e:hub 12/12); C2 or a later slice may make it exact. Its test title still +says "Archives you added" (a string, not a locator). + +| sha | what | +|---|---| +| `764f770e` | `common/lib/project.ts` `KOFI_URL`; Ko-fi in the hub footer (hub only) and the homepage footer's Elsewhere column | +| `0e9192e9` | Title Case h2s, the "archives I run" line removed, no description on the official cards — hub `ArchiveShelf` (+ `AddArchive` comment), homepage `page.tsx` + `ArchiveCards` | +| `35aafad3` | the spec locators above; Ko-fi specs (hub, homepage, site-mode absence) | + +**Gates** (on `35aafad3`): tsc clean (per commit). Common tests **1,845/1,845**. `pnpm --filter export +exec next build` (site) ok; `compose:hub` (worktree, no corpus: "0 built-in pool site(s) …; +hub-summary.json skipped") + `INSTANCE_MODE=hub … next build` ok (the known Turbopack warning) — the +seven compose-hub outputs in `export/public` swapped for copies before and relinked after, primary's +files untouched (`hub-sites.json` / `hub-summary.json` still 19:42); the built hub `index.html` carries +the Ko-fi anchor. `pnpm --filter homepage exec next build` ok. e2e, queued (no wait): **`e2e:hub` 12 +passed** (11 + 1), 45 s; export `site-branding.spec.ts theme.spec.ts` **8 passed**, 26 s; **homepage +suite 24 passed**, 57 s, with the Official Instances branch exercised (summary present). Editor build, +editor unit, test:scripts and mcp not run: nothing they build or test changed beyond a new constant in +`common/lib/project.ts`, which tsc covers. **Numbers tools: none.** + +**Found and left:** +- **Homepage worktree data must be COPIED, not symlinked.** `homepage/app/lib/snapshot.ts` reads + `public/downloads/snapshot.json` at build, and Turbopack refuses a symlink that leaves the project + root ("Symlink … is invalid, it points out of the filesystem root"). The four gitignored entries + (`channel-sites.json`, `downloads/`, `homepage-summary.json`, `stats/`; ~60 MB) were copied from the + primary. `export/public`'s per-path links work only because nothing there is imported at build. + Worth a line in FACTS / WORKTREES.md. +- **The export e2e harness writes the primary's `export/public/sw.js` through the worktree symlink.** + `export/playwright.config.ts` copies `service-worker/site-sw.js` to `public/sw.js` at load; in a + worktree that `sw.js` is a link to the primary's, so the site-spec run (20:13:39) replaced the + primary's hub service worker (10,027 B, from the 19:42 hub compose) with the site one (10,043 B). + Restored: the hub build's `out/sw.js` from this worktree is byte-identical to the primary's + `service-worker/sw-hub.js`, copied back and its mtime set to `hub-sites.json`'s (19:42:24). The C1 + gate statement "the six compose-hub outputs … relinked after" did not cover this path either. Fix + candidate: unlink `public/sw.js` before the copy in `playwright.config.ts` (or add `sw.js` to the + worktree-link exclusions). Not done here (not this slice's file). +- The homepage lists instances in the summary's order (Jeralyzer first), the hub in `hub-sites.json` + order (Anilyzer first); the colours agree per site. Pre-existing (C1), not a ruling. +- Needs a rebuild + deploy of the hub AND the homepage; this slice did not run build-hub, deploy-hub + or deploy homepage. + ## Rollout ## Rollout 2026-09-25 (evening) — `0213f6c8` live on :3001 (third restart of the day)