// Generate the Bluesky profile banners (3000x1000) for every site — one per scope + the friendly // external atproto.fr. Light treatment (from the Claude Design handoff): warm paper background with // the country character-map faint on the right. Unlike the design's first pass, the HEADLINE is the // tagline (the //atproto. domain is a small secondary mark, because Bluesky already shows the // avatar with that lockup on top), a "meetups / members / tools" foot line replaces the terminal // cursor, and the whole bottom-left is kept clear as the SAFE AREA where Bluesky overlays the avatar. // // node scripts/gen-banners.mjs [outdir] (default: dist/brandkit) -> //banner-.png // // Playwright + a tiny static server rooted at public/ (for the map's absolute /map/... paths), then // sharp downscale 2x. Font-dependent; run on a machine with the fonts. import { chromium } from 'playwright'; import sharp from 'sharp'; import { createServer } from 'node:http'; import { readFile, mkdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, resolve, extname } from 'node:path'; import { ACCENT_PACKS } from '../src/config/accents.ts'; import { SCOPES as SCOPES_BY_ID } from '../src/config/scopes.ts'; import { EU27, ALSO_IN_EUROPE } from '../src/data/europe-regions.ts'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); const pub = resolve(root, 'public'); const outDir = resolve(process.cwd(), process.argv[2] ?? 'dist/brandkit'); const W = 1500, H = 500; // rendered at 2x -> 3000x1000 (Bluesky banner 3:1) // Light (Gruvbox light) tokens — the banner is light, matching the design handoff. const PAPER = '#ebdbb2'; // warm terminal-tone background (map's ground=terminal matches it) const INKSTRONG = '#282828'; // headline + wordmark letters/dot const INK = '#504945'; // foot const RULE = '#bdae93'; // foot separators const MAP_VERSION = '9'; const IN_COUNTRY_EN = { nl: 'the Netherlands', be: 'Belgium', no: 'Norway', hu: 'Hungary', it: 'Italy', ch: 'Switzerland', eu: 'Europe', fr: 'France', de: 'Germany', es: 'Spain', pl: 'Poland', pt: 'Portugal', }; const lightHue = (pack) => ACCENT_PACKS[pack].light.accent; // light bg -> light accent tones const SITES = [ ...Object.values(SCOPES_BY_ID).map((s) => ({ cc: s.cc, pack: s.accent, accent: lightHue(s.accent), accent2: lightHue(s.accent2) })), { cc: 'fr', pack: 'cobalt', accent: lightHue('cobalt'), accent2: lightHue('red'), external: true }, // Prospective countries (no site yet); flag-derived pairs kept in sync with brand.astro / gen-brandkit. { cc: 'de', pack: 'red', accent: lightHue('red'), accent2: lightHue('yellow'), prospective: true }, ]; const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml', '.css': 'text/css; charset=utf-8' }; const server = createServer(async (req, res) => { try { const url = new URL(req.url, 'http://localhost'); const file = resolve(pub, '.' + url.pathname); if (!file.startsWith(pub)) return res.writeHead(403).end(); const body = await readFile(file); res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' }); res.end(body); } catch { res.writeHead(404).end(); } }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const port = server.address().port; const bannerHtml = ({ cc, accent, accent2, headline, mapDataUri }) => `
//atproto.${cc}
${headline}
Meetups/Members/Tools
`; const only = new Set(process.argv.slice(3).map((s) => s.toLowerCase())); const sites = only.size ? SITES.filter((s) => only.has(s.cc)) : SITES; const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2, colorScheme: 'light' }); for (const s of sites) { // 1) The country character-map at banner size, LIGHT, on the terminal-tone ground so it blends. const mapPage = await ctx.newPage(); const regions = s.cc === 'eu' ? `&active=${EU27.join(',')}&lit=${ALSO_IN_EUROPE.join(',')}` : ''; const mapUrl = `http://127.0.0.1:${port}/map/locality-map.html?cc=${s.cc}&accent=${s.pack}&w=${W}&h=${H}&fit=cover&ground=terminal&motion=off&v=${MAP_VERSION}${regions}`; await mapPage.goto(mapUrl, { waitUntil: 'networkidle' }); await mapPage.waitForFunction(() => document.querySelectorAll('svg text').length > 20, { timeout: 15000 }); const mapBuf = await mapPage.screenshot({ type: 'png' }); await mapPage.close(); // 2) Composite behind the headline. const page = await ctx.newPage(); await page.setContent( bannerHtml({ cc: s.cc, accent: s.accent, accent2: s.accent2, headline: `The AT Protocol community in ${IN_COUNTRY_EN[s.cc] ?? s.cc}`, mapDataUri: `data:image/png;base64,${mapBuf.toString('base64')}` }), { waitUntil: 'networkidle' }, ); await page.evaluate(() => document.fonts.ready); const buf = await page.screenshot({ type: 'png' }); await page.close(); const dir = resolve(outDir, s.cc); await mkdir(dir, { recursive: true }); await sharp(buf).resize(W * 2, H * 2).png({ compressionLevel: 9 }).toFile(resolve(dir, `banner-${s.cc}.png`)); console.log(`wrote ${s.cc}/banner-${s.cc}.png (${W * 2}x${H * 2})${s.external ? ' (external)' : s.prospective ? ' (proposed)' : ''}`); } await browser.close(); server.close();