diff --git a/public/icons/chat-icon-192.png b/public/icons/chat-icon-192.png new file mode 100644 index 0000000..52765f8 Binary files /dev/null and b/public/icons/chat-icon-192.png differ diff --git a/public/icons/chat-icon-512.png b/public/icons/chat-icon-512.png new file mode 100644 index 0000000..9261986 Binary files /dev/null and b/public/icons/chat-icon-512.png differ diff --git a/public/icons/chat-maskable-512.png b/public/icons/chat-maskable-512.png new file mode 100644 index 0000000..bbc7ca1 Binary files /dev/null and b/public/icons/chat-maskable-512.png differ diff --git a/scripts/build-icons.mjs b/scripts/build-icons.mjs new file mode 100644 index 0000000..0db1989 --- /dev/null +++ b/scripts/build-icons.mjs @@ -0,0 +1,218 @@ +"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. Falls back + * to system sans-serif if the font fetch fails. + */ +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", + white: "#ffffff", +}; + +async function loadFontBase64() { + // Check for a local font file first (committed to the repo so Docker + // builds work offline). Same fallback pattern as build-og-image.mjs. + const localPath = path.join(ROOT, "public/fonts/inter-800.woff"); + let buffer; + try { + buffer = await fs.readFile(localPath); + } catch { + const url = + "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuDyYMZtrib2Ai-4.woff"; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch Inter: ${res.status}`); + buffer = Buffer.from(await res.arrayBuffer()); + } + return buffer.toString("base64"); +} + +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 + `; +} + +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 renderDashboardAppleIcon() { + // Read the existing dashboard 512 PNG and downscale to 180x180 with + // ~22% corner radius for iOS. Use sharp's composite+mask to apply the + // rounded corner. + const src = path.join(ROOT, "public/icons/icon-512.png"); + const dst = path.join(ROOT, "src/app/apple-icon.png"); + + // 22% radius on a 180px square = ~40px corner radius + const radius = 40; + + // Generate a rounded-corner mask (white rounded rect on transparent) + const maskSvg = ` + + `; + const maskBuf = Buffer.from(maskSvg); + const maskPng = await sharp(maskBuf).png().toBuffer(); + + await sharp(src) + .resize({ width: 180, height: 180 }) + .composite([{ input: maskPng, blend: "dest-in" }]) + .png() + .toFile(dst); + + const stat = await fs.stat(dst); + console.log(`Wrote ${dst} (${(stat.size / 1024).toFixed(1)} KB, 180x180)`); +} + +// 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 +const chatAppleSrc = path.join(ROOT, "public/icons/chat-icon-512.png"); +const chatAppleDst = path.join(ROOT, "src/app/dashboard/chat/apple-icon.png"); +const appleRadius = 40; // ~22% on 180px +const appleMaskSvg = ` + +`; +const appleMaskPng = await sharp(Buffer.from(appleMaskSvg)).png().toBuffer(); +await sharp(chatAppleSrc) + .resize({ width: 180, height: 180 }) + .composite([{ input: appleMaskPng, blend: "dest-in" }]) + .png() + .toFile(chatAppleDst); +console.log(`Wrote ${chatAppleDst}`); + +// Dashboard apple-icon — derived from the existing dashboard icon +await renderDashboardAppleIcon(); + +console.log("All icons rendered."); \ No newline at end of file diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png index e720d13..9c72f46 100644 Binary files a/src/app/apple-icon.png and b/src/app/apple-icon.png differ diff --git a/src/app/dashboard/chat/apple-icon.png b/src/app/dashboard/chat/apple-icon.png new file mode 100644 index 0000000..33a36b4 Binary files /dev/null and b/src/app/dashboard/chat/apple-icon.png differ