scripts/slopscan.mjs
scripts/slopscan.mjsBrowse 27 files
6,295 tokens
22,174 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * slopscan.mjs — AI-slop design linter, zero dependencies, read-only4 * ponytail: regex tokenization over CSS blocks, no full parser needed for these rules5 */6import { readFileSync, statSync, readdirSync } from 'node:fs';7import { join, extname, resolve } from 'node:path';8 9const SCAN_EXTS = new Set(['.css','.scss','.html','.jsx','.tsx','.vue','.svelte','.astro','.js','.mjs','.ts']);10// Pure-JS files get only JS-relevant rules — CSS-block heuristics false-positive on JS object literals11const PURE_JS_EXTS = new Set(['.js','.mjs','.ts']);12const SKIP_DIRS = new Set(['node_modules','dist','build','.git','.next','out']);13 14// ── Color helpers ─────────────────────────────────────────────────────────────15 16function rgbToHsl(r, g, b) {17 r /= 255; g /= 255; b /= 255;18 const max = Math.max(r, g, b), min = Math.min(r, g, b);19 let h = 0, s = 0;20 const l = (max + min) / 2;21 if (max !== min) {22 const d = max - min;23 s = l > 0.5 ? d / (2 - max - min) : d / (max + min);24 if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;25 else if (max === g) h = ((b - r) / d + 2) / 6;26 else h = ((r - g) / d + 4) / 6;27 }28 return { h: h * 360, s, l, _rgb: { r, g, b } };29}30 31/** Returns {h, s, l, isOklch?, C?} or null */32function parseColor(tok) {33 tok = tok.trim().replace(/,$/, '');34 // oklch(L C H [/ alpha])35 let m = tok.match(/^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i);36 if (m) {37 const L = parseFloat(m[1]) / (m[1].endsWith('%') ? 100 : 1);38 const C = parseFloat(m[2]);39 const H = parseFloat(m[3]);40 return { h: H, s: C > 0 ? 1 : 0, l: L, isOklch: true, C };41 }42 // hsl/hsla43 m = tok.match(/^hsla?\(\s*([\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?/i);44 if (m) return { h: parseFloat(m[1]), s: parseFloat(m[2]) / 100, l: parseFloat(m[3]) / 100 };45 // rgb/rgba46 m = tok.match(/^rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)/i);47 if (m) return rgbToHsl(+m[1], +m[2], +m[3]);48 // #rrggbbaa / #rrggbb / #rgb / #rgba49 m = tok.match(/^#([0-9a-f]{3,8})$/i);50 if (m) {51 let h = m[1];52 if (h.length === 3 || h.length === 4) h = h.split('').map(c => c + c).join('').slice(0, 6);53 if (h.length > 6) h = h.slice(0, 6);54 if (h.length !== 6) return null;55 return rgbToHsl(parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16));56 }57 return null;58}59 60function isGray(c) {61 if (!c) return true;62 // ponytail: oklch chroma < 0.03 = achromatic; HSL saturation < 0.0563 if (c.isOklch) return (c.C ?? 0) < 0.03;64 return c.s < 0.05;65}66 67// Extract all color tokens from a gradient string68function extractGradientColors(str) {69 const re = /(#[0-9a-f]{3,8}|(?:oklch|rgba?|hsla?)\s*\([^)]+\))/gi;70 const colors = [];71 let m;72 while ((m = re.exec(str)) !== null) {73 const c = parseColor(m[1]);74 if (c) colors.push(c);75 }76 return colors;77}78 79// ── Line number helper ────────────────────────────────────────────────────────80 81function makeLineAt(text) {82 // ponytail: binary search over precomputed newline positions83 const starts = [0];84 for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);85 return pos => {86 let lo = 0, hi = starts.length - 1;87 while (lo < hi) {88 const mid = (lo + hi + 1) >> 1;89 if (starts[mid] <= pos) lo = mid; else hi = mid - 1;90 }91 return lo + 1;92 };93}94 95// ── CSS block extractor ───────────────────────────────────────────────────────96 97/**98 * Extracts leaf CSS rule blocks (those with no nested { }).99 * Returns [{selector, body, startLine}]100 */101function extractCSSBlocks(text) {102 const blocks = [];103 const lineAt = makeLineAt(text);104 const stack = []; // {selector, bodyStart}105 let i = 0, selectorStart = 0;106 107 while (i < text.length) {108 // Skip block comments109 if (text[i] === '/' && text[i+1] === '*') {110 const end = text.indexOf('*/', i + 2);111 i = end === -1 ? text.length : end + 2;112 continue;113 }114 // Skip strings115 if (text[i] === '"' || text[i] === "'") {116 const q = text[i++];117 while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++; }118 i++;119 continue;120 }121 if (text[i] === '{') {122 const sel = text.slice(selectorStart, i).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');123 stack.push({ selector: sel, bodyStart: i + 1, startLine: lineAt(i + 1) });124 selectorStart = i + 1;125 i++;126 continue;127 }128 if (text[i] === '}') {129 const frame = stack.pop();130 if (frame) {131 const body = text.slice(frame.bodyStart, i);132 if (!/{/.test(body)) { // leaf block133 const fullSel = [...stack.map(f => f.selector), frame.selector].filter(Boolean).join(' ');134 blocks.push({ selector: fullSel || frame.selector, body, startLine: frame.startLine });135 }136 }137 selectorStart = i + 1;138 i++;139 continue;140 }141 i++;142 }143 return blocks;144}145 146// ── Suppression parser ────────────────────────────────────────────────────────147 148function parseSuppressions(text) {149 const lineAt = makeLineAt(text);150 const suppressions = new Map();151 const patterns = [152 /\/\*\s*auteur-allow:\s*(\w+)\s*--\s*([\s\S]*?)\*\//g,153 /\/\/\s*auteur-allow:\s*(\w+)\s*--(.*)/gm,154 /<!--\s*auteur-allow:\s*(\w+)\s*--\s*(.*?)-->/g,155 ];156 for (const re of patterns) {157 let m;158 while ((m = re.exec(text)) !== null) {159 const ruleId = m[1].trim();160 const reason = m[2].trim();161 const reasonOk = reason.replace(/\s+/g, '').length >= 10;162 const line = lineAt(m.index);163 if (!suppressions.has(ruleId)) suppressions.set(ruleId, { line, reasonOk, reason });164 }165 }166 return suppressions;167}168 169// ── FAIL rules ────────────────────────────────────────────────────────────────170 171function checkFontDefaultSlop(text, findings) {172 const lineAt = makeLineAt(text);173 // CSS font-family declarations174 const re = /font-family\s*:\s*([^\n;{}]+)/gi;175 let m;176 while ((m = re.exec(text)) !== null) {177 const first = m[1].split(',')[0].trim().replace(/['"]/g, '').trim();178 if (/^inter$/i.test(first) || /^space grotesk$/i.test(first)) {179 findings.push({ rule: 'FONT_DEFAULT_SLOP', severity: 'fail', line: lineAt(m.index),180 detail: `font-family first family is "${first}"` });181 }182 }183 // Tailwind fontFamily config: fontFamily: { key: ['Inter', ...] }184 const twRe = /fontFamily\s*:\s*\{[^}]*\}/gs;185 while ((m = twRe.exec(text)) !== null) {186 const block = m[0];187 const entryRe = /:\s*\[['"]([^'"]+)['"]/g;188 let em;189 while ((em = entryRe.exec(block)) !== null) {190 const first = em[1].trim();191 if (/^inter$/i.test(first) || /^space grotesk$/i.test(first)) {192 findings.push({ rule: 'FONT_DEFAULT_SLOP', severity: 'fail', line: lineAt(m.index),193 detail: `Tailwind fontFamily first entry is "${first}"` });194 }195 }196 }197}198 199function checkAiGradient(text, findings) {200 const lineAt = makeLineAt(text);201 const re = /(?:linear|radial|conic)-gradient\s*\(/gi;202 let m;203 while ((m = re.exec(text)) !== null) {204 // Extract balanced parens205 let depth = 0, j = m.index + m[0].length - 1;206 const start = j;207 while (j < text.length) {208 if (text[j] === '(') depth++;209 else if (text[j] === ')') { depth--; if (depth === 0) break; }210 j++;211 }212 const gradStr = text.slice(start, j + 1);213 const colors = extractGradientColors(gradStr);214 const aiColors = colors.filter(c => !isGray(c) && c.h >= 250 && c.h <= 290);215 if (aiColors.length >= 2) {216 findings.push({ rule: 'AI_GRADIENT', severity: 'fail', line: lineAt(m.index),217 detail: `${aiColors.length} gradient stops in hue 250–290 (purple-blue AI default)` });218 }219 }220}221 222function checkGradientText(blocks, findings) {223 const gradRe = /(?:linear|radial|conic)-gradient/i;224 const clipRe = /-webkit-background-clip\s*:\s*text|background-clip\s*:\s*text|-webkit-text-fill-color\s*:\s*transparent/i;225 for (const block of blocks) {226 if (gradRe.test(block.body) && clipRe.test(block.body)) {227 findings.push({ rule: 'GRADIENT_TEXT', severity: 'fail', line: block.startLine,228 detail: `gradient + background-clip:text (or -webkit-text-fill-color:transparent) in same block` });229 }230 }231}232 233function checkTransitionAll(text, findings) {234 const lineAt = makeLineAt(text);235 const re = /transition\s*:\s*all\b/gi;236 let m;237 while ((m = re.exec(text)) !== null) {238 findings.push({ rule: 'TRANSITION_ALL', severity: 'fail', line: lineAt(m.index),239 detail: `transition: all — use specific properties instead` });240 }241}242 243function checkRawScrollListener(text, findings) {244 const lineAt = makeLineAt(text);245 let m;246 const re = /addEventListener\s*\(\s*['"]scroll['"]/g;247 while ((m = re.exec(text)) !== null) {248 findings.push({ rule: 'RAW_SCROLL_LISTENER', severity: 'fail', line: lineAt(m.index),249 detail: `addEventListener('scroll') — use IntersectionObserver or animation-timeline` });250 }251 const re2 = /\bonscroll\s*=/g;252 while ((m = re2.exec(text)) !== null) {253 findings.push({ rule: 'RAW_SCROLL_LISTENER', severity: 'fail', line: lineAt(m.index),254 detail: `onscroll= attribute — use IntersectionObserver or animation-timeline` });255 }256}257 258// ── WARN rules ────────────────────────────────────────────────────────────────259 260function checkGlassCard(blocks, findings) {261 for (const block of blocks) {262 if (!/card|tile|panel/i.test(block.selector)) continue;263 if (/backdrop-filter\s*:\s*blur\s*\(/i.test(block.body)) {264 findings.push({ rule: 'GLASS_CARD', severity: 'warn', line: block.startLine,265 detail: `backdrop-filter:blur() in "${block.selector.slice(0, 60)}"` });266 }267 }268}269 270function checkCardCloneGrid(blocks, findings) {271 const getTriple = body => {272 const get = prop => { const m = body.match(new RegExp(prop + '\\s*:\\s*([^;\\n]+)')); return m ? m[1].trim() : null; };273 const br = get('border-radius'), p = get('padding'), bs = get('box-shadow');274 return br && p && bs ? `${br}|${p}|${bs}` : null;275 };276 const seen = new Map();277 for (const block of blocks) {278 const key = getTriple(block.body);279 if (!key) continue;280 if (!seen.has(key)) seen.set(key, []);281 seen.get(key).push(block.startLine);282 }283 for (const [, lines] of seen) {284 if (lines.length >= 4) {285 findings.push({ rule: 'CARD_CLONE_GRID', severity: 'warn', line: lines[0],286 detail: `${lines.length} blocks share identical border-radius/padding/box-shadow (lines ${lines.join(', ')})` });287 }288 }289}290 291// Ban #16 is "em-dash-HEAVY sentences" — a density property, not the presence of the glyph.292// Flagging every occurrence trains you to suppress the rule, which is how a linter loses its293// authority; and the old remediation ("use —") renders the identical character, so294// following it changed nothing the rule claimed to detect.295const PROSE_EXT = new Set(['.html', '.htm', '.md', '.jsx', '.tsx', '.vue', '.svelte']);296function checkEmDashCopy(text, ext, findings) {297 if (!PROSE_EXT.has(ext)) return;298 // Keep only what a visitor actually reads: no script/style, no comments, no <title>/<meta>.299 const prose = text300 .replace(/<script[\s\S]*?<\/script>/gi, ' ')301 .replace(/<style[\s\S]*?<\/style>/gi, ' ')302 .replace(/<title[\s\S]*?<\/title>/gi, ' ')303 .replace(/<meta[^>]*>/gi, ' ')304 // Headings, terms and captions are LABELS, not sentences. "-200m — The Blue" is a dash doing305 // exactly the job a dash should do, and counting it made the rule argue against good typography.306 .replace(/<(h[1-6]|dt|summary|figcaption|legend|caption|th)\b[\s\S]*?<\/\1>/gi, ' ')307 .replace(/<!--[\s\S]*?-->/g, ' ')308 .replace(/\/\*[\s\S]*?\*\//g, ' ')309 .replace(/(^|\s)\/\/[^\n]*/g, ' ')310 .replace(/<[^>]+>/g, ' ');311 312 // Entity-encoded dashes render the identical glyph. Counting only the literal character meant the313 // rule was defeated by `—` — the exact dodge the comment above says the old advice suffered314 // from. Decode first, then count.315 const decoded = prose316 .replace(/—|—|&#[xX]2014;/g, '—')317 .replace(/–|–|&#[xX]2013;/g, '–')318 .replace(/…|…/g, '…')319 .replace(/ | /g, ' ');320 const dashes = (decoded.match(/—/g) || []).length;321 if (dashes < 3) return;322 const sentences = (decoded.match(/[.!?]["')\]]?(\s|$)/g) || []).length || 1;323 const words = (decoded.match(/\S+/g) || []).length || 1;324 const perSentence = dashes / sentences;325 const per100w = (dashes / words) * 100;326 // One criterion, because the ban is about the dash replacing sentence structure: if more than327 // half your sentences carry an em dash, it is structural. A per-100-words test looked reasonable328 // and flagged prose with one dash every four sentences, which is just writing.329 if (perSentence < 0.5) return;330 331 const lineAt = makeLineAt(text);332 const first = text.indexOf('—');333 findings.push({334 rule: 'EM_DASH_COPY', severity: 'warn', line: lineAt(first < 0 ? 0 : first),335 detail: `${dashes} em dashes across ${sentences} sentences / ${words} words of visible copy (${perSentence.toFixed(2)}/sentence, ${per100w.toFixed(1)} per 100 words) — the dash is doing the work sentence structure should. Rewrite the densest ones as full sentences; an occasional em dash is fine.`,336 });337}338 339function checkEyebrowEverywhere(blocks, findings) {340 const matched = [];341 for (const block of blocks) {342 if (!/text-transform\s*:\s*uppercase/i.test(block.body)) continue;343 const lsM = block.body.match(/letter-spacing\s*:\s*([\d.]+)(em|rem)/i);344 if (!lsM || parseFloat(lsM[1]) < 0.05) continue;345 const fsM = block.body.match(/font-size\s*:\s*([\d.]+)(rem|px|em)/i);346 if (!fsM) continue;347 const fs = parseFloat(fsM[1]), unit = fsM[2].toLowerCase();348 const fsRem = unit === 'px' ? fs / 16 : fs;349 if (fsRem > 0.875) continue;350 matched.push(block.startLine);351 }352 if (matched.length > 3) {353 findings.push({ rule: 'EYEBROW_EVERYWHERE', severity: 'warn', line: matched[0],354 detail: `${matched.length} blocks with text-transform:uppercase + letter-spacing≥0.05em + font-size≤0.875rem` });355 }356}357 358function checkCreamDefault(blocks, findings) {359 for (const block of blocks) {360 if (!/^(?::root|body|html)\b/i.test(block.selector.trim())) continue;361 const bgM = block.body.match(/background(?:-color)?\s*:\s*([^\n;]+)/i);362 if (!bgM) continue;363 const val = bgM[1].trim().split(/\s+/)[0];364 const c = parseColor(val);365 if (!c) continue;366 367 let inBand = false;368 if (c.isOklch) {369 inBand = c.l >= 0.84 && c.l <= 0.97 && (c.C ?? 0) < 0.06 && c.h >= 40 && c.h <= 100;370 } else {371 // ponytail: sRGB→OKLCH approximation via HSL; check lightness, small chroma gap, warm hue372 // max-min distance in [0,1] maps roughly to OKLCH chroma < 0.06 for near-white colors373 const rgb = c._rgb;374 if (!rgb) continue;375 const dRgb = Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);376 inBand = c.l >= 0.84 && c.l <= 0.97 && dRgb > 0.002 && dRgb < 0.08 && c.h >= 30 && c.h <= 110;377 }378 if (inBand) {379 findings.push({ rule: 'CREAM_DEFAULT', severity: 'warn', line: block.startLine,380 detail: `body/:root background "${val}" is in warm-cream default band` });381 }382 }383}384 385// ── Tier-1 motion / WebGL / audio rules ─────────────────────────────────────────386 387function checkAutoplaySound(text, findings) {388 const lineAt = makeLineAt(text);389 const re = /<(audio|video)\b([^>]*)>/gi;390 let m;391 while ((m = re.exec(text)) !== null) {392 if (/\bautoplay\b/i.test(m[2]) && !/\bmuted\b/i.test(m[2])) {393 findings.push({ rule: 'AUTOPLAY_SOUND', severity: 'fail', line: lineAt(m.index),394 detail: `<${m[1].toLowerCase()} autoplay> without muted — sound must start on a user gesture` });395 }396 }397}398 399function checkVideoNoPoster(text, findings) {400 const lineAt = makeLineAt(text);401 const re = /<video\b([^>]*)>/gi;402 let m;403 while ((m = re.exec(text)) !== null) {404 if (!/\bposter\s*=/i.test(m[1])) {405 findings.push({ rule: 'VIDEO_NO_POSTER', severity: 'warn', line: lineAt(m.index),406 detail: `<video> without poster — blank hero until the clip decodes` });407 }408 }409}410 411function checkWebglNoReducedMotion(text, findings) {412 const lineAt = makeLineAt(text);413 const ctxRe = /new\s+THREE\.WebGLRenderer|getContext\s*\(\s*['"](?:webgl2?|webgpu)['"]|new\s+GPUDevice|new\s+OGLRenderer/;414 const cm = ctxRe.exec(text);415 if (!cm) return;416 if (!/prefers-reduced-motion/i.test(text)) {417 findings.push({ rule: 'WEBGL_NO_REDUCED_MOTION', severity: 'warn', line: lineAt(cm.index),418 detail: `WebGL/WebGPU scene with no prefers-reduced-motion branch — reduced-motion is an alternative art direction, not an afterthought` });419 }420}421 422function checkPointerNoRaf(text, findings) {423 const lineAt = makeLineAt(text);424 const pm = /addEventListener\s*\(\s*['"](?:pointermove|mousemove)['"]/.exec(text);425 if (!pm) return;426 if (!/requestAnimationFrame/.test(text)) {427 findings.push({ rule: 'POINTER_NO_RAF', severity: 'warn', line: lineAt(pm.index),428 detail: `pointermove/mousemove handler with no requestAnimationFrame — throttle DOM/uniform writes to rAF, never per-event` });429 }430}431 432// ── File scanner ──────────────────────────────────────────────────────────────433 434function scanFile(filePath) {435 const text = readFileSync(filePath, 'utf8');436 const ext = extname(filePath).toLowerCase();437 const suppressions = parseSuppressions(text);438 const findings = [];439 const blocks = extractCSSBlocks(text);440 441 if (PURE_JS_EXTS.has(ext)) {442 checkTransitionAll(text, findings);443 checkRawScrollListener(text, findings);444 checkWebglNoReducedMotion(text, findings);445 checkPointerNoRaf(text, findings);446 } else {447 checkFontDefaultSlop(text, findings);448 checkAiGradient(text, findings);449 checkGradientText(blocks, findings);450 checkTransitionAll(text, findings);451 checkRawScrollListener(text, findings);452 checkGlassCard(blocks, findings);453 checkCardCloneGrid(blocks, findings);454 checkEmDashCopy(text, ext, findings);455 checkEyebrowEverywhere(blocks, findings);456 checkCreamDefault(blocks, findings);457 checkAutoplaySound(text, findings);458 checkVideoNoPoster(text, findings);459 checkWebglNoReducedMotion(text, findings);460 checkPointerNoRaf(text, findings);461 }462 463 const result = findings.map(f => {464 const sup = suppressions.get(f.rule);465 return { ...f, suppressed: !!(sup && sup.reasonOk) };466 });467 468 // Invalid suppressions: present but reason too short469 for (const [ruleId, sup] of suppressions) {470 if (!sup.reasonOk) {471 result.push({ rule: 'SUPPRESSION_NO_REASON', severity: 'warn', line: sup.line,472 detail: `auteur-allow: ${ruleId} — reason too short (need 10+ non-whitespace chars)`, suppressed: false });473 }474 }475 476 return { path: filePath, findings: result };477}478 479// ── File walker ───────────────────────────────────────────────────────────────480 481function walk(entry) {482 const files = [];483 let stat;484 try { stat = statSync(entry); } catch { return files; }485 if (stat.isFile()) {486 if (SCAN_EXTS.has(extname(entry).toLowerCase())) files.push(entry);487 return files;488 }489 for (const name of readdirSync(entry)) {490 if (SKIP_DIRS.has(name)) continue;491 const full = join(entry, name);492 try {493 const s = statSync(full);494 if (s.isDirectory()) files.push(...walk(full));495 else if (SCAN_EXTS.has(extname(name).toLowerCase())) files.push(full);496 } catch { /* skip unreadable */ }497 }498 return files;499}500 501// ── Main ──────────────────────────────────────────────────────────────────────502 503function main() {504 const argv = process.argv.slice(2);505 const jsonMode = argv.includes('--json');506 const target = argv.find(a => !a.startsWith('-'));507 508 if (!target) {509 process.stderr.write('Usage: node slopscan.mjs <dir-or-file> [--json]\n');510 process.exit(2);511 }512 513 const rootDir = resolve(target);514 const files = walk(rootDir);515 const fileResults = files.map(f => scanFile(f));516 517 let totalFails = 0, totalWarns = 0, totalSuppressed = 0;518 for (const fr of fileResults) {519 for (const f of fr.findings) {520 if (f.suppressed) totalSuppressed++;521 else if (f.severity === 'fail') totalFails++;522 else totalWarns++;523 }524 }525 526 if (jsonMode) {527 process.stdout.write(JSON.stringify({ files: fileResults, summary: { fails: totalFails, warns: totalWarns, suppressed: totalSuppressed } }, null, 2) + '\n');528 } else {529 for (const fr of fileResults) {530 if (!fr.findings.length) continue;531 const rel = fr.path.replace(rootDir, '').replace(/^[\\/]/, '');532 for (const f of fr.findings) {533 const tag = f.suppressed ? 'SKIP' : f.severity.toUpperCase();534 process.stdout.write(`${tag} ${f.rule} ${rel}:${f.line} — ${f.detail}\n`);535 }536 }537 process.stdout.write(`\nSummary: ${totalFails} fails, ${totalWarns} warns, ${totalSuppressed} suppressed\n`);538 }539 540 process.exit(totalFails > 0 ? 1 : 0);541}542 543main();544 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 166.SKILL.mdView in source ↗166- **Unverified commands**: the scripts pass `node --check` syntax validation, but full runs (which need `npm install playwright` + a chromium download) were not executed during porting. Treat `shoot.mjs`, `motionqa.mjs`, `systemscan.mjs`, `refscout.mjs`, `chromadiff.mjs`, `moodboard.mjs`, `source.mjs` end-to-end behavior, and all `ffmpeg`/video-encode recipes, as unverified upstream claims until you run them yourself.167- **slopscan verified shape**: `node scripts/slopscan.mjs <dir>` runs without npm deps; it prints per-rule findings and exits non-zero on failures (exit 0 when clean).168- **Font metadata cache**: `source.mjs font …` caches Google Fonts' ~2.6MB metadata JSON as `auteur-gf-metadata.json` in the OS temp directory (`os.tmpdir()`), not the project; delete it there to force a refresh.
Source excerpt starting at line 174.1741. `node scripts/slopscan.mjs <src-dir>` exits 0 (fails are fixed, not suppressed — `/* auteur-allow: RULE_ID -- reason */` exists for deliberate choices and demands a real reason);1752. `node scripts/shoot.mjs <url>` has produced screenshot journeys at 390 / 768 / 1440 and you have **looked at every frame** — text overflow, blank scenes, broken reveals, layout collapse are found by eyes, not by text search;