feat(t5): App-Icons, Manifest und Offline-Cache v5 (T5a A9)

- Honigzelle auf Tinte als Master-SVG; scripts/generate-icons.mjs erzeugt
  any/maskable 192/512, Monochrom 512, drei Shortcut-Icons, Apple-Icon und
  favicon.ico (16/32/48, ICO-Container von Hand) byte-gleich bei jedem Lauf.
- manifest.ts exakt nach Spec 12.3, Farben gleich dem hellen --background.
- Service Worker: SHELL_CACHE apicore-shell-v5, Icons im Shell-Cache; ein
  fehlendes Icon hält das Vorladen der Seiten nicht auf.
- Proxy-Matcher: nur echte Icon-Dateien (icons/*.png) ohne
  Sicherheits-Header, Punkte maskiert.
- BrandMark-Deckel mittig (linke Kante 18.8).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 17:51:41 +02:00
1 parent fee84c7a6b
commit 2395576c88
23 files changed
+632 -67

No files matched your search

+4 -3
View File
@@ -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"
},
+3 -1
View File
@@ -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"
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- ApiCore mark (spec 12.1): a capped honeycomb cell. Master for scripts/generate-icons.mjs;
the geometry equals BRAND_CELL_PATHS in src/components/brand-mark.tsx. -->
<path id="cell" d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" />
<path id="lid" d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" />
</svg>

After

Width:  |  Height:  |  Size: 477 B

+1 -8
View File
@@ -1,8 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0a0a0a" />
<g fill="#f5c518">
<rect x="156" y="176" width="200" height="52" rx="10" />
<rect x="156" y="242" width="200" height="52" rx="10" />
<rect x="156" y="308" width="200" height="52" rx="10" />
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112.64" fill="#2c1b0f"/><g transform="translate(51.2 51.2) scale(6.4)"><path d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" /><path d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" /></g></svg>

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+34 -1
View File
@@ -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?<hash>, 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)) {
+232
View File
@@ -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(`<path\\b[^>]*\\bid="${id}"[^>]*/>`))?.[0];
const d = element?.match(/\bd="([^"]+)"/)?.[1];
if (!element || !d) {
throw new Error(`${MASTER}: no <path id="${id}" d="…"/>`);
}
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 `<g transform="translate(${offset} ${offset}) scale(${scale})">${content}</g>`;
}
function svgDocument(body, size) {
const dimensions = size ? ` width="${size}" height="${size}"` : "";
return `<svg xmlns="http://www.w3.org/2000/svg"${dimensions} viewBox="0 0 ${GRID} ${GRID}">${body}</svg>\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: `<rect width="${GRID}" height="${GRID}" rx="${radius}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
apple: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
maskable: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${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 (
`<rect width="${GRID}" height="${GRID}" fill="${WAX}"/>` +
`<g transform="translate(${offset} ${offset}) scale(${scale})" fill="none" stroke="${INK}" ` +
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${markup}</g>`
);
}
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<string[]>} 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;
});
}
@@ -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);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

