commit 55dcd60381b74934d03c9edca6242abde4408491
parent 5967fccab7f0e1225cde99a516b2d4b05a7421d1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:17:41 -0400
Merge main (23c01dcc, release 9 slice C1b) into one-core/c2-federated-search
Diffstat:
13 files changed, 253 insertions(+), 89 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/STATE.md b/plans/STATE.md
@@ -14,14 +14,16 @@ top of release 8's `0e72ef73`), `BUILD_ID` `P0VMdKX7gbdsaiS5GvorF`. umtool on :3
See the [release-9 rollout record](release-9.md#rollout-2026-09-25-evening--0213f6c8-live-on-3001-third-restart-of-the-day).
-**Next (the 2026-09-25 evening plan, step C2 onward):** C2 federated-search UX (target behaviour
-proposed to the operator: all official sites by default with per-site chips, per-site loading/failed
-state, "N of M archives answered", a text source label on each result card, staged loading);
-then the Ko-fi line (ko-fi.com/archilyzer — copy: "funds development and better servers for the
-official instances", no setup details; homepage line + hub footer + config-gated site footers);
-then C3 (`HUB_LINK_ENABLED = true`, homepage rebuild) in the same homepage deploy; then release 10
-lows (soft-block classification, boot-pass storage-wait timeout, `/jobs` cancelReason) and Phase 4
-slice 3.
+**Next (the 2026-09-25 evening plan, step C2 onward) — the operator ruled at ~19:58:** C2 goes as
+proposed (all official sites by default with per-site chips, per-site loading/failed state, "N of M
+archives answered", a text source label on each result card, staged loading) — implementing on
+`one-core/c2-federated-search`. Ko-fi (ko-fi.com/archilyzer) is a BARE link, no copy at all (the
+operator manages the pitch on Ko-fi): homepage + hub footers, sites untouched — on
+`one-core/c1b-hub-copy` with the copy rulings: "Official Instances" in Title Case, the sentence under
+it removed on hub AND homepage, no subtitles unless needed, the per-site descriptions dropped from
+the instance cards. The Paramount Tactical filter clear was the operator's; a full download is wanted
+(handling `youtube`, captions arrive with each video). Then C3 (`HUB_LINK_ENABLED = true`) in the
+same homepage deploy as C1b/C2; then release 10 lows and Phase 4 slice 3.
See the
[release-8 rollout record](release-8.md#rollout-2026-09-25-late-afternoon--0e72ef73-live-on-3001-second-restart-of-the-day).
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)