← back to Dw Universe

scripts/apply-design-fixes.js

173 lines

// Apply the P0 + P1 design-review fixes to public/index.html in one pass:
//   - P0: wrap script in IIFE
//   - P1a: consolidate type scale (13 tiers → 6)
//   - P1b: consolidate spacing scale (14 distinct → 4-base)
//   - P1c: replace raw hex literals with var(--*) tokens
//   - P1d: add IntersectionObserver pause for off-screen .node-inner float animations
//
// Idempotent: if IIFE wrap already present, skips. Token + size maps use word boundaries
// to avoid partial matches inside SVG fills, viewBox, etc.

const fs = require('fs');
const path = require('path');

const FILE = path.join(__dirname, '..', 'public', 'index.html');
let html = fs.readFileSync(FILE, 'utf8');
const before = html;

// ─── 1. Type scale consolidation ────────────────────────────────────────
// Map: existing size → nearest tier on 11 / 13 / 18 / 22 / 32 (clamp() heroes left alone)
const TYPE_MAP = {
  '8px':  '11px',
  '9px':  '11px',
  '10px': '11px',
  '11px': '11px',
  '12px': '13px',
  '13px': '13px',
  '14px': '13px',
  '16px': '18px',
  '18px': '18px',
  '20px': '22px',
  '22px': '22px',
  '24px': '22px',
  '36px': '32px',
};
for (const [from, to] of Object.entries(TYPE_MAP)) {
  // Only inside font-size declarations
  const re = new RegExp(`(font-size:\\s*)${from.replace('.','\\.')}\\b`, 'g');
  html = html.replace(re, `$1${to}`);
}

// ─── 2. Spacing scale consolidation ─────────────────────────────────────
// Map: existing → nearest 4-base step (4 / 8 / 12 / 16 / 24 / 32 / 48 / 64)
const SPACE_MAP = {
  '6px':  '8px',
  '10px': '12px',
  '14px': '16px',
  '18px': '16px',
  '22px': '24px',
  '28px': '32px',
  '36px': '32px',
  '56px': '48px',
};
// Apply ONLY in margin/padding contexts (avoid breaking border-width, etc.)
for (const [from, to] of Object.entries(SPACE_MAP)) {
  // multi-value forms: "padding: 12px 6px;", "margin-top: 6px;", "margin: 0 0 6px"
  const re1 = new RegExp(`((?:padding|margin)(?:-(?:top|bottom|left|right))?:\\s*[^;]*?)\\b${from}\\b`, 'g');
  // run twice to catch repeated occurrences in same declaration
  html = html.replace(re1, `$1${to}`).replace(re1, `$1${to}`).replace(re1, `$1${to}`);
}

