feat(pwa): render chat icon set + per-route apple-touch-icons
This commit is contained in:
parent
055387cc6d
commit
5a7d2931f9
6 changed files with 218 additions and 0 deletions
BIN
public/icons/chat-icon-192.png
Normal file
BIN
public/icons/chat-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/icons/chat-icon-512.png
Normal file
BIN
public/icons/chat-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icons/chat-maskable-512.png
Normal file
BIN
public/icons/chat-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
218
scripts/build-icons.mjs
Normal file
218
scripts/build-icons.mjs
Normal file
|
|
@ -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 `<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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180">
|
||||||
|
<rect width="180" height="180" rx="${radius}" ry="${radius}" fill="white"/>
|
||||||
|
</svg>`;
|
||||||
|
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180">
|
||||||
|
<rect width="180" height="180" rx="${appleRadius}" ry="${appleRadius}" fill="white"/>
|
||||||
|
</svg>`;
|
||||||
|
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.");
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
BIN
src/app/dashboard/chat/apple-icon.png
Normal file
BIN
src/app/dashboard/chat/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Loading…
Reference in a new issue