"use strict"; /** * Generates /public/icons/chat-icon-192.png, chat-icon-512.png, * chat-maskable-512.png from inline SVG sources. Also derives the * 180x180 apple-touch-icon variants for the chat route * (src/app/dashboard/chat/apple-icon.png) and the dashboard root * (src/app/apple-icon.png, derived from the existing dashboard icon). * * Run with `node scripts/build-icons.mjs`. Requires `sharp` * (a transitive Next.js dependency, already pulled in by next-pwa). * * The SVG embeds the Inter font via base64 so the wordmark is identical * on every machine regardless of which fonts are installed. If no font * can be loaded, the SVG's font-family CSS stack * (Inter, system-ui, sans-serif) handles the fallback. */ import { promises as fs } from "node:fs"; import path from "node:path"; import sharp from "sharp"; const ROOT = path.resolve(process.cwd()); const palette = { orange: "#fdb13c", black: "#0f172a", }; async function loadFontBase64() { // Check for a local font file first (committed to the repo so Docker // builds work offline). Mirrors build-og-image.mjs; returns "" if no // font can be loaded, in which case the SVG's font-family CSS stack // (Inter, system-ui, sans-serif) handles the fallback. const localPath = path.join(ROOT, "public/fonts/inter-800.woff"); try { const buffer = await fs.readFile(localPath); return buffer.toString("base64"); } catch { const url = "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuDyYMZtrib2Ai-4.woff"; try { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch Inter: ${res.status}`); const buffer = Buffer.from(await res.arrayBuffer()); return buffer.toString("base64"); } catch (err) { console.warn(`Inter font unavailable — using system sans-serif (${err.message})`); return ""; } } } const fontB64 = await loadFontBase64(); // Non-maskable chat icon (192 and 512). Transparent background. // Layout: orange chat bubble + tail nub bottom-left, black cube mark // inside the bubble, "ASK" wordmark underneath. function chatSvg(size, fontPx) { // Bubble geometry, expressed in a 512-coord canvas then scaled to `size`. const bubbleX = 76; // bubble left const bubbleY = 70; // bubble top const bubbleW = 360; // bubble width const bubbleH = 300; // bubble height const bubbleR = 48; // bubble corner radius const tailW = 56; // tail width at top const tailH = 64; // tail length downward const tailOffsetX = bubbleX + 70; // tail start x (toward left side) // Cube mark geometry — same brand DNA as dashboard icon. const cubeX = 256 - 60; // centred horizontally (cube is 120 wide) const cubeY = 200; // inside the bubble const cubeSize = 120; // Wordmark geometry. const wordY = 420; return ` ASK `; } // Maskable variant — orange fills the whole canvas (so OS cropping still // shows orange). All meaningful content lives inside the centre ~40% // safe-zone, which means a 205x205 region centred on a 512 canvas. function chatMaskableSvg(size, fontPx) { // Scale everything to fit inside ~205x205 centred region. const scale = 205 / 512; const offset = (512 - 205) / 2; // centre the safe-zone // Bubble (scaled to ~120x100) const bubbleX = offset + 30; const bubbleY = offset + 24; const bubbleW = 145; const bubbleH = 120; const bubbleR = 19; const tailW = 22; const tailH = 26; const tailOffsetX = bubbleX + 28; // Cube mark inside the bubble (scaled to ~48) const cubeX = bubbleX + (bubbleW - 48) / 2; const cubeY = bubbleY + (bubbleH - 48) / 2; const cubeSize = 48; // Wordmark below the bubble. const wordY = offset + 175; return ` ASK `; } // MEGA Clock icon — dark background with a clock face on top of an // isometric cube. Each cube face has a bold "M" carved out in the // background colour, and the cube has two small orange triangular // "shadow" wedges at the bottom. No wordmark; this icon stands on // its own the same way the dashboard and chat icons do. function clockFaceAndHands() { const cx = 256; const cy = 175; const r = 110; // Clock hands: minute straight up, hour to 4 o'clock. // 4 o'clock = 120° clockwise from 12 (measured around the dial). const hourAngle = (120 * Math.PI) / 180; const hourLen = r * 0.42; const hourEndX = cx + hourLen * Math.sin(hourAngle); const hourEndY = cy - hourLen * Math.cos(hourAngle); const minuteEndY = cy - r * 0.82; // Orange arc on the upper-right: 12 o'clock → 4 o'clock. const arcEndAngle = (120 * Math.PI) / 180; const arcEndX = cx + r * Math.sin(arcEndAngle); const arcEndY = cy - r * Math.cos(arcEndAngle); return ` `; } // A bold, geometric "M" centred at (cx, cy), width w, height h, // thickness t. The result is a single filled path that can be tinted // via fill — used cut into the cube faces (fill = background colour). function mShapePath(cx, cy, w, h, t) { const xL = cx - w / 2; const xR = cx + w / 2; const xLi = xL + t; const xRi = xR - t; const yT = cy - h / 2; const yB = cy + h / 2; // Outer peak drops about 40% down from the top; inner peak sits // slightly above to keep the diagonal strokes consistent. const peakOuter = cy - h / 2 + h * 0.42; const peakInner = cy - h / 2 + h * 0.62; return ` `; } // Isometric cube with the bold "M" cut into each of its two visible // faces, plus the two orange base wedges below it. (x, y) is the // top-centre of the cube; width of each face is 200px each side, so // the cube spans from x-200 to x+200. function clockCube(x, y) { const topY = y; const outerTopY = y + 30; const outerBotY = y + 210; const botY = y + 240; const halfW = 200; const xC = x; const xL = x - halfW; const xR = x + halfW; // The cube faces are tilted parallelograms (top edges slope up to // the centre by 30px across 200px → atan(30/200) ≈ 8.5°), so each // M is rotated to sit on its face rather than floating upright. const leftMCenter = { x: x - 100, y: y + 125 }; const rightMCenter = { x: x + 100, y: y + 125 }; return ` ${mShapePath(leftMCenter.x, leftMCenter.y, 70, 110, 16)} ${mShapePath(rightMCenter.x, rightMCenter.y, 70, 110, 16)} `; } function clockSvg(size, fontPx) { return ` ${clockFaceAndHands()} ${clockCube(256, 310)} `; } // Maskable variant — orange fills the whole canvas. Content lives // inside the centre ~40% safe-zone (~205x205 inside a 512 canvas). // Uses the same clock + cube + M composition, recoloured as negative // space (orange on black becomes black on orange). function clockMaskableSvg(size, fontPx) { const W = 512; const H = 512; const offset = (W - 205) / 2; const cx = W / 2; const cy = offset + 60; const r = 50; // Hour hand to 4 o'clock, minute to 12. const hourAngle = (120 * Math.PI) / 180; const hourLen = r * 0.55; const hourEndX = cx + hourLen * Math.sin(hourAngle); const hourEndY = cy - hourLen * Math.cos(hourAngle); const minuteEndY = cy - r * 0.82; const arcEndAngle = (120 * Math.PI) / 180; const arcEndX = cx + r * Math.sin(arcEndAngle); const arcEndY = cy - r * Math.cos(arcEndAngle); // Tiny isometric cube below the clock, fully inside the safe zone. const topY = offset + 130; const outerTopY = topY + 8; const outerBotY = topY + 50; const botY = topY + 58; const halfW = 60; const xL = cx - halfW; const xR = cx + halfW; return ` ${mShapePath(cx - 30, topY + 30, 24, 36, 6)} ${mShapePath(cx + 30, topY + 30, 24, 36, 6)} `; } // 1200x630 Open Graph share image — left: the dark clock + cube // icon at a generous size; right: "MEGA Clock" wordmark and a // short tagline. Generated as a static PNG so Slack/iMessage/etc. // pick it up automatically from the route segment. function clockOgSvg() { const W = 1200; const H = 630; // Subtle dot grid + faint orange glow upper-right, same family as // the dashboard OG image so the share previews feel related. return ` ${clockFaceAndHands()} ${clockCube(256, 310)} MEGA INFRASTRUCTURE MEGA Clock Scan a worker's badge to clock in or out. `; } async function renderSvg(svg, outPath, size) { const buf = Buffer.from(svg); await sharp(buf, { density: 144 }) .resize({ width: size, height: size }) .png({ compressionLevel: 9 }) .toFile(outPath); const stat = await fs.stat(outPath); console.log(`Wrote ${outPath} (${(stat.size / 1024).toFixed(1)} KB, ${size}x${size})`); } async function renderAppleIcon(srcPath, dstPath, radius = 40) { // Derive an apple-touch-icon from an existing PNG, resizing to 180x180 // and applying a rounded-corner mask (default ~22% radius for the iOS // "squircle" look). const size = 180; const maskSvg = ` `; const maskPng = await sharp(Buffer.from(maskSvg)).png().toBuffer(); await sharp(srcPath) .resize({ width: size, height: size }) .composite([{ input: maskPng, blend: "dest-in" }]) .png() .toFile(dstPath); const stat = await fs.stat(dstPath); console.log(`Wrote ${dstPath} (${(stat.size / 1024).toFixed(1)} KB, ${size}x${size})`); } // Main await renderSvg(chatSvg(512, 80), path.join(ROOT, "public/icons/chat-icon-512.png"), 512); await renderSvg(chatSvg(192, 30), path.join(ROOT, "public/icons/chat-icon-192.png"), 192); await renderSvg(chatMaskableSvg(512, 38), path.join(ROOT, "public/icons/chat-maskable-512.png"), 512); // Apple-touch-icon for chat route — derived from the non-maskable 512 await renderAppleIcon( path.join(ROOT, "public/icons/chat-icon-512.png"), path.join(ROOT, "src/app/dashboard/chat/apple-icon.png"), ); // Dashboard apple-icon — derived from the existing dashboard icon await renderAppleIcon( path.join(ROOT, "public/icons/icon-512.png"), path.join(ROOT, "src/app/apple-icon.png"), ); // MEGA Clock icon set (non-maskable 192/512, maskable 512) await renderSvg(clockSvg(512, 0), path.join(ROOT, "public/icons/clock-icon-512.png"), 512); await renderSvg(clockSvg(192, 0), path.join(ROOT, "public/icons/clock-icon-192.png"), 192); await renderSvg(clockMaskableSvg(512, 0), path.join(ROOT, "public/icons/clock-maskable-512.png"), 512); await renderAppleIcon( path.join(ROOT, "public/icons/clock-icon-512.png"), path.join(ROOT, "src/app/clock/apple-icon.png"), ); // Open Graph share image for /clock. Next.js auto-detects // `opengraph-image.png` placed in a route segment and serves it // with the matching tag, so Slack / // iMessage / etc. preview the icon + title when the link is shared. { const out = path.join(ROOT, "src/app/clock/opengraph-image.png"); const buf = Buffer.from(clockOgSvg()); await sharp(buf, { density: 144 }) .resize({ width: 1200, height: 630 }) .png({ compressionLevel: 9 }) .toFile(out); const stat = await fs.stat(out); console.log(`Wrote ${out} (${(stat.size / 1024).toFixed(1)} KB, 1200x630)`); } console.log("All icons rendered.");