// Generate every static country-colour asset from the single source of truth (src/config/accents.ts // for the packs, src/config/scopes.ts for which scope uses which pack). Run: // node scripts/gen-brand.mjs # or: npm run gen:tokens && npm run gen:svg // OG cards are separate (Playwright) — see scripts/og-card.mjs (npm run gen:og). // // Node imports the .ts sources natively (>=22.6 strip-types; CI/dev are on 26). No new deps. import { writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { ACCENT_PACKS, cssValue } from '../src/config/accents.ts'; import { SCOPES } from '../src/config/scopes.ts'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); const scopes = Object.values(SCOPES); // Shared dark tones the marks sit on (global.css --terminal / --inkstrong, dark theme). const TILE = '#1d2021'; const MARK_INK = '#fbf1c7'; // --- src/styles/country-packs.css ------------------------------------------------------------- // UI roles (data-ui): the four accent roles the site chrome reads — numbers, links, buttons, bars. const uiRolesDecl = (r) => `--accent: ${cssValue(r.accent)}; --accent-fill: ${cssValue(r.fill)}; ` + `--accent-text: ${cssValue(r.text)}; --on-accent: ${cssValue(r.onAccent)};`; // Wordmark slash 1 (data-accent): the country's TRUE primary, kept separate from the UI accent so a // red-flag country can lead its UI with the non-red half (data-ui) while the lockup keeps its flag // colour order. Only the raw accent hex — the UI roles live on data-ui. const wmDecl = (r) => `--wm-accent: ${r.accent};`; const usedBy = (pack) => { const asP = scopes.filter((s) => s.accent === pack).map((s) => `.${s.cc}`); const asS = scopes.filter((s) => s.accent2 === pack).map((s) => `.${s.cc}`); const parts = []; if (asP.length) parts.push(`primary ${asP.join(' ')}`); if (asS.length) parts.push(`2nd slash ${asS.join(' ')}`); return parts.length ? parts.join('; ') : 'reserved — no scope yet'; }; const packs = Object.entries(ACCENT_PACKS); // data-accent -> --wm-accent (wordmark slash 1 + the brand-primary swatch). data-ui -> the four UI // roles. Split so the UI accent can differ from the logo primary for red-flag countries — see // uiAccentPack() in scopes.ts, which sets data-ui to the non-red half. const cssBody = packs .map( ([name, t]) => `/* ${name} — ${usedBy(name)} */\n` + `[data-accent="${name}"] { ${wmDecl(t.light)} }\n` + `[data-accent="${name}"][data-theme="dark"], [data-theme="dark"] [data-accent="${name}"] { ${wmDecl(t.dark)} }\n` + `[data-ui="${name}"] { ${uiRolesDecl(t.light)} }\n` + `[data-ui="${name}"][data-theme="dark"], [data-theme="dark"] [data-ui="${name}"] { ${uiRolesDecl(t.dark)} }`, ) .join('\n\n'); const cssMedia = packs .map( ([name, t]) => ` [data-accent="${name}"]:not([data-theme="light"]) { ${wmDecl(t.dark)} }\n` + ` [data-ui="${name}"]:not([data-theme="light"]) { ${uiRolesDecl(t.dark)} }`, ) .join('\n'); // Secondary packs: just --accent-2 (the wordmark's second slash), keyed on data-accent2. const accent2Decl = (r) => `--accent-2: ${r.accent};`; const css2Body = packs .map( ([name, t]) => `[data-accent2="${name}"] { ${accent2Decl(t.light)} }\n` + `[data-accent2="${name}"][data-theme="dark"], [data-theme="dark"] [data-accent2="${name}"] { ${accent2Decl(t.dark)} }`, ) .join('\n'); const css2Media = packs .map(([name, t]) => ` [data-accent2="${name}"]:not([data-theme="light"]) { ${accent2Decl(t.dark)} }`) .join('\n'); const countryPacksCss = `/* GENERATED by scripts/gen-brand.mjs from src/config/accents.ts — do NOT edit by hand. atproto. per-country accent packs. Three attributes on (from scopes.ts): data-accent -> --wm-accent (the country's TRUE primary: wordmark slash 1 + brand swatch) data-accent2 -> --accent-2 (wordmark slash 2) data-ui -> the four UI roles (--accent, --accent-fill, --accent-text, --on-accent) data-ui is the primary UNLESS it is red, then the non-red secondary (uiAccentPack in scopes.ts), so red-flag countries keep their lockup order but never lead the UI with an error-looking red. Yellow cannot reach 4.5:1 on cream at text size, so its light accent-text falls back to --inkstrong. */ ${cssBody} /* --- second-slash colours (data-accent2 -> --accent-2) --- */ ${css2Body} /* System dark with no explicit pin. Without this, global.css's own \`@media(prefers-color-scheme:dark) :root:not([data-theme="light"])\` (specificity 0,2,0) sets the NL orange accent and beats the packs' light rule (0,1,0) — so non-orange scopes would show orange in OS dark mode. These mirror the [data-theme="dark"] values at matching specificity; being imported after global.css, they win. \`:not([data-theme="light"])\` yields to a manual light pin. */ @media (prefers-color-scheme: dark) { ${cssMedia} ${css2Media} } `; // --- public/map/accents.json (consumed at runtime by public/map/locality-map.js) -------------- // Shape matches the renderer's old inline ACC table: pack -> [lightAccent, darkAccent]. const mapAccents = Object.fromEntries( packs.map(([name, t]) => [name, [t.light.accent, t.dark.accent]]), ); // --- public/brand/favicon-.svg + avatar-.svg ------------------------------------------ // The mark: a dark rounded tile with the cc in mono cream. It carries the two-colour identity like // the wordmark: the avatar's two slashes take slash 1 = primary (accent), slash 2 = secondary // (accent2), and its terminal cursor stays primary; the favicon has no slashes, so its bar splits // top = primary / bottom = secondary. Single-colour scopes (accent === accent2) read as one bar. const faviconSvg = (cc, accent, accent2) => `${cc}\n`; const avatarSvg = (cc, accent, accent2) => `at://${cc}\n`; // --- write ------------------------------------------------------------------------------------ const writes = [ ['src/styles/country-packs.css', countryPacksCss], ['public/map/accents.json', JSON.stringify(mapAccents, null, 2) + '\n'], ]; for (const s of scopes) { const accent = ACCENT_PACKS[s.accent].dark.accent; const accent2 = ACCENT_PACKS[s.accent2].dark.accent; writes.push([`public/brand/favicon-${s.cc}.svg`, faviconSvg(s.cc, accent, accent2)]); writes.push([`public/brand/avatar-${s.cc}.svg`, avatarSvg(s.cc, accent, accent2)]); } await Promise.all(writes.map(([rel, body]) => writeFile(resolve(root, rel), body))); for (const [rel] of writes) console.log(`wrote ${rel}`);