From 2395576c880df6f5059d8aaa016f4c73db9beb4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Fri, 16 Oct 2026 17:51:41 +0200 Subject: [PATCH] feat(t5): App-Icons, Manifest und Offline-Cache v5 (T5a A9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ApiCore-Frontend/package-lock.json | 7 +- ApiCore-Frontend/package.json | 4 +- .../public/brand/apicore-mark.svg | 6 + ApiCore-Frontend/public/icon.svg | 9 +- ApiCore-Frontend/public/icons/icon-192.png | Bin 0 -> 4116 bytes ApiCore-Frontend/public/icons/icon-512.png | Bin 0 -> 10164 bytes .../public/icons/maskable-192.png | Bin 0 -> 2074 bytes .../public/icons/maskable-512.png | Bin 0 -> 4313 bytes .../public/icons/monochrome-512.png | Bin 0 -> 3213 bytes .../public/icons/shortcut-inspection-96.png | Bin 0 -> 1039 bytes .../public/icons/shortcut-today-96.png | Bin 0 -> 620 bytes .../public/icons/shortcut-varroa-96.png | Bin 0 -> 1966 bytes ApiCore-Frontend/public/sw.js | 35 ++- ApiCore-Frontend/scripts/generate-icons.mjs | 232 ++++++++++++++++++ .../scripts/generate-icons.spec.ts | 66 +++++ ApiCore-Frontend/src/app/apple-icon.png | Bin 0 -> 2069 bytes ApiCore-Frontend/src/app/apple-icon.tsx | 42 ---- ApiCore-Frontend/src/app/favicon.ico | Bin 25931 -> 3031 bytes ApiCore-Frontend/src/app/manifest.spec.ts | 175 +++++++++++++ ApiCore-Frontend/src/app/manifest.ts | 40 ++- .../src/components/brand-mark.tsx | 2 +- ApiCore-Frontend/src/lib/offline/sw.spec.ts | 74 +++++- ApiCore-Frontend/src/proxy.ts | 7 +- 23 files changed, 632 insertions(+), 67 deletions(-) create mode 100644 ApiCore-Frontend/public/brand/apicore-mark.svg create mode 100644 ApiCore-Frontend/public/icons/icon-192.png create mode 100644 ApiCore-Frontend/public/icons/icon-512.png create mode 100644 ApiCore-Frontend/public/icons/maskable-192.png create mode 100644 ApiCore-Frontend/public/icons/maskable-512.png create mode 100644 ApiCore-Frontend/public/icons/monochrome-512.png create mode 100644 ApiCore-Frontend/public/icons/shortcut-inspection-96.png create mode 100644 ApiCore-Frontend/public/icons/shortcut-today-96.png create mode 100644 ApiCore-Frontend/public/icons/shortcut-varroa-96.png create mode 100644 ApiCore-Frontend/scripts/generate-icons.mjs create mode 100644 ApiCore-Frontend/scripts/generate-icons.spec.ts create mode 100644 ApiCore-Frontend/src/app/apple-icon.png delete mode 100644 ApiCore-Frontend/src/app/apple-icon.tsx create mode 100644 ApiCore-Frontend/src/app/manifest.spec.ts 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 0000000000000000000000000000000000000000..23c52fca53e05f63c09ccb2f158b1187031b936c GIT binary patch literal 4116 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 literal 0 HcmV?d00001 diff --git a/ApiCore-Frontend/public/icons/maskable-512.png b/ApiCore-Frontend/public/icons/maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ed0fe59ffd21de464a018aae0605ec0d41f7497e GIT binary patch literal 4313 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 literal 0 HcmV?d00001 diff --git a/ApiCore-Frontend/public/icons/monochrome-512.png b/ApiCore-Frontend/public/icons/monochrome-512.png new file mode 100644 index 0000000000000000000000000000000000000000..91afdd94879dd3f04b71c5a6a72d0ea1054aad3a GIT binary patch literal 3213 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, 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 `${body}\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 0000000000000000000000000000000000000000..dcba948274b5a898b52ce0d751de9d755ed30222 GIT binary patch literal 2069 zcmb7Fc~sI_8>W+4X=dbCW{z5^r9*0nAQ5nxA7iI8Xa1W%?tRX?-1pvd@AKR@)gOh_(b}p7 z0)ceA4tWLuSfw6oG=bUmxuz`uYYB&r$AUoGdg`$P#LV9U0zhQ){UXI;Y8AxJ(9+J| zo3pmrZslt4S{DoL6Ya(~A}qxj<0)2sv~V;n+-H~Y(=}+7zXx*;RV!)0rW+xhnif7F z7H1w62=L?;$q)G&+aj;BS5P$9Y}L@&V7IRu|Bmvn{et>3{eHg1ii)}G6U~Q;&ow9?KMZwtcyPX*diaK4*ML}R z&YHW+o+75Dd3J+q*x7M^;un;{J767|(}>D;BruP-#KlhcD$ElwWXk8jbXVrfmpRfw z`+hRkW@-F6lSX#o`V5p{TqvJ^SlK{Jl%7V}Ae^@iDNil+%Zr*8&K|hp1m`}D$Vv0+ zK3`v0ZDmA^^5-tacnU*w?YEQ;xRU3@zabpz?jP*=FuF0WrXaB%c>y*42yfO<#Fftm zgj^}GnygHNV8SUuk+9)PRmHVMH!S9Ogvc~9$*E-(m66UKvR%eLnt^qsfTU6S5MRTF zO2Jiw(0>Ik3JC6DqO^qWPr2`(2$0=71%&LLN74Hy%2I(koB9l1RBZIdU9#kp12cL{ zc0UW@TaTQ*sSXtxSknzMVSB703Spja(=l`<$?tjH0rf6Uz>Gssf{LFLf*#Xv3rjus+K3a~bmqKHiokKj0)n1O1$Q+VU6>Ex)A>&WH1LR$v?}#>8R7Wbj7iJ>tA2)URJo{5imL-O9gF#96r?IOKE?+W}6%KR?i4KjIJkJpiCmran1_y;xP z+}@I`9nr?`ucu(NKh1kkK7-yWE2el${OHr?mU4FwK~2Z@=I`<8W=Tcp*m(ES!GP5T zVxaW+Nw(b!)s2V%Wy(gA9W=%6N|>AC#06pth@Hl%>Q6 zy06V9RlOxbI7Hx7&>o`rm3b-6DE_`{O0S-*B}s@uhl$-P&}rvc zicyyqaFsjE1one_9#pDm3_gZ)pzJ*<3QIPPG{qqRBOAONwUt7F)MdjEsy5=r@Qt`n zwERY>S4JI)YCvNbCJwy5b$LQ-zM2`H4dY^t%|iM8bQ$}C)|Xn@>=;i`SSlu zC5E=Hvce%4b}e8T&+NQUN`YbTbW;b%ne|Kjv+4N5CW*E8bzfMQytI=~9Qyxy;?Zhn z4t8lraZ8tONA;+E3u~dR;;n1LhIZgeCGX3TFehb9sH(L#Y=+cSf4rwWeIi~Q4BspH zqg;y^E4sY6Yv^lAOM;HvvGoN(!m^waOqeH>6Q^7EO!|p4OVQXZ{byIFJhJ+!mG?5Q zfZH?S;}nf+&IO8Z>SA>Ds*%g+lBqGBVPg$Yq4OidCQ6ON3>zANh7QuF2>>G)u)j&* z%TVOLUlY7jKoSr%tn`*?vi5J9bY?QrfkM>We`FXX)FmG!hW%*c^-cYl|4}r=XYpwEN64xl{CJWf*n0uK%9nE(I) literal 0 HcmV?d00001 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 718d6fea4835ec2d246af9800eddb7ffb276240c..7475329524b63168e385d483ec2778cd541cc1c8 100644 GIT binary patch literal 3031 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 LrJugUA?W`A8`#=m 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(" { + 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); + } + }); +}); diff --git a/ApiCore-Frontend/src/app/manifest.ts b/ApiCore-Frontend/src/app/manifest.ts index 8d13d24..42b8474 100644 --- a/ApiCore-Frontend/src/app/manifest.ts +++ b/ApiCore-Frontend/src/app/manifest.ts @@ -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" }], }, ], }; diff --git a/ApiCore-Frontend/src/components/brand-mark.tsx b/ApiCore-Frontend/src/components/brand-mark.tsx index aa723fd..d56c3ea 100644 --- a/ApiCore-Frontend/src/components/brand-mark.tsx +++ b/ApiCore-Frontend/src/components/brand-mark.tsx @@ -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 { diff --git a/ApiCore-Frontend/src/lib/offline/sw.spec.ts b/ApiCore-Frontend/src/lib/offline/sw.spec.ts index 72204fe..a9adb3f 100644 --- a/ApiCore-Frontend/src/lib/offline/sw.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/sw.spec.ts @@ -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): Promise { 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 { 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 { - 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; + 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(); + }); + }); }); diff --git a/ApiCore-Frontend/src/proxy.ts b/ApiCore-Frontend/src/proxy.ts index 1345f92..61f826c 100644 --- a/ApiCore-Frontend/src/proxy.ts +++ b/ApiCore-Frontend/src/proxy.ts @@ -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$).*)", ], };