4a3e12c85a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 2m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The adaptive foreground's asterisk grazed the round mask's safe zone. Scale the foreground mark 0.75 about the icon centre (25% smaller, the letter-to-star arrangement unchanged) so it sits well inside the 61% safe zone. Independently, the legacy square ic_launcher.png now renders the full-bleed master instead of the safe-zone composite, so old Android (< API 26) shows a large mark rather than a shrunk one; the legacy round icon keeps the safe-zone composite (a round mask would clip the master's corner star). Regenerate the layer set, Android res and the brandbook foreground previews; update ICON_BRANDBOOK.md / ICONS.md.
164 lines
11 KiB
JavaScript
164 lines
11 KiB
JavaScript
'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 ✻).
|
|
// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
|
|
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
|
|
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
|
|
// legacy round icon) get more breathing room; the full-bleed master is untouched.
|
|
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
|
|
|
|
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: `<path transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)})" d="${p.toPathData(2)}" fill="${inkColour}"/>`,
|
|
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 `<path d="${d}" fill="${inkColour}"/>`;
|
|
}
|
|
|
|
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 += `<rect x="${x.toFixed(2)}" y="${yTop.toFixed(2)}" width="${sw.toFixed(2)}" height="${(yBot - yTop).toFixed(2)}" fill="${P.grain}" transform="rotate(${GRAIN_TILT} ${cx.toFixed(2)} ${cy.toFixed(2)})"/>`;
|
|
}
|
|
return `<g clip-path="url(#tile)">${s}</g>`;
|
|
}
|
|
|
|
let body = '';
|
|
const L = letterSvg();
|
|
if (drawBg) { // tile + grain + light/shadow (the background)
|
|
body += `<rect width="${S}" height="${S}" rx="${RX}" fill="${P.wood}"/>`
|
|
+ `<defs><clipPath id="tile"><rect width="${S}" height="${S}" rx="${RX}"/></clipPath>`
|
|
+ `<linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${SHEEN}"/></linearGradient>`
|
|
+ `<linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#000" stop-opacity="${SHADE}"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>`
|
|
+ `</defs>`
|
|
+ grain()
|
|
+ `<rect width="${S}" height="${S}" fill="url(#sheen)" clip-path="url(#tile)"/>`
|
|
+ `<rect width="${S}" height="${S}" fill="url(#shade)" clip-path="url(#tile)"/>`;
|
|
}
|
|
if (drawMark) body += L.svg + starPath(); // the mark
|
|
if (has('scheme')) body += scheme(L);
|
|
|
|
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">${body}</svg>\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 = `<g font-family="'Helvetica Neue',Arial,sans-serif">`;
|
|
for (let f = 1; f <= 3; f++) {
|
|
const p = f * S / 4;
|
|
g += `<line x1="${p}" y1="0" x2="${p}" y2="${S}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
|
g += `<line x1="0" y1="${p}" x2="${S}" y2="${p}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
|
g += `<text x="${p + 4}" y="20" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
|
g += `<text x="4" y="${p - 4}" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
|
}
|
|
g += `<line x1="${0.14 * S}" y1="${0.86 * S}" x2="${0.86 * S}" y2="${0.14 * S}" stroke="${AC}" stroke-width="3" stroke-dasharray="3 8" stroke-linecap="round"/>`;
|
|
g += `<circle cx="${0.86 * S}" cy="${0.14 * S}" r="6" fill="${AC}"/><circle cx="${0.14 * S}" cy="${0.86 * S}" r="6" fill="${AC}"/>`;
|
|
g += `<text x="${0.86 * S - 6}" y="${0.14 * S - 12}" text-anchor="end" font-size="18" font-weight="700" fill="${AC}" ${halo}>light: white 0%→15%</text>`;
|
|
g += `<text x="${0.14 * S + 10}" y="${0.86 * S + 26}" font-size="18" font-weight="700" fill="${AC}" ${halo}>shadow: black 15%→0%</text>`;
|
|
const { x, y, w, h } = L.box;
|
|
g += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
|
g += `<line x1="${MARK.lc[0] * S}" y1="${MARK.lc[1] * S - 16}" x2="${MARK.lc[0] * S}" y2="${MARK.lc[1] * S + 16}" stroke="${BX}" stroke-width="2.5"/><line x1="${MARK.lc[0] * S - 16}" y1="${MARK.lc[1] * S}" x2="${MARK.lc[0] * S + 16}" y2="${MARK.lc[1] * S}" stroke="${BX}" stroke-width="2.5"/>`;
|
|
g += `<text x="${x.toFixed(1)}" y="${(y - 10).toFixed(1)}" font-size="19" font-weight="700" fill="${BX}" ${halo}>«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)}</text>`;
|
|
g += `<text x="${x.toFixed(1)}" y="${(y + h + 24).toFixed(1)}" font-size="18" font-weight="700" fill="${BX}" ${halo}>box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)}</text>`;
|
|
g += `<rect x="${cx - sd / 2}" y="${cy - sd / 2}" width="${sd}" height="${sd}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
|
g += `<line x1="${cx}" y1="${cy - 14}" x2="${cx}" y2="${cy + 14}" stroke="${BX}" stroke-width="2.5"/><line x1="${cx - 14}" y1="${cy}" x2="${cx + 14}" y2="${cy}" stroke="${BX}" stroke-width="2.5"/>`;
|
|
g += `<text x="${(cx + sd / 2).toFixed(1)}" y="${(cy - sd / 2 - 10).toFixed(1)}" text-anchor="end" font-size="18" font-weight="700" fill="${BX}" ${halo}>✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)}</text>`;
|
|
g += `<text x="${RX + 14}" y="${RX - 6}" font-size="17" font-weight="700" fill="${GC}" ${halo}>corner r = 17%</text>`;
|
|
g += `<path d="M4 ${RX} A ${RX} ${RX} 0 0 1 ${RX} 4" fill="none" stroke="${GC}" stroke-width="2" stroke-dasharray="5 5"/>`;
|
|
g += `<rect x="${0.03 * S}" y="${S - 96}" width="${0.94 * S}" height="74" rx="12" fill="#0f172a" fill-opacity="0.82"/>`;
|
|
g += `<text x="${0.055 * S}" y="${S - 64}" font-size="18" font-weight="600" fill="#fff">Wood grain: 6 equal columns; a vertical stripe on each column's right edge,</text>`;
|
|
g += `<text x="${0.055 * S}" y="${S - 40}" font-size="18" font-weight="600" fill="#fff">width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.</text>`;
|
|
return g + `</g>`;
|
|
}
|