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

+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);
}
});
});