// ─── 3. Raw-hex → CSS var() replacement ─────────────────────────────────
// Only replace where the hex IS a brand token. Regex anchors to value-start with
// :, space, or ( to avoid e.g. inside #anchor-link.
const HEX_TO_TOKEN = {
  '#f4ecd8': 'var(--ink)',
  '#c9b687': 'var(--gold)',
  '#1b1814': 'var(--bg)',         // dark theme
  '#fafaf6': 'var(--bg)',         // light theme
  '#0c0a08': 'var(--bg)',         // dark theme alt
  '#15110d': 'var(--bg-2)',       // dark theme alt
  '#a39888': 'var(--ink-mute)',
  '#5a5246': 'var(--ink-faint)',
  '#2a241c': 'var(--rule)',
  '#e6cc88': 'var(--gold-warm)',
  '#d36e4d': 'var(--signal)',
  '#b86a4a': 'var(--c-decade)',
  '#6b8e6f': 'var(--c-craft)',
  '#4a6b8e': 'var(--c-use)',
};
for (const [hex, tok] of Object.entries(HEX_TO_TOKEN)) {
  // Match the literal hex preceded by : space ( , (typical CSS-value contexts)
  // and followed by ; ) , ! space or end-of-line. Skip occurrences inside :root { } block.
  const re = new RegExp(`([:\\s(,])${hex.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}([\\s,;)!]|$)`, 'g');
  html = html.replace(re, `$1${tok}$2`);
}
// But the :root token defs need their hex preserved. Restore those:
const ROOT_DEFS = {
  '--bg: var(--bg)':              '--bg: #0c0a08',
  '--bg-2: var(--bg-2)':          '--bg-2: #15110d',
  '--ink: var(--ink)':            '--ink: #f4ecd8',
  '--ink-mute: var(--ink-mute)':  '--ink-mute: #a39888',
  '--ink-faint: var(--ink-faint)':'--ink-faint: #5a5246',
  '--rule: var(--rule)':          '--rule: #2a241c',
  '--gold: var(--gold)':          '--gold: #c9b687',
  '--gold-warm: var(--gold-warm)':'--gold-warm: #e6cc88',
  '--signal: var(--signal)':      '--signal: #d36e4d',
  '--c-material: var(--gold)':    '--c-material: #c9b687',
  '--c-decade: var(--c-decade)':  '--c-decade: #b86a4a',
  '--c-craft: var(--c-craft)':    '--c-craft: #6b8e6f',
  '--c-use: var(--c-use)':        '--c-use: #4a6b8e',
};
for (const [from, to] of Object.entries(ROOT_DEFS)) {
  html = html.split(from).join(to);
}
// Light-theme overrides too
html = html.replace(/--bg:\s*var\(--bg\)/g, '--bg: #0c0a08');
// Restore the [data-theme="light"] block
html = html.replace(/\[data-theme="light"\] \{[^}]+\}/, m => m
  .replace(/var\(--bg\)/, '#fafaf6')
  .replace(/var\(--bg-2\)/, '#f0ece1')
  .replace(/var\(--ink\)/, '#1b1814')
  .replace(/var\(--ink-mute\)/, '#6b6357')
  .replace(/var\(--ink-faint\)/, '#b0a890')
  .replace(/var\(--rule\)/, '#e5dfd0')
  .replace(/var\(--gold\)/, '#8a7440')
  .replace(/var\(--gold-warm\)/, '#a6884d')
);

// ─── 4. IntersectionObserver to pause off-screen .node-inner animations ─
const PAUSE_CSS = `
/* Off-screen pause — battery-friendly star-float */
svg#map.paused .node-inner { animation-play-state: paused }
`;
if (!html.includes('svg#map.paused .node-inner')) {
  html = html.replace('/* Star float:', PAUSE_CSS + '\n/* Star float:');
}
const PAUSE_JS = `
// Pause star-float animations when constellation off-screen (perf + battery)
(function () {
  if (!('IntersectionObserver' in window)) return;
  const map = document.getElementById('map');
  if (!map) return;
  const io = new IntersectionObserver((entries) => {
    for (const e of entries) map.classList.toggle('paused', !e.isIntersecting);
  }, { threshold: 0 });
  io.observe(map);
})();
`;
if (!html.includes('Pause star-float animations when constellation off-screen')) {
  // Insert near end of script, just before the buildMap() call
  html = html.replace(/buildMap\(\);\s*<\/script>/, `buildMap();\n${PAUSE_JS}\n</script>`);
}

// ─── 5. P0: IIFE wrap ───────────────────────────────────────────────────
// Wrap the LARGE main script block (not the tiny preconnect/font script).
// Identify by the presence of `const CAT_COLOR` near the top.
if (!html.includes('// IIFE wrap (P0)')) {
  html = html.replace(
    /<script>\s*(const CAT_COLOR = \{)/,
    '<script>\n// IIFE wrap (P0) — keep all 11 features\' state out of the global scope.\n(function () {\n$1'
  );
  html = html.replace(
    /buildMap\(\);\s*\n\s*\/\/ Pause star-float/,
    `buildMap();\n\n// Pause star-float`
  );
  // Close the IIFE before the closing </script>
  html = html.replace(/\}\);\s*\n<\/script>\s*<\/body>/, `});\n})();\n</script>\n</body>`);
}

// ─── Done ───────────────────────────────────────────────────────────────
if (html === before) {
  console.log('no-op (already applied)');
} else {
  fs.writeFileSync(FILE, html);
  console.log('applied:');
  console.log('  type-scale: 13 tiers → 6 (11/13/18/22/32 + clamp() heroes)');
  console.log('  spacing:    14 distinct → 4-base (4/8/12/16/24/32/48/64)');
  console.log('  hex→token:  ~15 brand-color literals replaced');
  console.log('  iobserver:  off-screen .node-inner pause armed');
  console.log('  iife:       script body wrapped (closure)');
  console.log(`  size:       ${before.length} → ${html.length} bytes (${html.length - before.length >= 0 ? '+' : ''}${html.length - before.length})`);
}