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:
1 parent
fee84c7a6b
commit
2395576c88
23 files changed
+632
-67
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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 |
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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$).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user