// Builds every brand image of the two frontends from the two source SVGs in // the repo root (see docs/BRANDING.md): // logo.svg black mark on transparent — pages, favicons, PWA icons // logo_bk.svg the same mark on a white rounded card — dark backdrops // // node scripts/generate-brand-icons.cjs // // sharp is resolved from apps/api, which already depends on it. const { createRequire } = require('node:module'); const { readFileSync, writeFileSync } = require('node:fs'); const { join } = require('node:path'); const root = join(__dirname, '..'); const sharp = createRequire(join(root, 'apps/api/package.json'))('sharp'); const userApp = join(root, 'apps/user-app-v2'); const admin = join(root, 'apps/admin-panel'); // Illustrator's "Asset 1" is noise in an . const clean = (file) => readFileSync(join(root, file), 'utf8').replace(/\s*[^<]*<\/title>/, ''); const logo = clean('logo.svg'); const card = clean('logo_bk.svg'); /** The mark rendered `width` px wide, transparent. */ const mark = (width) => sharp(Buffer.from(logo), { density: 600 }).resize({ width }).png().toBuffer(); /** * Square icon: the mark centred on white. `fill` is the mark's width as a * share of the side; `radius` rounds the white square (0 = full bleed, as the * OS masks PWA icons itself). */ async function icon(size, { fill, radius }) { const r = Math.round(size * radius); const plate = Buffer.from( `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">` + `<rect width="${size}" height="${size}" rx="${r}" ry="${r}" fill="#fff"/></svg>`, ); return sharp(plate) .composite([{ input: await mark(Math.round(size * fill)), gravity: 'center' }]) .png() .toBuffer(); } /** .ico holding PNG entries (supported by every current browser). */ function ico(pngs) { const header = Buffer.alloc(6); header.writeUInt16LE(1, 2); header.writeUInt16LE(pngs.length, 4); const entries = []; let offset = 6 + 16 * pngs.length; for (const { size, data } of pngs) { const e = Buffer.alloc(16); e.writeUInt8(size >= 256 ? 0 : size, 0); e.writeUInt8(size >= 256 ? 0 : size, 1); e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6); e.writeUInt32LE(data.length, 8); e.writeUInt32LE(offset, 12); offset += data.length; entries.push(e); } return Buffer.concat([header, ...entries, ...pngs.map((p) => p.data)]); } function write(path, data) { writeFileSync(path, data); console.log('wrote', path.slice(root.length + 1)); } (async () => { write(join(userApp, 'public/img/brand-logo.svg'), logo); write(join(userApp, 'public/img/brand-logo-card.svg'), card); write(join(userApp, 'src/assets/img/brand-logo.svg'), logo); write(join(admin, 'src/assets/images/logo/brand-logo.svg'), logo); // Raster copies at the old public URLs, for anything linking them directly. const big = await mark(1024); write(join(userApp, 'public/img/brand-logo.png'), big); write(join(userApp, 'public/img/brand-logo.webp'), await sharp(big).webp({ quality: 90 }).toBuffer()); // PWA: one file serves "any" and "maskable", so the mark stays inside the // maskable safe circle (80% of the side): a 1.45:1 mark at 64% width does. write(join(userApp, 'public/icon-192.png'), await icon(192, { fill: 0.64, radius: 0 })); write(join(userApp, 'public/icon-512.png'), await icon(512, { fill: 0.64, radius: 0 })); // Tab icons: white rounded chip, so the black mark shows on dark tab strips too. const chip = { fill: 0.86, radius: 0.2 }; const favicon = ico(await Promise.all([16, 32, 48, 64].map(async (size) => ({ size, data: await icon(size, chip) })))); write(join(userApp, 'public/favicon.ico'), favicon); write(join(admin, 'public/favicon.ico'), favicon); write(join(admin, 'public/favicon.png'), await icon(192, chip)); })().catch((e) => { console.error(e); process.exit(1); });