/** * Render the social card and icon set to PNG. * * node apps/web/tools/render-assets.mjs * * Generated rather than hand-drawn so that the mark, the wordmark and the * tagline cannot drift apart from the product, and so regenerating after a * brand change is one command. * * Uses the system Chrome via `channel: 'chrome'` rather than Playwright's own * bundled browser, so `npm install` does not pull a ~150MB download for a * script that runs occasionally, by hand, when the brand changes. */ import { chromium } from 'playwright'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const publicDir = join(here, '..', 'public'); const PIG_SVG = ` `; const icon = (ink, paper) => PIG_SVG.replaceAll('INK', ink).replaceAll('PAPER', paper); /** * An icon page with padding around the mark. * * Home-screen and maskable icons are cropped by the platform — iOS rounds the * corners, Android may apply a circle. A mark drawn edge to edge loses its * ears to that crop, so it is inset inside a filled plate. */ function iconPage({ size, inset, background, ink, radius = 0 }) { return `
${icon(ink, background)}`; } const browser = await chromium.launch({ channel: 'chrome' }); async function shoot(html, path, width, height, scale = 1) { const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: scale, }); const page = await ctx.newPage(); await page.setContent(html, { waitUntil: 'networkidle' }); await page.screenshot({ path, omitBackground: false }); await ctx.close(); console.log(` ${path.replace(publicDir + '/', '')} ${width * scale}×${height * scale}`); } console.log('Rendering assets…'); // Social card. Rendered at 1× because 1200×630 is already the target size — // unfurlers do not use a 2× variant and the extra bytes cost load time. const { readFile } = await import('node:fs/promises'); const ogHtml = await readFile(join(here, 'og-template.html'), 'utf8'); await shoot(ogHtml, join(publicDir, 'og.png'), 1200, 630, 1); // Apple touch icon. iOS composites onto whatever it likes and rounds the // corners itself, so this is drawn on an opaque white plate with generous // inset — a transparent one renders black on some home screens. await shoot( iconPage({ size: 180, inset: 26, background: '#ffffff', ink: '#09090b' }), join(publicDir, 'apple-touch-icon.png'), 180, 180, ); // PWA icons. `maskable` gets a larger inset so the safe zone survives a // circular mask on Android. await shoot( iconPage({ size: 192, inset: 24, background: '#ffffff', ink: '#09090b' }), join(publicDir, 'icon-192.png'), 192, 192, ); await shoot( iconPage({ size: 512, inset: 64, background: '#ffffff', ink: '#09090b' }), join(publicDir, 'icon-512.png'), 512, 512, ); await shoot( iconPage({ size: 512, inset: 112, background: '#ffffff', ink: '#09090b' }), join(publicDir, 'icon-maskable-512.png'), 512, 512, ); // No separate tiny favicon is rendered. Chrome hangs screenshotting viewports // below roughly 128px, and it is unnecessary: browsers prefer the SVG, and // icon-192 downscales cleanly for the ones that do not. await browser.close(); console.log('Done.');