2026-08-30 20:15:15 +00:00
|
|
|
"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
|
2026-08-30 20:18:25 +00:00
|
|
|
* 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.
|
2026-08-30 20:15:15 +00:00
|
|
|
*/
|
|
|
|
|
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
|
2026-08-30 20:18:25 +00:00
|
|
|
// 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.
|
2026-08-30 20:15:15 +00:00
|
|
|
const localPath = path.join(ROOT, "public/fonts/inter-800.woff");
|
|
|
|
|
try {
|
2026-08-30 20:18:25 +00:00
|
|
|
const buffer = await fs.readFile(localPath);
|
|
|
|
|
return buffer.toString("base64");
|
2026-08-30 20:15:15 +00:00
|
|
|
} catch {
|
|
|
|
|
const url =
|
|
|
|
|
"https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuDyYMZtrib2Ai-4.woff";
|
2026-08-30 20:18:25 +00:00
|
|
|
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 "";
|
|
|
|
|
}
|
2026-08-30 20:15:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512">
|
|
|
|
|
<defs>
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: 'Inter';
|
|
|
|
|
font-style: normal;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
src: url(data:font/woff;base64,${fontB64}) format('woff');
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</defs>
|
|
|
|
|
|
|
|
|
|
<!-- Chat bubble body -->
|
|
|
|
|
<rect x="${bubbleX}" y="${bubbleY}" width="${bubbleW}" height="${bubbleH}" rx="${bubbleR}" fill="${palette.orange}"/>
|
|
|
|
|
|
|
|
|
|
<!-- Chat bubble tail (bottom-left) -->
|
|
|
|
|
<path d="M ${tailOffsetX} ${bubbleY + bubbleH} L ${tailOffsetX + tailW / 2} ${bubbleY + bubbleH + tailH} L ${tailOffsetX + tailW} ${bubbleY + bubbleH} Z" fill="${palette.orange}"/>
|
|
|
|
|
|
|
|
|
|
<!-- Cube mark (dashboard DNA) inside the bubble -->
|
|
|
|
|
<g transform="translate(${cubeX}, ${cubeY})">
|
|
|
|
|
<rect x="0" y="0" width="${cubeSize}" height="${cubeSize}" rx="20" fill="${palette.black}"/>
|
|
|
|
|
<rect x="12" y="12" width="${cubeSize - 24}" height="${cubeSize - 24}" rx="14" fill="${palette.orange}"/>
|
|
|
|
|
<path d="M ${cubeSize / 2} 28 L ${cubeSize - 32} ${cubeSize / 2} L ${cubeSize / 2} ${cubeSize - 28} L 32 ${cubeSize / 2} Z" fill="${palette.black}"/>
|
|
|
|
|
<circle cx="${cubeSize / 2}" cy="${cubeSize / 2}" r="11" fill="${palette.orange}"/>
|
|
|
|
|
</g>
|
|
|
|
|
|
|
|
|
|
<!-- Wordmark -->
|
|
|
|
|
<text x="256" y="${wordY}" font-family="Inter, system-ui, sans-serif" font-size="${fontPx}" font-weight="800" letter-spacing="6" text-anchor="middle" fill="${palette.black}">ASK</text>
|
|
|
|
|
</svg>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512">
|
|
|
|
|
<defs>
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: 'Inter';
|
|
|
|
|
font-style: normal;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
src: url(data:font/woff;base64,${fontB64}) format('woff');
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</defs>
|
|
|
|
|
|
|
|
|
|
<!-- Full-bleed orange background (OS-safe) -->
|
|
|
|
|
<rect width="512" height="512" fill="${palette.orange}"/>
|
|
|
|
|
|
|
|
|
|
<!-- Chat bubble (smaller, centred) -->
|
|
|
|
|
<rect x="${bubbleX}" y="${bubbleY}" width="${bubbleW}" height="${bubbleH}" rx="${bubbleR}" fill="${palette.black}"/>
|
|
|
|
|
<path d="M ${tailOffsetX} ${bubbleY + bubbleH} L ${tailOffsetX + tailW / 2} ${bubbleY + bubbleH + tailH} L ${tailOffsetX + tailW} ${bubbleY + bubbleH} Z" fill="${palette.black}"/>
|
|
|
|
|
|
|
|
|
|
<!-- Cube mark inside the bubble (negative-space: orange on black) -->
|
|
|
|
|
<g transform="translate(${cubeX}, ${cubeY})">
|
|
|
|
|
<rect x="0" y="0" width="${cubeSize}" height="${cubeSize}" rx="8" fill="${palette.orange}"/>
|
|
|
|
|
<rect x="5" y="5" width="${cubeSize - 10}" height="${cubeSize - 10}" rx="6" fill="${palette.black}"/>
|
|
|
|
|
<path d="M ${cubeSize / 2} 12 L ${cubeSize - 13} ${cubeSize / 2} L ${cubeSize / 2} ${cubeSize - 12} L 13 ${cubeSize / 2} Z" fill="${palette.orange}"/>
|
|
|
|
|
<circle cx="${cubeSize / 2}" cy="${cubeSize / 2}" r="4" fill="${palette.black}"/>
|
|
|
|
|
</g>
|
|
|
|
|
|
|
|
|
|
<!-- Wordmark (negative-space: black on orange) -->
|
|
|
|
|
<text x="256" y="${wordY}" font-family="Inter, system-ui, sans-serif" font-size="${fontPx}" font-weight="800" letter-spacing="3" text-anchor="middle" fill="${palette.black}">ASK</text>
|
|
|
|
|
</svg>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
// 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() {
|
2026-09-11 22:17:25 +00:00
|
|
|
const cx = 256;
|
2026-09-13 19:55:40 +00:00
|
|
|
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 `
|
|
|
|
|
<!-- Clock face (slightly lighter than the background) -->
|
|
|
|
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="#171c2a"/>
|
|
|
|
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#2a3144" stroke-width="2"/>
|
|
|
|
|
|
|
|
|
|
<!-- Orange arc (upper-right quadrant) -->
|
|
|
|
|
<path d="M ${cx} ${cy - r} A ${r} ${r} 0 0 1 ${arcEndX.toFixed(1)} ${arcEndY.toFixed(1)}"
|
|
|
|
|
stroke="${palette.orange}" stroke-width="18" fill="none" stroke-linecap="round"/>
|
|
|
|
|
|
|
|
|
|
<!-- Minute hand (12 o'clock) -->
|
|
|
|
|
<line x1="${cx}" y1="${cy}" x2="${cx}" y2="${minuteEndY}"
|
|
|
|
|
stroke="${palette.orange}" stroke-width="12" stroke-linecap="round"/>
|
|
|
|
|
|
|
|
|
|
<!-- Hour hand (4 o'clock) -->
|
|
|
|
|
<line x1="${cx}" y1="${cy}" x2="${hourEndX.toFixed(1)}" y2="${hourEndY.toFixed(1)}"
|
|
|
|
|
stroke="${palette.orange}" stroke-width="12" stroke-linecap="round"/>
|
|
|
|
|
|
|
|
|
|
<!-- Centre hub -->
|
|
|
|
|
<circle cx="${cx}" cy="${cy}" r="11" fill="${palette.orange}"/>
|
|
|
|
|
`;
|
|
|
|
|
}
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
// 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 `
|
|
|
|
|
<path d="
|
|
|
|
|
M ${xL} ${yB}
|
|
|
|
|
L ${xL} ${yT}
|
|
|
|
|
L ${cx} ${peakOuter}
|
|
|
|
|
L ${xR} ${yT}
|
|
|
|
|
L ${xR} ${yB}
|
|
|
|
|
L ${xRi} ${yB}
|
|
|
|
|
L ${xRi} ${yT + 4}
|
|
|
|
|
L ${cx} ${peakInner}
|
|
|
|
|
L ${xLi} ${yT + 4}
|
|
|
|
|
L ${xLi} ${yB}
|
|
|
|
|
Z
|
|
|
|
|
"/>`;
|
|
|
|
|
}
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
// 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 `
|
|
|
|
|
<!-- Left face (slightly lighter) -->
|
|
|
|
|
<polygon points="${xL},${outerTopY} ${xC},${topY} ${xC},${botY} ${xL},${outerBotY}" fill="#1f2434"/>
|
|
|
|
|
<!-- Right face (slightly darker so the centre edge reads as a fold) -->
|
|
|
|
|
<polygon points="${xC},${topY} ${xR},${outerTopY} ${xR},${outerBotY} ${xC},${botY}" fill="#171c2a"/>
|
|
|
|
|
<!-- M on left face — cut into background -->
|
|
|
|
|
<g transform="rotate(-8.5 ${leftMCenter.x} ${leftMCenter.y})">
|
|
|
|
|
${mShapePath(leftMCenter.x, leftMCenter.y, 70, 110, 16)}
|
|
|
|
|
</g>
|
|
|
|
|
<!-- M on right face -->
|
|
|
|
|
<g transform="rotate(8.5 ${rightMCenter.x} ${rightMCenter.y})">
|
|
|
|
|
${mShapePath(rightMCenter.x, rightMCenter.y, 70, 110, 16)}
|
|
|
|
|
</g>
|
|
|
|
|
<!-- Orange base wedges (the "lit floor" effect at the cube's bottom corners) -->
|
|
|
|
|
<polygon points="${xL - 36},${outerBotY + 36} ${xL},${outerBotY} ${xC},${botY} ${xC - 36},${botY + 36}" fill="${palette.orange}"/>
|
|
|
|
|
<polygon points="${xC + 36},${botY + 36} ${xC},${botY} ${xR},${outerBotY} ${xR + 36},${outerBotY + 36}" fill="${palette.orange}"/>
|
|
|
|
|
`;
|
|
|
|
|
}
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
function clockSvg(size, fontPx) {
|
2026-09-11 22:17:25 +00:00
|
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512">
|
2026-09-13 19:55:40 +00:00
|
|
|
<!-- Dark background -->
|
|
|
|
|
<rect width="512" height="512" fill="${palette.black}"/>
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
${clockFaceAndHands()}
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
${clockCube(256, 310)}
|
2026-09-11 22:17:25 +00:00
|
|
|
</svg>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Maskable variant — orange fills the whole canvas. Content lives
|
|
|
|
|
// inside the centre ~40% safe-zone (~205x205 inside a 512 canvas).
|
2026-09-13 19:55:40 +00:00
|
|
|
// Uses the same clock + cube + M composition, recoloured as negative
|
|
|
|
|
// space (orange on black becomes black on orange).
|
2026-09-11 22:17:25 +00:00
|
|
|
function clockMaskableSvg(size, fontPx) {
|
2026-09-13 19:55:40 +00:00
|
|
|
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 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${W} ${H}">
|
|
|
|
|
<!-- Full-bleed orange background (OS-safe) -->
|
|
|
|
|
<rect width="${W}" height="${H}" fill="${palette.orange}"/>
|
|
|
|
|
|
|
|
|
|
<!-- Clock face (negative space: black on orange) -->
|
|
|
|
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="${palette.black}"/>
|
|
|
|
|
|
|
|
|
|
<!-- Orange arc on upper-right (knocked back into the black face) -->
|
|
|
|
|
<path d="M ${cx} ${cy - r} A ${r} ${r} 0 0 1 ${arcEndX.toFixed(1)} ${arcEndY.toFixed(1)}"
|
|
|
|
|
stroke="${palette.orange}" stroke-width="8" fill="none" stroke-linecap="round"/>
|
|
|
|
|
|
|
|
|
|
<!-- Minute hand -->
|
|
|
|
|
<line x1="${cx}" y1="${cy}" x2="${cx}" y2="${minuteEndY}"
|
|
|
|
|
stroke="${palette.orange}" stroke-width="6" stroke-linecap="round"/>
|
|
|
|
|
<!-- Hour hand -->
|
|
|
|
|
<line x1="${cx}" y1="${cy}" x2="${hourEndX.toFixed(1)}" y2="${hourEndY.toFixed(1)}"
|
|
|
|
|
stroke="${palette.orange}" stroke-width="6" stroke-linecap="round"/>
|
|
|
|
|
<!-- Centre hub -->
|
|
|
|
|
<circle cx="${cx}" cy="${cy}" r="5" fill="${palette.orange}"/>
|
|
|
|
|
|
|
|
|
|
<!-- Cube (negative-space: black on orange) -->
|
|
|
|
|
<polygon points="${xL},${outerTopY} ${cx},${topY} ${cx},${botY} ${xL},${outerBotY}" fill="${palette.black}"/>
|
|
|
|
|
<polygon points="${cx},${topY} ${xR},${outerTopY} ${xR},${outerBotY} ${cx},${botY}" fill="${palette.black}"/>
|
|
|
|
|
<!-- Two small M's in orange on the cube faces -->
|
|
|
|
|
<g fill="${palette.orange}">
|
|
|
|
|
${mShapePath(cx - 30, topY + 30, 24, 36, 6)}
|
|
|
|
|
${mShapePath(cx + 30, topY + 30, 24, 36, 6)}
|
|
|
|
|
</g>
|
|
|
|
|
</svg>`;
|
|
|
|
|
}
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
// 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 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
2026-09-11 22:17:25 +00:00
|
|
|
<defs>
|
|
|
|
|
<style type="text/css">
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: 'Inter';
|
|
|
|
|
font-style: normal;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
src: url(data:font/woff;base64,${fontB64}) format('woff');
|
|
|
|
|
}
|
|
|
|
|
</style>
|
2026-09-13 19:55:40 +00:00
|
|
|
<radialGradient id="glow" cx="0.88" cy="0.12" r="0.55">
|
|
|
|
|
<stop offset="0%" stop-color="${palette.orange}" stop-opacity="0.35"/>
|
|
|
|
|
<stop offset="60%" stop-color="${palette.orange}" stop-opacity="0.05"/>
|
|
|
|
|
<stop offset="100%" stop-color="${palette.orange}" stop-opacity="0"/>
|
|
|
|
|
</radialGradient>
|
|
|
|
|
<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
|
|
|
|
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="rgba(255,255,255,0.04)" stroke-width="1"/>
|
|
|
|
|
</pattern>
|
2026-09-11 22:17:25 +00:00
|
|
|
</defs>
|
|
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
<!-- Background -->
|
|
|
|
|
<rect width="${W}" height="${H}" fill="${palette.black}"/>
|
|
|
|
|
<rect width="${W}" height="${H}" fill="url(#grid)"/>
|
|
|
|
|
<rect width="${W}" height="${H}" fill="url(#glow)"/>
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
<!-- Icon block on the left (smaller, padded 70px in) -->
|
|
|
|
|
<svg x="70" y="115" width="400" height="400" viewBox="0 0 512 512">
|
|
|
|
|
<rect width="512" height="512" fill="${palette.black}"/>
|
|
|
|
|
${clockFaceAndHands()}
|
|
|
|
|
${clockCube(256, 310)}
|
|
|
|
|
</svg>
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
<!-- Brand line -->
|
|
|
|
|
<text x="520" y="220" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" letter-spacing="6" fill="${palette.orange}">MEGA INFRASTRUCTURE</text>
|
2026-09-11 22:17:25 +00:00
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
<!-- Big title -->
|
|
|
|
|
<text x="520" y="340" font-family="Inter, system-ui, sans-serif" font-size="92" font-weight="800" letter-spacing="-2" fill="#ffffff">MEGA Clock</text>
|
|
|
|
|
|
|
|
|
|
<!-- Tagline -->
|
|
|
|
|
<text x="520" y="410" font-family="Inter, system-ui, sans-serif" font-size="26" font-weight="500" fill="rgba(255,255,255,0.75)">Scan a worker's badge to clock in or out.</text>
|
|
|
|
|
|
|
|
|
|
<!-- Bottom accent bars (same family as the dashboard OG image) -->
|
|
|
|
|
<g transform="translate(520, 540)">
|
|
|
|
|
<rect width="180" height="6" rx="3" fill="${palette.orange}"/>
|
|
|
|
|
<rect x="200" width="180" height="6" rx="3" fill="#a1ae3b"/>
|
|
|
|
|
<rect x="400" width="180" height="6" rx="3" fill="#4f9c5b"/>
|
|
|
|
|
</g>
|
2026-09-11 22:17:25 +00:00
|
|
|
</svg>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 20:15:15 +00:00
|
|
|
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})`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 20:18:25 +00:00
|
|
|
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
|
|
|
<rect width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="white"/>
|
2026-08-30 20:15:15 +00:00
|
|
|
</svg>`;
|
2026-08-30 20:18:25 +00:00
|
|
|
const maskPng = await sharp(Buffer.from(maskSvg)).png().toBuffer();
|
|
|
|
|
await sharp(srcPath)
|
|
|
|
|
.resize({ width: size, height: size })
|
2026-08-30 20:15:15 +00:00
|
|
|
.composite([{ input: maskPng, blend: "dest-in" }])
|
|
|
|
|
.png()
|
2026-08-30 20:18:25 +00:00
|
|
|
.toFile(dstPath);
|
|
|
|
|
const stat = await fs.stat(dstPath);
|
|
|
|
|
console.log(`Wrote ${dstPath} (${(stat.size / 1024).toFixed(1)} KB, ${size}x${size})`);
|
2026-08-30 20:15:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-08-30 20:18:25 +00:00
|
|
|
await renderAppleIcon(
|
|
|
|
|
path.join(ROOT, "public/icons/chat-icon-512.png"),
|
|
|
|
|
path.join(ROOT, "src/app/dashboard/chat/apple-icon.png"),
|
|
|
|
|
);
|
2026-08-30 20:15:15 +00:00
|
|
|
|
|
|
|
|
// Dashboard apple-icon — derived from the existing dashboard icon
|
2026-08-30 20:18:25 +00:00
|
|
|
await renderAppleIcon(
|
|
|
|
|
path.join(ROOT, "public/icons/icon-512.png"),
|
|
|
|
|
path.join(ROOT, "src/app/apple-icon.png"),
|
|
|
|
|
);
|
2026-08-30 20:15:15 +00:00
|
|
|
|
2026-09-11 22:17:25 +00:00
|
|
|
// MEGA Clock icon set (non-maskable 192/512, maskable 512)
|
2026-09-13 19:55:40 +00:00
|
|
|
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);
|
2026-09-11 22:17:25 +00:00
|
|
|
|
|
|
|
|
await renderAppleIcon(
|
|
|
|
|
path.join(ROOT, "public/icons/clock-icon-512.png"),
|
|
|
|
|
path.join(ROOT, "src/app/clock/apple-icon.png"),
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-13 19:55:40 +00:00
|
|
|
// Open Graph share image for /clock. Next.js auto-detects
|
|
|
|
|
// `opengraph-image.png` placed in a route segment and serves it
|
|
|
|
|
// with the matching <meta property="og:image"> 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)`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 20:15:15 +00:00
|
|
|
console.log("All icons rendered.");
|