#!/usr/bin/env node /** * Generates the shipped default favicons (issue #306): * `apps/api/assets/default-favicon-32.png` and `-180.png`. * * The api serves these whenever an operator has not uploaded one, so an * instance always has a tab icon — the `` in index.html is * static and its resource must never 404. * * Drawn here rather than pulled in as a binary: the whole toolchain must * survive the `--network none` offline build (96-offline-build-protokoll.md), * and adding an image library for one 32×32 icon would be the tail wagging * the dog. Node's own zlib is enough to write a PNG. * * Motif: a pond seen from above — the accent-green disc with two ripples. * * Regenerate with `node apps/api/scripts/gen-default-favicon.mjs`, commit * script and binaries together. */ import { deflateSync } from 'node:zlib'; import { writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; /** Brand green — the same value as index.html's light `theme-color`. */ const GREEN = [0x2f, 0x6f, 0x4f]; const LIGHT = [0xe8, 0xf2, 0xec]; const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(buf) { let c = 0xffffffff; for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const length = Buffer.alloc(4); length.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([length, body, crc]); } /** Minimal RGBA PNG writer — no filtering, one IDAT. */ function encodePng(size, rgba) { 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 raw = Buffer.alloc(size * (size * 4 + 1)); for (let y = 0; y < size; y += 1) { raw[y * (size * 4 + 1)] = 0; // filter: none rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); } return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)), ]); } /** * Colour at one point of the unit square, in continuous coordinates — the * caller supersamples it, which is where the anti-aliasing comes from. */ function sample(x, y) { const dx = x - 0.5; const dy = y - 0.5; const r = Math.hypot(dx, dy); if (r > 0.48) return null; // outside the disc: transparent // Two ripples spreading from a point struck slightly above centre — rings // rather than a bullseye, which is why the centre stays green and the // spacing widens outward the way real ripples do. const rr = Math.hypot(dx, dy + 0.06); const onRing = (radius, width) => Math.abs(rr - radius) < width; if (onRing(0.33, 0.028) || onRing(0.19, 0.026)) return LIGHT; return GREEN; } function render(size) { const SS = 4; // supersampling factor const out = Buffer.alloc(size * size * 4); for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { let r = 0; let g = 0; let b = 0; let a = 0; for (let sy = 0; sy < SS; sy += 1) { for (let sx = 0; sx < SS; sx += 1) { const c = sample((x + (sx + 0.5) / SS) / size, (y + (sy + 0.5) / SS) / size); if (c) { r += c[0]; g += c[1]; b += c[2]; a += 255; } } } const n = SS * SS; const covered = a / 255; const i = (y * size + x) * 4; // Premultiplied average of the covered samples only, so the edge fades // in alpha rather than towards black. out[i] = covered ? Math.round(r / covered) : 0; out[i + 1] = covered ? Math.round(g / covered) : 0; out[i + 2] = covered ? Math.round(b / covered) : 0; out[i + 3] = Math.round(a / n); } } return out; } const assets = join(dirname(fileURLToPath(import.meta.url)), '../assets'); for (const size of [32, 180]) { const file = join(assets, `default-favicon-${size}.png`); writeFileSync(file, encodePng(size, render(size))); console.log(`wrote ${file}`); }