Archilyzer · Source

archilyzer

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

commit 22ad0cb713d806d345aa857ff21b6bdce65c5ca2
parent c84a3ee0cf2fabfd370ab96ba4a1e95d6a6ad852
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 18:45:44 -0400

Merge export/kofi-mark — release 10 slice O: the official Ko-fi symbol as the last item of every site's and the hub's social row (accessible name Ko-fi, no copy, official colours, vendored byte-identical asset, offline-cached like the icons); supersedes the hub-only bare link

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

Diffstat:
Mcommon/lib/archive/serviceWorkerRouting.test.ts | 11+++++++++++
Mcommon/lib/project.ts | 9+++++----
Mexport/CHANGELOG.md | 1+
Mexport/app/components/Footer.tsx | 46+++++++++++++++++++++++++++++++---------------
Mexport/e2e-hub/official-instances.spec.ts | 17+++++++----------
Mexport/e2e/helpers.ts | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mexport/e2e/site-branding.spec.ts | 23++++++++++++++++-------
Aexport/public/kofi-symbol.svg | 13+++++++++++++
Mexport/service-worker/site-sw.js | 7+++++--
Mexport/service-worker/sw-hub.js | 4+++-
Mplans/release-10.md | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 276 insertions(+), 40 deletions(-)

diff --git a/common/lib/archive/serviceWorkerRouting.test.ts b/common/lib/archive/serviceWorkerRouting.test.ts @@ -129,6 +129,17 @@ for (const file of ["export/service-worker/site-sw.js", "export/service-worker/s assert.equal(sw.cached("shell-v2", "/icons/icon.svg"), undefined); }); + test(`${file}: the footer's /kofi-symbol.svg is network-first in shell-v2 — an installed app draws it offline`, async () => { + // Not under /icons/: it is a static file of its own, on every page's + // footer. Without its own rule it fell through to the network, so an + // offline launch drew an empty link where the mark should be. + const sw = loadWorker(file); + assert.equal(await sw.request("/kofi-symbol.svg", { online: true, network: "mark" }), "mark"); + assert.deepEqual(sw.fetched, ["/kofi-symbol.svg"]); + assert.equal(sw.cached("shell-v2", "/kofi-symbol.svg"), "mark"); + assert.equal(await sw.request("/kofi-symbol.svg", { online: false }), "mark"); + }); + test(`${file}: /_next/static/ stays cache-first`, async () => { const sw = loadWorker(file); sw.seed("shell-v2", "/_next/static/chunks/app.js", "cached chunk"); diff --git a/common/lib/project.ts b/common/lib/project.ts @@ -32,10 +32,11 @@ export const PROJECT_WORDMARK_LEAD = "Archi"; // 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. +// The project's Ko-fi page. No copy around it, anywhere. On every export build +// (each site and the hub) it is Ko-fi's official symbol, the last item of the +// footer's social row, accessible name "Ko-fi" (the operator's 2026-09-26 +// ruling, which replaced "hub and homepage only"). The homepage footer still +// carries it as a bare text link, "Ko-fi". export const KOFI_URL = "https://ko-fi.com/archilyzer"; export const PROJECT_TAGLINE = "Self-hosted, searchable video-transcript archives."; diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -4,6 +4,7 @@ - **The hub lists its official instances in the homepage's order, and each wears one colour everywhere.** The cards and the scope chips follow the homepage (most transcripts first) instead of `hub-sites.json`'s alphabetical order. An archive whose site sets no accent wears its homepage card's colour, and now on every surface: its card, its chip's dot, the left edge of its search results and its channel group. Before, it had a card stripe and nothing else. The hub waits for `/hub-summary.json`, which is requested together with the list, before it lists the official instances, so they never reorder a moment later. - **The hub's Ask AI searches only the archives the scope chips leave in.** An archive switched off on the hub's front page is not fetched on `/ask` either, so the chat answers from what the search showed. It still waits for every archive in scope before it answers. With every archive switched off, the question box is disabled and one line under it says why. Before, a question went out over no records at all. - **A member archive with no live chat is ready about a second sooner on the hub.** Its missing subs manifest (a 404) is read as "no live chat" at once, instead of as an error and a second request. A subs manifest that fails for any other reason is asked for once more. If it fails again, the archive is searched without its live chat, and its chip reads ready. +- **Every archive's footer ends with Ko-fi's mark.** The last icon of the footer's social row, on every site and on the hub, is Ko-fi's official symbol in Ko-fi's own colours. It links the project's Ko-fi page (`https://ko-fi.com/archilyzer`) in a new tab. Its accessible name is "Ko-fi", and no text sits beside it. The file is served as `/kofi-symbol.svg`, exactly as Ko-fi publishes it, and both service workers keep it for offline use, as they do the icons. The hub's "Ko-fi" text link is gone, so each footer has one Ko-fi link. ## [0.9.0] - 2026-09-26 - **A reader picks a ground and an accent; the five theme families are gone.** The header's theme menu has **Base** (System, Light, Sepia, Dark) and **Accent** (Signal, Brass, Vermilion, Violet, Sakura, Blue, Green, with the site's own tagged *default*, and *Site colour* first on a site with a custom hex). The toggle beside it cycles System → Light → Sepia → Dark; on a phone both lists are in the menu sheet. A site opens on the reader's system setting in the accent from its `site.json` (a named id such as `brass`, or a hex), rendered as `<html data-accent>` so it is right before any script runs; the hub opens on Dark in Signal, already in the server markup. A reader's accent is stored only while it differs from the site's. A stored theme from before carries over once (Archive light becomes Sepia) and the old keys are deleted. Type is Archivo, IBM Plex Sans and IBM Plex Mono; status text reads at 4.5:1 on its tinted fill on every ground; charts have fixed colours per ground; the browser bar follows the ground (a light/dark pair for a site, dark for the hub). diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -95,20 +95,16 @@ export default function Footer() { </a> </span> </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 && ( + {/* The social row: the operator's links (admin data, recoloured to + currentColor by normalizeSocialSvg), then Ko-fi, always LAST. + Ko-fi is on every build — site and hub — by the operator's + 2026-09-26 ruling. It is the project's tip jar, not an operator + link, so it never goes through normalizeSocialSvg: it is Ko-fi's + official symbol, vendored byte-for-byte as public/kofi-symbol.svg + and shown in Ko-fi's own colours. It is an <img>, not inlined, + because the file carries a <mask id> that would collide if the + markup were ever inlined twice. No copy: the accessible name is + exactly "Ko-fi" and nothing is visible but the mark. */} <ul className="flex items-center gap-3 list-none"> {socialLinks.map((link, i) => ( <li key={`${link.url}-${i}`}> @@ -123,8 +119,28 @@ export default function Footer() { /> </li> ))} + <li> + <a + href={KOFI_URL} + title="Ko-fi" + aria-label="Ko-fi" + target="_blank" + rel="noopener noreferrer" + className="inline-block w-5 h-5 hover:opacity-80 transition-opacity" + > + {/* A static file served as-is (images are unoptimized in this + static export, and next/image adds nothing for an SVG). */} + {/* eslint-disable-next-line @next/next/no-img-element */} + <img + src="/kofi-symbol.svg" + alt="" + width={20} + height={20} + className="block w-full h-full object-contain" + /> + </a> + </li> </ul> - )} </div> </div> {related.length > 0 && ( diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page, type Route } from "@playwright/test"; -import { KOFI_URL } from "../../common/lib/project"; +import { expectKofiMarkLast } from "../e2e/helpers"; // 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 @@ -117,20 +117,17 @@ test.describe("hub official instances", () => { ).toHaveCount(0); }); - test("the hub footer carries a bare Ko-fi link", async ({ page }) => { + test("the hub footer carries the Ko-fi mark, once, last in the social row", 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"); + // The same mark as every site (the operator's 2026-09-26 ruling). The + // hub's own "Ko-fi" text link is gone: one Ko-fi per footer. + await expectKofiMarkLast(page); }); test("without hub-summary.json, the card renders with no figures", async ({ diff --git a/export/e2e/helpers.ts b/export/e2e/helpers.ts @@ -1,4 +1,6 @@ -import type { Page, Route } from "@playwright/test"; +import { createHash } from "node:crypto"; +import { expect, type Page, type Route } from "@playwright/test"; +import { KOFI_URL } from "../../common/lib/project"; import { channelSubsManifest, channelTranscriptsManifest, @@ -173,3 +175,57 @@ export async function closeModal(page: Page) { .getByRole("button", { name: "Mark clip start at current time" }) .waitFor({ state: "detached" }); } + +// sha256 of Ko-fi's official `kofi_symbol.svg` (their brand-asset zip), which +// the export vendors byte-for-byte as public/kofi-symbol.svg: a brand asset is +// used as shipped, never recoloured or re-exported. +export const KOFI_SYMBOL_SHA256 = + "f5a2d5f1abb5c7f13392ecf6e7d5dffc8bef77917ab59c28a6080deab52f5078"; + +// The footer's Ko-fi mark, on every build (site and hub): exactly one Ko-fi +// link on the page, the LAST item of the footer's social row, opening Ko-fi in +// a new tab, with no visible text — only the official symbol, which is drawn +// (not a broken image) and served unmodified. +export async function expectKofiMarkLast(page: Page) { + const footer = page.locator("footer"); + const kofi = footer.getByRole("link", { name: "Ko-fi", exact: true }); + await expect(kofi).toHaveCount(1); + // One per page: no second Ko-fi link anywhere (the hub's old text link). + await expect(page.getByRole("link", { name: /ko-?fi/i })).toHaveCount(1); + await expect(page.locator(`a[href="${KOFI_URL}"]`)).toHaveCount(1); + await expect(kofi).toBeVisible(); + await expect(kofi).toHaveAttribute("href", KOFI_URL); + await expect(kofi).toHaveAttribute("target", "_blank"); + await expect(kofi).toHaveAttribute("rel", "noopener noreferrer"); + // No copy: the mark is the whole link. + expect((await kofi.innerText()).trim()).toBe(""); + + const img = kofi.locator('img[src="/kofi-symbol.svg"]'); + await expect(img).toHaveCount(1); + await expect(img).toHaveAttribute("alt", ""); + await expect + .poll(() => + img.evaluate((el) => { + const i = el as HTMLImageElement; + return i.complete && i.naturalWidth > 0; + }), + ) + .toBe(true); + + // `has` is queried from each <ul>, so it takes a page-rooted locator: `kofi` + // itself starts at <footer> and would look for a footer inside the list. + const row = footer + .locator("ul") + .filter({ has: page.getByRole("link", { name: "Ko-fi", exact: true }) }); + await expect(row).toHaveCount(1); + await expect( + row.locator(":scope > li").last().getByRole("link"), + ).toHaveAccessibleName("Ko-fi"); + + const res = await page.request.get("/kofi-symbol.svg"); + expect(res.status()).toBe(200); + expect(res.headers()["content-type"]).toMatch(/^image\/svg\+xml/); + expect(createHash("sha256").update(await res.body()).digest("hex")).toBe( + KOFI_SYMBOL_SHA256, + ); +} diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; -import { KOFI_URL, PROJECT_URL } from "../../common/lib/project"; -import { installRoutes } from "./helpers"; +import { PROJECT_URL } from "../../common/lib/project"; +import { expectKofiMarkLast, installRoutes } from "./helpers"; // The export app is built per-site; its SSR branding (document title, header, // home tagline, footer social links) comes from currentSite() reading the @@ -45,13 +45,22 @@ 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 }) => { +test("every build carries the Ko-fi mark, last in the social row", 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); + // The operator's 2026-09-26 ruling replaced "hub and homepage only": the + // project's Ko-fi is on every site, as Ko-fi's official symbol after the + // site's own social links, with no copy around it. + await expectKofiMarkLast(page); + // The site's own links come first, in the same row. + const row = page + .locator("footer ul") + .filter({ has: page.getByRole("link", { name: "Ko-fi", exact: true }) }); + const items = row.locator(":scope > li"); + await expect(items).toHaveCount(2); + await expect(items.first().getByRole("link")).toHaveAccessibleName("GitHub"); }); test("the Downloads eyebrow appears only when something is behind it", async ({ diff --git a/export/public/kofi-symbol.svg b/export/public/kofi-symbol.svg @@ -0,0 +1,13 @@ +<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg"> +<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194"> +<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/> +</mask> +<g mask="url(#mask0_1_219)"> +<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/> +<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/> +<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/> +<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/> +<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/> +<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/> +</g> +</svg> diff --git a/export/service-worker/site-sw.js b/export/service-worker/site-sw.js @@ -8,7 +8,8 @@ * Caches: * SHELL — app shell: hashed /_next/static/* (immutable, cache-first) + HTML * navigations (network-first, cache fallback) + /icons/* (network- - * first: they follow the site's accent, which a redeploy can change). + * first: they follow the site's accent, which a redeploy can change) + * + the footer's /kofi-symbol.svg (network-first, the same rule). * PAGES — every published JSON document: the per-channel shard trees * (manifest.json + page-NNNN.json), the site-wide flat trees * (summaries, stats) and the root documents (corpus.json, site.json, @@ -104,7 +105,9 @@ self.addEventListener("fetch", (event) => { event.respondWith(cacheFirst(req, SHELL)); return; } - if (url.pathname.startsWith("/icons/")) { + // The footer's Ko-fi mark is a static file of its own (/kofi-symbol.svg, + // on every page): the same rule, so an installed app still draws it offline. + if (url.pathname.startsWith("/icons/") || url.pathname === "/kofi-symbol.svg") { event.respondWith(networkFirst(req, SHELL)); return; } diff --git a/export/service-worker/sw-hub.js b/export/service-worker/sw-hub.js @@ -94,7 +94,9 @@ self.addEventListener("fetch", (event) => { event.respondWith(cacheFirst(req, SHELL)); return; } - if (url.pathname.startsWith("/icons/")) { + // The footer's Ko-fi mark is a static file of its own (/kofi-symbol.svg, + // on every page): the same rule, so an installed app still draws it offline. + if (url.pathname.startsWith("/icons/") || url.pathname === "/kofi-symbol.svg") { event.respondWith(networkFirst(req, SHELL)); return; } diff --git a/plans/release-10.md b/plans/release-10.md @@ -1401,6 +1401,133 @@ questions ruled as asked — the card un-gated, no `--no-overwrites`, the histor writing metadata, so there is no history. The retry is not a forced download, and its transcript comes from the subtitle pass, so there is no attempt 3. - Left for a later slice: render the card while its run lives, as `SourceVideoSection` does. +### Slice O, as shipped — the Ko-fi mark on every site (2026-09-26) + +Branch `export/kofi-mark` off `main` `9860bcbe`, worktree `/home/user/Projects/export-kofi-mark`, +one Opus implementer. The plan is [`kofi-mark-on-sites.md`](kofi-mark-on-sites.md), Implementation +items 1–5. The operator's 2026-09-26 ruling supersedes the 2026-09-25 one ("a site build carries no +Ko-fi link"). Every export build, each site and the hub, now ends its footer's social row with +Ko-fi's official symbol. "No copy" still holds: the accessible name is exactly "Ko-fi" and nothing +else is visible. The homepage is untouched and keeps its bare text link (decision 4). + +**The asset.** `export/public/kofi-symbol.svg` is `kofi_brandasset/kofi_symbol.svg` from +`~/Downloads/kofi_brandasset.zip`, byte-for-byte: 3,234 bytes, sha256 +`f5a2d5f1abb5c7f13392ecf6e7d5dffc8bef77917ab59c28a6080deab52f5078`. The same hash comes from the +unpacked copy, from `unzip -p` of the zip, from the tracked file and from the build's +`out/kofi-symbol.svg`. It is a tracked real file beside the five starter SVGs. `.gitignore` names +the generated `export/public` entries one by one, so nothing ignores it, and the worktree's +`export/public` links from the primary leave it alone. + +**The footer** (`export/app/components/Footer.tsx`). +- The social `<ul>` now always renders. The operator's links are mapped first (an empty list maps + to nothing), then one last `<li>`: `<a href={KOFI_URL} title="Ko-fi" aria-label="Ko-fi" + target="_blank" rel="noopener noreferrer">` holding `<img src="/kofi-symbol.svg" alt="" + width={20} height={20} className="block w-full h-full object-contain">`. `object-contain` + letterboxes the 241 × 194 mark in the 20 px square (20 × 16). +- It never goes through `normalizeSocialSvg` / `sizeSocialSvg`, so it keeps Ko-fi's colours. The + hover is `hover:opacity-80`, where the operator's icons change colour. +- The `<img>` carries `eslint-disable-next-line @next/next/no-img-element`, umtool's precedent. The + export's images are `unoptimized`, and `next/image` adds nothing for an SVG. `eslint` on the four + changed TS files is clean. +- **The hub's "Ko-fi" text link is removed**, and its comment block is replaced by the plan's. +- `KOFI_URL`'s comment in `common/lib/project.ts` said "A site build never shows it". It now says + where the link renders. This is a comment-only edit outside `export/`. + +**Offline** (decision 4). Neither service worker precaches anything, and `offlineCache.ts` +enumerates only archive documents. Both workers route by prefix, so `/kofi-symbol.svg` fell +through to the network, and an installed app opened offline would draw an empty 20 px link. Both +workers (`site-sw.js`, `sw-hub.js`) now give it the `/icons/` rule, network-first into `shell-v2`. +No cache was renamed, and nothing is evicted. `common/lib/archive/serviceWorkerRouting.test.ts` +gains +1 per worker: online, the mark is fetched and stored; offline, it is served. The test fails +with the site worker's old condition. + +**The specs.** +- One helper, `expectKofiMarkLast(page)` in `export/e2e/helpers.ts`, which the hub spec already + imports from. It checks: + - exactly one Ko-fi link in the footer, and one on the page (`/ko-?fi/i` and `a[href=KOFI_URL]`), + so the hub's text link is gone; + - the link is visible, with href `KOFI_URL`, `target=_blank` and `rel="noopener noreferrer"`; + - its `innerText` is empty; + - it holds an `img[src="/kofi-symbol.svg"]` with `alt=""`, which is drawn (`complete && + naturalWidth > 0`); + - it is the LAST `<li>` of the footer's social `<ul>`; + - `/kofi-symbol.svg` is served 200 as `image/svg+xml`, with the official sha256. +- `site-branding.spec.ts`: "a site build carries no Ko-fi link" becomes "every build carries the + Ko-fi mark, last in the social row". It adds that the row has two items and the fixture's GitHub + link is first. +- `e2e-hub/official-instances.spec.ts`: "the hub footer carries a bare Ko-fi link" becomes "the + hub footer carries the Ko-fi mark, once, last in the social row". +- **The tests bite.** With `main`'s `Footer.tsx` put back, both fail: + - the site test: `toHaveCount(1)`, received 0; + - the hub test: the empty-text check, received "Ko-fi". + +**Docs.** There is no `export/README.md`, and `AGENTS.md` does not mention Ko-fi, so neither +changes (item 4). `SITE.md` and `SETTINGS.md` are generated and gain no key. The changelog bullet +is in `export/CHANGELOG.md` `[Unreleased]`, which every site's `/changelog` reads, beside L1's +three hub bullets. + +| sha | what | +|---|---| +| `17ee125d` | `export:` vendor the official `kofi_symbol.svg` as `public/kofi-symbol.svg` (sha256 in the message) | +| `f27b1a83` | `export:` the mark as the last `<li>` of every footer's social row; the hub's text link removed; the `KOFI_URL` comment | +| `f144c85e` | `export:` both service workers cache `/kofi-symbol.svg` like the icons; `serviceWorkerRouting.test.ts` +2 | +| `7e0989e8` | `export(e2e):` `expectKofiMarkLast`; the site-branding and hub official-instances tests flipped | +| _this_ | `plans:` this record; the `export/CHANGELOG.md` `[Unreleased]` bullet | + +**Gates**, all from the worktree root; the logs are `o-*.log` in the job's scratch dir. +- **tsc:** + - `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit` is clean on the tip tree, in + 109 s (`o-tsc2.log`). + - The two intermediate states are clean too: `f27b1a83` and `f144c85e`, each with the later + edits stashed, under `--filter export --filter yt-dlp-transcript-common` (`o-tsc-c1.log`, + `o-tsc-c2.log`). + - The asset commit has no code, and the base tree was clean (`o-tsc1.log`). +- **common 1,956/1,956** (1,954 + the 2 worker tests), 41 s. Editor unit, `test:scripts` and mcp + were not run, because nothing under `editor/`, `scripts/`, `mcp/` or `umtool/` changed. +- **`pnpm --filter export exec next build`: ok, in 61 s.** + - `out/kofi-symbol.svg` has the official sha256, and `out/index.html` carries the mark. + - The one Turbopack warning is the existing NFT trace of `export/next.config.ts`, which is not + this slice. +- **e2e** (queued, detached, `node scripts/worktree.mjs run -- …`; no queue wait on any run): + + | run | passed | failed | time | + |---|---|---|---| + | `e2e site-branding.spec.ts` | 7 | 0 | 45.4 s | + | `e2e:hub official-instances.spec.ts` | 3 | 0 | 17.3 s | + | bite check (`main`'s Footer, `-g "Ko-fi"`), site + hub | 0 | 1 + 1 | — | + | **export full** | **204** | **0** | **8.8 min** | + | **`e2e:hub` full** | **24** | **0** | **54.7 s** | + + - `e2e:2origin` was not run: it asserts nothing about the footer. + - The homepage suite was not run: the homepage is untouched. + - The worktree's tree was clean after every run. Its `public/sw.js` is the config's real copy, + not a link, so nothing wrote through to the primary. +- **Screenshots** (the job's scratch dir): `o-footer-light.png` and `o-footer-dark.png`, with + `-row` crops of the social row. + - Taken from a static `serve` of the build's `out/` at `localhost:3710`, 1280 px wide at 2×, + with `colorScheme` emulated. + - The page is the default site that a worktree build renders. Its row is the worktree + `settings.json`'s two operator links (Kiwi Farms, x.com), then Ko-fi. + - The mark is drawn in both, as a 20 × 20 box at the right end of the row. +- **Numbers tool: none.** + +**Found and left.** +- **Decision 5 was too pessimistic about dark.** The official file's first path is a white halo + around the whole mark, so on the dark ground the mark is outlined in white. The `#202020` ring + sits inside it, and the cup and heart read. +- **The operator's x.com icon is near-invisible on the light ground**, which you can see in + `o-footer-light.png`. Its `<path>` carries `fill="white"`, and `normalizeSocialSvg` sets + `currentColor` only on the root `<svg>`. This predates the slice and is operator data + (`settings.json` `socialLinks`), so it is not touched here. Two possible fixes: re-paste the + icon without the path fill, or have `normalizeSocialSvg` strip child fills. +- **Files outside `export/`:** the `KOFI_URL` comment (`common/lib/project.ts`) and the worker + test (`common/lib/archive/serviceWorkerRouting.test.ts`). Neither touches slice N's files. +- **Commit trailers:** they carry `Claude Opus 5.5 (1M context)`, as slice M's commits do, and not + the rules file's `Claude Fable 5.1`, because this implementer is Opus. +- **Rollout** is the plan's: every site plus the hub rebuild and redeploy (runbook steps 5–6). + Check live with `curl -sI https://<site>/kofi-symbol.svg` (expect 200) and look for the + footer's Ko-fi link on each site. An installed PWA picks up the worker change on its next + update check, and no data cache moves. ## Rollout