04c7f6e68a
Tests · UI / test (push) Failing after 7m31s
Native SvelteKit service worker (src/service-worker.ts): a version-keyed cache precaches the app shell + build artefacts (incl. core.wasm) + static files; activate purges old caches; the gateway is never intercepted; navigations fall back to the cached shell offline. Adds static/manifest.webmanifest, a generated placeholder icon set (scripts/gen-pwa-icons.mjs — dependency-free pure-Node PNG encoder), and manifest / theme-color / apple-touch tags in app.html. Gated by Playwright against a production preview (playwright.pwa.config.ts + tests/pwa/pwa.spec.ts via `pnpm test:pwa`, wired into ui-test): manifest + installable icons, SW registration + a single version-keyed cache, and offline shell load. Lighthouse is not used — its PWA category was removed in v12. Docs: ui/docs/pwa-strategy.md (+ index); F5 marked done. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.2 KiB
JavaScript
102 lines
3.2 KiB
JavaScript
// Generates the PWA icon set from the project's galaxy mark, with no
|
|
// image dependency (a tiny pure-Node RGBA PNG encoder over zlib). The
|
|
// mark mirrors `static/favicon.svg`: a dark disc with an amber core,
|
|
// plus an amber orbit ring. Re-run after changing the palette/mark:
|
|
//
|
|
// node scripts/gen-pwa-icons.mjs
|
|
//
|
|
// Outputs into static/icons/. The committed PNGs are placeholders; swap
|
|
// in real artwork at the same paths/sizes and the manifest is unchanged.
|
|
|
|
import { deflateSync } from "node:zlib";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
|
const BG = [31, 41, 55]; // #1f2937
|
|
const AMBER = [251, 191, 36]; // #fbbf24
|
|
|
|
const CRC_TABLE = (() => {
|
|
const t = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
t[n] = c >>> 0;
|
|
}
|
|
return t;
|
|
})();
|
|
function crc32(buf) {
|
|
let c = 0xffffffff;
|
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
}
|
|
function chunk(type, data) {
|
|
const len = Buffer.alloc(4);
|
|
len.writeUInt32BE(data.length, 0);
|
|
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(body), 0);
|
|
return Buffer.concat([len, body, crc]);
|
|
}
|
|
function encodePng(size, rgba) {
|
|
const stride = size * 4;
|
|
const raw = Buffer.alloc((stride + 1) * size);
|
|
for (let y = 0; y < size; y++) {
|
|
raw[y * (stride + 1)] = 0; // filter: none
|
|
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
}
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(size, 0);
|
|
ihdr.writeUInt32BE(size, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 6; // colour type RGBA
|
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
return Buffer.concat([
|
|
sig,
|
|
chunk("IHDR", ihdr),
|
|
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
chunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
// `fullBleed` fills the whole square opaquely (maskable / apple-touch);
|
|
// otherwise the disc is drawn on transparency with content in the safe
|
|
// zone untouched.
|
|
function draw(size, fullBleed) {
|
|
const buf = Buffer.alloc(size * size * 4);
|
|
const c = size / 2;
|
|
const discR = size * 0.46;
|
|
const ringR = fullBleed ? size * 0.3 : size * 0.34;
|
|
const ringW = size * 0.04;
|
|
const coreR = fullBleed ? size * 0.09 : size * 0.1;
|
|
for (let y = 0; y < size; y++) {
|
|
for (let x = 0; x < size; x++) {
|
|
const i = (y * size + x) * 4;
|
|
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
|
let col = null;
|
|
if (fullBleed) col = BG;
|
|
else if (d <= discR) col = BG;
|
|
if (col !== null) {
|
|
if (Math.abs(d - ringR) <= ringW / 2) col = AMBER;
|
|
if (d <= coreR) col = AMBER;
|
|
buf[i] = col[0];
|
|
buf[i + 1] = col[1];
|
|
buf[i + 2] = col[2];
|
|
buf[i + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
mkdirSync(new URL("../static/icons/", import.meta.url), { recursive: true });
|
|
const targets = [
|
|
["icon-192.png", 192, false],
|
|
["icon-512.png", 512, false],
|
|
["icon-maskable-512.png", 512, true],
|
|
["apple-touch-icon-180.png", 180, true],
|
|
];
|
|
for (const [name, size, fullBleed] of targets) {
|
|
const path = new URL(`../static/icons/${name}`, import.meta.url);
|
|
writeFileSync(path, encodePng(size, draw(size, fullBleed)));
|
|
console.log("wrote", name, `${size}x${size}`);
|
|
}
|