← back to Dw Fleet Registry
brand/build-themes.mjs
217 lines
#!/usr/bin/env node
// Hybrid-brand demonstrator (DTD verdict C, 2026-06-01).
// Same structural skeleton as the DW master brand board, themed PER SITE from the
// REAL style-guide tokens in dw-domain-fleet/sites/*.json (accent/bg/ink/palette/font).
// Proves: shared DNA, per-site skin. Emits one themed HTML board per chosen site.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const SITES_DIR = path.join(os.homedir(), 'Projects', 'dw-domain-fleet', 'sites');
const OUT = path.dirname(new URL(import.meta.url).pathname);
// 3 distinct materials that have REAL hero imagery live (manifest sites have none).
const CHOSEN = ['silkwallpaper', 'corkwallcovering', 'linenwallpaper'];
const PROJECTS = path.join(os.homedir(), 'Projects');
// Resolve a site's config from manifest JSON, else local site.config.json, else a stub.
function resolveSite(slug) {
const mf = path.join(SITES_DIR, slug + '.json');
if (fs.existsSync(mf)) return JSON.parse(fs.readFileSync(mf, 'utf8'));
const lc = path.join(PROJECTS, slug, 'site.config.json');
if (fs.existsSync(lc)) return JSON.parse(fs.readFileSync(lc, 'utf8'));
return { slug, domain: slug + '.com', siteName: slug };
}
const FONT_FAMILY = {
cormorant: { name: 'Cormorant Garamond', css: "'Cormorant Garamond',Georgia,serif", g: 'Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400' },
bodoni: { name: 'Bodoni Moda', css: "'Bodoni Moda',Didot,'Times New Roman',serif", g: 'Bodoni+Moda:ital,opsz,wght@0,6..96,400;0,6..96,500;1,6..96,400' },
playfair: { name: 'Playfair Display', css: "'Playfair Display',Georgia,serif", g: 'Playfair+Display:ital,wght@0,400;0,500;0,600;1,400' },
georgia: { name: 'Georgia', css: "Georgia,'Times New Roman',serif", g: null },
};
// darken a hex by mixing toward black
function darken(hex, amt = 0.32) {
const n = parseInt(hex.replace('#', ''), 16);
const r = Math.round(((n >> 16) & 255) * (1 - amt));
const g = Math.round(((n >> 8) & 255) * (1 - amt));
const b = Math.round((n & 255) * (1 - amt));
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
}
// WCAG relative luminance (0=black .. 1=white) from a hex color
function lum(hex) {
const h = String(hex).replace('#', '');
const f = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
if (f.length < 6) return 0.5;
const n = parseInt(f.slice(0, 6), 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(v => {
v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
}
// pick the legible ink (near-black or near-white) for a given background —
// whichever yields the higher WCAG contrast ratio against that background.
const inkOn = (bg, dark = '#0a1113', light = '#f0f4f5') => {
const L = lum(bg);
const cDark = (L + 0.05) / (lum(dark) + 0.05); // contrast using dark text
const cLight = (lum(light) + 0.05) / (L + 0.05); // contrast using light text
return cDark >= cLight ? dark : light;
};
const esc = s => String(s || '').replace(/&/g, '&').replace(/</g, '<');
const LIVE = process.argv.includes('--live');
// Fetch a site live and pull (a) theme tokens straight from its served CSS, and
// (b) its real hero image (og:image) — so the board reflects the LIVE site, not a snapshot.
async function fetchLive(domain) {
const out = { cssVars: {}, hero: null };
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 7000);
const r = await fetch('https://' + domain + '/', { signal: ctrl.signal, headers: { 'User-Agent': 'dw-brand-build/1.0' } });
clearTimeout(t);
const html = (await r.text()).slice(0, 120000);
const nearWB = hex => { const h = hex.replace('#', ''); const f = h.length === 3 ? h.split('').map(c => c + c).join('') : h; if (f.length !== 6) return false; const n = parseInt(f, 16), r = n >> 16 & 255, g = n >> 8 & 255, b = n & 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b); return (mx > 238 && mn > 238) || (mx < 26); };
for (const v of ['accent', 'bg', 'ink', 'bgLight', 'rule']) {
// grab ALL definitions; for `accent` skip near-white/near-black (those are text/bg vars, not the brand accent)
const re = new RegExp('--' + v + '\\s*:\\s*(#[0-9a-fA-F]{3,8}|rgb[^;]+)', 'ig');
let m, picked = null;
while ((m = re.exec(html))) { const val = m[1].trim(); if (v === 'accent' && val.startsWith('#') && nearWB(val)) continue; picked = val; if (v === 'accent') break; }
if (picked) out.cssVars[v] = picked;
}
const og = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
if (og) out.hero = og[1];
else { const img = html.match(/<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|webp|avif)[^"']*)["']/i); if (img) out.hero = img[1]; }
if (out.hero && out.hero.startsWith('/')) out.hero = 'https://' + domain + out.hero;
// validate the hero actually returns a real image (sites often point og:image at a 404)
if (out.hero) {
try {
const ic = new AbortController(); const it = setTimeout(() => ic.abort(), 6000);
const ir = await fetch(out.hero, { signal: ic.signal, headers: { 'User-Agent': 'dw-brand-build/1.0' } });
clearTimeout(it);
const ct = ir.headers.get('content-type') || '';
if (!ir.ok || !/^image\//i.test(ct)) out.hero = null;
} catch { out.hero = null; }
}
} catch { /* offline — fall back to manifest */ }
return out;
}
function board(site, heroImage) {
const t = site.theme || {};
const font = FONT_FAMILY[t.font] || FONT_FAMILY.cormorant;
let ink = t.ink || '#f0ede6';
const bg = t.bg || '#0e0e0c', accent = t.accent || '#a8884a';
// Contrast invariant: ground + ink must sit on OPPOSITE luminance sides, else
// the board washes out (live-fetched tokens can hand us a light bg AND light ink).
if ((lum(ink) > 0.42) === (lum(bg) > 0.42)) ink = inkOn(bg);
const darkGround = lum(bg) <= 0.42;
const paper = bg;
const accentDeep = darken(accent, 0.28);
// muted text + hairlines must track ground polarity (was hardcoded white → invisible on a light ground)
const muted = darkGround ? 'rgba(255,255,255,.45)' : 'rgba(0,0,0,.5)';
const rule = t.rule || (darkGround ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.14)');
const name = (site.siteName || site.slug);
const words = name.split(' ');
const wTop = words.length > 1 ? words.slice(0, -1).join(' ') : name;
const wBot = words.length > 1 ? words[words.length - 1] : '';
const tagline = site.tagline || 'Designer wallcoverings for discerning walls.';
const pos = (site.niche && site.niche.pos) || [];
const tiles = (pos.length ? pos : ['Silk', 'Grasscloth', 'Cork', 'Linen']).slice(0, 5);
const gfont = font.g ? `<link href="https://fonts.googleapis.com/css2?family=${font.g}&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">` : `<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">`;
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<title>${esc(name)} — brand board</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
${gfont}
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
<style>
:root{--ink:${ink};--bg:${bg};--paper:${paper};--accent:${accent};--accent-deep:${accentDeep};--rule:${rule};
--serif:${font.css};--sans:'Inter',system-ui,sans-serif;--muted:${muted}}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);font-family:var(--sans);color:var(--ink);-webkit-font-smoothing:antialiased}
.board{width:1456px;margin:0 auto;background:var(--bg)}
.hero{padding:92px 110px 78px;border-bottom:1px solid var(--rule);position:relative}
.mono{font-family:var(--serif);font-weight:500;font-size:28px;letter-spacing:.2em;color:var(--accent);margin-bottom:48px;text-transform:uppercase}
.wordmark{font-family:var(--serif);font-weight:500;font-size:88px;line-height:1.0;letter-spacing:.03em;text-transform:uppercase}
.wordmark .thin{font-weight:400;color:var(--accent)}
.tagline{font-family:var(--serif);font-style:italic;font-size:28px;color:var(--muted);margin-top:24px}
.hairline{width:120px;height:1px;background:var(--accent);margin-top:36px}
.badge{position:absolute;top:92px;right:110px;font-size:11px;letter-spacing:.3em;color:var(--muted);text-transform:uppercase}
.row{display:grid;grid-template-columns:1fr 1fr}
.panel{padding:58px 110px}.panel+.panel{border-left:1px solid var(--rule)}
.eyebrow{font-size:11px;letter-spacing:.28em;text-transform:uppercase;color:var(--accent);margin-bottom:30px;font-weight:600}
.tr{display:flex;align-items:baseline;gap:18px;padding:13px 0;border-bottom:1px solid var(--rule)}.tr:last-child{border-bottom:0}
.tt{font-size:10px;letter-spacing:.1em;color:var(--muted);width:90px;flex-shrink:0;text-transform:uppercase}
.d{font-family:var(--serif);font-weight:500;font-size:48px;line-height:1}
.h1{font-family:var(--serif);font-weight:500;font-size:32px}
.h2{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:.03em;text-transform:uppercase}
.bd{font-family:var(--sans);font-size:16px;line-height:1.5;color:var(--ink);opacity:.82}
.cap{font-family:var(--sans);font-weight:500;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
.sw{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.s{border-radius:8px;overflow:hidden;border:1px solid var(--rule)}.s .c{height:88px}.s .m{padding:10px 12px}
.s .n{font-size:12px;font-weight:600}.s .x{font-size:10px;color:var(--muted);letter-spacing:.06em;margin-top:2px}
.strip{display:flex;border-top:1px solid var(--rule)}
.tile{flex:1;height:132px;display:flex;align-items:center;justify-content:center;font-family:var(--serif);font-weight:500;font-size:18px;letter-spacing:.14em;text-transform:uppercase}
.tile+.tile{border-left:1px solid var(--rule)}
.ft{padding:26px 110px;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--rule)}
.ft .l{font-family:var(--serif);font-size:16px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent)}
.ft .r{font-size:11px;color:var(--muted)}
</style></head><body><div class="board">
<section class="hero"><div class="badge">DW fleet · ${esc(site.domain || site.slug)}</div>
<div class="mono">DW</div>
<h1 class="wordmark">${esc(wTop)}${wBot ? `<br><span class="thin">${esc(wBot)}</span>` : ''}</h1>
<p class="tagline">${esc(tagline)}</p><div class="hairline"></div></section>
${heroImage ? `<div style="position:relative;border-bottom:1px solid var(--rule);background:#0a0a0a"><img src="${heroImage}" alt="" style="display:block;width:100%;height:320px;object-fit:cover"><div style="position:absolute;left:110px;bottom:18px;font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:#fff;background:rgba(0,0,0,.5);padding:5px 12px;border-radius:3px;font-family:var(--sans)">Live hero · ${esc(site.domain || site.slug)}</div></div>` : ''}
<div class="row">
<div class="panel"><div class="eyebrow">Type Scale · ${esc(font.name)}</div>
<div class="tr"><span class="tt">Display</span><span class="d">Aa</span><span class="cap">${esc(font.name)}</span></div>
<div class="tr"><span class="tt">H1 · 32</span><span class="h1">Hand-crafted, to order</span></div>
<div class="tr"><span class="tt">H2 · 22</span><span class="h2">The Collection</span></div>
<div class="tr"><span class="tt">Body · 16</span><span class="bd">Free memo samples, expert trade service, hand-holding on every order.</span></div>
<div class="tr"><span class="tt">Caption</span><span class="cap">Free memo samples · Trade pricing</span></div></div>
<div class="panel"><div class="eyebrow">Palette · ${esc(t.palette || '')}</div>
<div class="sw">
<div class="s"><div class="c" style="background:${ink}"></div><div class="m"><div class="n">Ink</div><div class="x">${ink.toUpperCase()}</div></div></div>
<div class="s"><div class="c" style="background:${accent}"></div><div class="m"><div class="n">Accent</div><div class="x">${accent.toUpperCase()}</div></div></div>
<div class="s"><div class="c" style="background:${accentDeep}"></div><div class="m"><div class="n">Accent deep</div><div class="x">${accentDeep.toUpperCase()}</div></div></div>
<div class="s"><div class="c" style="background:${bg}"></div><div class="m"><div class="n">Ground</div><div class="x">${bg.toUpperCase()}</div></div></div>
<div class="s"><div class="c" style="background:${t.bgLight || '#f3f1ea'}"></div><div class="m"><div class="n">Light</div><div class="x">${(t.bgLight || '#F3F1EA').toUpperCase()}</div></div></div>
<div class="s"><div class="c" style="background:${darken(accent, -0.4)}"></div><div class="m"><div class="n">Accent tint</div><div class="x">${darken(accent, -0.4).toUpperCase()}</div></div></div>
</div></div></div>
<div class="strip">${tiles.map((x, i) => {
const cols = [ink, accent, accentDeep, darken(accent, 0.5), t.bgLight || '#f3f1ea'];
const c = cols[i % cols.length];
const fg = inkOn(c); // legible text per-tile, computed from each tile's own background
return `<div class="tile" style="background:${c};color:${fg}">${esc(x)}</div>`;
}).join('')}</div>
<div class="ft"><div class="l">${esc(name)}</div><div class="r">DW hybrid brand · shared skeleton · per-site theme from style guide · ${esc(t.palette || '')}/${esc(t.font || '')}</div></div>
</div></body></html>`;
}
for (const slug of CHOSEN) {
const site = resolveSite(slug);
let hero = null, srcNote = 'config snapshot';
if (LIVE) {
const live = await fetchLive(site.domain || slug);
// download the live hero same-origin (the Figma capture can't pull cross-origin images)
if (live.hero) {
try {
const r = await fetch(live.hero, { headers: { 'User-Agent': 'dw-brand-build/1.0' } });
const buf = Buffer.from(await r.arrayBuffer());
const ext = (live.hero.match(/\.(jpg|jpeg|png|webp|avif)/i)?.[1] || 'jpg').toLowerCase();
fs.mkdirSync(path.join(OUT, 'heroes'), { recursive: true });
fs.writeFileSync(path.join(OUT, 'heroes', slug + '.' + ext), buf);
hero = 'heroes/' + slug + '.' + ext;
} catch { hero = null; }
}
// override theme tokens with whatever the LIVE site actually serves
if (Object.keys(live.cssVars).length) { site.theme = { ...(site.theme || {}), ...live.cssVars }; srcNote = 'LIVE css: ' + Object.keys(live.cssVars).join(','); }
}
const html = board(site, hero);
fs.writeFileSync(path.join(OUT, slug + '.html'), html);
console.log(`wrote ${slug}.html (accent ${site.theme?.accent}, font ${site.theme?.font}) — ${srcNote}${hero ? ' · hero✓' : (LIVE ? ' · no hero' : '')}`);
}