'use strict'; // Site icons + the Open Graph card for the public landing, drawn from the same // design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's // board palette (ui/src/app.css). Everything is composed as SVG from the committed // glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build // time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright // chromium (@playwright/test re-exports the browser API). Outputs go straight to // ui/public/: // favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small) // favicon.ico 32x32 PNG-in-ICO render of the same // apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners) // og-image.png 1200x630 card: tile + wordmark on the board green const fs = require('fs'); const path = require('path'); const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8')); const UI = path.resolve(__dirname, '../../../ui'); const OUT = path.join(UI, 'public'); // ---- palette (vk loader tile + app.css board tokens) ------------------------ const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A'; const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf'; // ---- glyph outlines -> SVG path data ---------------------------------------- const r2 = n => Math.round(n * 100) / 100; // pathD renders one glyph's contours scaled by sc and translated by (tx, ty). function pathD(g, sc, tx, ty) { const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`; const Z = [0, 0]; return g.contours.map(ct => { const n = ct.v.length; let d = `M${pt(ct.v[0], Z)}`; for (let k = 1; k <= n; k++) { const a = k - 1, b = k % n; d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`; } return d + 'Z'; }).join(''); } // glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the // same placement rule as the vk loader's glyph(). stroke fattens it slightly. function glyphAt(ch, capPx, cx, cy, stroke, colour) { const g = G.glyphs[ch], sc = capPx / g.bbox.h; const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc); return ``; } // textLine lays out a string on a baseline from the per-glyph advances; capPx sets // the capital height (measured on «Э»). Returns the combined path + the width. function textLine(str, capPx, x, y, colour) { const sc = capPx / G.glyphs['Э'].bbox.h; let d = '', w = 0; for (const ch of str) { const g = G.glyphs[ch]; if (g.contours.length) d += pathD(g, sc, x + w, y); w += g.adv * sc; } return { svg: ``, width: w }; } // tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with // the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8». function tile(cx, cy, size, withScore) { const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52); let s = ``; s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH); if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH); return s; } const svg = (w, h, body) => `${body}`; // ---- favicon.svg (the committed vector master) ------------------------------- const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n'; // ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ---- const appleTouch = svg(180, 180, `` + `` + glyphAt('Э', 100, 90, 88, 3, GLYPH) + glyphAt('8', 26, 146, 142, 1.5, GLYPH)); // ---- og-image: tile + wordmark, centred as one group on the board green ------ function ogImage() { const W = 1200, H = 630, tileSize = 340, gap = 84; const l1 = textLine('Эрудит', 112, 0, 0, TEXT); const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED); const textW = Math.max(l1.width, l2.width); const left = (W - (tileSize + gap + textW)) / 2; const tx = left + tileSize + gap; // Two baselines around the vertical centre; the tile centre sits between them. const b1 = 295, b2 = 408; const body = `` + tile(left + tileSize / 2, H / 2, tileSize, true) + textLine('Эрудит', 112, tx, b1, TEXT).svg + textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg; console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`); return svg(W, H, body); } // ---- rasterisation (Playwright chromium from ui/node_modules) ---------------- async function shoot(page, markup, w, h, transparent) { await page.setViewportSize({ width: w, height: h }); await page.setContent(`${markup}`); return page.screenshot({ omitBackground: transparent }); } // icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG). function icoFromPNG(png, sizePx) { const h = Buffer.alloc(22); h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32 h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset return Buffer.concat([h, png]); } (async () => { fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon); const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test')); const browser = await chromium.launch(); const page = await browser.newPage(); const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true); fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32)); fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false)); fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false)); await browser.close(); for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) { console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes'); } })();