Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites atproto.eu
community atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131// 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.<cc> 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) -> <outdir>/<cc>/banner-<cc>.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/dotconst INK = '#504945'; // footconst RULE = '#bdae93'; // foot separatorsconst 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 tonesconst 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 }) => `<!doctype html><html><head><meta charset="utf-8"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Next:wght@400;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet"><style> * { margin: 0; box-sizing: border-box; } html, body { width: ${W}px; height: ${H}px; } body { background: ${PAPER}; color: ${INK}; font-family: 'Atkinson Hyperlegible Next', sans-serif; position: relative; overflow: hidden; } /* The map is a FAINT full-bleed texture (like the design handoff): Bluesky crops the banner's edges/bottom, so a prominent country shape would look broken — faint texture survives cropping. */ .map { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0.34; } /* Bluesky safe zone (per its community specs): central ~60% horizontally + upper two-thirds vertically, bottom-left kept clear for the avatar and top-left for the back button. Content is boxed to x 26%..84% and the upper band so it survives side/bottom crops and both overlays. */ /* Vertically centred in the banner (content is clear of the avatar/back button horizontally, at x 26%..80%, so it can use the full height); the small bottom offset keeps it above Bluesky's bottom crop. */ .content { position: absolute; left: 26%; right: 20%; top: 0; bottom: 6%; display: flex; flex-direction: column; justify-content: center; } .wordmark { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 38px; letter-spacing: -0.01em; color: ${INKSTRONG}; line-height: 1; } .wordmark .s1 { color: ${accent}; } .wordmark .s2 { color: ${accent2}; margin-left: 0.05em; } .headline { font-weight: 700; font-size: 50px; line-height: 1.1; color: ${INKSTRONG}; margin-top: 14px; letter-spacing: -0.01em; } .rule { height: 7px; width: 110px; background: ${accent}; border-radius: 4px; margin: 18px 0 14px; } .foot { font-family: 'JetBrains Mono', monospace; font-weight: 500; font-size: 22px; letter-spacing: 0.08em; color: ${INK}; text-transform: uppercase; } .foot .sep { color: ${RULE}; padding: 0 12px; }</style></head><body> <img class="map" src="${mapDataUri}" alt=""> <div class="content"> <div class="wordmark"><span class="s1">/</span><span class="s2">/</span>atproto.${cc}</div> <div class="headline">${headline}</div> <div class="rule"></div> <div class="foot">Meetups<span class="sep">/</span>Members<span class="sep">/</span>Tools</div> </div></body></html>`;
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();