/** * Clone Architect โ€” Snapshot Diff Feature (unique moat vs getdesign.md) * * Compare two extractions of the same domain (at different dates) and report * what changed in the design tokens: colors, typography, spacing, components. * * Use case: "What did Linear change in the last 3 months?" * * Usage: * npx tsx scripts/diff-snapshots.ts # latest vs previous in archive/ * npx tsx scripts/diff-snapshots.ts # specific snapshots * npx tsx scripts/diff-snapshots.ts --create # snapshot current state */ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync } from 'fs'; import { join } from 'path'; const ROOT = process.cwd(); const ARCHIVE_DIR = join(ROOT, 'extractions', '_snapshots'); interface DesignTokens { meta?: { extractedAt?: string; domain?: string }; colors?: { background?: { primary?: string; secondary?: string; tertiary?: string }; text?: { primary?: string; secondary?: string; muted?: string }; accent?: any; border?: any; }; typography?: { fontFamily?: { primary?: string; secondary?: string; mono?: string }; fontSize?: Record; fontWeight?: Record; }; spacing?: Record; borderRadius?: Record; shadows?: Record; } function loadTokens(domain: string, snapshot?: string): DesignTokens | null { let path: string; if (snapshot) { path = join(ARCHIVE_DIR, domain, snapshot, 'tokens.json'); } else { path = join(ROOT, 'extractions', domain, 'tokens.json'); } if (!existsSync(path)) return null; return JSON.parse(readFileSync(path, 'utf-8')); } function listSnapshots(domain: string): string[] { const dir = join(ARCHIVE_DIR, domain); if (!existsSync(dir)) return []; return readdirSync(dir).filter(d => /^\d{4}-\d{2}-\d{2}/.test(d)).sort(); } function createSnapshot(domain: string): void { const extractDir = join(ROOT, 'extractions', domain); if (!existsSync(extractDir)) { console.error(`โŒ Extraction not found: ${extractDir}`); process.exit(1); } const date = new Date().toISOString().slice(0, 10); const snapDir = join(ARCHIVE_DIR, domain, date); mkdirSync(snapDir, { recursive: true }); // Copy tokens.json + DESIGN.md for (const file of ['tokens.json', 'DESIGN.md', 'extraction-summary.json']) { const src = join(extractDir, file); if (existsSync(src)) copyFileSync(src, join(snapDir, file)); } console.log(`๐Ÿ“ธ Snapshot created: ${snapDir}`); } function diffValue(label: string, a: any, b: any): string[] { const lines: string[] = []; if (a === b) return lines; if (a === undefined || a === null) { lines.push(` + ${label}: ${JSON.stringify(b)} (added)`); } else if (b === undefined || b === null) { lines.push(` - ${label}: ${JSON.stringify(a)} (removed)`); } else { lines.push(` ~ ${label}: ${JSON.stringify(a)} โ†’ ${JSON.stringify(b)}`); } return lines; } function diffObject(label: string, a: Record = {}, b: Record = {}): string[] { const lines: string[] = []; const keys = new Set([...Object.keys(a), ...Object.keys(b)]); for (const k of keys) { if (a[k] !== b[k]) { lines.push(...diffValue(`${label}.${k}`, a[k], b[k])); } } return lines; } function diffTokens(before: DesignTokens, after: DesignTokens): { summary: { changed: number; added: number; removed: number }; lines: string[]; } { const lines: string[] = []; // Meta const dateBefore = before.meta?.extractedAt?.slice(0, 10) || '?'; const dateAfter = after.meta?.extractedAt?.slice(0, 10) || '?'; lines.push(`\n๐Ÿ“… Comparison: ${dateBefore} โ†’ ${dateAfter}\n`); // Colors const colorChanges: string[] = []; colorChanges.push(...diffObject('background', before.colors?.background, after.colors?.background)); colorChanges.push(...diffObject('text', before.colors?.text, after.colors?.text)); if (typeof before.colors?.accent === 'object' && typeof after.colors?.accent === 'object') { colorChanges.push(...diffObject('accent', before.colors.accent, after.colors.accent)); } if (typeof before.colors?.border === 'object' && typeof after.colors?.border === 'object') { colorChanges.push(...diffObject('border', before.colors.border, after.colors.border)); } if (colorChanges.length > 0) { lines.push('๐ŸŽจ Colors:'); lines.push(...colorChanges); } // Typography const typoChanges: string[] = []; typoChanges.push(...diffObject('fontFamily', before.typography?.fontFamily, after.typography?.fontFamily)); typoChanges.push(...diffObject('fontSize', before.typography?.fontSize, after.typography?.fontSize)); typoChanges.push(...diffObject('fontWeight', before.typography?.fontWeight, after.typography?.fontWeight)); if (typoChanges.length > 0) { lines.push('\n๐Ÿ”ค Typography:'); lines.push(...typoChanges); } // Spacing const spacingChanges = diffObject('spacing', before.spacing, after.spacing); if (spacingChanges.length > 0) { lines.push('\n๐Ÿ“ Spacing:'); lines.push(...spacingChanges); } // Border radius const radiusChanges = diffObject('borderRadius', before.borderRadius, after.borderRadius); if (radiusChanges.length > 0) { lines.push('\nโญ• Border radius:'); lines.push(...radiusChanges); } // Shadows const shadowChanges = diffObject('shadow', before.shadows, after.shadows); if (shadowChanges.length > 0) { lines.push('\n๐Ÿ’ง Shadows:'); lines.push(...shadowChanges); } // Summary let changed = 0, added = 0, removed = 0; for (const l of lines) { if (l.startsWith(' ~ ')) changed++; else if (l.startsWith(' + ')) added++; else if (l.startsWith(' - ')) removed++; } return { summary: { changed, added, removed }, lines, }; } async function main() { const args = process.argv.slice(2); const createMode = args.includes('--create'); if (createMode) { const domain = args[args.indexOf('--create') + 1]; if (!domain) { console.error('Usage: --create '); process.exit(1); } createSnapshot(domain); return; } const domain = args[0]; if (!domain) { console.error(`Usage: npx tsx scripts/diff-snapshots.ts # latest vs previous in archive npx tsx scripts/diff-snapshots.ts # specific snapshots npx tsx scripts/diff-snapshots.ts --create # snapshot current state`); process.exit(1); } const snapshots = listSnapshots(domain); let before: DesignTokens | null = null; let after: DesignTokens | null = null; let beforeLabel = '?', afterLabel = '?'; if (args.length >= 3) { beforeLabel = args[1]; afterLabel = args[2]; before = loadTokens(domain, beforeLabel); after = loadTokens(domain, afterLabel); } else if (snapshots.length >= 1) { // Compare oldest archived snapshot vs current extraction beforeLabel = snapshots[0]; afterLabel = 'current'; before = loadTokens(domain, beforeLabel); after = loadTokens(domain); } else { console.error(`โŒ No snapshots found for ${domain}. Create one with: --create ${domain}`); process.exit(1); } if (!before || !after) { console.error(`โŒ Missing tokens.json (before: ${!!before}, after: ${!!after})`); process.exit(1); } console.log(`\n๐Ÿ” Clone Architect โ€” Diff ${domain}`); console.log(` ${beforeLabel} โ†’ ${afterLabel}\n`); const diff = diffTokens(before, after); console.log(diff.lines.join('\n')); console.log(`\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•`); console.log(`๐Ÿ“Š Summary: ~${diff.summary.changed} changed ยท +${diff.summary.added} added ยท -${diff.summary.removed} removed`); console.log(`โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n`); } main().catch(err => { console.error(err); process.exit(1); });