← 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`);