Files
cup26/scripts/make-icons.mjs
NilsBriggen 4e4e75a1d8 Phase 0: scaffold cup26 — Vite+React+Fastify TS PWA, broadcast theme, app shell
Mirrors the TTRPG stack (strict TS, Tailwind 4, vite-plugin-pwa, esbuild server
bundle, multi-stage Dockerfile, Traefik compose). Floodlit-pitch design tokens,
UI primitives (Button/Card/Badge), TanStack Router shell with Live/Groups/
Bracket/Predict/Story routes (placeholders), dependency-free PWA icon generator.
Raw data staged: openfootball 2026 fixtures + martj42 international results CSV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 12:49:13 +02:00

138 lines
4.9 KiB
JavaScript

// Dependency-free icon generator: draws a "floodlit pitch" crest and writes
// favicon.svg + pwa-192x192.png + pwa-512x512.png into public/.
// Pure JS (zlib only) so it runs in the minimal Docker build image too — no
// ImageMagick / librsvg required.
import { deflateSync } from 'node:zlib';
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const OUT = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
mkdirSync(OUT, { recursive: true });
// ---- palette (matches the dark theme) ----
const BG = [10, 20, 16]; // surface
const FIELD = [20, 83, 56]; // pitch green
const FIELD_DK = [15, 64, 44];
const LINE = [220, 240, 230];
const BALL = [224, 184, 92]; // gold
// ---- pure-JS PNG encoder (RGBA, filter 0) ----
const CRC = (() => {
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[(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 typeBuf = Buffer.from(type, 'ascii');
const body = Buffer.concat([typeBuf, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
function encodePNG(width, height, rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
return Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
// ---- drawing helpers ----
function makeIcon(size) {
const buf = Buffer.alloc(size * size * 4);
const set = (x, y, [r, g, b], a = 255) => {
if (x < 0 || y < 0 || x >= size || y >= size) return;
const i = (y * size + x) * 4;
const ia = a / 255;
buf[i] = Math.round(buf[i] * (1 - ia) + r * ia);
buf[i + 1] = Math.round(buf[i + 1] * (1 - ia) + g * ia);
buf[i + 2] = Math.round(buf[i + 2] * (1 - ia) + b * ia);
buf[i + 3] = Math.max(buf[i + 3], a);
};
// full-bleed background (good for maskable)
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) set(x, y, BG);
// inset rounded field
const pad = Math.round(size * 0.14);
const r = Math.round(size * 0.18);
const x0 = pad, y0 = pad, x1 = size - pad, y1 = size - pad;
const inField = (x, y) => {
if (x < x0 || x > x1 || y < y0 || y > y1) return false;
const cx = x < x0 + r ? x0 + r : x > x1 - r ? x1 - r : x;
const cy = y < y0 + r ? y0 + r : y > y1 - r ? y1 - r : y;
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
};
const cx = size / 2;
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
if (!inField(x, y)) continue;
// subtle vertical mowing stripes
const stripe = Math.floor((x - x0) / (size * 0.09)) % 2 === 0;
set(x, y, stripe ? FIELD : FIELD_DK);
}
}
// markings (white): halfway line, center circle, center spot as gold ball
const lw = Math.max(2, Math.round(size * 0.012));
const ring = Math.round(size * 0.17);
const ringW = Math.max(2, Math.round(size * 0.016));
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
if (!inField(x, y)) continue;
// halfway line
if (Math.abs(x - cx) <= lw / 2) set(x, y, LINE, 220);
// center circle ring
const d = Math.hypot(x - cx, y - size / 2);
if (Math.abs(d - ring) <= ringW / 2) set(x, y, LINE, 220);
}
}
// center gold ball
const ballR = Math.round(size * 0.055);
for (let y = -ballR; y <= ballR; y++)
for (let x = -ballR; x <= ballR; x++)
if (x * x + y * y <= ballR * ballR) set(Math.round(cx + x), Math.round(size / 2 + y), BALL);
return encodePNG(size, size, buf);
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="0" fill="#0a1410"/>
<rect x="72" y="72" width="368" height="368" rx="92" fill="#14533a"/>
<g stroke="#dcf0e6" stroke-width="7" fill="none" opacity="0.85">
<line x1="256" y1="72" x2="256" y2="440"/>
<circle cx="256" cy="256" r="86"/>
</g>
<circle cx="256" cy="256" r="28" fill="#e0b85c"/>
</svg>
`;
writeFileSync(join(OUT, 'favicon.svg'), svg);
writeFileSync(join(OUT, 'pwa-192x192.png'), makeIcon(192));
writeFileSync(join(OUT, 'pwa-512x512.png'), makeIcon(512));
console.log('icons → public/favicon.svg, pwa-192x192.png, pwa-512x512.png');