'use strict'; // Erudit app-icon — the reference generator. Every dimension below is a FRACTION // of the icon side, so this file reads the same as docs/ICON_BRANDBOOK.md and the // icon reproduces at any resolution. Emits one variant/layer's SVG to stdout. // // node build-icon.mjs --variant light|dark [--layer full|background|foreground] [--scheme] > icon.svg // // Layers (for Android adaptive icons): // full the standalone master (rounded tile + mark) — iOS / PWA / favicon // background wood + grain + light/shadow, full-bleed square (the OS applies the mask) // foreground only «Э» + ✻, transparent, re-placed for the round mask (see FG_* below) // // Deps: opentype.js (npm i opentype.js). Font: ./Spectral-Bold.ttf (OFL, bundled). import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import opentype from 'opentype.js'; const DIR = dirname(fileURLToPath(import.meta.url)); const args = process.argv.slice(2); const has = f => args.includes('--' + f); const val = (f, d) => { const i = args.indexOf('--' + f); return i >= 0 ? args[i + 1] : d; }; // ---- the design, in fractions of the side ---- const S = 1024; // reference side (the icon is resolution-free) const RADIUS = 0.17; // tile corner radius (full master) const STAR_BULB = 0.22; // spoke-end (and hub) radius, as fraction of star radius const STAR_SPOKES = 6; const GRAIN_COLS = 6, GRAIN_W = 1 / 32, GRAIN_SHIFT = 1 / 16, GRAIN_TILT = 4; const SHEEN = 0.15; // white, transparent bottom-left -> this alpha top-right const SHADE = 0.15; // black, this alpha bottom-left -> transparent top-right // The mark, placed for the standalone master (bottom-right biased, full-bleed): const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] }; // The mark, placed for the Android foreground layer (centred-ish inside the 61% safe // zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻): const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] }; const PALETTE = { light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' }, dark: { wood: '#4a3316', ink: '#f2d9a0', grain: '#432e14' }, }; // Layers: // full rounded tile + master mark (favicon.svg — browser rounds nothing) // flat square tile + master mark (apple-touch, PWA "any" — OS rounds) // background square tile, no mark (Android adaptive background) // foreground mark only, transparent (Android adaptive foreground) // maskable square tile + foreground mark (PWA maskable — safe-zone aware) // monochrome foreground mark only, flat colour (Android 13+ themed layer) const LAYERS = ['full', 'flat', 'background', 'foreground', 'maskable', 'monochrome']; const variant = val('variant', 'light'); const layer = val('layer', 'full'); const P = PALETTE[variant]; if (!P) { console.error(`unknown --variant ${variant} (light|dark)`); process.exit(1); } if (!LAYERS.includes(layer)) { console.error(`unknown --layer ${layer} (${LAYERS.join('|')})`); process.exit(1); } const usesForegroundPlacement = ['foreground', 'maskable', 'monochrome'].includes(layer); const drawBg = ['full', 'flat', 'background', 'maskable'].includes(layer); const drawMark = layer !== 'background'; const MARK = usesForegroundPlacement ? FOREGROUND : MASTER; const inkColour = layer === 'monochrome' ? val('mono', '#ffffff') : P.ink; const RX = layer === 'full' ? RADIUS * S : 0; // only the standalone master keeps rounded corners const font = opentype.parse(readFileSync(join(DIR, 'Spectral-Bold.ttf')).buffer); // «Э» outline scaled so its ink bounding box is MARK.lh of the side, box centred on MARK.lc. function letterSvg() { const g = font.charToGlyph('Э'); const h0 = (() => { const b = g.getPath(0, 0, 1000).getBoundingBox(); return b.y2 - b.y1; })(); const scale = (MARK.lh * S) / h0; const p = g.getPath(0, 0, 1000 * scale); const bb = p.getBoundingBox(); const w = bb.x2 - bb.x1, h = bb.y2 - bb.y1; const tx = MARK.lc[0] * S - (bb.x1 + w / 2); const ty = MARK.lc[1] * S - (bb.y1 + h / 2); return { svg: ``, box: { x: bb.x1 + tx, y: bb.y1 + ty, w, h } }; } // Teardrop-spoked asterisk ✻: each spoke tapers to a point at the centre and ends // in a round bulb; a central disc equal to the bulb fuses them. function starPath() { const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, Rout = MARK.sd * S / 2; const rb = Rout * STAR_BULB, R = Rout - rb; const L = Math.sqrt(R * R - rb * rb), theta = Math.asin(rb / R); const rot = (v, t) => [v[0] * Math.cos(t) - v[1] * Math.sin(t), v[0] * Math.sin(t) + v[1] * Math.cos(t)]; let d = ''; for (let k = 0; k < STAR_SPOKES; k++) { const a = -Math.PI / 2 + k * 2 * Math.PI / STAR_SPOKES; const u = [Math.cos(a), Math.sin(a)]; const d1 = rot(u, theta), d2 = rot(u, -theta); const T1 = [cx + L * d1[0], cy + L * d1[1]], T2 = [cx + L * d2[0], cy + L * d2[1]]; d += `M${cx.toFixed(2)} ${cy.toFixed(2)} L${T1[0].toFixed(2)} ${T1[1].toFixed(2)} A${rb.toFixed(2)} ${rb.toFixed(2)} 0 1 0 ${T2[0].toFixed(2)} ${T2[1].toFixed(2)} Z `; } d += `M${cx} ${cy} m${-rb} 0 a${rb} ${rb} 0 1 0 ${2 * rb} 0 a${rb} ${rb} 0 1 0 ${-2 * rb} 0 Z`; return ``; } function grain() { const colW = S / GRAIN_COLS, sw = GRAIN_W * S, shift = GRAIN_SHIFT * S; const yTop = -0.2 * S, yBot = 1.2 * S, cy = S / 2; let s = ''; for (let i = 1; i <= GRAIN_COLS; i++) { const xr = i * colW - shift, x = xr - sw, cx = xr - sw / 2; s += ``; } return `${s}`; } let body = ''; const L = letterSvg(); if (drawBg) { // tile + grain + light/shadow (the background) body += `` + `` + `` + `` + `` + grain() + `` + ``; } if (drawMark) body += L.svg + starPath(); // the mark if (has('scheme')) body += scheme(L); process.stdout.write(`${body}\n`); // Percent-based construction overlay for the brand book (full master only). function scheme(L) { const pc = n => (100 * n / S).toFixed(1) + '%'; const GC = '#0f172a', BX = '#d81b60', AC = '#0d9488'; const halo = 'paint-order="stroke" stroke="#fff" stroke-width="4"'; const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, sd = MARK.sd * S; let g = ``; for (let f = 1; f <= 3; f++) { const p = f * S / 4; g += ``; g += ``; g += `${f * 25}%`; g += `${f * 25}%`; } g += ``; g += ``; g += `light: white 0%→15%`; g += `shadow: black 15%→0%`; const { x, y, w, h } = L.box; g += ``; g += ``; g += `«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)}`; g += `box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)}`; g += ``; g += ``; g += `✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)}`; g += `corner r = 17%`; g += ``; g += ``; g += `Wood grain: 6 equal columns; a vertical stripe on each column's right edge,`; g += `width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.`; return g + ``; }