Files
KCal/scripts/generate-icons.js
T
ort 2826a3819c Add Web Push notifications, PWA support, and full-screen tablet kiosk
Parents get a real push notification when a kid checks off a task
(false->true transitions only, fire-and-forget, degrades gracefully with
no VAPID keys configured). Dashboard is a fully installable iOS/Android
PWA; each child's kiosk link gets its own dynamic per-token manifest so
"Add to Home Screen" opens straight into their board in standalone mode.

Kiosk view is reworked for tablets: safe-area-aware full-bleed layout,
the whole task row is now tappable (previously only the 24px checkbox
was, well under Apple's touch-target minimum), and app icons are
generated by a small dependency-free PNG encoder (no image tooling
available in this environment).

Push requires real HTTPS (iOS Safari won't allow it otherwise) - README
and UNRAID.md cover VAPID setup and the HTTPS prerequisite.
2026-08-15 16:14:56 -04:00

146 lines
5.2 KiB
JavaScript

// One-off dev-time script — generates the app's PWA icon PNGs with zero
// dependencies (no ImageMagick/PIL/canvas available in this environment).
// Run once (`node scripts/generate-icons.js`), commit the output under
// public/icons/ like any other static asset. Not required at runtime.
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const OUT_DIR = path.join(__dirname, '..', 'public', 'icons');
// App's own --school blue (public/css/shared.css)
const BG = [74, 144, 217];
const WHITE = [255, 255, 255];
function distToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSq = dx * dx + dy * dy;
let t = lengthSq === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / lengthSq;
t = Math.max(0, Math.min(1, t));
const cx = x1 + t * dx;
const cy = y1 + t * dy;
return Math.hypot(px - cx, py - cy);
}
// Rounded-rect coverage (0..1) for anti-aliased corners; `radius` in px.
function roundedRectCoverage(x, y, w, h, radius) {
const inCoreX = x >= radius && x <= w - radius;
const inCoreY = y >= radius && y <= h - radius;
if (inCoreX || inCoreY) return 1;
const cx = x < radius ? radius : w - radius;
const cy = y < radius ? radius : h - radius;
const dist = Math.hypot(x - cx, y - cy);
if (dist <= radius - 0.5) return 1;
if (dist >= radius + 0.5) return 0;
return radius + 0.5 - dist; // 1px anti-aliased band
}
function mix(a, b, t) {
return a + (b - a) * t;
}
// Checkmark path, proportional to icon size.
function checkmarkCoverage(x, y, size, strokeHalfWidth) {
const p1 = [size * 0.27, size * 0.53];
const p2 = [size * 0.43, size * 0.68];
const p3 = [size * 0.74, size * 0.32];
const d = Math.min(
distToSegment(x, y, p1[0], p1[1], p2[0], p2[1]),
distToSegment(x, y, p2[0], p2[1], p3[0], p3[1])
);
if (d <= strokeHalfWidth - 0.5) return 1;
if (d >= strokeHalfWidth + 0.5) return 0;
return strokeHalfWidth + 0.5 - d;
}
function renderIcon({ size, cornerRadiusRatio, glyphStrokeRatio }) {
const buf = Buffer.alloc(size * size * 4);
const radius = size * cornerRadiusRatio;
const strokeHalfWidth = size * glyphStrokeRatio;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const bgCoverage = cornerRadiusRatio > 0 ? roundedRectCoverage(x + 0.5, y + 0.5, size, size, radius) : 1;
const glyphCoverage = checkmarkCoverage(x + 0.5, y + 0.5, size, strokeHalfWidth);
// Composite: background (with its own edge coverage against
// transparent) under the white glyph stroke.
let r = mix(0, BG[0], bgCoverage);
let g = mix(0, BG[1], bgCoverage);
let b = mix(0, BG[2], bgCoverage);
let a = mix(0, 255, bgCoverage);
r = mix(r, WHITE[0], glyphCoverage);
g = mix(g, WHITE[1], glyphCoverage);
b = mix(b, WHITE[2], glyphCoverage);
a = mix(a, 255, glyphCoverage);
const idx = (y * size + x) * 4;
buf[idx] = Math.round(r);
buf[idx + 1] = Math.round(g);
buf[idx + 2] = Math.round(b);
buf[idx + 3] = Math.round(a);
}
}
return buf;
}
function crc32(buf) {
return zlib.crc32(buf);
}
function chunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii');
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(data.length, 0);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
}
function encodePng(rgbaBuf, width, height) {
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0);
ihdrData.writeUInt32BE(height, 4);
ihdrData[8] = 8; // bit depth
ihdrData[9] = 6; // color type: truecolor + alpha
ihdrData[10] = 0; // compression
ihdrData[11] = 0; // filter
ihdrData[12] = 0; // interlace
const ihdr = chunk('IHDR', ihdrData);
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0; // filter type: None
rgbaBuf.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
}
const idat = chunk('IDAT', zlib.deflateSync(raw));
const iend = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([signature, ihdr, idat, iend]);
}
function writeIcon(filename, { size, cornerRadiusRatio, glyphStrokeRatio }) {
const rgba = renderIcon({ size, cornerRadiusRatio, glyphStrokeRatio });
const png = encodePng(rgba, size, size);
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(path.join(OUT_DIR, filename), png);
console.log(`wrote ${filename} (${png.length} bytes)`);
}
writeIcon('icon-192.png', { size: 192, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 });
writeIcon('icon-512.png', { size: 512, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 });
writeIcon('apple-touch-icon-180.png', { size: 180, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 });
// Maskable: background fills edge-to-edge (no rounding — the OS applies its
// own mask), glyph confined within the inner ~80% "safe zone" is handled
// implicitly here since the checkmark path itself is well within that area.
writeIcon('icon-maskable-512.png', { size: 512, cornerRadiusRatio: 0, glyphStrokeRatio: 0.038 });