-42
View File
@@ -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 <link rel="apple-touch-icon">, 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(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
position: "relative",
background: "#0a0a0a",
}}
>
<div style={{ ...barStyle, top: "34%" }} />
<div style={{ ...barStyle, top: "47%" }} />
<div style={{ ...barStyle, top: "60%" }} />
</div>
),
{ ...size },
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+175
View File
@@ -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("<svg");
continue;
}
expect(icon.type ?? "image/png").toBe("image/png");
const [width, height] = (icon.sizes ?? "").split("x").map(Number);
expect(pngSize(readFileSync(file)), icon.src).toEqual({ width, height });
}
});
it("declares any and maskable icons at 192 and 512 and one monochrome icon", () => {
const sizesOf = (purpose: string) =>
icons.filter((icon) => (icon.purpose ?? "any") === purpose).map((icon) => icon.sizes);
expect(sizesOf("any")).toEqual(expect.arrayContaining(["192x192", "512x512", "any"]));
expect(sizesOf("maskable")).toEqual(["192x192", "512x512"]);
expect(sizesOf("monochrome")).toEqual(["512x512"]);
});
it("background_color and theme_color equal the light --background", () => {
const css = readFileSync(path.join(ROOT, "src/app/globals.css"), "utf8");
const lightBackground = hexOf(oklchOf(tokenSet(css, "light").background));
expect(lightBackground).toBe("#f9f4ea");
expect(data.background_color).toBe(lightBackground);
expect(data.theme_color).toBe(lightBackground);
});
it("is installable from the root with German metadata", () => {
expect(data).toMatchObject({
id: "/",
name: "ApiCore – Imkereiverwaltung",
short_name: "ApiCore",
lang: "de",
dir: "ltr",
start_url: "/",
scope: "/",
display: "standalone",
categories: ["productivity", "business"],
});
});
it("offers exactly the three spec shortcuts with their 96 px icons", () => {
expect(
(data.shortcuts ?? []).map((shortcut) => ({
name: shortcut.name,
url: shortcut.url,
icons: shortcut.icons?.map((icon) => `${icon.src} ${icon.sizes}`),
})),
).toEqual([
{ name: "Durchsicht erfassen", url: "/inspections/new", icons: ["/icons/shortcut-inspection-96.png 96x96"] },
{ name: "Varroa messen", url: "/varroa/new", icons: ["/icons/shortcut-varroa-96.png 96x96"] },
{ name: "Heute", url: "/dashboard", icons: ["/icons/shortcut-today-96.png 96x96"] },
]);
});
it("every shortcut url is in SHELL_ROUTES", () => {
const require = createRequire(import.meta.url);
const { SHELL_ROUTES } = require(path.join(PUBLIC_DIR, "sw-routes.js")) as {
SHELL_ROUTES: readonly string[];
};
for (const shortcut of data.shortcuts ?? []) {
expect(SHELL_ROUTES, shortcut.url).toContain(shortcut.url);
}
});
});
describe("app icons (spec 12.2)", () => {
it("favicon.ico holds 16, 32 and 48 px entries", () => {
const ico = readFileSync(path.join(ROOT, "src/app/favicon.ico"));
expect(ico.readUInt16LE(0)).toBe(0);
expect(ico.readUInt16LE(2)).toBe(1);
const count = ico.readUInt16LE(4);
const entries = Array.from({ length: count }, (_, index) => {
const at = 6 + index * 16;
const width = ico.readUInt8(at);
const length = ico.readUInt32LE(at + 8);
const offset = ico.readUInt32LE(at + 12);
const image = ico.subarray(offset, offset + length);
expect(image.length).toBe(length);
expect(pngSize(image)).toEqual({ width, height: ico.readUInt8(at + 1) });
return width;
});
expect(entries).toEqual([16, 32, 48]);
});
it("apple-icon.png is a 180 px PNG and replaces the generated apple-icon route", () => {
expect(pngSize(readFileSync(path.join(ROOT, "src/app/apple-icon.png")))).toEqual({ width: 180, height: 180 });
expect(existsSync(path.join(ROOT, "src/app/apple-icon.tsx"))).toBe(false);
});
});
describe("proxy matcher", () => {
const [matcher] = proxyConfig.matcher;
const runsProxy = (pathname: string) => new RegExp(`^${matcher}$`).test(pathname);
it("the proxy matcher excludes icons and apple-icon.png", () => {
for (const pathname of [
"/icons/icon-192.png",
"/icons/maskable-512.png",
"/icons/shortcut-today-96.png",
"/apple-icon.png",
"/icon.svg",
"/favicon.ico",
"/sw.js",
"/manifest.webmanifest",
]) {
expect(runsProxy(pathname), pathname).toBe(false);
}
});
it("keeps the security headers on pages that only resemble an excluded file", () => {
for (const pathname of [
"/",
"/dashboard",
"/inspections/new",
"/icons",
"/icons/x/y",
"/icons/missing",
"/iconsx/page",
"/apple-iconxpng",
"/iconxsvg",
"/faviconxico",
"/swxjs",
]) {
expect(runsProxy(pathname), pathname).toBe(true);
}
});
});
+35 -5
View File
@@ -1,19 +1,49 @@
import type { MetadataRoute } from "next";
import { THEME_COLORS } from "@/lib/appearance/appearance-core";
// Spec 12.3. The icon files come from `npm run icons` (scripts/generate-icons.mjs);
// the shortcut URLs are shell routes of the service worker, so they open offline too.
export default function manifest(): MetadataRoute.Manifest {
return {
id: "/",
name: "ApiCore – Imkereiverwaltung",
short_name: "ApiCore",
description: "Digitale Imkereiverwaltung für Wabenhain",
lang: "de",
dir: "ltr",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#0a0a0a",
// = light --background (manifest.spec checks it against globals.css); dark
// comes from viewport.themeColor in the root layout.
background_color: THEME_COLORS.light,
theme_color: THEME_COLORS.light,
categories: ["productivity", "business"],
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icons/maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "/icons/monochrome-512.png", sizes: "512x512", type: "image/png", purpose: "monochrome" },
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
],
shortcuts: [
{
src: "/icon.svg",
sizes: "any",
type: "image/svg+xml",
name: "Durchsicht erfassen",
short_name: "Durchsicht",
url: "/inspections/new",
icons: [{ src: "/icons/shortcut-inspection-96.png", sizes: "96x96", type: "image/png" }],
},
{
name: "Varroa messen",
short_name: "Varroa",
url: "/varroa/new",
icons: [{ src: "/icons/shortcut-varroa-96.png", sizes: "96x96", type: "image/png" }],
},
{
name: "Heute",
url: "/dashboard",
icons: [{ src: "/icons/shortcut-today-96.png", sizes: "96x96", type: "image/png" }],
},
],
};
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
*/
export const BRAND_CELL_PATHS = {
cell: "M32 4 L56 18 V46 L32 60 L8 46 V18 Z",
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L19.8 36.7 V21.3 Z",
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z",
} as const;
interface BrandMarkProps {
+69 -5
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import manifest from "@/app/manifest";
// public/sw.js itself, run in a sandbox with an in-memory Cache Storage and a
// fake network - the worker's wiring, beyond the pure rules in sw-routes.js.
@@ -10,6 +11,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const APP = "https://apicore.example";
const API = "https://apicorebackend.example";
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
// Spec 12.3: raised so installed apps fetch the new icons and tokens.
const SHELL = "apicore-shell-v5";
interface MatchOptions {
ignoreSearch?: boolean;
@@ -154,7 +157,7 @@ function page(asset: string, marker: string): string {
async function workerWithBackend(network: ReturnType<typeof vi.fn>): Promise<Worker> {
const caches = new FakeCacheStorage();
const shell = await caches.open("apicore-shell-v4");
const shell = await caches.open(SHELL);
await shell.put("/api/runtime-config", json({ apiUrl: `${API}/api/v1` }));
const worker = loadWorker(caches, network);
// Let the worker read the backend origin from the cached config.
@@ -289,7 +292,7 @@ describe("service worker", () => {
describe("background refresh of a cached offline page", () => {
async function workerWithOldOfflinePage(): Promise<Worker> {
const caches = new FakeCacheStorage();
const shell = await caches.open("apicore-shell-v4");
const shell = await caches.open(SHELL);
await shell.put("/offline/colony", html(page("/_next/static/chunks/old.js", "alt")));
await shell.put("/_next/static/chunks/old.js", new Response("old()"));
caches.log.length = 0;
@@ -297,7 +300,7 @@ describe("service worker", () => {
}
async function cachedOfflinePage(worker: Worker): Promise<string | undefined> {
const shell = await worker.caches.open("apicore-shell-v4");
const shell = await worker.caches.open(SHELL);
return (await shell.match("/offline/colony"))?.text();
}
@@ -317,8 +320,8 @@ describe("service worker", () => {
// ... and the new one is kept with its assets, the assets first.
expect(await cachedOfflinePage(worker)).toContain("neu");
expect(worker.caches.log).toEqual([
"apicore-shell-v4 /_next/static/chunks/new.js",
"apicore-shell-v4 /offline/colony",
`${SHELL} /_next/static/chunks/new.js`,
`${SHELL} /offline/colony`,
]);
});
@@ -335,4 +338,65 @@ describe("service worker", () => {
expect(await cachedOfflinePage(worker)).toContain("alt");
});
});
describe("app icons in the shell (spec 12.3)", () => {
function answerEverything() {
network.mockImplementation(async (request: RequestInfo) => {
const { pathname } = new URL(keyOf(request));
if (pathname === "/api/runtime-config") return json({ apiUrl: `${API}/api/v1` });
if (pathname.startsWith("/_next/static/")) return new Response("asset()");
if (/\.(png|ico|svg)$/.test(pathname)) {
return new Response("image", { headers: { "Content-Type": "image/png" } });
}
return html(page("/_next/static/chunks/app.js", pathname));
});
}
it("the service worker cache version is apicore-shell-v5", async () => {
const caches = new FakeCacheStorage();
await (await caches.open("apicore-shell-v4")).put("/dashboard", html("alt"));
answerEverything();
const worker = loadWorker(caches, network);
await dispatch(worker, "install", {});
await dispatch(worker, "activate", {});
expect(await caches.keys()).toContain(SHELL);
expect(await caches.keys()).not.toContain("apicore-shell-v4");
});
it("install keeps every manifest icon in the shell cache", async () => {
answerEverything();
const worker = loadWorker(new FakeCacheStorage(), network);
await dispatch(worker, "install", {});
const data = manifest();
const icons = [
...(data.icons ?? []),
...(data.shortcuts ?? []).flatMap((shortcut) => shortcut.icons ?? []),
].map((icon) => icon.src);
const shell = await worker.caches.open(SHELL);
for (const src of icons) {
expect(await shell.match(src), src).toBeDefined();
}
});
it("a missing icon does not stop the warm-up of the pages", async () => {
answerEverything();
const pages = network.getMockImplementation() as (request: RequestInfo) => Promise<Response>;
network.mockImplementation(async (request: RequestInfo) =>
new URL(keyOf(request)).pathname.startsWith("/icons/")
? new Response("gone", { status: 404 })
: pages(request),
);
const worker = loadWorker(new FakeCacheStorage(), network);
await dispatch(worker, "install", {});
const shell = await worker.caches.open(SHELL);
expect(await shell.match("/dashboard")).toBeDefined();
expect(await shell.match("/icons/icon-192.png")).toBeUndefined();
});
});
});
+6 -1
View File
@@ -66,8 +66,13 @@ export function proxy() {
return withSecurityHeaders(NextResponse.next());
}
// Static files without security headers: build assets, the worker, the
// manifest and the app icons (public/icons/*.png, src/app/apple-icon.png).
// Dots are escaped and file names end at `$`, so no page that merely starts
// like one of them (`/iconsx`, `/favicon.icox`, `/icons/x/y`) loses its
// headers; manifest.spec checks it.
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sw.js|manifest.webmanifest|icon.svg).*)",
"/((?!_next/static/|_next/image$|favicon\\.ico$|sw\\.js$|manifest\\.webmanifest$|icon\\.svg$|apple-icon\\.png$|icons/[\\w-]+\\.png$).*)",
],
};