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 ``;
+}
+
+// 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 ``;
+}
+
+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