commit 7982d433a32c69a43548d4a9ac8f6d0a98c45200
parent c0e6bbbabb2c835d10460b6f93ed333ac16a2a3d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 21:26:55 -0400
common: brandIcons — every icon file rendered from the mark at build (next/og PNGs, a PNG-payload ICO) (brand S1)
ICON_FILES names the seven /icons/ URLs; renderIconPng puts markSvg into
ImageResponse as an <img> data URI, so the raster is the SVG's geometry;
pngToIco packs 16/32/48 for /favicon.ico; siteIconPalette lights a child
site's icon with its accent's on-dark value; ICON_METADATA is the one <head>
link list both layouts use. next/og runs under tsx, so the tests check the
PNG IHDR as well as the ICO directory.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 308 insertions(+), 0 deletions(-)
diff --git a/common/lib/brandIcons.test.ts b/common/lib/brandIcons.test.ts
@@ -0,0 +1,148 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { ACCENTS, ICON_PALETTES, markSvg } from "./brand";
+import { resolveAccent } from "./accent";
+import {
+ FAVICON_SIZES,
+ ICON_FILES,
+ ICON_METADATA,
+ iconFile,
+ pngToIco,
+ renderFaviconIco,
+ renderIconFile,
+ renderIconPng,
+ siteIconPalette,
+} 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) };
+}
+
+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("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);
+});
diff --git a/common/lib/brandIcons.ts b/common/lib/brandIcons.ts
@@ -0,0 +1,160 @@
+// 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: `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.
+
+import { createElement } from "react";
+import { ImageResponse } from "next/og";
+import {
+ childIconPalette,
+ markSvg,
+ 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/` cache-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);
+}
+
+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" },
+ });
+}