Archilyzer · Source

archilyzer

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

commit f73008025ee9cb13a8f4fa4a2905611952bb6de9
parent 35417b701bbe6465cdd7dbe6efcca58427fada1f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 22:25:03 -0400

Merge brand/mark — brand slice S1: the Found-line mark everywhere — static icon + favicon routes per site (the site's accent; the parent mark on the hub and homepage), manifest on ink, SW shell-v2 with network-first icons, BrandMark + Wordmark in the site, hub, homepage and editor headers, the footer credit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/components/BrandMark.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/Wordmark.tsx | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/accent.ts | 8+++++---
Acommon/lib/archive/serviceWorkerRouting.test.ts | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandIconFiles.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandIcons.test.ts | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandIcons.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Aeditor/app/icon.svg | 1+
Meditor/app/layout.tsx | 28+++++++++++++++++++---------
Aeditor/app/lib/brandIcon.test.ts | 18++++++++++++++++++
Meditor/e2e/branding.spec.ts | 29+++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
Mexport/app/components/Footer.tsx | 24+++++++++++++++---------
Mexport/app/components/Header.tsx | 27+++++++++++++++++----------
Dexport/app/favicon.ico | 0
Aexport/app/favicon.ico/route.ts | 11+++++++++++
Aexport/app/icons/[file]/route.ts | 24++++++++++++++++++++++++
Mexport/app/layout.tsx | 11++++-------
Aexport/app/lib/brand.ts | 26++++++++++++++++++++++++++
Mexport/app/manifest.ts | 30+++++++++++++++++-------------
Aexport/e2e-hub/brand.spec.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/brand.spec.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/fixtures/sites/testsite/site.json | 1+
Mexport/e2e/pwa.spec.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
Dexport/public/icons/apple-touch-icon.png | 0
Dexport/public/icons/icon-192.png | 0
Dexport/public/icons/icon-512.png | 0
Dexport/public/icons/icon.svg | 6------
Dexport/public/icons/maskable-512.png | 0
Dexport/public/icons/maskable.svg | 6------
Mexport/service-worker/site-sw.js | 25+++++++++++++++++++++----
Mexport/service-worker/sw-hub.js | 20++++++++++++++++++--
Mhomepage/CHANGELOG.md | 9+++++++++
Mhomepage/app/components/Header.tsx | 27+++++++++++++++++----------
Dhomepage/app/favicon.ico | 0
Ahomepage/app/favicon.ico/route.ts | 10++++++++++
Ahomepage/app/icons/[file]/route.ts | 24++++++++++++++++++++++++
Mhomepage/app/layout.tsx | 11+++--------
Ahomepage/e2e/brand.spec.ts | 38++++++++++++++++++++++++++++++++++++++
Dhomepage/public/icons/apple-touch-icon.png | 0
Dhomepage/public/icons/icon-192.png | 0
Dhomepage/public/icons/icon-512.png | 0
Dhomepage/public/icons/icon.svg | 10----------
Dhomepage/public/icons/maskable-512.png | 0
Dhomepage/public/icons/maskable.svg | 7-------
Mplans/brand-and-themes.md | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
47 files changed, 1394 insertions(+), 112 deletions(-)

diff --git a/common/components/BrandMark.tsx b/common/components/BrandMark.tsx @@ -0,0 +1,60 @@ +import type { CSSProperties } from "react"; +import { MARK, MARK_VIEWBOX, type MarkTone } from "../lib/brand"; + +// A palette whose slots are any CSS colour — a hex, or a `var()` expression +// such as the export header's `var(--brand-mark, var(--brand))`, which is why +// this is not lib/brand.ts's hex-only IconPalette. +export type BrandMarkPalette = Readonly<Record<MarkTone, string>>; + +// The Found-line mark, inline — drawn from the same MARK shape list as +// markSvg (the icon files), so the two cannot drift. Decorative: the text +// beside it names the link. Colours go through `style`, not a `fill="var()"` +// attribute, because a presentation attribute does not resolve custom +// properties everywhere a style declaration does. Size it with `className` +// (e.g. `size-7`) or `size`. +export function BrandMark({ + palette, + size, + className, + style, +}: { + palette: BrandMarkPalette; + size?: number; + className?: string; + style?: CSSProperties; +}) { + return ( + <svg + viewBox={`0 0 ${MARK_VIEWBOX} ${MARK_VIEWBOX}`} + width={size} + height={size} + aria-hidden="true" + focusable="false" + className={className} + style={style} + data-brand-mark="" + > + {MARK.map((s) => + s.kind === "rect" ? ( + <rect + key={s.part} + x={s.x || undefined} + y={s.y || undefined} + width={s.width} + height={s.height} + rx={s.rx} + data-tone={s.tone} + style={{ fill: palette[s.tone] }} + /> + ) : ( + <polygon + key={s.part} + points={s.points.map(([x, y]) => `${x},${y}`).join(" ")} + data-tone={s.tone} + style={{ fill: palette[s.tone] }} + /> + ), + )} + </svg> + ); +} diff --git a/common/components/Wordmark.tsx b/common/components/Wordmark.tsx @@ -0,0 +1,53 @@ +import { splitWordmark } from "../lib/brand"; + +// The two-weight wordmark: Archivo at `font-stretch: 118%`, the lead (the +// SUBJECT's name — "Jer", "Rekieta", "Archi") heavy in the foreground, the +// suffix light and muted. The split is configured (site.json `wordmarkLead`, +// PROJECT_WORDMARK_LEAD), never guessed; with no usable lead the whole title +// is the lead. +// +// The two spans are adjacent with NO whitespace between them and stay inline, +// so the enclosing link's accessible name is the title exactly ("Jeralyzer", +// not "Jer alyzer"). Do not make this element `flex`: that would blockify the +// spans and the browser would put a space between them in the name. +// +// Size, leading and truncation come from `className`. The face is Archivo +// wherever it is loaded: `--font-grotesk` names it until the themes slice makes +// Archivo the display face everywhere, after which the fallback is the same +// face and the first term can go. +export function Wordmark({ + title, + lead, + className, +}: { + title: string; + lead?: string; + className?: string; +}) { + const parts = splitWordmark(title, lead); + return ( + <span + className={className} + data-wordmark="" + style={{ + fontFamily: "var(--font-grotesk, var(--font-display))", + fontStretch: "118%", + }} + > + <span + data-wordmark-lead="" + style={{ fontWeight: 720, color: "var(--foreground)" }} + > + {parts.lead} + </span> + {parts.suffix ? ( + <span + data-wordmark-suffix="" + style={{ fontWeight: 380, color: "var(--muted-foreground)" }} + > + {parts.suffix} + </span> + ) : null} + </span> + ); +} diff --git a/common/lib/accent.ts b/common/lib/accent.ts @@ -99,9 +99,11 @@ export function resolveAccent(input: unknown): ResolvedAccent { } // The PUBLISHED accent: always a hex. An id becomes its on-dark value (the -// family's hub and homepage are dark, and the value is also the lit line of the -// site's icon); a custom hex is published as stored. Absent or malformed → -// undefined, so a publisher omits the key exactly as before. +// family's hub and homepage are dark, and for a NAMED accent that value is also +// the lit line of the site's icon); a custom hex is published as stored — its +// icon is lit with the dark-fitted resolveAccent().dark instead, which can +// differ. Absent or malformed → undefined, so a publisher omits the key exactly +// as before. export function accentHex(input: unknown): string | undefined { const setting = parseAccentSetting(input); if (!setting) return undefined; diff --git a/common/lib/archive/serviceWorkerRouting.test.ts b/common/lib/archive/serviceWorkerRouting.test.ts @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +// The service workers' CACHING STRATEGY, run for real: each worker file is +// evaluated in a node:vm context with a fake `self` / `caches` / `fetch`, and +// its fetch handler is driven with synthetic events. contract.test.ts pins the +// URL families these workers match; this pins what they DO with a match. +// +// The case that matters: /icons/* keeps its URL across a redeploy that changes +// what it draws (a site's icons are lit with its accent), so it must be +// network-first — a cache-first icon would outlive every accent change. +// /_next/static/* is content-hashed and stays cache-first. + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const ORIGIN = "https://archive.example"; + +type FakeResponse = { body: string; ok: boolean; clone(): FakeResponse }; + +function res(body: string): FakeResponse { + return { body, ok: true, clone: () => res(body) }; +} + +// One worker, loaded fresh: returns a `request(path, {online})` that dispatches a +// GET fetch event and resolves to the served body, plus what the network saw +// and a way to seed the caches. +function loadWorker(file: string) { + const handlers: Record<string, (e: unknown) => void> = {}; + const stores = new Map<string, Map<string, FakeResponse>>(); + const store = (name: string) => { + if (!stores.has(name)) stores.set(name, new Map()); + return stores.get(name)!; + }; + let online = true; + let network = ""; + const fetched: string[] = []; + const keyOf = (r: string | { url: string }) => + new URL(typeof r === "string" ? r : r.url, ORIGIN).href; + const context = { + self: { + addEventListener: (type: string, fn: (e: unknown) => void) => { + handlers[type] = fn; + }, + location: { origin: ORIGIN }, + skipWaiting: () => {}, + clients: { claim: async () => {} }, + }, + caches: { + open: async (name: string) => ({ + match: async (r: string | { url: string }) => store(name).get(keyOf(r)), + put: async (r: string | { url: string }, v: FakeResponse) => { + store(name).set(keyOf(r), v); + }, + }), + keys: async () => [...stores.keys()], + delete: async (name: string) => stores.delete(name), + }, + fetch: async (r: { url: string }) => { + fetched.push(new URL(r.url).pathname); + if (!online) throw new TypeError("Failed to fetch"); + return res(network); + }, + Response: { error: () => ({ body: "<error>", ok: false }) }, + URL, + console, + }; + vm.runInNewContext(readFileSync(path.join(REPO, file), "utf8"), context, { + filename: file, + }); + assert.equal(typeof handlers.fetch, "function", `${file} registers no fetch handler`); + + return { + fetched, + seed: (cacheName: string, p: string, body: string) => + store(cacheName).set(new URL(p, ORIGIN).href, res(body)), + cached: (cacheName: string, p: string) => store(cacheName).get(new URL(p, ORIGIN).href)?.body, + request: async (p: string, opts: { online: boolean; network?: string }) => { + online = opts.online; + network = opts.network ?? ""; + let served: Promise<FakeResponse> | undefined; + handlers.fetch({ + request: { method: "GET", url: new URL(p, ORIGIN).href, mode: "no-cors", destination: "image" }, + respondWith: (p2: Promise<FakeResponse>) => { + served = p2; + }, + }); + assert.ok(served, `${p}: the worker did not answer`); + return (await served).body; + }, + }; +} + +for (const file of ["export/service-worker/site-sw.js", "export/service-worker/sw-hub.js"]) { + test(`${file}: /icons/ is network-first in shell-v2 — a redeployed accent reaches readers`, async () => { + const sw = loadWorker(file); + sw.seed("shell-v2", "/icons/icon.svg", "old mark"); + // Online: the fresh icon wins over the cached one, and replaces it. + assert.equal(await sw.request("/icons/icon.svg", { online: true, network: "new mark" }), "new mark"); + assert.deepEqual(sw.fetched, ["/icons/icon.svg"]); + assert.equal(sw.cached("shell-v2", "/icons/icon.svg"), "new mark"); + // Offline: the cached icon is still served. + assert.equal(await sw.request("/icons/icon.svg", { online: false }), "new mark"); + }); + + test(`${file}: /_next/static/ stays cache-first`, async () => { + const sw = loadWorker(file); + sw.seed("shell-v2", "/_next/static/chunks/app.js", "cached chunk"); + assert.equal( + await sw.request("/_next/static/chunks/app.js", { online: true, network: "network chunk" }), + "cached chunk", + ); + assert.deepEqual(sw.fetched, []); + }); +} diff --git a/common/lib/brandIconFiles.ts b/common/lib/brandIconFiles.ts @@ -0,0 +1,59 @@ +// THE ICON SET, AS DATA — which files exist under /icons/, how <head> links +// them, which sizes /favicon.ico packs, and which palette a site's icons are lit +// with. PURE (no `next/og`, no I/O), so the layouts, the export header and the +// manifest can read it without pulling the renderer into every page's server +// graph. The renderers live in lib/brandIcons.ts, which only the icon and +// favicon route handlers import. + +import { childIconPalette, type IconPalette, type MarkVariant } from "./brand"; +import { resolveAccent } from "./accent"; + +export type IconFile = + | { file: string; format: "svg"; variant: MarkVariant; contentType: "image/svg+xml" } + | { file: string; format: "png"; variant: MarkVariant; size: number; contentType: "image/png" }; + +// Every file under /icons/, in the order the manifest and <head> want them. +// The URLs are a contract: the manifest, both layouts' `icons` metadata, the +// service workers' `/icons/` network-first rule, the homepage's OG image and +// installed PWAs all name them. +export const ICON_FILES: ReadonlyArray<IconFile> = [ + { file: "icon.svg", format: "svg", variant: "any", contentType: "image/svg+xml" }, + { file: "maskable.svg", format: "svg", variant: "maskable", contentType: "image/svg+xml" }, + { file: "icon-32.png", format: "png", variant: "any", size: 32, contentType: "image/png" }, + { file: "icon-192.png", format: "png", variant: "any", size: 192, contentType: "image/png" }, + { file: "icon-512.png", format: "png", variant: "any", size: 512, contentType: "image/png" }, + { file: "maskable-512.png", format: "png", variant: "maskable", size: 512, contentType: "image/png" }, + { file: "apple-touch-icon.png", format: "png", variant: "apple", size: 180, contentType: "image/png" }, +]; + +// The <head> icon links, shared by the export and homepage layouts: the SVG +// first (every current browser takes it, and it stays sharp at any size), the +// 32 px PNG for tabs that will not, the two app sizes, and the touch icon. +// /favicon.ico is served too, but is not linked. (Not typed with Next's +// `Metadata`: importing the `next` root types into common adds Next's global +// ProcessEnv augmentation to every common test's program.) +export const ICON_METADATA = { + icon: [ + { url: "/icons/icon.svg", type: "image/svg+xml" }, + { url: "/icons/icon-32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], +}; + +export function iconFile(name: string): IconFile | undefined { + return ICON_FILES.find((f) => f.file === name); +} + +// The sizes packed into /favicon.ico. Browsers that still ask for it pick the +// entry nearest their tab size. +export const FAVICON_SIZES = [16, 32, 48] as const; + +// The palette an archive site's icons are lit with: the child ground, and its +// accent's on-dark value (an absent accent reads as Signal; a custom hex is +// fitted to the dark ground). The hub, homepage and editor use +// ICON_PALETTES.archilyzer instead. +export function siteIconPalette(accent: unknown): IconPalette { + return childIconPalette(resolveAccent(accent).dark); +} diff --git a/common/lib/brandIcons.test.ts b/common/lib/brandIcons.test.ts @@ -0,0 +1,201 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { inflateSync } from "node:zlib"; +import { ACCENTS, ICON_PALETTES, markSvg } from "./brand"; +import { resolveAccent } from "./accent"; +import { + FAVICON_SIZES, + ICON_FILES, + ICON_METADATA, + iconFile, + siteIconPalette, +} from "./brandIconFiles"; +import { pngToIco, renderFaviconIco, renderIconFile, renderIconPng } from "./brandIcons"; + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +// A PNG's first chunk is IHDR: length(4) "IHDR"(4) width(4) height(4), big-endian. +function ihdr(png: Uint8Array): { width: number; height: number } { + assert.deepEqual([...png.subarray(0, 8)], PNG_MAGIC, "PNG magic"); + assert.equal(Buffer.from(png.subarray(12, 16)).toString("latin1"), "IHDR"); + const v = new DataView(png.buffer, png.byteOffset, png.byteLength); + return { width: v.getUint32(16), height: v.getUint32(20) }; +} + +// The RGBA of pixel (0, 0). Every PNG filter predicts the first pixel of the +// first scanline from zeros, so after inflating the IDAT stream its bytes are +// the pixel as stored, whichever filter the encoder chose — no unfiltering. +function topLeftPixel(png: Uint8Array): number[] { + const b = Buffer.from(png); + assert.equal(b[24], 8, "bit depth 8"); + assert.equal(b[25], 6, "colour type 6 (RGBA)"); + const idat: Buffer[] = []; + for (let off = 8; off < b.length; ) { + const len = b.readUInt32BE(off); + if (b.toString("latin1", off + 4, off + 8) === "IDAT") idat.push(b.subarray(off + 8, off + 8 + len)); + off += 12 + len; + } + const raw = inflateSync(Buffer.concat(idat)); + return [...raw.subarray(1, 5)]; // byte 0 is the scanline's filter type +} + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("ICON_FILES: the seven URLs the manifest, <head>, OG image and PWAs name", () => { + assert.deepEqual( + ICON_FILES.map((f) => f.file), + [ + "icon.svg", + "maskable.svg", + "icon-32.png", + "icon-192.png", + "icon-512.png", + "maskable-512.png", + "apple-touch-icon.png", + ], + ); + const sizes = Object.fromEntries( + ICON_FILES.flatMap((f) => (f.format === "png" ? [[f.file, f.size]] : [])), + ); + assert.deepEqual(sizes, { + "icon-32.png": 32, + "icon-192.png": 192, + "icon-512.png": 512, + "maskable-512.png": 512, + "apple-touch-icon.png": 180, + }); + assert.equal(iconFile("maskable-512.png")?.variant, "maskable"); + assert.equal(iconFile("apple-touch-icon.png")?.variant, "apple"); + assert.equal(iconFile("favicon.ico"), undefined); +}); + +test("ICON_METADATA links only files the routes serve, SVG first", () => { + const served = new Set(ICON_FILES.map((f) => `/icons/${f.file}`)); + const links = [...ICON_METADATA.icon, ...ICON_METADATA.apple]; + for (const l of links) assert.ok(served.has(l.url), l.url); + assert.deepEqual(ICON_METADATA.icon[0], { url: "/icons/icon.svg", type: "image/svg+xml" }); + for (const l of ICON_METADATA.icon.slice(1)) { + const f = iconFile(l.url.slice("/icons/".length)); + assert.ok(f && f.format === "png"); + assert.equal(l.sizes, `${f.size}x${f.size}`); + } +}); + +test("siteIconPalette: the child ground, lit by the accent's on-dark value", () => { + assert.deepEqual(siteIconPalette("brass"), { + ground: ICON_PALETTES.child.ground, + dim: ICON_PALETTES.child.dim, + lit: ACCENTS.brass.onDark, + }); + // Absent or malformed reads as Signal. + assert.equal(siteIconPalette(undefined).lit, ACCENTS.signal.onDark); + assert.equal(siteIconPalette("not-a-colour").lit, ACCENTS.signal.onDark); + // A custom hex is lit with its dark-ground fit, the value the header uses. + assert.equal(siteIconPalette("#cc3366").lit, resolveAccent("#cc3366").dark); + assert.equal(siteIconPalette("#cc3366").lit, "#d14574"); +}); + +test("pngToIco: ICONDIR, one ICONDIRENTRY per image, then the payloads verbatim", () => { + const a = Uint8Array.from([1, 2, 3]); + const b = Uint8Array.from([4, 5, 6, 7, 8]); + const ico = pngToIco([ + { size: 16, png: a }, + { size: 256, png: b }, + ]); + const v = new DataView(ico.buffer, ico.byteOffset, ico.byteLength); + // 00 00 01 00: reserved, type 1 (icon); then the count. + assert.deepEqual([...ico.subarray(0, 4)], [0, 0, 1, 0]); + assert.equal(v.getUint16(4, true), 2); + assert.equal(ico.byteLength, 6 + 16 * 2 + a.byteLength + b.byteLength); + // Entry 1: 16×16, 1 plane, 32 bpp, 3 bytes at offset 38. + assert.deepEqual([ico[6], ico[7], ico[8], ico[9]], [16, 16, 0, 0]); + assert.equal(v.getUint16(10, true), 1); + assert.equal(v.getUint16(12, true), 32); + assert.equal(v.getUint32(14, true), 3); + assert.equal(v.getUint32(18, true), 38); + // Entry 2: 256 is written as 0; its payload follows the first. + assert.deepEqual([ico[22], ico[23]], [0, 0]); + assert.equal(v.getUint32(30, true), 5); + assert.equal(v.getUint32(34, true), 41); + assert.deepEqual([...ico.subarray(38, 41)], [...a]); + assert.deepEqual([...ico.subarray(41)], [...b]); + assert.throws(() => pngToIco([{ size: 0, png: a }]), /1\.\.256/); + assert.throws(() => pngToIco([{ size: 257, png: a }]), /1\.\.256/); +}); + +test("renderIconPng: a PNG of the requested size, per variant", async () => { + for (const [variant, size] of [ + ["any", 32], + ["any", 192], + ["maskable", 512], + ["apple", 180], + ] as const) { + const png = await renderIconPng(ICON_PALETTES.archilyzer, { variant, size }); + assert.deepEqual(ihdr(png), { width: size, height: size }, `${variant} ${size}`); + } +}); + +test("renderIconPng draws the variant: any has transparent corners, maskable and apple are full-bleed", async () => { + const p = ICON_PALETTES.archilyzer; + const ground = [0x15, 0x1b, 0x20, 255]; // #151b20, opaque + const any = await renderIconPng(p, { variant: "any", size: 512 }); + assert.equal(topLeftPixel(any)[3], 0, "any: the rounded corner is transparent"); + assert.deepEqual(topLeftPixel(await renderIconPng(p, { variant: "maskable", size: 512 })), ground); + assert.deepEqual(topLeftPixel(await renderIconPng(p, { variant: "apple", size: 180 })), ground); +}); + +test("renderFaviconIco: 16/32/48 PNG entries whose IHDR matches the directory", async () => { + const ico = await renderFaviconIco(siteIconPalette("violet")); + const v = new DataView(ico.buffer, ico.byteOffset, ico.byteLength); + assert.deepEqual([...ico.subarray(0, 4)], [0, 0, 1, 0]); + assert.equal(v.getUint16(4, true), FAVICON_SIZES.length); + FAVICON_SIZES.forEach((size, i) => { + const e = 6 + 16 * i; + assert.equal(ico[e], size); + assert.equal(ico[e + 1], size); + const len = v.getUint32(e + 8, true); + const off = v.getUint32(e + 12, true); + assert.deepEqual(ihdr(ico.subarray(off, off + len)), { width: size, height: size }); + }); +}); + +test("renderIconFile: SVGs are markSvg verbatim, PNGs are PNGs, anything else is null", async () => { + const palette = siteIconPalette("green"); + const svg = await renderIconFile("icon.svg", palette); + assert.deepEqual(svg, { body: markSvg(palette), contentType: "image/svg+xml" }); + const maskable = await renderIconFile("maskable.svg", palette); + assert.equal(maskable?.body, markSvg(palette, { variant: "maskable" })); + const apple = await renderIconFile("apple-touch-icon.png", palette); + assert.equal(apple?.contentType, "image/png"); + assert.ok(apple && typeof apple.body !== "string"); + assert.deepEqual(ihdr(apple.body as Uint8Array), { width: 180, height: 180 }); + assert.equal(await renderIconFile("icon-64.png", palette), null); + assert.equal(await renderIconFile("../site.json", palette), null); +}); + +// next/og belongs to the icon and favicon routes alone: a page, layout or +// header that imported lib/brandIcons would put the renderer in every page's +// server graph. The pure data is lib/brandIconFiles.ts, which must not import +// next at all. +test("only the icon and favicon route handlers import lib/brandIcons; brandIconFiles imports no next", () => { + const importers: string[] = []; + for (const app of ["export/app", "homepage/app", "editor/app"]) { + for (const rel of readdirSync(path.join(REPO, app), { recursive: true }) as string[]) { + if (!/\.tsx?$/.test(rel)) continue; + const src = readFileSync(path.join(REPO, app, rel), "utf8"); + if (src.includes('lib/brandIcons"')) importers.push(`${app}/${rel}`); + } + } + assert.deepEqual(importers.sort(), [ + "export/app/favicon.ico/route.ts", + "export/app/icons/[file]/route.ts", + "homepage/app/favicon.ico/route.ts", + "homepage/app/icons/[file]/route.ts", + ]); + const pure = readFileSync(path.join(REPO, "common/lib/brandIconFiles.ts"), "utf8"); + assert.doesNotMatch(pure, /from "next/); + assert.doesNotMatch(pure, /from "\.\/brandIcons"/); +}); diff --git a/common/lib/brandIcons.ts b/common/lib/brandIcons.ts @@ -0,0 +1,108 @@ +// THE ICON FILES — every favicon, app icon and touch icon the family ships, +// rendered from the one mark (lib/brand.ts markSvg) at build time instead of +// committed as binaries. A site's icons are lit with its own accent, so they +// cannot be committed once for everyone. +// +// Served by a static GET route handler in each app (export/app/icons/[file], +// homepage/app/icons/[file], and a favicon.ico route beside them). Under +// `output: "export"` Next copies such a handler's body byte-for-byte to its +// exact path (next/dist/export/index.js, the `.body` branch), so +// `out/icons/icon-192.png` is a real PNG at a stable URL — `app/icon.tsx` +// cannot do this: its URLs are always hashed. +// +// SERVER-ONLY, and imported ONLY by those route handlers: `next/og` (satori + +// resvg, bundled with Next) renders the PNGs on the node runtime. The SVG goes +// in as an <img> data URI, so the raster is exactly markSvg's geometry — no +// second drawing of the mark to drift. The icon set as data (ICON_FILES, +// ICON_METADATA, FAVICON_SIZES, siteIconPalette) is lib/brandIconFiles.ts, which +// is pure, so the layouts and the header never load this module. + +import { createElement } from "react"; +import { ImageResponse } from "next/og"; +import { markSvg, type IconPalette, type MarkVariant } from "./brand"; +import { FAVICON_SIZES, iconFile } from "./brandIconFiles"; + +export function svgDataUri(svg: string): string { + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; +} + +// The mark as a PNG of `size` × `size`. satori lays out one <img> of the SVG; +// resvg rasterises it. +export async function renderIconPng( + palette: IconPalette, + opts: { variant?: MarkVariant; size: number }, +): Promise<Uint8Array> { + const { size } = opts; + const src = svgDataUri(markSvg(palette, { variant: opts.variant ?? "any" })); + const res = new ImageResponse( + createElement("img", { src, width: size, height: size, alt: "" }), + { width: size, height: size }, + ); + return new Uint8Array(await res.arrayBuffer()); +} + +// A PNG-payload ICO: the 6-byte ICONDIR, one 16-byte ICONDIRENTRY per image, +// then the PNG files verbatim (Vista+ and every current browser read PNG +// entries). A width or height of 256 is written as 0, per the format. +export function pngToIco(images: ReadonlyArray<{ size: number; png: Uint8Array }>): Uint8Array { + const HEADER = 6; + const ENTRY = 16; + const total = + HEADER + ENTRY * images.length + images.reduce((n, i) => n + i.png.byteLength, 0); + const out = new Uint8Array(total); + const view = new DataView(out.buffer); + view.setUint16(0, 0, true); // reserved + view.setUint16(2, 1, true); // type 1 = icon + view.setUint16(4, images.length, true); + let offset = HEADER + ENTRY * images.length; + images.forEach(({ size, png }, i) => { + if (!Number.isInteger(size) || size < 1 || size > 256) { + throw new Error(`pngToIco: size must be 1..256, got ${size}`); + } + const e = HEADER + ENTRY * i; + view.setUint8(e, size === 256 ? 0 : size); // width + view.setUint8(e + 1, size === 256 ? 0 : size); // height + view.setUint8(e + 2, 0); // palette colours (none) + view.setUint8(e + 3, 0); // reserved + view.setUint16(e + 4, 1, true); // colour planes + view.setUint16(e + 6, 32, true); // bits per pixel + view.setUint32(e + 8, png.byteLength, true); // payload size + view.setUint32(e + 12, offset, true); // payload offset + out.set(png, offset); + offset += png.byteLength; + }); + return out; +} + +export async function renderFaviconIco(palette: IconPalette): Promise<Uint8Array> { + const images = await Promise.all( + FAVICON_SIZES.map(async (size) => ({ size, png: await renderIconPng(palette, { size }) })), + ); + return pngToIco(images); +} + +// One /icons/<file>'s bytes and type, or null for a name that is not in +// ICON_FILES. +export async function renderIconFile( + name: string, + palette: IconPalette, +): Promise<{ body: Uint8Array | string; contentType: string } | null> { + const f = iconFile(name); + if (!f) return null; + if (f.format === "svg") { + return { body: markSvg(palette, { variant: f.variant }), contentType: f.contentType }; + } + return { + body: await renderIconPng(palette, { variant: f.variant, size: f.size }), + contentType: f.contentType, + }; +} + +// A route handler's Response for a rendered body. Revalidating, because a +// site's accent (and so its icons) can change between deploys at the same URL. +// In a static export only the body survives; the host sets the real headers. +export function iconResponse(body: Uint8Array | string, contentType: string): Response { + return new Response(typeof body === "string" ? body : new Blob([body as BlobPart]), { + headers: { "content-type": contentType, "cache-control": "public, max-age=0, must-revalidate" }, + }); +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Every site, the hub, the homepage and the editor wear the new Found-line mark, and a site's header splits its wordmark.** The mark is four transcript lines on a rounded square, the second lit and carrying a play head. The favicon, app icons and touch icon are no longer committed files: each build draws them from the mark and writes `/icons/icon.svg`, `maskable.svg`, `icon-32.png`, `icon-192.png`, `icon-512.png`, `maskable-512.png`, `apple-touch-icon.png` and `/favicon.ico` (16, 32 and 48 px). A site's icons are an ink tile lit with its own accent (Signal when it sets none; a custom colour is lightened until it reads on the tile). The hub, the homepage and the editor use the parent mark, bone on slate. The site header shows the mark and the header title split at the site's **Wordmark lead**, the lead heavy and the rest light (Jer|alyzer); with no lead the whole title is heavy. The header mark's lit line will follow the reader's accent once the theme picker lands; the favicon keeps the site's. The footer's "Built with Archilyzer" has the small parent mark in front of it, outside the link. The homepage header shows the parent mark and "Archi|lyzer", no longer in spaced capitals. An installed site's title bar is the dark theme's ground (`#0c0a08`) and its splash the icon's tile, where it used to fall back to blue for a site with a named accent. The service workers' shell cache is renamed to `shell-v2`, so an installed app drops the old icons; readers' offline channel downloads are kept. The editor's sidebar shows the parent mark beside the admin title, and the editor now has a favicon. Needs a rebuild and deploy of every site, the hub and the homepage. - **A site's accent is one of seven named colours or a custom one, and a site can split its wordmark.** The site form's **Brand accent** is now a row of swatches: Signal (the family default), Brass, Vermilion, Violet, Sakura, Blue and Green, plus **Custom**, whose colour goes in the **Custom hex** field (typing there picks Custom). `site.json` stores the accent's id (`"accent": "brass"`) or the hex. Picking Signal stores no key, because no key means Signal. A custom colour is darkened or lightened for each reading theme until it reads at 4.5:1; the seven named colours already do. A new **Wordmark lead** field names the heavy first part of the header wordmark, e.g. `Jer` for Jeralyzer. The form refuses a lead that is not how the header title starts, and `site.json` keeps `wordmarkLead` only when it is. What other hubs read does not change: `/site.json`, the hub's `hub-sites.json` and the homepage summary still carry a hex, and an id goes out as its colour on dark. `SITE.md` lists both keys. The header, icons and theme picker that use them come with the rest of the brand work; until then a site with a named accent is tinted with that colour, as a custom hex is today. - **The hub's search shows each archive's state, lets you choose which archives to search, and no longer waits for the slowest.** Under the line "Searching N archives …" is a row of chips, one per archive on the hub (official and added). Each says whether that archive is loading, how many videos it has in the search once it is in, or that it failed, with a Retry beside it. Pressing a chip takes that archive out of the search: nothing more is fetched from it and its results disappear. The choice is kept in this browser, and an archive it has never seen is searched. The search now runs as soon as one archive has loaded and runs again as each further one arrives, where it used to wait for all of them. When an archive does not answer, a line above the results says so once the others have loaded ("4 of 5 archives answered. Hasanalyzer did not, so its videos are not in these results.") with a Retry, and the other archives' results show as usual. None of that archive's videos, posts or live chat are searched until a Retry succeeds; before, it dropped out silently or in part. Each result names its archive in text before the channel ("Jeralyzer · TheQuartering · 2026-09-25"). The hub now loads at most six summaries pages at a time from each archive, so a large archive does not hold up the small ones. The line under the archives now counts "videos", as the chips do. `/ask` on the hub is unchanged: it searches every archive and waits for all of them. Published sites are unchanged. Needs a rebuild and deploy of the hub. - **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. diff --git a/editor/app/icon.svg b/editor/app/icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#151b20"/><rect x="112" y="128" width="288" height="44" rx="22" fill="#3f4c56"/><polygon points="112,210 172,234 112,258" fill="#e7edf1"/><rect x="188" y="212" width="212" height="44" rx="22" fill="#e7edf1"/><rect x="112" y="296" width="232" height="44" rx="22" fill="#3f4c56"/><rect x="112" y="380" width="152" height="44" rx="22" fill="#3f4c56"/></svg> diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -12,6 +12,8 @@ import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript"; import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; import { AppFrame } from "./components/AppFrame"; import { AutoRefresh } from "./components/AutoRefresh"; import { CommandPalette } from "./components/CommandPalette"; @@ -131,15 +133,23 @@ export default async function RootLayout({ sidebar={ <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-border bg-card flex flex-col"> <div className="px-3 py-2.5 md:py-3 md:border-b md:border-border flex items-start justify-between gap-2"> - <div className="min-w-0"> - <Link - href="/" - className="font-display text-base font-semibold tracking-tight block leading-tight" - > - {headerLabel} - </Link> - <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground mt-0.5"> - editor + <div className="min-w-0 flex items-center gap-2.5"> + {/* The parent mark, beside the operator's title — which is + NOT split: adminTitle is free text, not a wordmark. */} + <BrandMark + palette={ICON_PALETTES.archilyzer} + className="size-8 shrink-0" + /> + <div className="min-w-0"> + <Link + href="/" + className="font-display text-base font-semibold tracking-tight block leading-tight" + > + {headerLabel} + </Link> + <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground mt-0.5"> + editor + </div> </div> </div> <div className="flex items-center gap-1.5 shrink-0"> diff --git a/editor/app/lib/brandIcon.test.ts b/editor/app/lib/brandIcon.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { ICON_PALETTES, markSvg } from "yt-dlp-transcript-common/lib/brand"; + +// editor/app/icon.svg is the editor's favicon (Next's app/icon.svg file +// convention links it from every page). It is a static file because the +// editor's icon never changes per install — it is the parent mark — but it +// must stay the mark: markSvg is the geometry, this file a copy of it. +// Regenerate with markSvg(ICON_PALETTES.archilyzer) if this fails. +test("app/icon.svg is markSvg(ICON_PALETTES.archilyzer), byte for byte", () => { + const file = fileURLToPath(new URL("../icon.svg", import.meta.url)); + assert.equal( + readFileSync(file, "utf8").trimEnd(), + markSvg(ICON_PALETTES.archilyzer), + ); +}); diff --git a/editor/e2e/branding.spec.ts b/editor/e2e/branding.spec.ts @@ -35,3 +35,32 @@ test("reflects sidebar header after saving via the settings form", async ({ page.locator("aside").getByRole("link", { name: "Saved Admin" }), ).toBeVisible(); }); + +test("the sidebar carries the parent mark beside adminTitle, and the favicon is the mark", async ({ + page, + request, +}) => { + await writeSettings({ + adminTitle: "Cypress HQ", + maxTranscriptPageBytes: 8388608, + sleepBetweenDownloadsSeconds: 0, + }); + await page.goto("/"); + const aside = page.locator("aside"); + // Decorative, and NOT inside the title link: its name stays adminTitle. + const mark = aside.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + await expect(mark).toBeVisible(); + await expect( + aside.getByRole("link", { name: "Cypress HQ", exact: true }), + ).toBeVisible(); + // app/icon.svg (Next's file convention) is linked from <head>. + const href = await page + .locator('head link[rel="icon"][type="image/svg+xml"]') + .getAttribute("href"); + expect(href).toMatch(/^\/icon\.svg/); + const svg = await (await request.get(href as string)).text(); + expect(svg).toContain('fill="#151b20"'); + expect(svg).toContain('fill="#e7edf1"'); +}); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **The Found-line mark, and a split wordmark.** The site header's rotated square is now the family mark — four transcript lines on an ink tile, the second lit with the site's accent and carrying a play head — and the header title splits at the site's `wordmarkLead` (heavy lead, light rest: Jer|alyzer). The hub wears the parent mark, bone on slate, and splits as Archi|lyzer. The footer credit has the small parent mark before "Built with"; the link's name is still exactly "Archilyzer". The icons are no longer committed: `app/icons/[file]/route.ts` and `app/favicon.ico/route.ts` render them from `common/lib/brand.ts` at build (static export writes `out/icons/*` and `out/favicon.ico`), lit with the site's accent. `<head>` links `icon.svg` first, then `icon-32.png`, 192, 512 and the touch icon. The manifest lists `icon.svg` (sizes "any"); its `theme_color` is the dark base's ground `#0c0a08` and its `background_color` the icon's tile. Both service workers rename only their shell cache (`shell-v2`) so installed apps drop the old icons; the data caches, and readers' offline downloads, are kept. + ## [0.8.7] - 2026-08-12 - **Every archive now says what built it, and stopped shipping its own copy of the source.** The footer carried a `code.tar.gz` link on every site — `create-archives.sh` wrote a tarball into `export/public/` and each deployed archive served its own duplicate of the whole workspace (verified live: `200 application/gzip` on jeralyzer). That link is **gone**, replaced by **"Built with [Archilyzer](https://archilyzer.pages.dev)"** beside the social links. The source is now published once, centrally, with a checksum and a commit — rather than N times, undated, with no way to tell two copies apart. The back-link is deliberately **ungated by instance mode** (a hub is Archilyzer too, and a credit that appeared on sites but not hubs would make them look like different software), **independent of any configured hub URL** (that is the operator's family link, a different relationship), and opens **in the same tab**, matching the header's hub link — only social icons open new ones. *"Built with"* sits outside the anchor so the accessible name is exactly `Archilyzer`. One knock-on: the **Downloads** eyebrow used to be unconditional because that link always followed it; it now appears only when the transcript-archive or offline link is actually there, instead of captioning empty space. - **`corpus.json` and `llms.txt` name the software that produced them.** Both the per-site and the hub builders stamp a `generator` string, and both `llms.txt` renderings end with a `Generated by …` line — so anything reading an archive machine-side can find the tool that built it. **The corpus spec version is deliberately unchanged at 3**, and a test asserts it: every prior bump announced a new *fetchable layer*, where a client that ignored it would miss retrievable data. An informational credit breaks no reader, so forcing every consumer to re-evaluate compatibility for a byline would be the wrong trade. The reasoning is recorded in the version-history comment rather than left for the next person to reconstruct. diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -12,6 +12,8 @@ import { PROJECT_NAME, PROJECT_URL, } from "yt-dlp-transcript-common/lib/project"; +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; @@ -79,15 +81,19 @@ export default function Footer() { different thing), and NOT target="_blank": the header's hub link navigates in the same tab, and only the social icons open new ones. "Built with" sits OUTSIDE the anchor so the accessible name is - exactly "Archilyzer". */} - <span className="text-xs"> - Built with{" "} - <a - href={PROJECT_URL} - className="underline underline-offset-2 hover:text-foreground transition-colors" - > - {PROJECT_NAME} - </a> + exactly "Archilyzer" — and so does the parent mark before it, + which is decorative (aria-hidden) and the same on every site. */} + <span className="inline-flex items-center gap-1.5 text-xs"> + <BrandMark palette={ICON_PALETTES.archilyzer} className="size-4 shrink-0" /> + <span> + Built with{" "} + <a + href={PROJECT_URL} + className="underline underline-offset-2 hover:text-foreground transition-colors" + > + {PROJECT_NAME} + </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 diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -8,18 +8,23 @@ import { } from "yt-dlp-transcript-common/lib/site"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; import { currentSite } from "../lib/site"; +import { headerMarkPalette } from "../lib/brand"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; import { hasDuplicates } from "../lib/duplicates"; import SiblingSwitcher from "./SiblingSwitcher"; import MobileMenu from "./MobileMenu"; -// The export site's masthead: a brand-colored mark + wordmark, calm sans nav, -// and the cross-site "family" chrome (hub backlink + sibling switcher). The -// wordmark and mark track the active theme — sans + blue under the default base -// family, serif + brass under Archive. All cross-site data is resolved at build -// time from the pool, so single-site installs simply render neither. +// The export site's masthead: the Found-line mark + the split wordmark, calm +// sans nav, and the cross-site "family" chrome (hub backlink + sibling +// switcher). The mark's lit line follows the reader's accent (lib/brand.ts +// headerMarkPalette); the hub wears the parent mark. The link's accessible name +// is the header title exactly — the mark is decorative and the wordmark's two +// spans join without a space. All cross-site data is resolved at build time +// from the pool, so single-site installs simply render neither. export default function Header() { const site = currentSite(); const settings = getSettings(); @@ -65,11 +70,13 @@ export default function Header() { put the overflow rows OUTSIDE the sticky header's background at phone widths, and the page scrolled through them. */} <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-x-5 gap-y-1 flex-nowrap"> - <Link href="/" className="group flex min-w-0 items-center gap-2.5"> - <span className="h-2.5 w-2.5 shrink-0 rotate-45 rounded-[3px] bg-brand shadow-[0_0_14px_var(--brand)] transition-colors group-hover:bg-brand-strong" /> - <span className="truncate font-display text-[1.35rem] font-semibold leading-none tracking-tight text-foreground"> - {site.headerTitle} - </span> + <Link href="/" className="flex min-w-0 items-center gap-2.5"> + <BrandMark palette={headerMarkPalette()} className="size-7 shrink-0" /> + <Wordmark + title={site.headerTitle} + lead={site.wordmarkLead} + className="min-w-0 truncate text-[1.35rem] leading-none tracking-[-0.01em]" + /> </Link> <nav className="hidden md:flex items-center gap-4 text-sm font-medium"> diff --git a/export/app/favicon.ico b/export/app/favicon.ico Binary files differ. diff --git a/export/app/favicon.ico/route.ts b/export/app/favicon.ico/route.ts @@ -0,0 +1,11 @@ +import { iconResponse, renderFaviconIco } from "yt-dlp-transcript-common/lib/brandIcons"; +import { iconPalette } from "../lib/brand"; + +// /favicon.ico: the mark at 16/32/48 in one PNG-payload ICO, for the browsers +// and crawlers that ask for it by name whatever <head> says. Static; written to +// out/favicon.ico at build. +export const dynamic = "force-static"; + +export async function GET() { + return iconResponse(await renderFaviconIco(iconPalette()), "image/x-icon"); +} diff --git a/export/app/icons/[file]/route.ts b/export/app/icons/[file]/route.ts @@ -0,0 +1,24 @@ +import { ICON_FILES } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import { iconResponse, renderIconFile } from "yt-dlp-transcript-common/lib/brandIcons"; +import { iconPalette } from "../../lib/brand"; + +// /icons/<file>: the site's icon set, rendered from the one mark at build time +// (common/lib/brandIcons.ts). Static: under `output: "export"` each listed file +// is written to out/icons/<file> byte-for-byte, and nothing else resolves. +// Never reads the request. +export const dynamic = "force-static"; +export const dynamicParams = false; + +export function generateStaticParams() { + return ICON_FILES.map((f) => ({ file: f.file })); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ file: string }> }, +) { + const { file } = await params; + const out = await renderIconFile(file, iconPalette()); + if (!out) return new Response("Not found", { status: 404 }); + return iconResponse(out.body, out.contentType); +} diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -4,6 +4,7 @@ import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript"; import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider"; import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider"; import { parseAccent, siteAccentVars } from "yt-dlp-transcript-common/lib/accent"; +import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIconFiles"; import { currentSite } from "./lib/site"; import { instanceMode, shipsPwa } from "./lib/mode"; import Header from "./components/Header"; @@ -42,13 +43,9 @@ export function generateMetadata(): Metadata { // (and registers the SW below). A dumb instance omits both — it stays // federatable JSON, but not independently installable. ...(shipsPwa() ? { manifest: "/manifest.webmanifest" } : {}), - icons: { - icon: [ - { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, - { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, - ], - apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], - }, + // Rendered per build by app/icons/[file]/route.ts: the site's accent, or + // the parent mark on the hub. + icons: ICON_METADATA, appleWebApp: { capable: true, statusBarStyle: "black-translucent", diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts @@ -0,0 +1,26 @@ +import { ICON_PALETTES, type IconPalette } from "yt-dlp-transcript-common/lib/brand"; +import { siteIconPalette } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import type { BrandMarkPalette } from "yt-dlp-transcript-common/components/BrandMark"; +import { currentSite } from "./site"; +import { instanceMode } from "./mode"; + +// The palette this build's icons are drawn in. The hub is the family's own +// tool, so it wears the parent mark — achromatic, like the homepage and the +// editor. Every archive site is lit with its own accent's on-dark value. +// Server-only, like currentSite(). +export function iconPalette(): IconPalette { + return instanceMode() === "hub" + ? ICON_PALETTES.archilyzer + : siteIconPalette(currentSite().accent); +} + +// The header mark's palette. On a site it is the icon's ink tile, but its lit +// line follows the READER's accent — `--brand-mark` is the picked accent's +// on-dark value (the tile is always ink) — while the favicon keeps the site's +// default. Until the base × accent tokens define `--brand-mark`, it falls back +// to `--brand`. The hub's mark is the parent mark and does not follow. +export function headerMarkPalette(): BrandMarkPalette { + return instanceMode() === "hub" + ? ICON_PALETTES.archilyzer + : { ...ICON_PALETTES.child, lit: "var(--brand-mark, var(--brand))" }; +} diff --git a/export/app/manifest.ts b/export/app/manifest.ts @@ -1,22 +1,17 @@ import type { MetadataRoute } from "next"; -import { parseAccent } from "yt-dlp-transcript-common/lib/accent"; +import { BASE_GROUNDS } from "yt-dlp-transcript-common/lib/brand"; import { currentSite } from "./lib/site"; +import { iconPalette } from "./lib/brand"; // Web App Manifest → statically emitted as /manifest.webmanifest at build time -// (output: "export"). Per-site: name/description from currentSite(), theme_color -// from the site's brand accent (falls back to the base brand blue). Makes each -// deployed export site installable as a standalone PWA. Icons are the shared -// mark under public/icons (a per-site accent-tinted icon is a later -// refinement — theme_color already differentiates the browser chrome). +// (output: "export"). Per-site: name/description from currentSite(). Makes each +// deployed export site installable as a standalone PWA. The icons are this +// build's own — the site's accent, or the parent mark on the hub — rendered by +// app/icons/[file]/route.ts. export const dynamic = "force-static"; -// Base brand blue (tokens.css --brand), used when a site sets no accent — the -// export default family is base. -const FALLBACK_THEME_COLOR = "#2563eb"; - export default function manifest(): MetadataRoute.Manifest { const site = currentSite(); - const themeColor = parseAccent(site.accent) ?? FALLBACK_THEME_COLOR; return { name: site.siteTitle, short_name: site.headerTitle || site.siteTitle, @@ -26,10 +21,19 @@ export default function manifest(): MetadataRoute.Manifest { display: "standalone", // No `orientation` lock: the archive is mostly video, and landscape is the // right shape for the player view. - background_color: "#0a0a0a", - theme_color: themeColor, + // The splash is the icon's own ground, so the mark sits on its tile's + // colour; the installed app's chrome is the dark base's ground. Neither is + // the accent: a reader may pick another, and the manifest cannot follow. + background_color: iconPalette().ground, + theme_color: BASE_GROUNDS.dark, icons: [ { + src: "/icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "any", + }, + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", diff --git a/export/e2e-hub/brand.spec.ts b/export/e2e-hub/brand.spec.ts @@ -0,0 +1,50 @@ +import { expect, test, type Route } from "@playwright/test"; +import { BASE_GROUNDS, ICON_PALETTES, markSvg } from "../../common/lib/brand"; + +// The hub is the family's own tool, so it wears the PARENT mark — bone on +// slate, achromatic — in its icons and its header, like the homepage and the +// editor; never an archive's accent (plans/brand-and-themes.md, slice S1). + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }); +} + +test("the hub's icons are the parent mark", async ({ request }) => { + const svg = await (await request.get("/icons/icon.svg")).text(); + expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.ground}"`); // #151b20 + expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.lit}"`); // #e7edf1 + expect(svg).toBe(markSvg(ICON_PALETTES.archilyzer)); + + const png = await request.get("/icons/icon-192.png"); + expect(png.headers()["content-type"]).toBe("image/png"); + const ico = await request.get("/favicon.ico"); + expect([...(await ico.body()).subarray(0, 4)]).toEqual([0, 0, 1, 0]); + + const m = await (await request.get("/manifest.webmanifest")).json(); + expect(m.background_color).toBe(ICON_PALETTES.archilyzer.ground); + expect(m.theme_color).toBe(BASE_GROUNDS.dark); +}); + +test("the hub header is the parent mark + Archi|lyzer", async ({ page }) => { + // No members: the shelf stays empty and nothing reaches a real origin. + await page.route("**/hub-sites.json", (r) => fulfillJson(r, [])); + await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" })); + await page.goto("/"); + const home = page + .getByRole("banner") + .getByRole("link", { name: "Archilyzer", exact: true }); + await expect(home).toBeVisible(); + await expect(home.locator("[data-wordmark-lead]")).toHaveText("Archi"); + await expect(home.locator("[data-wordmark-suffix]")).toHaveText("lyzer"); + const fills = await home.locator("svg[data-brand-mark]").evaluate((svg) => + ["ground", "dim", "lit"].map( + (t) => getComputedStyle(svg.querySelector(`[data-tone="${t}"]`) as Element).fill, + ), + ); + expect(fills).toEqual(["rgb(21, 27, 32)", "rgb(63, 76, 86)", "rgb(231, 237, 241)"]); +}); diff --git a/export/e2e/brand.spec.ts b/export/e2e/brand.spec.ts @@ -0,0 +1,133 @@ +import { test, expect, type Locator } from "@playwright/test"; +import { ICON_PALETTES, childIconPalette, markSvg } from "../../common/lib/brand"; +import { resolveAccent } from "../../common/lib/accent"; +import { installRoutes } from "./helpers"; + +// The family brand on an archive site (plans/brand-and-themes.md, slice S1): +// the Found-line mark and the two-weight wordmark in the header, the parent +// mark beside the footer credit, and the icon set rendered per build from the +// site's accent. The fixture site (e2e/fixtures/sites/testsite/site.json) is +// headerTitle "Fixture Header", wordmarkLead "Fixture", accent #cc3366. + +// The computed fill of each part of a BrandMark, plus what the reader's accent +// resolves to — so the lit line can be checked against the token it follows +// without pinning a colour the themes slice will change. +async function markFills(mark: Locator) { + return mark.evaluate((svg) => { + const fill = (tone: string) => + getComputedStyle(svg.querySelector(`[data-tone="${tone}"]`) as Element).fill; + const probe = document.createElement("span"); + probe.style.color = "var(--brand-mark, var(--brand))"; + document.body.append(probe); + const accent = getComputedStyle(probe).color; + probe.remove(); + return { ground: fill("ground"), dim: fill("dim"), lit: fill("lit"), accent }; + }); +} + +test("the header link is the mark + the split wordmark, named by the header title", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const home = page + .getByRole("banner") + .getByRole("link", { name: "Fixture Header", exact: true }); + await expect(home).toBeVisible(); + await expect(home).toHaveAttribute("href", "/"); + + // Two adjacent spans, no whitespace node between them: the lead is the + // configured prefix, the suffix is the rest (its leading space included). + const wordmark = home.locator("[data-wordmark]"); + const parts = await wordmark.evaluate((w) => + [...w.childNodes].map((n) => ({ + tag: n.nodeName, + text: n.textContent, + weight: n instanceof Element ? getComputedStyle(n).fontWeight : null, + display: n instanceof Element ? getComputedStyle(n).display : null, + })), + ); + expect(parts).toEqual([ + { tag: "SPAN", text: "Fixture", weight: "720", display: "inline" }, + { tag: "SPAN", text: " Header", weight: "380", display: "inline" }, + ]); + + const mark = home.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + await expect(mark).toHaveAttribute("focusable", "false"); + await expect(mark).toBeVisible(); +}); + +test("the header mark is the ink tile, its lit line following the reader's accent", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const mark = page.getByRole("banner").locator("svg[data-brand-mark]"); + const f = await markFills(mark); + expect(f.ground).toBe("rgb(12, 10, 8)"); // ICON_PALETTES.child.ground + expect(f.dim).toBe("rgb(59, 51, 39)"); // ICON_PALETTES.child.dim + expect(f.lit).toBe(f.accent); + expect(f.lit).not.toBe(f.dim); +}); + +test("the footer carries the parent mark before the credit, outside the link", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const footer = page.locator("footer"); + const credit = footer.getByRole("link", { name: "Archilyzer", exact: true }); + await expect(credit).toBeVisible(); + await expect(credit.locator("svg")).toHaveCount(0); + + const mark = footer.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + // The mark's next sibling holds "Built with" and the credit. + await expect( + footer.locator("svg[data-brand-mark] + span").getByRole("link", { + name: "Archilyzer", + exact: true, + }), + ).toHaveCount(1); + const f = await markFills(mark); + expect(f.ground).toBe("rgb(21, 27, 32)"); // #151b20 + expect(f.lit).toBe("rgb(231, 237, 241)"); // #e7edf1, bone: the parent mark +}); + +test("<head> links the rendered icon set, SVG first", async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + const icons = await page + .locator('head link[rel="icon"]') + .evaluateAll((ls) => + ls.map((l) => [l.getAttribute("href"), l.getAttribute("type"), l.getAttribute("sizes")]), + ); + expect(icons).toEqual([ + ["/icons/icon.svg", "image/svg+xml", null], + ["/icons/icon-32.png", "image/png", "32x32"], + ["/icons/icon-192.png", "image/png", "192x192"], + ["/icons/icon-512.png", "image/png", "512x512"], + ]); + await expect(page.locator('head link[rel="apple-touch-icon"]')).toHaveAttribute( + "href", + "/icons/apple-touch-icon.png", + ); +}); + +test("the site's icons are lit with its own accent, fitted to the ink tile", async ({ + request, +}) => { + const lit = resolveAccent("#cc3366").dark; + const res = await request.get("/icons/icon.svg"); + expect(res.ok()).toBeTruthy(); + expect(await res.text()).toBe(markSvg(childIconPalette(lit))); + const maskable = await request.get("/icons/maskable.svg"); + expect(await maskable.text()).toBe( + markSvg(childIconPalette(lit), { variant: "maskable" }), + ); + // Not the parent mark: that is the hub's, the homepage's and the editor's. + expect(await res.text()).not.toContain(ICON_PALETTES.archilyzer.ground); +}); diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json @@ -3,6 +3,7 @@ "siteTitle": "Fixture Site Title", "siteDescription": "Fixture site description", "headerTitle": "Fixture Header", + "wordmarkLead": "Fixture", "homeTagline": "Fixture tagline here", "accent": "#cc3366", "socialLinks": [ diff --git a/export/e2e/pwa.spec.ts b/export/e2e/pwa.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { BASE_GROUNDS, ICON_PALETTES } from "../../common/lib/brand"; // PWA surface for the export (Archive) sites: an installable web app manifest, // a registered service worker, and the /offline management page. The transcript @@ -23,22 +24,61 @@ test("serves a valid web app manifest with per-site fields", async ({ expect( m.icons.some((i: { purpose?: string }) => i.purpose === "maskable"), ).toBe(true); - // theme_color is a hex string (from the site accent or the family brass). - expect(m.theme_color).toMatch(/^#[0-9a-f]{6}$/i); + // The scalable mark, for launchers that take one. + expect(m.icons).toContainEqual({ + src: "/icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "any", + }); + // The chrome is the dark base's ground and the splash the icon's own tile — + // never the accent, which a reader may change and the manifest cannot follow. + expect(m.theme_color).toBe(BASE_GROUNDS.dark); + expect(m.background_color).toBe(ICON_PALETTES.child.ground); }); +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + test("serves the service worker and the icon set", async ({ request }) => { const sw = await request.get("/sw.js"); expect(sw.ok()).toBeTruthy(); - for (const icon of [ - "/icons/icon-192.png", - "/icons/icon-512.png", - "/icons/maskable-512.png", - "/icons/apple-touch-icon.png", - ]) { + // Rendered by app/icons/[file]/route.ts, not committed: each is a real PNG + // of its size (IHDR width/height at bytes 16 and 20). + for (const [icon, size] of [ + ["/icons/icon-32.png", 32], + ["/icons/icon-192.png", 192], + ["/icons/icon-512.png", 512], + ["/icons/maskable-512.png", 512], + ["/icons/apple-touch-icon.png", 180], + ] as const) { + const r = await request.get(icon); + expect(r.ok(), icon).toBeTruthy(); + expect(r.headers()["content-type"], icon).toBe("image/png"); + const body = await r.body(); + expect([...body.subarray(0, 8)], icon).toEqual(PNG_MAGIC); + expect([body.readUInt32BE(16), body.readUInt32BE(20)], icon).toEqual([size, size]); + } + for (const icon of ["/icons/icon.svg", "/icons/maskable.svg"]) { const r = await request.get(icon); expect(r.ok(), icon).toBeTruthy(); + expect(r.headers()["content-type"], icon).toBe("image/svg+xml"); + expect(await r.text(), icon).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/); } + // /favicon.ico: an ICO directory (00 00 01 00) of PNG entries. + const ico = await request.get("/favicon.ico"); + expect(ico.ok()).toBeTruthy(); + expect(ico.headers()["content-type"]).toBe("image/x-icon"); + const b = await ico.body(); + expect([...b.subarray(0, 4)]).toEqual([0, 0, 1, 0]); + expect(b.readUInt16LE(4)).toBeGreaterThan(0); + const first = b.readUInt32LE(6 + 12); + expect([...b.subarray(first, first + 8)]).toEqual(PNG_MAGIC); + // Anything not in the set is not an icon. EXPECTED in the dev server's log: + // "⨯ Failed to generate static paths for /icons/[file] … missing param" — + // Next's dev message when a name outside generateStaticParams meets + // dynamicParams=false under output: "export". The answer is still the 404 + // asserted here; it is not a failure. + expect((await request.get("/icons/icon-64.png")).status()).toBe(404); }); // Note: the service worker registers in PRODUCTION builds only (it caches diff --git a/export/public/icons/apple-touch-icon.png b/export/public/icons/apple-touch-icon.png Binary files differ. diff --git a/export/public/icons/icon-192.png b/export/public/icons/icon-192.png Binary files differ. diff --git a/export/public/icons/icon-512.png b/export/public/icons/icon-512.png Binary files differ. diff --git a/export/public/icons/icon.svg b/export/public/icons/icon.svg @@ -1,6 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archive"> - <rect width="512" height="512" rx="112" fill="#0c0a08"/> - <polygon points="256,92 420,256 256,420 92,256" fill="#e3b15c"/> - <polygon points="256,150 362,256 256,362 150,256" fill="#0c0a08"/> - <polygon points="256,196 316,256 256,316 196,256" fill="#e3b15c"/> -</svg> diff --git a/export/public/icons/maskable-512.png b/export/public/icons/maskable-512.png Binary files differ. diff --git a/export/public/icons/maskable.svg b/export/public/icons/maskable.svg @@ -1,6 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archive"> - <rect width="512" height="512" fill="#0c0a08"/> - <polygon points="256,140 372,256 256,372 140,256" fill="#e3b15c"/> - <polygon points="256,182 330,256 256,330 182,256" fill="#0c0a08"/> - <polygon points="256,214 298,256 256,298 214,256" fill="#e3b15c"/> -</svg> diff --git a/export/service-worker/site-sw.js b/export/service-worker/site-sw.js @@ -7,7 +7,8 @@ * * Caches: * SHELL — app shell: hashed /_next/static/* (immutable, cache-first) + HTML - * navigations (network-first, cache fallback) + icons/manifest. + * navigations (network-first, cache fallback) + /icons/* (network- + * first: they follow the site's accent, which a redeploy can change). * 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, @@ -24,8 +25,17 @@ * (avoids IndexedDB inside the SW). */ +// VERSION names the DATA caches, and activate deletes every cache it does not +// keep — so bumping it throws away every reader's offline channel downloads. +// Never bump it for a shell change. SHELL is named on its own: "shell-v2" +// retired the pre-brand icons (the old worker served /icons/ cache-first, so an +// installed app would have kept the old mark forever) while PAGES and META +// survive. The rename has one cost: activate also deletes shell-v1's cached +// HTML and /_next/static chunks, so right after the update the installed app +// does not open OFFLINE until the reader has made one more online visit, which +// refills shell-v2. Their channel downloads are not touched. const VERSION = "v1"; -const SHELL = `shell-${VERSION}`; +const SHELL = "shell-v2"; const PAGES = `pages-${VERSION}`; const META = `meta-${VERSION}`; @@ -86,11 +96,18 @@ self.addEventListener("fetch", (event) => { return; } - // App shell. - if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) { + // App shell. /_next/static/* is content-hashed, so a URL never changes what + // it names: cache-first. /icons/* is NOT: the icons are lit with the site's + // accent, a site.json setting that can change between deploys at the same + // URL, so they are network-first — fresh online, still there offline. + if (url.pathname.startsWith("/_next/static/")) { event.respondWith(cacheFirst(req, SHELL)); return; } + if (url.pathname.startsWith("/icons/")) { + event.respondWith(networkFirst(req, SHELL)); + return; + } if (req.mode === "navigate" || req.destination === "document") { event.respondWith(networkFirstDoc(req)); return; diff --git a/export/service-worker/sw-hub.js b/export/service-worker/sw-hub.js @@ -16,8 +16,17 @@ * - the page's message API carries `origin` + absolute shard URLs. */ +// VERSION names the DATA caches, and activate deletes every cache it does not +// keep — so bumping it throws away every reader's offline channel downloads. +// Never bump it for a shell change. SHELL is named on its own: "shell-v2" +// retired the pre-brand icons (the old worker served /icons/ cache-first, so an +// installed app would have kept the old mark forever) while PAGES and META +// survive. The rename has one cost: activate also deletes shell-v1's cached +// HTML and /_next/static chunks, so right after the update the installed app +// does not open OFFLINE until the reader has made one more online visit, which +// refills shell-v2. Their channel downloads are not touched. const VERSION = "v1"; -const SHELL = `shell-${VERSION}`; +const SHELL = "shell-v2"; const PAGES = `pages-${VERSION}`; const META = `meta-${VERSION}`; @@ -78,10 +87,17 @@ self.addEventListener("fetch", (event) => { // App shell is same-origin only (the hub's own bundle). if (url.origin !== self.location.origin) return; - if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) { + // /_next/static/* is content-hashed: cache-first. /icons/* keeps its URL + // across deploys that change what it draws (the site SW's reason; the hub's + // parent mark changes less, but it is the same rule): network-first. + if (url.pathname.startsWith("/_next/static/")) { event.respondWith(cacheFirst(req, SHELL)); return; } + if (url.pathname.startsWith("/icons/")) { + event.respondWith(networkFirst(req, SHELL)); + return; + } if (req.mode === "navigate" || req.destination === "document") { event.respondWith(networkFirstDoc(req)); return; diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -1,5 +1,14 @@ # Homepage Changelog +## [Unreleased] + +- **The Found-line mark.** The header's CSS triangle is now the family's parent mark (bone + on slate) and "ARCHILYZER" is the split wordmark "Archi|lyzer" — heavy lead, light + suffix, no longer tracked uppercase; the link is still named "Archilyzer home". The + icons and `/favicon.ico` are no longer committed: `app/icons/[file]/route.ts` and + `app/favicon.ico/route.ts` render them from `common/lib/brand.ts` at build, and + `<head>` gains `icon-32.png`. The OG image is still `/icons/icon-512.png`. + ## 2026-08-12 - **This package is the project's site now.** It was a shelf of one operator's archives diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx @@ -1,5 +1,11 @@ import Link from "next/link"; -import { PROJECT_NAME } from "yt-dlp-transcript-common/lib/project"; +import { + PROJECT_NAME, + PROJECT_WORDMARK_LEAD, +} from "yt-dlp-transcript-common/lib/project"; +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; import { NAV } from "../lib/nav"; @@ -30,24 +36,25 @@ function NavList({ className }: { className?: string }) { // is the same string on every install. The operator's own naming still governs // /stats/ and the social links in the footer. // -// The mark is a solid triangle — a play head, the thing every recording here -// began as. Bone, not brass: this family spends no colour on chrome. +// The mark is the family's Found-line mark in its parent colours — bone on +// slate, no accent: the project spends no colour on its own chrome. The +// wordmark splits on the subject ("Archi" + "lyzer"); the link keeps its +// explicit name, "Archilyzer home". export default function Header() { return ( <header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md"> <div className="max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-6"> <Link href="/" - className="group flex items-center gap-2.5 shrink-0" + className="flex items-center gap-2.5 shrink-0" aria-label={`${PROJECT_NAME} home`} > - <span - aria-hidden="true" - className="h-0 w-0 border-y-[6px] border-y-transparent border-l-[9px] border-l-[var(--foreground)] transition-colors group-hover:border-l-[var(--brand)]" + <BrandMark palette={ICON_PALETTES.archilyzer} className="size-7 shrink-0" /> + <Wordmark + title={PROJECT_NAME} + lead={PROJECT_WORDMARK_LEAD} + className="text-[1.3rem] leading-none tracking-[-0.01em]" /> - <span className="font-display text-[1.05rem] font-semibold uppercase leading-none tracking-[0.11em] text-[var(--foreground)]"> - {PROJECT_NAME} - </span> </Link> <nav aria-label="Main" className="ml-auto hidden sm:block"> <NavList className="gap-6" /> diff --git a/homepage/app/favicon.ico b/homepage/app/favicon.ico Binary files differ. diff --git a/homepage/app/favicon.ico/route.ts b/homepage/app/favicon.ico/route.ts @@ -0,0 +1,10 @@ +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { iconResponse, renderFaviconIco } from "yt-dlp-transcript-common/lib/brandIcons"; + +// /favicon.ico: the parent mark at 16/32/48 in one PNG-payload ICO. Static; +// written to out/favicon.ico at build. +export const dynamic = "force-static"; + +export async function GET() { + return iconResponse(await renderFaviconIco(ICON_PALETTES.archilyzer), "image/x-icon"); +} diff --git a/homepage/app/icons/[file]/route.ts b/homepage/app/icons/[file]/route.ts @@ -0,0 +1,24 @@ +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { ICON_FILES } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import { iconResponse, renderIconFile } from "yt-dlp-transcript-common/lib/brandIcons"; + +// /icons/<file>: the project site's icon set — the parent mark, achromatic +// (bone on slate), rendered at build time from common/lib/brand.ts. The twin +// of export/app/icons/[file]/route.ts; under `output: "export"` each listed +// file lands at out/icons/<file>. The OG image names /icons/icon-512.png. +export const dynamic = "force-static"; +export const dynamicParams = false; + +export function generateStaticParams() { + return ICON_FILES.map((f) => ({ file: f.file })); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ file: string }> }, +) { + const { file } = await params; + const out = await renderIconFile(file, ICON_PALETTES.archilyzer); + if (!out) return new Response("Not found", { status: 404 }); + return iconResponse(out.body, out.contentType); +} diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx @@ -7,6 +7,7 @@ import { PROJECT_TAGLINE, PROJECT_URL, } from "yt-dlp-transcript-common/lib/project"; +import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIconFiles"; import Header from "./components/Header"; import Footer from "./components/Footer"; import "./globals.css"; @@ -31,14 +32,8 @@ export const metadata: Metadata = { description: "Archilyzer downloads a channel's back catalogue, transcribes it on your " + "own machine, and builds a static, searchable site you host yourself.", - icons: { - icon: [ - { url: "/icons/icon.svg", type: "image/svg+xml" }, - { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, - { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, - ], - apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], - }, + // Rendered at build by app/icons/[file]/route.ts — the parent mark. + icons: ICON_METADATA, openGraph: { type: "website", siteName: PROJECT_NAME, diff --git a/homepage/e2e/brand.spec.ts b/homepage/e2e/brand.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { ICON_PALETTES, markSvg } from "../../common/lib/brand"; + +// The project site wears the PARENT mark (plans/brand-and-themes.md, slice +// S1): bone on slate in its icons and its header, and the wordmark split on the +// subject — "Archi" heavy, "lyzer" light — no longer tracked uppercase. + +test("the header is the parent mark + Archi|lyzer, still named 'Archilyzer home'", async ({ + page, +}) => { + await page.goto("/"); + const home = page.getByRole("link", { name: "Archilyzer home", exact: true }); + await expect(home).toBeVisible(); + await expect(home.locator("[data-wordmark-lead]")).toHaveText("Archi"); + await expect(home.locator("[data-wordmark-suffix]")).toHaveText("lyzer"); + await expect(home.locator("[data-wordmark]")).not.toHaveCSS("text-transform", "uppercase"); + const mark = home.locator("svg[data-brand-mark]"); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + const fills = await mark.evaluate((svg) => + ["ground", "dim", "lit"].map( + (t) => getComputedStyle(svg.querySelector(`[data-tone="${t}"]`) as Element).fill, + ), + ); + expect(fills).toEqual(["rgb(21, 27, 32)", "rgb(63, 76, 86)", "rgb(231, 237, 241)"]); +}); + +test("the icon set is rendered from the parent mark", async ({ request }) => { + const svg = await request.get("/icons/icon.svg"); + expect(svg.headers()["content-type"]).toBe("image/svg+xml"); + expect(await svg.text()).toBe(markSvg(ICON_PALETTES.archilyzer)); + // The OG image names this one. + const png = await request.get("/icons/icon-512.png"); + expect(png.headers()["content-type"]).toBe("image/png"); + const b = await png.body(); + expect([b.readUInt32BE(16), b.readUInt32BE(20)]).toEqual([512, 512]); + const ico = await request.get("/favicon.ico"); + expect([...(await ico.body()).subarray(0, 4)]).toEqual([0, 0, 1, 0]); +}); diff --git a/homepage/public/icons/apple-touch-icon.png b/homepage/public/icons/apple-touch-icon.png Binary files differ. diff --git a/homepage/public/icons/icon-192.png b/homepage/public/icons/icon-192.png Binary files differ. diff --git a/homepage/public/icons/icon-512.png b/homepage/public/icons/icon-512.png Binary files differ. diff --git a/homepage/public/icons/icon.svg b/homepage/public/icons/icon.svg @@ -1,10 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archilyzer"> - <!-- The tool's mark, not an archive's. The export sites wear a brass diamond - on warm black; this is a bone play head on graphite — the instrument that - builds them, in the instrument family's palette (see tokens.css, - [data-theme="archilyzer"]). The bar beneath is the transcript: the - recording resolved into a line of text. --> - <rect width="512" height="512" rx="96" fill="#151b20"/> - <polygon points="188,132 372,236 188,340" fill="#e7edf1"/> - <rect x="140" y="384" width="232" height="20" rx="4" fill="#8496a2"/> -</svg> diff --git a/homepage/public/icons/maskable-512.png b/homepage/public/icons/maskable-512.png Binary files differ. diff --git a/homepage/public/icons/maskable.svg b/homepage/public/icons/maskable.svg @@ -1,7 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archilyzer"> - <!-- Maskable variant: same mark, pulled into the safe zone (the inner 80% - circle) because a launcher may crop this to any shape. --> - <rect width="512" height="512" fill="#151b20"/> - <polygon points="206,164 350,246 206,328" fill="#e7edf1"/> - <rect x="176" y="356" width="160" height="16" rx="4" fill="#8496a2"/> -</svg> diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md @@ -557,6 +557,240 @@ operator picks an accent or a lead. - The worktree is port block **#1** (editor 3101, test 3111, export 3110), not #2: `pnpm wt list` sorts, and `diet-series` is #2. +### Slice S1, as shipped — mark, icons, wordmark (2026-09-25) + +Branch `brand/mark` off S0's tip `02295a94` (worktree `../brand-mark`, port block #2). S1 draws the +Found-line mark everywhere the family shows itself: every icon file is rendered from `markSvg` at +build instead of committed, the site header is the mark plus the split wordmark, the hub, homepage +and editor wear the parent mark, and the footer credit carries it. No `common/styles/**`, theme +component, `MobileMenu`, theme spec, `site-branding` spec or C2 file was touched. In the two layouts +only the `icons` metadata changed. + +**The spike passed on the plan's primary path in about 15 minutes; no fallback was taken** +(`$T/s1-spike.md`). +- `next/og` runs under tsx, so the unit tests check the PNG IHDR as well as the ICO directory. A + probe script needs `.mts`: tsx refuses top-level await in a `.ts` it compiles to CJS. +- `app/icons/[file]/route.ts` (force-static, `dynamicParams = false`, `generateStaticParams` over + `ICON_FILES`) and `app/favicon.ico/route.ts` (force-static) emit the right files for both export + and homepage: + - `out/icons/*`: icon-192 is `8950 4e47 0d0a 1a0a … 4948 4452 0000 00c0 0000 00c0`, a 192×192 + IHDR; + - `out/favicon.ico`: starts `0000 0100 0300`, three PNG entries. + - Both were looked at. +- The favicon route is missing from the build's route table, because Next files `/favicon.ico` as a + metadata route, but it IS emitted. +- `next dev`, which every e2e server runs, serves all of them too. `/icons/nope.png` is a 404. + +**`common/lib/brandIcons.ts` (new, server-only).** +- `ICON_FILES`: the seven `/icons/` names, each with its format, variant, size and content type. + `iconFile(name)` looks one up. +- `ICON_METADATA`: the one `<head>` link list both layouts use: `icon.svg` first, then `icon-32.png`, + 192, 512, and apple. +- `FAVICON_SIZES`: 16, 32 and 48. +- `siteIconPalette(accent)` = `childIconPalette(resolveAccent(accent).dark)`. +- `renderIconPng(palette, {variant, size})`: an `ImageResponse` of a single `<img>` whose `src` is a + base64 data URI of `markSvg`, built with `createElement`, so the raster IS the SVG's geometry. +- `pngToIco(images)`: a 6-byte ICONDIR, 16-byte entries (256 is written as 0), then the PNG payloads + verbatim. `renderFaviconIco(palette)` packs the three sizes. +- `renderIconFile(name, palette)` returns null for any name not in the set; `iconResponse(body, type)` + wraps the result. + +**Routes.** +- Export: `app/icons/[file]/route.ts` + `app/favicon.ico/route.ts`. The palette is `iconPalette()` + (`export/app/lib/brand.ts`): `ICON_PALETTES.archilyzer` when `instanceMode() === "hub"`, else + `siteIconPalette(currentSite().accent)`. +- Homepage: the same two routes, always the parent palette. +- Neither reads the request. +- Deleted: `export/public/icons/*`, `homepage/public/icons/*`, and both `app/favicon.ico`. +- Next no longer adds its own `/favicon.ico` link, which it did for the static file, so `<head>` is + exactly `ICON_METADATA`. +- The homepage OG image is still `/icons/icon-512.png`. + +**Manifest** (`export/app/manifest.ts`): +- `theme_color` = `BASE_GROUNDS.dark` (`#0c0a08`) on sites and the hub; +- `background_color` = `iconPalette().ground` (`#0c0a08` on a site, `#151b20` on the hub); +- a new first icon `{src:"/icons/icon.svg", sizes:"any", type:"image/svg+xml", purpose:"any"}`; +- the 192/512/maskable PNG entries kept. + +The hex-only `parseAccent` fallback, which S0 flagged at `:19`, is gone. + +**Service workers.** `site-sw.js` and `sw-hub.js`: `const SHELL = "shell-v2"`, with a comment on why. +`VERSION` stays `"v1"`, so `pages-v1` / `meta-v1`, the readers' offline downloads, survive activate. +`contract.test.ts` still reads both files green. + +**Components.** +- `common/components/BrandMark.tsx`: + - maps `MARK` (the list `markSvg` walks) to `<rect>`/`<polygon>`; + - `aria-hidden`, `focusable="false"`, `data-brand-mark`, and `data-tone` on each shape; + - every fill goes in `style`, so a slot can be `var(…)`; + - its palette type is `BrandMarkPalette` (any CSS colour). +- `common/components/Wordmark.tsx`: + - `splitWordmark(title, lead)`: the lead at weight 720 in `var(--foreground)`, the suffix at 380 in + `var(--muted-foreground)`, `font-stretch: 118%`; + - two adjacent INLINE spans with no whitespace node, so the link's name is the title exactly; + - `data-wordmark(-lead|-suffix)` hooks; + - the face is `var(--font-grotesk, var(--font-display))`, which is Archivo both before S2 (the + grotesk variable) and after (S2 makes Archivo `--font-display`). S3 can drop the first term. +- Export `Header.tsx`: + - `<BrandMark palette={headerMarkPalette()} className="size-7">` + `<Wordmark title={site.headerTitle} + lead={site.wordmarkLead}>`, truncating as before; + - `headerMarkPalette()` is the child ink tile with `lit: "var(--brand-mark, var(--brand))"` on a + site, and the parent mark on the hub. +- Export `Footer.tsx`: `<span class="inline-flex …"><BrandMark archilyzer size-4/><span>Built with + <a>Archilyzer</a></span></span>`. The mark is outside the link. +- Homepage `Header.tsx`: the parent mark + `Archi|lyzer` (`PROJECT_WORDMARK_LEAD`), no uppercase or + tracking; `aria-label="Archilyzer home"` kept. +- Editor sidebar (`editor/app/layout.tsx`): the parent mark (`size-8`) before the title block, + outside the title link. The title is not split. +- **Editor favicon:** `editor/app/icon.svg` = `markSvg(ICON_PALETTES.archilyzer)`. Next links it as + `/icon.svg?icon.<hash>.svg`. `editor/app/lib/brandIcon.test.ts` pins it byte for byte. + **There was no `editor/app/favicon.ico`** (the editor had no favicon at all), so nothing was + deleted. + +**Fixture:** `"wordmarkLead": "Fixture"` in `export/e2e/fixtures/sites/testsite/site.json`. + +**Tests.** +- New `export/e2e/brand.spec.ts` (5 tests): + - the header link `{name:"Fixture Header", exact}` → `href="/"`, exactly two inline spans + `Fixture`/` Header` at 720/380, and an aria-hidden, non-focusable mark; + - the header mark's ground and dim are the ink tile, and its lit fill equals whatever + `var(--brand-mark, var(--brand))` resolves to, so it survives S2; + - the footer mark is the parent mark, the credit link has no svg, and `svg + span` holds the credit; + - `<head>` icon links in order; + - `/icons/icon.svg` and `maskable.svg` = `markSvg(childIconPalette(resolveAccent("#cc3366").dark))` + (`#d14574`). +- `pwa.spec.ts`: + - the manifest carries the `icon.svg` entry, `theme_color` = `BASE_GROUNDS.dark` and + `background_color` = the child ground; + - each of the five PNGs has `content-type: image/png`, the PNG magic and its IHDR size; + - both SVGs have `image/svg+xml`; + - `favicon.ico` is `image/x-icon`, starts `00 00 01 00`, and its first entry is a PNG; + - `/icons/icon-64.png` is a 404. +- New `export/e2e-hub/brand.spec.ts` (2 tests): + - `/icons/icon.svg` contains `fill="#151b20"` and `fill="#e7edf1"` and equals the parent markSvg; + - the hub PNG, ICO and manifest (`background_color` `#151b20`); + - the header `Archi|lyzer`, with mark fills slate / slate-dim / bone. +- New `homepage/e2e/brand.spec.ts` (2 tests): the header (name, split, no uppercase, fills) and the + icon set. +- Editor `branding.spec.ts` +1: the sidebar mark sits outside the "Cypress HQ" link, and the linked + `/icon.svg…` carries the parent colours. +- Unit tests: + - `common/lib/brandIcons.test.ts` (7): ICON_FILES, ICON_METADATA, siteIconPalette, the pngToIco + header, entries and offsets, 256→0 and range errors, the renderIconPng IHDR for all three + variants, the renderFaviconIco directory ↔ IHDR, renderIconFile; + - `editor/app/lib/brandIcon.test.ts` (1). + +| sha | what | +|---|---| +| `446be496` | `common/lib/brandIcons.ts` (+ test): ICON_FILES, ICON_METADATA, siteIconPalette, renderIconPng (next/og), pngToIco, renderFaviconIco, renderIconFile | +| `d69cf1b1` | export `app/icons/[file]` + `app/favicon.ico` routes, `lib/brand.ts` iconPalette, layout `icons: ICON_METADATA`, manifest (BASE_GROUNDS.dark / icon ground / icon.svg); committed icons + favicon.ico deleted | +| `cee051f2` | homepage twin routes (parent palette), layout `icons: ICON_METADATA`; committed icons + favicon.ico deleted | +| `fa7823fe` | `site-sw.js`, `sw-hub.js`: `SHELL = "shell-v2"`, VERSION unchanged | +| `fee7394c` | `common/components/BrandMark.tsx`, `Wordmark.tsx` | +| `4c90c7fc` | export Header (mark + Wordmark, `headerMarkPalette`), Footer (parent mark before the credit), fixture `wordmarkLead`, new `e2e/brand.spec.ts` + `e2e-hub/brand.spec.ts`, `pwa.spec.ts` extended | +| `e1df9815` | homepage Header (parent mark + the Archi · lyzer split), new `e2e/brand.spec.ts` | +| `adcd9482` | editor sidebar mark, `app/icon.svg` + parity test, `branding.spec` +1 | + +**Gates.** +- tsc: clean before each code commit (runs 2 and 3; run 1 is under "Found and left"), and run 4 on the final tree. +- Unit and script tests: + - common **1,881/1,881** (S0's 1,874 + 7); + - editor unit **79/79** (+1); + - `test:scripts` **162 + 1 skip**; + - mcp **219/219**. +- Builds, all with `export/public` seeded (`sw.js` a copy, no dangling links) and `homepage/public` + data copied: + - `pnpm --filter editor exec next build`: ok, 46 s. `/icon.svg` is emitted and linked. + - `pnpm --filter export exec next build` (site mode, the worktree's default site): ok, 27 s. The + seven `out/icons` files plus `out/favicon.ico`, PNG magic and IHDR per file (180, 192, 32, 512, + 512), ICO `0000 0100 0300 1010`, icon.svg lit Signal `#5fa8a0`, manifest as above. + - `pnpm --filter homepage exec next build`: + - run 1 failed in 8 s, 24 Turbopack errors in `next/font/google` for IBM Plex Sans ("queries have + exactly one entry") from the untouched `fonts.ts`, while another worktree built at the same + time; + - the retry was ok, 16 s. Same file set and magic; icon.svg is the parent mark; og:image still + `/icons/icon-512.png`. + - Hub mode (`INSTANCE_MODE=hub pnpm --filter export exec next build`, the build step of + `build:hub` without compose): ok, 31 s. `out/icons/icon.svg` holds `#151b20` / `#3f4c56` / + `#e7edf1`; the manifest's `background_color` is `#151b20`. + - A demo site (`SITES_DIR=$T/s1-demo-sites`, "Jeralyzer", lead "Jer", accent brass): ok, 33 s; + icon.svg lit `#e3b15c`. Used for the shots. +- e2e (`$T/s1-e2e.sh`, one run, 21:37–21:56): + - export full: **199 passed, 1 failed**, 11.2 min. The failure is `theme-family.spec.ts:18`, see + "Found and left". Rerun alone, `theme-family.spec.ts theme.spec.ts --repeat-each 3`: **6 passed**, 32.6 s (after a ~7.5 min queue wait behind `brand-themes`). + - `e2e:hub`: **14 passed**, 32 s (12 + the new 2). + - homepage full: **26 passed**, 1.0 min. + - editor `branding auto-refresh dashboard navigation widget`: **48 passed**, 4.0 min. + - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`): **3 passed**, 48 s. Before it, the seven entries compose + writes (`hub-sites.json site.json corpus.json llms.txt robots.txt sitemap.xml _headers`) were + swapped for copies; afterwards they and `hub-summary.json` were relinked and the seed re-run. +- **The primary's `export/public` was never written:** + - `sw.js` is 10,027 B, mtime 20:47:37, before and after; + - `corpus.json`, `hub-sites.json`, `llms.txt`, `robots.txt`, `_headers`, `hub-summary.json` and + `sitemap.xml` kept their pre-run mtimes. +- Screenshots (`$T/s1-*.png`, from served `out/` on localhost, all looked at): + - `site-header-{390,1280}-{light,dark}`: brass Jer|alyzer; + - `site-footer-light`, `site-footer-390-dark`; + - `hub-header-{1280,390}`, `hub-footer`; + - `homepage-header-{1280,390}`. + + They match the canvas's *In context* board. On light the ink tile is a real icon tile; on the + dark base it coincides with the page ground and the lines carry the mark, as the canvas draws + Jeralyzer on dark and the parent mark on slate. +- Numbers tools: none. + +**Found and left.** +- **Importing the `next` root types into `common/` breaks common's tsc.** tsc run 1 put 10 errors in + existing tests (`bin/_cli.test.ts`, `publish/build.test.ts`: "Property 'NODE_ENV' is missing … + ProcessEnv"). The cause was `import type { Metadata } from "next"` in `brandIcons.ts`: `next/index.d.ts` + references `types/global`, which makes `NODE_ENV` required on every `ProcessEnv` in the program. + So `ICON_METADATA` is untyped; the layouts type-check it against `Metadata`. `next/og` does not + carry that reference. +- **`theme-family.spec.ts:18` timed out** waiting for `menuitemradio "Swiss"`, in the second + `pickFamily`, right after `page.reload({waitUntil:"commit"})` + `themeReady`. The shot shows + Selenized applied and the menu closed: the click landed before hydration. `themeReady` is set by + the pre-paint script, not by React. + - It is the same reload timing `plans/deflake-e2e.md` §2 fixed for the read, which still races the + click after it. + - S1 touches no theme code. + - S2 deletes this spec. +- **`export/playwright.config.ts:29` still says "exactly like the committed public/icons/*".** The + plan lists that comment under S2's leftover copy, so it was left for S2/S3. +- **`export/.2origin/hubA`** is a hub build (gitignored, about 2.5 GB, because `out/` copies the + linked public data). It stays in the worktree for the next 2origin run. + +**Review fixes** (review verdict SHIP, `$T/s1-review.md`; review nit 5, the `layout.tsx` import +conflict with S2, is left for the merge). +- `541a46e0`, should-fix 1 + nit 2: + - both service workers serve `/icons/` network-first into shell-v2 (fresh online, cached + offline), so a redeployed accent reaches readers; + - `/_next/static/` stays cache-first; `SHELL` stays `"shell-v2"` and `VERSION` `"v1"`; + - the `SHELL` comment now says the rename also drops the cached HTML and chunks, so the installed + app opens offline again only after one more online visit. + - No test covered the strategy (`contract.test.ts` pins only the URL families). The new + `lib/archive/serviceWorkerRouting.test.ts` runs each worker in `node:vm` with a fake + `self`/`caches`/`fetch` and drives its fetch handler. Reverting site-sw's routing turns its icon + test red. +- `405fccd5`, nits 3 + 7: + - `ICON_FILES`, `iconFile`, `ICON_METADATA`, `FAVICON_SIZES` and `siteIconPalette` moved to the pure + `common/lib/brandIconFiles.ts` (no `next` import); + - `lib/brandIcons.ts` keeps the renderers beside `next/og`, and its only importers are the four + icon/favicon routes; the layouts and `export/app/lib/brand.ts` read the pure module. A test pins + the importer set and the purity. + - The variant test: pixel (0,0) is transparent on the `any` 512 and the opaque ground on the + maskable 512 and the apple 180. A variant-ignoring `renderIconPng` turns it red. +- `951a0ff5`, nit 6: the `accentHex` comment says the published hex is the icon's lit line only for a + named accent. +- `4a7641af`, nit 4: `pwa.spec`'s 404 probe names the dev log line it causes as expected. +- Gates: + - tsc clean before each commit; + - common **1,887** (+6), editor unit **79**; + - export build ok (23 s), homepage build ok (14 s), with the same seven `out/icons` files + favicon + and the same PNG/ICO magic; + - export `pwa.spec.ts brand.spec.ts`: **8 passed**, 15 s; `e2e:hub`: **14 passed**, 24 s; + - the worktree's `sw.js` stayed a copy, and the primary's `sw.js` is unchanged (10,027 B, + 20:47:37). + ## Operator rollout (after merge) 1. Restart the live :3001 editor on the new `main`; it needs S0's form.