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 `${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 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(" { + 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$).*)", ], };