← back to Dw Fleet Registry
audit-templates.mjs
112 lines
#!/usr/bin/env node
// READ-ONLY fleet template-drift audit (DTD verdict B, 2026-06-01: dw-fashion-templates
// is the authoritative style guide). For each mapped DW site, compare its ACTUAL
// :root --accent / colour scheme against its ASSIGNED fashion template's signature.
// Writes template-audit.json + template-audit.md. Touches NOTHING on any site.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const PROJECTS = path.join(os.homedir(), 'Projects');
const OUT = path.dirname(new URL(import.meta.url).pathname);
// site -> assigned template (from dw-fashion-templates SKILL.md mapping table)
const MAP = {
corkwallcovering:'hermes', silkwallpaper:'saint_laurent', linenwallpaper:'loewe',
handcraftedwallpaper:'bottega', '1890swallpaper':'gucci', '1900swallpaper':'gucci',
'1920swallpaper':'chanel', '1930swallpaper':'chanel', '1960swallpaper':'prada',
'1970swallpaper':'prada', '1980swallpaper':'balenciaga', metallicwallpaper:'dior',
mylarwallpaper:'dior', silverleafwallpaper:'valentino', glitterwalls:'valentino',
naturalwallcoverings:'celine', recycledwallpaper:'celine', jutewallpaper:'loewe',
raffiawalls:'loewe', raffiawallcoverings:'loewe', museumwallpaper:'therow',
restaurantwallpaper:'burberry', hotelwallcoverings:'burberry', hospitalitywallpaper:'burberry',
contractwallpaper:'toteme', healthcarewallpaper:'toteme', madagascarwallpaper:'khaite',
suedewallpaper:'khaite', screenprintedwallpaper:'margiela', blockprintedwallpaper:'margiela',
embroideredwallpaper:'miu_miu', textilewallpaper:'miu_miu', selfadhesivewallpaper:'allbirds',
apartmentwallpaper:'allbirds', vinylwallpaper:'phoebe_philo', fabricwallpaper:'phoebe_philo',
wallpapersback:'reformation',
};
// template -> expected accent signature. scheme: 'color' (hue-matched) | 'mono' | 'neutral'
const TPL = {
hermes:{primary:'#FF4F00',scheme:'color'}, bottega:{primary:'#107A3B',scheme:'color'},
saint_laurent:{scheme:'mono'}, loewe:{primary:'#9B7B4F',scheme:'color'},
prada:{scheme:'mono'}, chanel:{scheme:'mono'}, gucci:{primary:'#006837',scheme:'color'},
balenciaga:{primary:'#FF4F00',scheme:'color'}, dior:{primary:'#A98B5B',scheme:'color'},
valentino:{primary:'#D70000',scheme:'color'}, celine:{scheme:'mono'},
toteme:{scheme:'neutral'}, khaite:{primary:'#3A3530',scheme:'neutral'},
burberry:{primary:'#A47C5A',scheme:'color'}, miu_miu:{primary:'#FFB6C1',scheme:'color'},
margiela:{scheme:'mono'}, allbirds:{scheme:'neutral'}, reformation:{scheme:'neutral'},
phoebe_philo:{scheme:'mono'}, therow:{scheme:'neutral'},
};
function hsl(hex) {
const h = String(hex).replace('#', '');
const f = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
if (!/^[0-9a-f]{6}$/i.test(f)) return null;
let r = parseInt(f.slice(0,2),16)/255, g = parseInt(f.slice(2,4),16)/255, b = parseInt(f.slice(4,6),16)/255;
const mx=Math.max(r,g,b), mn=Math.min(r,g,b), d=mx-mn, l=(mx+mn)/2;
let hh=0, s=0;
if (d) { s = d/(1-Math.abs(2*l-1)); if(mx===r)hh=((g-b)/d)%6; else if(mx===g)hh=(b-r)/d+2; else hh=(r-g)/d+4; hh=Math.round(hh*60+360)%360; }
return { h: hh, s, l };
}
const hueDiff = (a,b) => { const d = Math.abs(a-b)%360; return d>180?360-d:d; };
// pull the FIRST :root --accent (dark default) + --bg from a site's index.html
function readTokens(slug) {
const file = path.join(PROJECTS, slug, 'public', 'index.html');
if (!fs.existsSync(file)) return null;
const html = fs.readFileSync(file, 'utf8').slice(0, 40000);
const acc = html.match(/--accent\s*:\s*(#[0-9a-fA-F]{3,8}|rgb[^;]+)/i);
const bg = html.match(/--bg\s*:\s*(#[0-9a-fA-F]{3,8}|rgb[^;]+)/i);
const tmpl = html.match(/template[^*\n]{0,40}/i); // the "Saint Laurent template" comment, if any
return { accent: acc ? acc[1].trim() : null, bg: bg ? bg[1].trim() : null, note: tmpl ? tmpl[0].trim() : null };
}
function verdict(actualAccent, tpl) {
if (!actualAccent || !actualAccent.startsWith('#')) return { v: 'UNKNOWN', why: 'no hex --accent token found' };
const a = hsl(actualAccent);
if (!a) return { v: 'UNKNOWN', why: 'unparseable accent' };
const sat = a.s;
if (tpl.scheme === 'mono' || tpl.scheme === 'neutral') {
return sat < 0.20
? { v: 'MATCH', why: `${tpl.scheme} template, accent is neutral (sat ${sat.toFixed(2)})` }
: { v: 'DRIFT', why: `${tpl.scheme} template expects neutral accent, but accent is saturated (${actualAccent}, sat ${sat.toFixed(2)})` };
}
// color template — hue must match
const exp = hsl(tpl.primary);
if (sat < 0.15) return { v: 'DRIFT', why: `template expects ${tpl.primary}, but accent is desaturated (${actualAccent})` };
const dh = hueDiff(a.h, exp.h);
return dh <= 28
? { v: 'MATCH', why: `accent ${actualAccent} hue ~${a.h}° matches template ${tpl.primary} hue ${exp.h}° (Δ${dh}°)` }
: { v: 'DRIFT', why: `accent ${actualAccent} hue ${a.h}° ≠ template ${tpl.primary} hue ${exp.h}° (Δ${dh}°)` };
}
const rows = [];
for (const [slug, tplKey] of Object.entries(MAP)) {
const tpl = TPL[tplKey];
const tok = readTokens(slug);
if (!tok) { rows.push({ slug, template: tplKey, status: 'NO-LOCAL-DIR' }); continue; }
const ver = verdict(tok.accent, tpl);
rows.push({ slug, template: tplKey, expected: tpl.primary || tpl.scheme, actualAccent: tok.accent, actualBg: tok.bg, status: ver.v, why: ver.why });
}
const counts = rows.reduce((m, r) => (m[r.status] = (m[r.status]||0)+1, m), {});
const report = { auditedAt: new Date().toISOString(), authority: 'dw-fashion-templates (DTD verdict B)', counts, sites: rows };
fs.writeFileSync(path.join(OUT, 'template-audit.json'), JSON.stringify(report, null, 2) + '\n');
// markdown
let md = `# DW Fleet — Template-Drift Audit\n\n_Authority: dw-fashion-templates (DTD verdict B). Read-only._\n\n`;
md += `**Counts:** ` + Object.entries(counts).map(([k,v])=>`${k} ${v}`).join(' · ') + `\n\n`;
md += `| Site | Template | Expected | Actual accent | Verdict | Note |\n|---|---|---|---|---|---|\n`;
for (const r of rows.sort((a,b)=>(a.status>b.status?1:-1)||a.slug.localeCompare(b.slug))) {
md += `| ${r.slug} | ${r.template} | ${r.expected||'—'} | ${r.actualAccent||'—'} | ${r.status} | ${r.why||''} |\n`;
}
fs.writeFileSync(path.join(OUT, 'template-audit.md'), md);
console.log('Template-drift audit —', Object.entries(counts).map(([k,v])=>`${k}: ${v}`).join(' '));
console.log('DRIFT sites:');
for (const r of rows.filter(r=>r.status==='DRIFT')) console.log(` ${r.slug.padEnd(24)} ${r.template.padEnd(14)} expected ${String(r.expected).padEnd(10)} actual ${r.actualAccent}`);
console.log(`\nWrote template-audit.json + template-audit.md`);