4e4e75a1d8
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>
138 lines
4.9 KiB
JavaScript
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');
|