diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json
index 6d171cf..68b9189 100644
--- a/ApiCore-Frontend/package-lock.json
+++ b/ApiCore-Frontend/package-lock.json
@@ -47,6 +47,7 @@
"eslint-config-next": "16.3.8",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.0.1",
+ "sharp": "0.35.5",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.11"
@@ -1781,8 +1782,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "devOptional": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -11158,8 +11159,8 @@
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
+ "devOptional": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -11208,8 +11209,8 @@
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "devOptional": true,
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json
index 088e049..5f30211 100644
--- a/ApiCore-Frontend/package.json
+++ b/ApiCore-Frontend/package.json
@@ -10,7 +10,8 @@
"typecheck": "next typegen && tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
- "check:3d-budget": "node scripts/check-3d-budget.mjs"
+ "check:3d-budget": "node scripts/check-3d-budget.mjs",
+ "icons": "node scripts/generate-icons.mjs"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
@@ -52,6 +53,7 @@
"eslint-config-next": "16.3.8",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.0.1",
+ "sharp": "0.35.5",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.11"
diff --git a/ApiCore-Frontend/public/brand/apicore-mark.svg b/ApiCore-Frontend/public/brand/apicore-mark.svg
new file mode 100644
index 0000000..2d960d8
--- /dev/null
+++ b/ApiCore-Frontend/public/brand/apicore-mark.svg
@@ -0,0 +1,6 @@
+
diff --git a/ApiCore-Frontend/public/icon.svg b/ApiCore-Frontend/public/icon.svg
index 7593cc6..a2a5e4d 100644
--- a/ApiCore-Frontend/public/icon.svg
+++ b/ApiCore-Frontend/public/icon.svg
@@ -1,8 +1 @@
-
+
diff --git a/ApiCore-Frontend/public/icons/icon-192.png b/ApiCore-Frontend/public/icons/icon-192.png
new file mode 100644
index 0000000..23c52fc
Binary files /dev/null and b/ApiCore-Frontend/public/icons/icon-192.png differ
diff --git a/ApiCore-Frontend/public/icons/icon-512.png b/ApiCore-Frontend/public/icons/icon-512.png
new file mode 100644
index 0000000..fa54af9
Binary files /dev/null and b/ApiCore-Frontend/public/icons/icon-512.png differ
diff --git a/ApiCore-Frontend/public/icons/maskable-192.png b/ApiCore-Frontend/public/icons/maskable-192.png
new file mode 100644
index 0000000..6861d13
Binary files /dev/null and b/ApiCore-Frontend/public/icons/maskable-192.png differ
diff --git a/ApiCore-Frontend/public/icons/maskable-512.png b/ApiCore-Frontend/public/icons/maskable-512.png
new file mode 100644
index 0000000..ed0fe59
Binary files /dev/null and b/ApiCore-Frontend/public/icons/maskable-512.png differ
diff --git a/ApiCore-Frontend/public/icons/monochrome-512.png b/ApiCore-Frontend/public/icons/monochrome-512.png
new file mode 100644
index 0000000..91afdd9
Binary files /dev/null and b/ApiCore-Frontend/public/icons/monochrome-512.png differ
diff --git a/ApiCore-Frontend/public/icons/shortcut-inspection-96.png b/ApiCore-Frontend/public/icons/shortcut-inspection-96.png
new file mode 100644
index 0000000..7d91325
Binary files /dev/null and b/ApiCore-Frontend/public/icons/shortcut-inspection-96.png differ
diff --git a/ApiCore-Frontend/public/icons/shortcut-today-96.png b/ApiCore-Frontend/public/icons/shortcut-today-96.png
new file mode 100644
index 0000000..d484d59
Binary files /dev/null and b/ApiCore-Frontend/public/icons/shortcut-today-96.png differ
diff --git a/ApiCore-Frontend/public/icons/shortcut-varroa-96.png b/ApiCore-Frontend/public/icons/shortcut-varroa-96.png
new file mode 100644
index 0000000..e5a5306
Binary files /dev/null and b/ApiCore-Frontend/public/icons/shortcut-varroa-96.png differ
diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js
index 588a5a6..1d849b8 100644
--- a/ApiCore-Frontend/public/sw.js
+++ b/ApiCore-Frontend/public/sw.js
@@ -37,11 +37,28 @@ const {
buildAssetsToPrune,
} = self.ApiCoreSwRoutes;
-const SHELL_CACHE = "apicore-shell-v4";
+// v5: new app icons and colour tokens (T5 spec 12.3) reach installed apps.
+const SHELL_CACHE = "apicore-shell-v5";
// v2: v1 could hold answers this version never keeps (exports, downloads).
const DATA_CACHE = "apicore-data-v2";
const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE];
+// The manifest icons (src/app/manifest.ts; sw.spec checks that every one of
+// them is kept): kept with the shell, so an offline start and the shortcuts
+// show the app's own icons. Not the favicon: Next links it as
+// /favicon.ico?, so a cached /favicon.ico would never answer it.
+const SHELL_ICONS = [
+ "/icon.svg",
+ "/icons/icon-192.png",
+ "/icons/icon-512.png",
+ "/icons/maskable-192.png",
+ "/icons/maskable-512.png",
+ "/icons/monochrome-512.png",
+ "/icons/shortcut-inspection-96.png",
+ "/icons/shortcut-varroa-96.png",
+ "/icons/shortcut-today-96.png",
+];
+
// Same budget as every request of the app (lib/http/fetch-with-timeout.ts).
const ASSET_TIMEOUT_MS = 4000;
const RUNTIME_CONFIG_PATH = "/api/runtime-config";
@@ -453,6 +470,7 @@ async function warmShell() {
}
}
await keepRuntimeConfig(cache).catch(() => undefined);
+ await keepIcons(cache);
// Only with the full picture of the current build may old assets go.
if (allPagesKept) {
await pruneOldBuildAssets(cache, [...assets]);
@@ -497,6 +515,21 @@ async function keepAsset(cache, asset) {
await cache.put(asset, response);
}
+/**
+ * Fetches the icons fresh on every warm-up (their URLs carry no build hash).
+ * A missing icon is skipped; the pages do not depend on it.
+ */
+async function keepIcons(cache) {
+ await Promise.allSettled(
+ SHELL_ICONS.map(async (icon) => {
+ const response = await fetch(icon, { cache: "no-cache" });
+ if (isGoodAnswer(response)) {
+ await cache.put(icon, response);
+ }
+ }),
+ );
+}
+
async function keepRuntimeConfig(cache) {
const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" });
if (isGoodAnswer(response)) {
diff --git a/ApiCore-Frontend/scripts/generate-icons.mjs b/ApiCore-Frontend/scripts/generate-icons.mjs
new file mode 100644
index 0000000..1fa85f7
--- /dev/null
+++ b/ApiCore-Frontend/scripts/generate-icons.mjs
@@ -0,0 +1,232 @@
+// App icons from the master mark (spec 12.2): `npm run icons`.
+// Reads public/brand/apicore-mark.svg and writes the PNG/SVG/ICO files listed
+// in ICON_FILES. Idempotent: the same input gives byte-identical files, and a
+// file whose bytes would not change is not rewritten. The output is committed,
+// so the Docker image never runs this script.
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import sharp from "sharp";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const MASTER = "public/brand/apicore-mark.svg";
+
+// Spec 12.1: dark ink oklch(0.24 0.035 55) behind the cell, light wax for shortcuts.
+const INK = "#2c1b0f";
+const WAX = "#f9d280";
+const WHITE = "#ffffff";
+
+// Every variant is drawn on a 512 grid; the 64 grid of the mark is scaled into it.
+const GRID = 512;
+const MARK_GRID = 64;
+// Share of the edge the 64 box of the mark takes. "any": the cell is 70 % high.
+// Maskable/monochrome: the cell (stroke included) stays inside the safe circle
+// of 40 % radius and below 66 % of the edge (spec 12.2): 0.68 gives 32 % / 64 %.
+const MARK_BOX = { any: 0.8, maskable: 0.68, favicon: 1 };
+// Spec 12.2: "Ecken 22 % gerundet".
+const ANY_CORNER_RADIUS = 0.22;
+// Shortcut icons: the 24 px area icon takes half the edge, inside any launcher mask.
+const SHORTCUT_ICON_SHARE = 0.5;
+const LUCIDE_GRID = 24;
+
+/** Shortcut icon per manifest shortcut: the lucide icon of its area (spec 11). */
+export const SHORTCUT_ICONS = [
+ { file: "public/icons/shortcut-inspection-96.png", area: "inspections", lucide: "clipboard-check" },
+ { file: "public/icons/shortcut-varroa-96.png", area: "varroa", lucide: "bug" },
+ { file: "public/icons/shortcut-today-96.png", area: "dashboard", lucide: "layout-dashboard" },
+];
+const SHORTCUT_SIZE = 96;
+
+export const ICON_FILES = [
+ "public/icon.svg",
+ "public/icons/icon-192.png",
+ "public/icons/icon-512.png",
+ "public/icons/maskable-192.png",
+ "public/icons/maskable-512.png",
+ "public/icons/monochrome-512.png",
+ ...SHORTCUT_ICONS.map((shortcut) => shortcut.file),
+ "src/app/apple-icon.png",
+ "src/app/favicon.ico",
+];
+
+const FAVICON_SIZES = [16, 32, 48];
+const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: true, palette: false };
+
+/**
+ * The `d` of the cell and the lid in the master SVG.
+ * @param {string} svg
+ * @returns {{ cell: string, lid: string }}
+ */
+export function readMasterPaths(svg) {
+ return { cell: pathElement(svg, "cell").d, lid: pathElement(svg, "lid").d };
+}
+
+/**
+ * @param {string} svg
+ * @param {string} id
+ * @returns {{ element: string, d: string }}
+ */
+function pathElement(svg, id) {
+ const element = svg.match(new RegExp(`]*\\bid="${id}"[^>]*/>`))?.[0];
+ const d = element?.match(/\bd="([^"]+)"/)?.[1];
+ if (!element || !d) {
+ throw new Error(`${MASTER}: no `);
+ }
+ return { element, d };
+}
+
+const round = (value) => Number(value.toFixed(4));
+
+/** The mark scaled into the 512 grid, centred, taking `box` of the edge. */
+function markGroup(content, box) {
+ const scale = round((box * GRID) / MARK_GRID);
+ const offset = round((GRID - box * GRID) / 2);
+ return `${content}`;
+}
+
+function svgDocument(body, size) {
+ const dimensions = size ? ` width="${size}" height="${size}"` : "";
+ return `\n`;
+}
+
+/** The SVG bodies of every mark variant, from the master. */
+function variants(master) {
+ const cell = pathElement(master, "cell");
+ const lid = pathElement(master, "lid");
+ const withoutId = (element) => element.replace(/\sid="[^"]*"/, "");
+ const colouredMark = `${withoutId(cell.element)}${withoutId(lid.element)}`;
+ const silhouette = withoutId(cell.element)
+ .replace(/\bfill="[^"]*"/, `fill="${WHITE}"`)
+ .replace(/\bstroke="[^"]*"/, `stroke="${WHITE}"`);
+ const radius = round(ANY_CORNER_RADIUS * GRID);
+ return {
+ any: `${markGroup(colouredMark, MARK_BOX.any)}`,
+ apple: `${markGroup(colouredMark, MARK_BOX.any)}`,
+ maskable: `${markGroup(colouredMark, MARK_BOX.maskable)}`,
+ monochrome: markGroup(silhouette, MARK_BOX.maskable),
+ favicon: markGroup(colouredMark, MARK_BOX.favicon),
+ };
+}
+
+/** Lucide icon node (`[tag, attrs][]`) of an icon name, from the installed lucide-react. */
+async function lucideNode(name) {
+ const icon = await import(`lucide-react/dist/esm/icons/${name}.mjs`);
+ return icon.__iconNode;
+}
+
+function lucideMarkup(node) {
+ return node
+ .map(([tag, attrs]) => {
+ const attributes = Object.entries(attrs)
+ .filter(([key]) => key !== "key")
+ .map(([key, value]) => `${key}="${value}"`)
+ .join(" ");
+ return `<${tag} ${attributes}/>`;
+ })
+ .join("");
+}
+
+/** Area icon in ink on wax, full bleed (spec 12.2). */
+function shortcutSvg(markup) {
+ const scale = round((SHORTCUT_ICON_SHARE * GRID) / LUCIDE_GRID);
+ const offset = round((GRID - SHORTCUT_ICON_SHARE * GRID) / 2);
+ return (
+ `` +
+ `${markup}`
+ );
+}
+
+function renderPng(body, size) {
+ return sharp(Buffer.from(svgDocument(body, size))).png(PNG_OPTIONS).toBuffer();
+}
+
+/**
+ * ICO container with PNG entries (sharp writes no ICO): ICONDIR, one
+ * ICONDIRENTRY per image, then the PNG data.
+ * @param {{ size: number, png: Buffer }[]} images
+ * @returns {Buffer}
+ */
+export function buildIco(images) {
+ const HEADER = 6;
+ const ENTRY = 16;
+ const header = Buffer.alloc(HEADER);
+ header.writeUInt16LE(0, 0);
+ header.writeUInt16LE(1, 2);
+ header.writeUInt16LE(images.length, 4);
+ let offset = HEADER + ENTRY * images.length;
+ const entries = images.map(({ size, png }) => {
+ const entry = Buffer.alloc(ENTRY);
+ // One byte each: 256 is written as 0.
+ entry.writeUInt8(size >= 256 ? 0 : size, 0);
+ entry.writeUInt8(size >= 256 ? 0 : size, 1);
+ entry.writeUInt8(0, 2);
+ entry.writeUInt8(0, 3);
+ entry.writeUInt16LE(1, 4);
+ entry.writeUInt16LE(32, 6);
+ entry.writeUInt32LE(png.length, 8);
+ entry.writeUInt32LE(offset, 12);
+ offset += png.length;
+ return entry;
+ });
+ return Buffer.concat([header, ...entries, ...images.map((image) => image.png)]);
+}
+
+function writeIfChanged(file, content) {
+ mkdirSync(path.dirname(file), { recursive: true });
+ let current = null;
+ try {
+ current = readFileSync(file);
+ } catch {
+ // Not there yet.
+ }
+ if (!current || !current.equals(content)) {
+ writeFileSync(file, content);
+ }
+}
+
+/**
+ * Renders every icon into `outRoot` (same relative paths as in the app).
+ * @param {{ root?: string, outRoot?: string }} [options]
+ * @returns {Promise} the files, relative to `outRoot`
+ */
+export async function generateIcons({ root = ROOT, outRoot = root } = {}) {
+ const master = readFileSync(path.join(root, MASTER), "utf8");
+ const bodies = variants(master);
+ const outputs = new Map();
+
+ outputs.set("public/icon.svg", Buffer.from(svgDocument(bodies.any)));
+ for (const size of [192, 512]) {
+ outputs.set(`public/icons/icon-${size}.png`, await renderPng(bodies.any, size));
+ outputs.set(`public/icons/maskable-${size}.png`, await renderPng(bodies.maskable, size));
+ }
+ outputs.set("public/icons/monochrome-512.png", await renderPng(bodies.monochrome, 512));
+ for (const shortcut of SHORTCUT_ICONS) {
+ const markup = lucideMarkup(await lucideNode(shortcut.lucide));
+ outputs.set(shortcut.file, await renderPng(shortcutSvg(markup), SHORTCUT_SIZE));
+ }
+ outputs.set("src/app/apple-icon.png", await renderPng(bodies.apple, 180));
+ const favicons = [];
+ for (const size of FAVICON_SIZES) {
+ favicons.push({ size, png: await renderPng(bodies.favicon, size) });
+ }
+ outputs.set("src/app/favicon.ico", buildIco(favicons));
+
+ const files = ICON_FILES.filter((file) => outputs.has(file));
+ for (const file of files) {
+ writeIfChanged(path.join(outRoot, ...file.split("/")), outputs.get(file));
+ }
+ return files;
+}
+
+const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+if (isCli) {
+ generateIcons()
+ .then((files) => {
+ process.stdout.write(`${files.join("\n")}\n`);
+ })
+ .catch((error) => {
+ process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
+ process.exitCode = 1;
+ });
+}
diff --git a/ApiCore-Frontend/scripts/generate-icons.spec.ts b/ApiCore-Frontend/scripts/generate-icons.spec.ts
new file mode 100644
index 0000000..c05880f
--- /dev/null
+++ b/ApiCore-Frontend/scripts/generate-icons.spec.ts
@@ -0,0 +1,66 @@
+// @vitest-environment node
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { BRAND_CELL_PATHS } from "@/components/brand-mark";
+import { AREAS } from "@/lib/areas";
+import { ICON_FILES, SHORTCUT_ICONS, buildIco, generateIcons, readMasterPaths } from "./generate-icons.mjs";
+
+const ROOT = process.cwd();
+
+describe("generate-icons (spec 12.2)", () => {
+ let outDir: string;
+ beforeEach(() => {
+ outDir = mkdtempSync(path.join(tmpdir(), "icons-"));
+ });
+ afterEach(() => rmSync(outDir, { recursive: true, force: true }));
+
+ it("the master SVG has the BrandMark geometry", () => {
+ expect(readMasterPaths(readFileSync(path.join(ROOT, "public/brand/apicore-mark.svg"), "utf8"))).toEqual(
+ BRAND_CELL_PATHS,
+ );
+ });
+
+ it("the lid sits centred over the cell, only lifted", () => {
+ const xs = (d: string) => [...d.matchAll(/L([\d.]+)\s/g)].map((match) => Number(match[1]));
+ const lidXs = xs(BRAND_CELL_PATHS.lid);
+ expect(Math.min(...lidXs) + Math.max(...lidXs)).toBeCloseTo(64, 5);
+ });
+
+ it("shortcut icons use the icon of their area", () => {
+ const pascal = (name: string) => name.replace(/(^|-)(\w)/g, (_, __, char: string) => char.toUpperCase());
+ for (const shortcut of SHORTCUT_ICONS) {
+ expect(AREAS[shortcut.area as keyof typeof AREAS].icon.displayName, shortcut.file).toBe(pascal(shortcut.lucide));
+ }
+ });
+
+ it("running the icon script twice leaves the files byte-identical", async () => {
+ const first = await generateIcons({ root: ROOT, outRoot: outDir });
+ const snapshot = new Map(first.map((file) => [file, readFileSync(path.join(outDir, file))]));
+ const second = await generateIcons({ root: ROOT, outRoot: outDir });
+
+ expect(second).toEqual(first);
+ expect([...first].sort()).toEqual([...ICON_FILES].sort());
+ for (const file of second) {
+ expect(readFileSync(path.join(outDir, file)).equals(snapshot.get(file) as Buffer), file).toBe(true);
+ }
+ });
+
+ it("buildIco writes a header, one entry per PNG and the PNGs behind it", () => {
+ const png = (size: number) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(size)]);
+ const ico = buildIco([
+ { size: 16, png: png(10) },
+ { size: 256, png: png(20) },
+ ]);
+
+ expect([ico.readUInt16LE(0), ico.readUInt16LE(2), ico.readUInt16LE(4)]).toEqual([0, 1, 2]);
+ // 256 px is written as 0 in the one-byte width and height fields.
+ expect([ico.readUInt8(6), ico.readUInt8(22)]).toEqual([16, 0]);
+ expect(ico.readUInt16LE(6 + 6)).toBe(32);
+ const secondOffset = ico.readUInt32LE(22 + 12);
+ expect(ico.readUInt32LE(6 + 12)).toBe(6 + 2 * 16);
+ expect(secondOffset).toBe(6 + 2 * 16 + 14);
+ expect(ico.length).toBe(secondOffset + 24);
+ });
+});
diff --git a/ApiCore-Frontend/src/app/apple-icon.png b/ApiCore-Frontend/src/app/apple-icon.png
new file mode 100644
index 0000000..dcba948
Binary files /dev/null and b/ApiCore-Frontend/src/app/apple-icon.png differ
diff --git a/ApiCore-Frontend/src/app/apple-icon.tsx b/ApiCore-Frontend/src/app/apple-icon.tsx
deleted file mode 100644
index 69b4c1c..0000000
--- a/ApiCore-Frontend/src/app/apple-icon.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { ImageResponse } from "next/og";
-
-// iOS's "Add to Home Screen" doesn't read the web manifest at all - it needs
-// its own , which only exists if this special
-// file (or a literal apple-icon.png) is present. Without it, iOS falls back
-// to a screenshot of whatever page was open, not the app's brand mark.
-// Mirrors public/icon.svg's existing design (used in the manifest for
-// Android/Chrome) as plain positioned divs rather than inline SVG paths,
-// since that renders reliably through Satori (next/og's renderer). iOS
-// applies its own rounded-square mask on top, so this stays a plain square.
-export const size = { width: 180, height: 180 };
-export const contentType = "image/png";
-
-export default function AppleIcon() {
- const barStyle = {
- position: "absolute" as const,
- left: "30%",
- width: "40%",
- height: "10%",
- borderRadius: 6,
- background: "#f5c518",
- };
-
- return new ImageResponse(
- (
-
- ),
- { ...size },
- );
-}
diff --git a/ApiCore-Frontend/src/app/favicon.ico b/ApiCore-Frontend/src/app/favicon.ico
index 718d6fe..7475329 100644
Binary files a/ApiCore-Frontend/src/app/favicon.ico and b/ApiCore-Frontend/src/app/favicon.ico differ
diff --git a/ApiCore-Frontend/src/app/manifest.spec.ts b/ApiCore-Frontend/src/app/manifest.spec.ts
new file mode 100644
index 0000000..e15a278
--- /dev/null
+++ b/ApiCore-Frontend/src/app/manifest.spec.ts
@@ -0,0 +1,175 @@
+// @vitest-environment node
+import { existsSync, readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { hexOf, oklchOf, tokenSet } from "@/styles/css-tokens";
+import { config as proxyConfig } from "@/proxy";
+import manifest from "./manifest";
+
+// Spec 12.2/12.3 and 13.1: the web manifest, its files and what serves them.
+
+const ROOT = process.cwd();
+const PUBLIC_DIR = path.join(ROOT, "public");
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+interface Icon {
+ src: string;
+ sizes?: string;
+ type?: string;
+ purpose?: string;
+}
+
+/** Width and height from the IHDR chunk of a PNG. */
+function pngSize(png: Buffer): { width: number; height: number } {
+ expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true);
+ expect(png.toString("latin1", 12, 16)).toBe("IHDR");
+ return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
+}
+
+function publicFile(src: string): string {
+ return path.join(PUBLIC_DIR, ...src.split("/").filter(Boolean));
+}
+
+const data = manifest();
+const icons: Icon[] = data.icons ?? [];
+const shortcutIcons: Icon[] = (data.shortcuts ?? []).flatMap((shortcut) => shortcut.icons ?? []);
+
+describe("web manifest (spec 12.3)", () => {
+ it("every manifest icon exists under public with the declared size", () => {
+ const all = [...icons, ...shortcutIcons];
+ expect(all.length).toBeGreaterThan(0);
+ for (const icon of all) {
+ const file = publicFile(icon.src);
+ expect(existsSync(file), icon.src).toBe(true);
+ if (icon.sizes === "any") {
+ expect(icon.type).toBe("image/svg+xml");
+ expect(readFileSync(file, "utf8")).toContain("