"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 ``;
}
// 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 ``;
}
// 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 ``;
}
// 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 ``;
}
// 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 ``;
}
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.");