mirror of
https://github.com/Manavarya09/design-extract.git
synced 2026-09-19 02:41:14 +08:00
feat: multi-page crawling with --depth flag
Adds ability to crawl multiple internal pages and merge styles across the site for a more complete design system extraction. Follows up to 15 internal links per crawl. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+125
-20
@@ -1,9 +1,11 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const MAX_ELEMENTS = 5000;
|
||||
|
||||
export async function crawlPage(url, options = {}) {
|
||||
const { width = 1280, height = 800, wait = 0, dark = false } = options;
|
||||
const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '' } = options;
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
@@ -14,12 +16,32 @@ export async function crawlPage(url, options = {}) {
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
if (wait > 0) await page.waitForTimeout(wait);
|
||||
|
||||
// Wait for fonts to load
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const title = await page.title();
|
||||
const lightData = await extractPageData(page);
|
||||
|
||||
// Component screenshots
|
||||
let componentScreenshots = {};
|
||||
if (screenshots && outDir) {
|
||||
componentScreenshots = await captureComponentScreenshots(page, outDir);
|
||||
}
|
||||
|
||||
// Multi-page crawl: discover internal links and extract from them
|
||||
let additionalPages = [];
|
||||
if (depth > 0) {
|
||||
const internalLinks = await discoverInternalLinks(page, url, depth);
|
||||
for (const link of internalLinks) {
|
||||
try {
|
||||
await page.goto(link, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const pageData = await extractPageData(page);
|
||||
additionalPages.push({ url: link, data: pageData });
|
||||
} catch { /* skip failed pages */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode extraction
|
||||
let darkData = null;
|
||||
if (dark) {
|
||||
await context.close();
|
||||
@@ -32,12 +54,104 @@ export async function crawlPage(url, options = {}) {
|
||||
await darkPage.evaluate(() => document.fonts.ready);
|
||||
darkData = await extractPageData(darkPage);
|
||||
await darkContext.close();
|
||||
} else {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const title = await page.title();
|
||||
await browser.close();
|
||||
|
||||
return { url, title, light: lightData, dark: darkData };
|
||||
// Merge additional page data into light data
|
||||
if (additionalPages.length > 0) {
|
||||
lightData.computedStyles = mergeStyles(lightData.computedStyles, additionalPages);
|
||||
for (const ap of additionalPages) {
|
||||
Object.assign(lightData.cssVariables, ap.data.cssVariables);
|
||||
lightData.mediaQueries.push(...ap.data.mediaQueries);
|
||||
lightData.keyframes.push(...ap.data.keyframes);
|
||||
}
|
||||
// Deduplicate media queries and keyframes
|
||||
lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
|
||||
const seenKf = new Set();
|
||||
lightData.keyframes = lightData.keyframes.filter(kf => {
|
||||
if (seenKf.has(kf.name)) return false;
|
||||
seenKf.add(kf.name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url, title,
|
||||
light: lightData,
|
||||
dark: darkData,
|
||||
pagesAnalyzed: 1 + additionalPages.length,
|
||||
componentScreenshots,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeStyles(primary, additionalPages) {
|
||||
// Add styles from additional pages, capping total
|
||||
const all = [...primary];
|
||||
for (const ap of additionalPages) {
|
||||
if (all.length >= MAX_ELEMENTS * 2) break;
|
||||
all.push(...ap.data.computedStyles);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function discoverInternalLinks(page, baseUrl, maxLinks) {
|
||||
const base = new URL(baseUrl);
|
||||
const links = await page.evaluate((hostname) => {
|
||||
return Array.from(document.querySelectorAll('a[href]'))
|
||||
.map(a => a.href)
|
||||
.filter(href => {
|
||||
try {
|
||||
const u = new URL(href);
|
||||
return u.hostname === hostname && !href.includes('#') && !href.match(/\.(png|jpg|jpeg|gif|svg|pdf|zip|mp4|mp3)$/i);
|
||||
} catch { return false; }
|
||||
});
|
||||
}, base.hostname);
|
||||
|
||||
// Deduplicate and limit
|
||||
const unique = [...new Set(links)].filter(l => l !== baseUrl);
|
||||
return unique.slice(0, Math.min(maxLinks * 3, 15)); // crawl up to 15 pages max
|
||||
}
|
||||
|
||||
export async function captureComponentScreenshots(page, outDir) {
|
||||
const screenshotDir = join(outDir, 'screenshots');
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
|
||||
const result = {};
|
||||
|
||||
// Find representative elements for each component type
|
||||
const selectors = [
|
||||
{ name: 'button', selector: 'button:not(:empty), a[role="button"], [class*="btn"]:not(:empty)', label: 'Buttons' },
|
||||
{ name: 'card', selector: '[class*="card"]:not(:empty)', label: 'Cards' },
|
||||
{ name: 'input', selector: 'input[type="text"], input[type="email"], input[type="search"], textarea', label: 'Inputs' },
|
||||
{ name: 'nav', selector: 'nav, [role="navigation"]', label: 'Navigation' },
|
||||
{ name: 'hero', selector: '[class*="hero"], section:first-of-type', label: 'Hero Section' },
|
||||
];
|
||||
|
||||
for (const { name, selector, label } of selectors) {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (el) {
|
||||
const box = await el.boundingBox();
|
||||
if (box && box.width > 20 && box.height > 10) {
|
||||
const path = join(screenshotDir, `${name}.png`);
|
||||
await el.screenshot({ path });
|
||||
result[name] = { path: `screenshots/${name}.png`, label };
|
||||
}
|
||||
}
|
||||
} catch { /* skip if screenshot fails */ }
|
||||
}
|
||||
|
||||
// Full page screenshot
|
||||
try {
|
||||
const fullPath = join(screenshotDir, 'full-page.png');
|
||||
await page.screenshot({ path: fullPath, fullPage: true });
|
||||
result.fullPage = { path: 'screenshots/full-page.png', label: 'Full Page' };
|
||||
} catch { /* skip */ }
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function extractPageData(page) {
|
||||
@@ -49,7 +163,6 @@ async function extractPageData(page) {
|
||||
keyframes: [],
|
||||
};
|
||||
|
||||
// 1. Walk all elements and collect computed styles
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const elements = allElements.length > maxElements
|
||||
? Array.from(allElements).slice(0, maxElements)
|
||||
@@ -60,16 +173,11 @@ async function extractPageData(page) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const classList = Array.from(el.classList).join(' ');
|
||||
const role = el.getAttribute('role') || '';
|
||||
|
||||
// Get bounding rect for area estimation
|
||||
const rect = el.getBoundingClientRect();
|
||||
const area = rect.width * rect.height;
|
||||
|
||||
results.computedStyles.push({
|
||||
tag,
|
||||
classList,
|
||||
role,
|
||||
area,
|
||||
tag, classList, role, area,
|
||||
color: cs.color,
|
||||
backgroundColor: cs.backgroundColor,
|
||||
backgroundImage: cs.backgroundImage,
|
||||
@@ -98,9 +206,8 @@ async function extractPageData(page) {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract CSS custom properties from :root
|
||||
// CSS custom properties
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
// Get all custom properties by iterating stylesheets
|
||||
try {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
@@ -114,12 +221,10 @@ async function extractPageData(page) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin stylesheet, skip */ }
|
||||
} catch { /* cross-origin */ }
|
||||
}
|
||||
} catch { /* no stylesheets accessible */ }
|
||||
} catch { /* no access */ }
|
||||
|
||||
// Also get any custom properties from the computed style
|
||||
// (fallback for CSS-in-JS that sets vars on :root)
|
||||
for (let i = 0; i < rootStyles.length; i++) {
|
||||
const prop = rootStyles[i];
|
||||
if (prop.startsWith('--') && !results.cssVariables[prop]) {
|
||||
@@ -127,7 +232,7 @@ async function extractPageData(page) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract media queries from stylesheets
|
||||
// Media queries
|
||||
try {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
@@ -140,7 +245,7 @@ async function extractPageData(page) {
|
||||
}
|
||||
} catch { /* no access */ }
|
||||
|
||||
// 4. Extract keyframes
|
||||
// Keyframes
|
||||
try {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// Design diff engine — compare two design systems
|
||||
|
||||
export function diffDesigns(designA, designB) {
|
||||
const diff = { urlA: designA.meta.url, urlB: designB.meta.url, sections: [] };
|
||||
|
||||
// Color diff
|
||||
const colorDiff = {
|
||||
name: 'Colors',
|
||||
onlyA: [], onlyB: [], shared: [], changed: [],
|
||||
};
|
||||
const hexesA = new Set(designA.colors.all.map(c => c.hex));
|
||||
const hexesB = new Set(designB.colors.all.map(c => c.hex));
|
||||
for (const h of hexesA) { if (!hexesB.has(h)) colorDiff.onlyA.push(h); }
|
||||
for (const h of hexesB) { if (!hexesA.has(h)) colorDiff.onlyB.push(h); }
|
||||
for (const h of hexesA) { if (hexesB.has(h)) colorDiff.shared.push(h); }
|
||||
|
||||
// Primary color comparison
|
||||
if (designA.colors.primary && designB.colors.primary && designA.colors.primary.hex !== designB.colors.primary.hex) {
|
||||
colorDiff.changed.push({ property: 'primary', a: designA.colors.primary.hex, b: designB.colors.primary.hex });
|
||||
}
|
||||
if (designA.colors.secondary && designB.colors.secondary && designA.colors.secondary.hex !== designB.colors.secondary.hex) {
|
||||
colorDiff.changed.push({ property: 'secondary', a: designA.colors.secondary.hex, b: designB.colors.secondary.hex });
|
||||
}
|
||||
diff.sections.push(colorDiff);
|
||||
|
||||
// Typography diff
|
||||
const typeDiff = { name: 'Typography', onlyA: [], onlyB: [], shared: [], changed: [] };
|
||||
const fontsA = new Set(designA.typography.families.map(f => f.name));
|
||||
const fontsB = new Set(designB.typography.families.map(f => f.name));
|
||||
for (const f of fontsA) { if (!fontsB.has(f)) typeDiff.onlyA.push(f); }
|
||||
for (const f of fontsB) { if (!fontsA.has(f)) typeDiff.onlyB.push(f); }
|
||||
for (const f of fontsA) { if (fontsB.has(f)) typeDiff.shared.push(f); }
|
||||
diff.sections.push(typeDiff);
|
||||
|
||||
// Spacing diff
|
||||
const spaceDiff = { name: 'Spacing', changed: [] };
|
||||
if (designA.spacing.base !== designB.spacing.base) {
|
||||
spaceDiff.changed.push({ property: 'base unit', a: `${designA.spacing.base}px`, b: `${designB.spacing.base}px` });
|
||||
}
|
||||
spaceDiff.countA = designA.spacing.scale.length;
|
||||
spaceDiff.countB = designB.spacing.scale.length;
|
||||
diff.sections.push(spaceDiff);
|
||||
|
||||
// Accessibility diff
|
||||
if (designA.accessibility && designB.accessibility) {
|
||||
diff.sections.push({
|
||||
name: 'Accessibility',
|
||||
changed: [{ property: 'WCAG score', a: `${designA.accessibility.score}%`, b: `${designB.accessibility.score}%` }],
|
||||
});
|
||||
}
|
||||
|
||||
// Component diff
|
||||
const compDiff = { name: 'Components', onlyA: [], onlyB: [], shared: [] };
|
||||
const compsA = new Set(Object.keys(designA.components));
|
||||
const compsB = new Set(Object.keys(designB.components));
|
||||
for (const c of compsA) { if (!compsB.has(c)) compDiff.onlyA.push(c); }
|
||||
for (const c of compsB) { if (!compsA.has(c)) compDiff.onlyB.push(c); }
|
||||
for (const c of compsA) { if (compsB.has(c)) compDiff.shared.push(c); }
|
||||
diff.sections.push(compDiff);
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
export function formatDiffMarkdown(diff) {
|
||||
const lines = [];
|
||||
lines.push(`# Design Comparison`);
|
||||
lines.push('');
|
||||
lines.push(`| | Site A | Site B |`);
|
||||
lines.push(`|---|--------|--------|`);
|
||||
lines.push(`| URL | ${diff.urlA} | ${diff.urlB} |`);
|
||||
lines.push('');
|
||||
|
||||
for (const section of diff.sections) {
|
||||
lines.push(`## ${section.name}`);
|
||||
lines.push('');
|
||||
|
||||
if (section.changed && section.changed.length > 0) {
|
||||
lines.push('### Differences');
|
||||
lines.push('');
|
||||
lines.push('| Property | Site A | Site B |');
|
||||
lines.push('|----------|--------|--------|');
|
||||
for (const c of section.changed) {
|
||||
lines.push(`| ${c.property} | \`${c.a}\` | \`${c.b}\` |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (section.onlyA && section.onlyA.length > 0) {
|
||||
lines.push(`**Only in Site A:** ${section.onlyA.map(v => `\`${v}\``).join(', ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
if (section.onlyB && section.onlyB.length > 0) {
|
||||
lines.push(`**Only in Site B:** ${section.onlyB.map(v => `\`${v}\``).join(', ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
if (section.shared && section.shared.length > 0) {
|
||||
lines.push(`**Shared:** ${section.shared.map(v => `\`${v}\``).join(', ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatDiffHtml(diff) {
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>Design Comparison</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:-apple-system,sans-serif; background:#0a0a0a; color:#e5e5e5; padding:40px; }
|
||||
h1 { font-size:32px; color:#fff; margin-bottom:24px; }
|
||||
h2 { font-size:20px; color:#fff; margin:32px 0 16px; border-bottom:1px solid #222; padding-bottom:8px; }
|
||||
.urls { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:32px; }
|
||||
.url-card { background:#141414; border:1px solid #222; border-radius:12px; padding:16px; }
|
||||
.url-card h3 { font-size:12px; color:#666; margin-bottom:4px; }
|
||||
.url-card a { color:#3b82f6; font-size:14px; }
|
||||
.diff-row { display:grid; grid-template-columns:120px 1fr 1fr; gap:12px; padding:10px 16px; border-radius:8px; margin-bottom:4px; }
|
||||
.diff-row:nth-child(odd) { background:#111; }
|
||||
.diff-label { color:#888; font-size:13px; }
|
||||
.diff-val { font-family:monospace; font-size:13px; }
|
||||
.swatch-inline { display:inline-block; width:14px; height:14px; border-radius:3px; vertical-align:middle; margin-right:6px; border:1px solid #333; }
|
||||
.only-a { color:#f97316; } .only-b { color:#8b5cf6; } .shared { color:#22c55e; }
|
||||
.tag { display:inline-block; font-size:12px; padding:2px 8px; border-radius:4px; margin:2px; }
|
||||
.tag-a { background:#f9731620; color:#f97316; }
|
||||
.tag-b { background:#8b5cf620; color:#8b5cf6; }
|
||||
.tag-shared { background:#22c55e20; color:#22c55e; }
|
||||
</style></head><body>
|
||||
<h1>Design Comparison</h1>
|
||||
<div class="urls">
|
||||
<div class="url-card"><h3>Site A</h3><a href="${diff.urlA}">${diff.urlA}</a></div>
|
||||
<div class="url-card"><h3>Site B</h3><a href="${diff.urlB}">${diff.urlB}</a></div>
|
||||
</div>
|
||||
${diff.sections.map(s => `
|
||||
<h2>${s.name}</h2>
|
||||
${s.changed && s.changed.length > 0 ? s.changed.map(c => `
|
||||
<div class="diff-row">
|
||||
<span class="diff-label">${c.property}</span>
|
||||
<span class="diff-val">${c.a.startsWith('#') ? `<span class="swatch-inline" style="background:${c.a}"></span>` : ''}${c.a}</span>
|
||||
<span class="diff-val">${c.b.startsWith('#') ? `<span class="swatch-inline" style="background:${c.b}"></span>` : ''}${c.b}</span>
|
||||
</div>`).join('') : ''}
|
||||
${s.onlyA && s.onlyA.length > 0 ? `<p style="margin:8px 0"><span class="only-a">Only in A:</span> ${s.onlyA.slice(0, 15).map(v => `<span class="tag tag-a">${v.startsWith('#') ? `<span class="swatch-inline" style="background:${v}"></span>` : ''}${v}</span>`).join('')}</p>` : ''}
|
||||
${s.onlyB && s.onlyB.length > 0 ? `<p style="margin:8px 0"><span class="only-b">Only in B:</span> ${s.onlyB.slice(0, 15).map(v => `<span class="tag tag-b">${v.startsWith('#') ? `<span class="swatch-inline" style="background:${v}"></span>` : ''}${v}</span>`).join('')}</p>` : ''}
|
||||
${s.shared && s.shared.length > 0 ? `<p style="margin:8px 0"><span class="shared">Shared:</span> ${s.shared.slice(0, 15).map(v => `<span class="tag tag-shared">${v.startsWith('#') ? `<span class="swatch-inline" style="background:${v}"></span>` : ''}${v}</span>`).join('')}</p>` : ''}
|
||||
`).join('')}
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { parseColor, rgbToHex } from '../utils.js';
|
||||
|
||||
// WCAG 2.1 relative luminance
|
||||
function luminance({ r, g, b }) {
|
||||
const [rs, gs, bs] = [r, g, b].map(c => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
function contrastRatio(c1, c2) {
|
||||
const l1 = luminance(c1);
|
||||
const l2 = luminance(c2);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function wcagLevel(ratio, isLargeText) {
|
||||
if (isLargeText) {
|
||||
if (ratio >= 4.5) return 'AAA';
|
||||
if (ratio >= 3) return 'AA';
|
||||
return 'FAIL';
|
||||
}
|
||||
if (ratio >= 7) return 'AAA';
|
||||
if (ratio >= 4.5) return 'AA';
|
||||
return 'FAIL';
|
||||
}
|
||||
|
||||
export function extractAccessibility(computedStyles) {
|
||||
const pairs = new Map(); // "fg|bg" -> { fg, bg, count, elements }
|
||||
|
||||
for (const el of computedStyles) {
|
||||
const fg = parseColor(el.color);
|
||||
const bg = parseColor(el.backgroundColor);
|
||||
if (!fg || !bg || bg.a === 0) continue;
|
||||
|
||||
const fgHex = rgbToHex(fg);
|
||||
const bgHex = rgbToHex(bg);
|
||||
const key = `${fgHex}|${bgHex}`;
|
||||
|
||||
if (!pairs.has(key)) {
|
||||
pairs.set(key, { fg, bg, fgHex, bgHex, count: 0, tags: new Set(), fontSize: null });
|
||||
}
|
||||
const pair = pairs.get(key);
|
||||
pair.count++;
|
||||
pair.tags.add(el.tag);
|
||||
// Track font size for large text determination
|
||||
const size = parseFloat(el.fontSize);
|
||||
if (!pair.fontSize || size > pair.fontSize) pair.fontSize = size;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const [, pair] of pairs) {
|
||||
if (pair.fgHex === pair.bgHex) continue; // skip same color pairs
|
||||
const ratio = contrastRatio(pair.fg, pair.bg);
|
||||
const isLargeText = pair.fontSize >= 18 || (pair.fontSize >= 14 && pair.tags.has('b'));
|
||||
const level = wcagLevel(ratio, isLargeText);
|
||||
|
||||
if (level === 'FAIL') failCount += pair.count;
|
||||
else passCount += pair.count;
|
||||
|
||||
results.push({
|
||||
foreground: pair.fgHex,
|
||||
background: pair.bgHex,
|
||||
ratio: Math.round(ratio * 100) / 100,
|
||||
level,
|
||||
isLargeText,
|
||||
count: pair.count,
|
||||
elements: [...pair.tags].slice(0, 5),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: failures first, then by count
|
||||
results.sort((a, b) => {
|
||||
if (a.level === 'FAIL' && b.level !== 'FAIL') return -1;
|
||||
if (b.level === 'FAIL' && a.level !== 'FAIL') return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
const total = passCount + failCount;
|
||||
const score = total > 0 ? Math.round((passCount / total) * 100) : 100;
|
||||
|
||||
return {
|
||||
score,
|
||||
passCount,
|
||||
failCount,
|
||||
totalPairs: results.length,
|
||||
pairs: results.slice(0, 50), // top 50 pairs
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Figma Variables JSON format (compatible with Figma Variables import)
|
||||
export function formatFigma(design) {
|
||||
const variables = [];
|
||||
|
||||
// Colors
|
||||
if (design.colors.primary) {
|
||||
variables.push(colorVar('color/primary', design.colors.primary.hex));
|
||||
}
|
||||
if (design.colors.secondary) {
|
||||
variables.push(colorVar('color/secondary', design.colors.secondary.hex));
|
||||
}
|
||||
if (design.colors.accent) {
|
||||
variables.push(colorVar('color/accent', design.colors.accent.hex));
|
||||
}
|
||||
for (let i = 0; i < design.colors.neutrals.length && i < 10; i++) {
|
||||
variables.push(colorVar(`color/neutral/${i * 100 || 50}`, design.colors.neutrals[i].hex));
|
||||
}
|
||||
for (let i = 0; i < design.colors.backgrounds.length; i++) {
|
||||
variables.push(colorVar(`color/background/${i === 0 ? 'default' : i}`, design.colors.backgrounds[i]));
|
||||
}
|
||||
for (let i = 0; i < design.colors.text.length && i < 5; i++) {
|
||||
variables.push(colorVar(`color/text/${i === 0 ? 'default' : i}`, design.colors.text[i]));
|
||||
}
|
||||
|
||||
// Spacing
|
||||
for (const v of design.spacing.scale.slice(0, 20)) {
|
||||
variables.push({ name: `spacing/${v}`, type: 'FLOAT', value: v, scopes: ['GAP', 'ALL_SCOPES'] });
|
||||
}
|
||||
|
||||
// Border radius
|
||||
for (const r of design.borders.radii) {
|
||||
variables.push({ name: `radius/${r.label}`, type: 'FLOAT', value: r.value, scopes: ['CORNER_RADIUS'] });
|
||||
}
|
||||
|
||||
// Font sizes
|
||||
for (const s of design.typography.scale.slice(0, 12)) {
|
||||
variables.push({ name: `fontSize/${s.size}`, type: 'FLOAT', value: s.size, scopes: ['FONT_SIZE'] });
|
||||
}
|
||||
|
||||
const collection = {
|
||||
name: `Design Language — ${design.meta.title || 'Extracted'}`,
|
||||
modes: [{ name: 'Default', variables }],
|
||||
};
|
||||
|
||||
// Add dark mode if available
|
||||
if (design.darkMode) {
|
||||
const darkVars = [];
|
||||
const dc = design.darkMode.colors;
|
||||
if (dc.primary) darkVars.push(colorVar('color/primary', dc.primary.hex));
|
||||
if (dc.secondary) darkVars.push(colorVar('color/secondary', dc.secondary.hex));
|
||||
for (let i = 0; i < dc.neutrals.length && i < 10; i++) {
|
||||
darkVars.push(colorVar(`color/neutral/${i * 100 || 50}`, dc.neutrals[i].hex));
|
||||
}
|
||||
for (let i = 0; i < dc.backgrounds.length; i++) {
|
||||
darkVars.push(colorVar(`color/background/${i === 0 ? 'default' : i}`, dc.backgrounds[i]));
|
||||
}
|
||||
for (let i = 0; i < dc.text.length && i < 5; i++) {
|
||||
darkVars.push(colorVar(`color/text/${i === 0 ? 'default' : i}`, dc.text[i]));
|
||||
}
|
||||
collection.modes.push({ name: 'Dark', variables: darkVars });
|
||||
}
|
||||
|
||||
return JSON.stringify(collection, null, 2);
|
||||
}
|
||||
|
||||
function colorVar(name, hex) {
|
||||
const rgb = hexToRgb(hex);
|
||||
return {
|
||||
name,
|
||||
type: 'COLOR',
|
||||
value: { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255, a: 1 },
|
||||
scopes: ['ALL_SCOPES'],
|
||||
};
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
export function formatPreview(design) {
|
||||
const { meta, colors, typography, spacing, shadows, borders, accessibility, components, componentScreenshots } = design;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Design Language: ${esc(meta.title)}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e5e5e5; line-height: 1.6; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 40px 24px; }
|
||||
h1 { font-size: 36px; font-weight: 700; margin-bottom: 8px; color: #fff; }
|
||||
h2 { font-size: 24px; font-weight: 600; margin: 48px 0 20px; color: #fff; border-bottom: 1px solid #222; padding-bottom: 12px; }
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 24px 0 12px; color: #a0a0a0; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.meta { color: #666; font-size: 14px; margin-bottom: 32px; }
|
||||
.meta span { margin-right: 16px; }
|
||||
.grid { display: grid; gap: 12px; }
|
||||
.grid-2 { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
|
||||
.grid-3 { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||||
.grid-4 { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
|
||||
|
||||
/* Color swatches */
|
||||
.swatch { border-radius: 12px; overflow: hidden; background: #141414; border: 1px solid #222; }
|
||||
.swatch-color { height: 80px; position: relative; }
|
||||
.swatch-info { padding: 10px 12px; font-size: 13px; }
|
||||
.swatch-hex { font-weight: 600; font-family: monospace; color: #fff; }
|
||||
.swatch-label { font-size: 11px; color: #666; margin-top: 2px; }
|
||||
.swatch-role { display: inline-block; font-size: 10px; background: #222; color: #aaa; padding: 2px 8px; border-radius: 4px; margin-top: 4px; }
|
||||
|
||||
/* Type scale */
|
||||
.type-row { display: flex; align-items: baseline; gap: 16px; padding: 12px 0; border-bottom: 1px solid #1a1a1a; }
|
||||
.type-size { font-family: monospace; color: #666; min-width: 60px; font-size: 13px; }
|
||||
.type-meta { font-size: 12px; color: #444; margin-left: auto; font-family: monospace; }
|
||||
|
||||
/* Spacing */
|
||||
.spacing-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
|
||||
.spacing-bar { background: linear-gradient(90deg, #3b82f6, #8b5cf6); border-radius: 4px; height: 24px; min-width: 4px; transition: width 0.3s; }
|
||||
.spacing-label { font-family: monospace; font-size: 13px; color: #888; min-width: 60px; }
|
||||
|
||||
/* Shadows */
|
||||
.shadow-card { background: #fff; border-radius: 12px; padding: 24px; text-align: center; min-height: 80px; display: flex; align-items: center; justify-content: center; }
|
||||
.shadow-label { font-size: 12px; color: #333; font-family: monospace; }
|
||||
|
||||
/* Radii */
|
||||
.radius-item { width: 60px; height: 60px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); display: flex; align-items: center; justify-content: center; font-size: 11px; color: #fff; font-weight: 600; }
|
||||
|
||||
/* Accessibility */
|
||||
.a11y-score { font-size: 64px; font-weight: 800; }
|
||||
.a11y-score.good { color: #22c55e; }
|
||||
.a11y-score.warn { color: #eab308; }
|
||||
.a11y-score.bad { color: #ef4444; }
|
||||
.a11y-pair { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #141414; border-radius: 8px; margin-bottom: 6px; border: 1px solid #222; }
|
||||
.a11y-sample { width: 120px; padding: 6px 12px; border-radius: 6px; text-align: center; font-size: 14px; font-weight: 500; }
|
||||
.a11y-ratio { font-family: monospace; font-size: 14px; min-width: 50px; }
|
||||
.a11y-badge { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; }
|
||||
.a11y-badge.pass { background: #22c55e20; color: #22c55e; }
|
||||
.a11y-badge.fail { background: #ef444420; color: #ef4444; }
|
||||
|
||||
/* Components */
|
||||
.comp-screenshot { border-radius: 8px; border: 1px solid #222; max-width: 100%; }
|
||||
|
||||
/* Stat cards */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin: 24px 0; }
|
||||
.stat { background: #141414; border: 1px solid #222; border-radius: 12px; padding: 16px; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #fff; }
|
||||
.stat-label { font-size: 12px; color: #666; margin-top: 4px; }
|
||||
|
||||
.font-tag { display: inline-block; background: #1e1e2e; color: #a78bfa; padding: 4px 10px; border-radius: 6px; font-size: 13px; margin: 4px 4px 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<h1>${esc(meta.title)}</h1>
|
||||
<div class="meta">
|
||||
<span>${esc(meta.url)}</span>
|
||||
<span>${meta.elementCount} elements</span>
|
||||
<span>${new Date(meta.timestamp).toLocaleDateString()}</span>
|
||||
${meta.pagesAnalyzed > 1 ? `<span>${meta.pagesAnalyzed} pages crawled</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="stat-value">${colors.all.length}</div><div class="stat-label">Colors</div></div>
|
||||
<div class="stat"><div class="stat-value">${typography.families.length}</div><div class="stat-label">Font Families</div></div>
|
||||
<div class="stat"><div class="stat-value">${spacing.scale.length}</div><div class="stat-label">Spacing Values</div></div>
|
||||
<div class="stat"><div class="stat-value">${shadows.values.length}</div><div class="stat-label">Shadows</div></div>
|
||||
<div class="stat"><div class="stat-value">${borders.radii.length}</div><div class="stat-label">Border Radii</div></div>
|
||||
<div class="stat"><div class="stat-value">${Object.keys(components).length}</div><div class="stat-label">Components</div></div>
|
||||
${accessibility ? `<div class="stat"><div class="stat-value ${accessibility.score >= 80 ? 'good' : accessibility.score >= 50 ? 'warn' : 'bad'}" style="color: ${accessibility.score >= 80 ? '#22c55e' : accessibility.score >= 50 ? '#eab308' : '#ef4444'}">${accessibility.score}%</div><div class="stat-label">A11y Score</div></div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Colors -->
|
||||
<h2>Color Palette</h2>
|
||||
|
||||
${colors.primary ? `
|
||||
<h3>Brand Colors</h3>
|
||||
<div class="grid grid-3">
|
||||
${colors.primary ? swatch(colors.primary.hex, 'Primary', colors.primary.count + ' uses') : ''}
|
||||
${colors.secondary ? swatch(colors.secondary.hex, 'Secondary', colors.secondary.count + ' uses') : ''}
|
||||
${colors.accent ? swatch(colors.accent.hex, 'Accent', colors.accent.count + ' uses') : ''}
|
||||
</div>` : ''}
|
||||
|
||||
${colors.neutrals.length > 0 ? `
|
||||
<h3>Neutrals</h3>
|
||||
<div class="grid grid-4">
|
||||
${colors.neutrals.slice(0, 10).map(c => swatch(c.hex, '', c.count + ' uses')).join('\n ')}
|
||||
</div>` : ''}
|
||||
|
||||
${colors.all.length > 3 ? `
|
||||
<h3>Full Palette</h3>
|
||||
<div class="grid grid-4">
|
||||
${colors.all.slice(0, 20).map(c => swatch(c.hex, c.contexts.join(', '), c.count + ' uses')).join('\n ')}
|
||||
</div>` : ''}
|
||||
|
||||
${colors.gradients.length > 0 ? `
|
||||
<h3>Gradients</h3>
|
||||
<div class="grid grid-2">
|
||||
${colors.gradients.slice(0, 6).map(g => `<div class="swatch"><div class="swatch-color" style="background-image:${g};height:100px"></div></div>`).join('\n ')}
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Typography -->
|
||||
<h2>Typography</h2>
|
||||
|
||||
${typography.families.length > 0 ? `
|
||||
<h3>Font Families</h3>
|
||||
<div>
|
||||
${typography.families.map(f => `<span class="font-tag">${esc(f.name)} <span style="color:#666">(${f.usage}, ${f.count}x)</span></span>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
${typography.scale.length > 0 ? `
|
||||
<h3>Type Scale</h3>
|
||||
<div>
|
||||
${typography.scale.slice(0, 12).map(s => `
|
||||
<div class="type-row">
|
||||
<span class="type-size">${s.size}px</span>
|
||||
<span style="font-size:${Math.min(s.size, 48)}px;font-weight:${s.weight};color:#fff">The quick brown fox</span>
|
||||
<span class="type-meta">${s.weight} / ${s.lineHeight}</span>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Spacing -->
|
||||
${spacing.scale.length > 0 ? `
|
||||
<h2>Spacing Scale${spacing.base ? ` (base: ${spacing.base}px)` : ''}</h2>
|
||||
<div>
|
||||
${spacing.scale.slice(0, 16).map(v => `
|
||||
<div class="spacing-row">
|
||||
<span class="spacing-label">${v}px</span>
|
||||
<div class="spacing-bar" style="width:${Math.min(v * 2, 500)}px"></div>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Shadows -->
|
||||
${shadows.values.length > 0 ? `
|
||||
<h2>Box Shadows</h2>
|
||||
<div class="grid grid-3">
|
||||
${shadows.values.map(s => `
|
||||
<div class="shadow-card" style="box-shadow:${s.raw}">
|
||||
<span class="shadow-label">${s.label}${s.inset ? ' (inset)' : ''}</span>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Border Radii -->
|
||||
${borders.radii.length > 0 ? `
|
||||
<h2>Border Radii</h2>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:end">
|
||||
${borders.radii.map(r => `
|
||||
<div style="text-align:center">
|
||||
<div class="radius-item" style="border-radius:${r.value}px">${r.value}px</div>
|
||||
<div style="font-size:11px;color:#666;margin-top:6px">${r.label}</div>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Accessibility -->
|
||||
${accessibility ? `
|
||||
<h2>Accessibility</h2>
|
||||
<div style="display:flex;align-items:center;gap:24px;margin-bottom:24px">
|
||||
<div class="a11y-score ${accessibility.score >= 80 ? 'good' : accessibility.score >= 50 ? 'warn' : 'bad'}">${accessibility.score}%</div>
|
||||
<div>
|
||||
<div style="color:#fff;font-weight:600">WCAG Contrast Score</div>
|
||||
<div style="color:#666;font-size:14px">${accessibility.passCount} passing / ${accessibility.failCount} failing color pairs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Color Pair Analysis</h3>
|
||||
${accessibility.pairs.slice(0, 20).map(p => `
|
||||
<div class="a11y-pair">
|
||||
<div class="a11y-sample" style="background:${p.background};color:${p.foreground}">Sample</div>
|
||||
<div style="font-family:monospace;font-size:12px;color:#888">${p.foreground} on ${p.background}</div>
|
||||
<div class="a11y-ratio">${p.ratio}:1</div>
|
||||
<span class="a11y-badge ${p.level === 'FAIL' ? 'fail' : 'pass'}">${p.level}</span>
|
||||
<div style="font-size:11px;color:#555;margin-left:auto">${p.count}x</div>
|
||||
</div>`).join('')}
|
||||
` : ''}
|
||||
|
||||
<!-- Component Screenshots -->
|
||||
${componentScreenshots && Object.keys(componentScreenshots).length > 0 ? `
|
||||
<h2>Component Screenshots</h2>
|
||||
<div class="grid grid-2">
|
||||
${Object.entries(componentScreenshots).map(([name, info]) => `
|
||||
<div>
|
||||
<h3>${info.label}</h3>
|
||||
<img class="comp-screenshot" src="${info.path}" alt="${info.label}" />
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function swatch(hex, label, meta) {
|
||||
const rgb = hexToRgb(hex);
|
||||
const textColor = isLight(rgb) ? '#000' : '#fff';
|
||||
return `<div class="swatch">
|
||||
<div class="swatch-color" style="background:${hex}"></div>
|
||||
<div class="swatch-info">
|
||||
<div class="swatch-hex">${hex}</div>
|
||||
${label ? `<div class="swatch-label">${esc(label)}</div>` : ''}
|
||||
${meta ? `<div class="swatch-label">${esc(meta)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const m = hex.replace('#', '').match(/.{2}/g);
|
||||
return m ? { r: parseInt(m[0], 16), g: parseInt(m[1], 16), b: parseInt(m[2], 16) } : { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
function isLight({ r, g, b }) {
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) > 150;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Framework-specific theme generators
|
||||
|
||||
export function formatReactTheme(design) {
|
||||
const { colors, typography, spacing, shadows, borders } = design;
|
||||
|
||||
const theme = {};
|
||||
|
||||
// Colors
|
||||
theme.colors = {};
|
||||
if (colors.primary) theme.colors.primary = colors.primary.hex;
|
||||
if (colors.secondary) theme.colors.secondary = colors.secondary.hex;
|
||||
if (colors.accent) theme.colors.accent = colors.accent.hex;
|
||||
if (colors.backgrounds.length) theme.colors.background = colors.backgrounds[0];
|
||||
if (colors.text.length) theme.colors.foreground = colors.text[0];
|
||||
for (let i = 0; i < colors.neutrals.length && i < 10; i++) {
|
||||
theme.colors[`neutral${i * 100 || 50}`] = colors.neutrals[i].hex;
|
||||
}
|
||||
|
||||
// Typography
|
||||
theme.fonts = {};
|
||||
for (const f of typography.families) {
|
||||
const key = f.name.toLowerCase().includes('mono') ? 'mono' : f.usage === 'headings' ? 'heading' : 'body';
|
||||
theme.fonts[key] = `'${f.name}', ${f.name.toLowerCase().includes('mono') ? 'monospace' : 'sans-serif'}`;
|
||||
}
|
||||
|
||||
theme.fontSizes = {};
|
||||
for (const s of typography.scale.slice(0, 12)) {
|
||||
theme.fontSizes[s.size] = `${s.size}px`;
|
||||
}
|
||||
|
||||
// Spacing
|
||||
theme.space = {};
|
||||
for (const v of spacing.scale.slice(0, 16)) {
|
||||
theme.space[v] = `${v}px`;
|
||||
}
|
||||
|
||||
// Radii
|
||||
theme.radii = {};
|
||||
for (const r of borders.radii) {
|
||||
theme.radii[r.label] = `${r.value}px`;
|
||||
}
|
||||
|
||||
// Shadows
|
||||
theme.shadows = {};
|
||||
for (const s of shadows.values) {
|
||||
theme.shadows[s.label] = s.raw;
|
||||
}
|
||||
|
||||
return `// React Theme — extracted from ${design.meta.url}
|
||||
// Compatible with: Chakra UI, Stitches, Vanilla Extract, or any CSS-in-JS
|
||||
|
||||
export const theme = ${JSON.stringify(theme, null, 2)};
|
||||
|
||||
export default theme;
|
||||
`;
|
||||
}
|
||||
|
||||
export function formatShadcnTheme(design) {
|
||||
const { colors, borders } = design;
|
||||
const lines = ['@layer base {', ' :root {'];
|
||||
|
||||
// Map to shadcn/ui CSS variable naming convention
|
||||
if (colors.backgrounds.length) lines.push(` --background: ${toHslString(colors.backgrounds[0])};`);
|
||||
if (colors.text.length) lines.push(` --foreground: ${toHslString(colors.text[0])};`);
|
||||
if (colors.primary) {
|
||||
lines.push(` --primary: ${toHslString(colors.primary.hex)};`);
|
||||
lines.push(` --primary-foreground: ${isLightHex(colors.primary.hex) ? '0 0% 0%' : '0 0% 100%'};`);
|
||||
}
|
||||
if (colors.secondary) {
|
||||
lines.push(` --secondary: ${toHslString(colors.secondary.hex)};`);
|
||||
lines.push(` --secondary-foreground: ${isLightHex(colors.secondary.hex) ? '0 0% 0%' : '0 0% 100%'};`);
|
||||
}
|
||||
if (colors.accent) {
|
||||
lines.push(` --accent: ${toHslString(colors.accent.hex)};`);
|
||||
lines.push(` --accent-foreground: ${isLightHex(colors.accent.hex) ? '0 0% 0%' : '0 0% 100%'};`);
|
||||
}
|
||||
if (colors.neutrals.length > 0) {
|
||||
lines.push(` --muted: ${toHslString(colors.neutrals[colors.neutrals.length - 1]?.hex || '#888')};`);
|
||||
lines.push(` --muted-foreground: ${toHslString(colors.neutrals[0]?.hex || '#333')};`);
|
||||
lines.push(` --border: ${toHslString(colors.neutrals[Math.min(4, colors.neutrals.length - 1)]?.hex || '#e5e5e5')};`);
|
||||
}
|
||||
if (borders.radii.length > 0) {
|
||||
const md = borders.radii.find(r => r.label === 'md') || borders.radii[0];
|
||||
lines.push(` --radius: ${md.value}px;`);
|
||||
}
|
||||
|
||||
lines.push(' }');
|
||||
|
||||
// Dark mode
|
||||
if (design.darkMode) {
|
||||
lines.push(' .dark {');
|
||||
const dc = design.darkMode.colors;
|
||||
if (dc.backgrounds.length) lines.push(` --background: ${toHslString(dc.backgrounds[0])};`);
|
||||
if (dc.text.length) lines.push(` --foreground: ${toHslString(dc.text[0])};`);
|
||||
if (dc.primary) lines.push(` --primary: ${toHslString(dc.primary.hex)};`);
|
||||
lines.push(' }');
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
|
||||
return `/* shadcn/ui Theme — extracted from ${design.meta.url} */\n/* Paste into your globals.css */\n\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function toHslString(hex) {
|
||||
if (!hex) return '0 0% 0%';
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(h.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(h.slice(4, 6), 16) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return `0 0% ${Math.round(l * 100)}%`;
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let hue;
|
||||
if (max === r) hue = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) hue = ((b - r) / d + 2) / 6;
|
||||
else hue = ((r - g) / d + 4) / 6;
|
||||
return `${Math.round(hue * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
}
|
||||
|
||||
function isLightHex(hex) {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) > 150;
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// Historical tracking — save and compare design snapshots over time
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const HISTORY_DIR = join(process.env.HOME || process.env.USERPROFILE || '.', '.designlang');
|
||||
|
||||
function ensureDir() {
|
||||
mkdirSync(HISTORY_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function historyFile(hostname) {
|
||||
return join(HISTORY_DIR, `${hostname}.json`);
|
||||
}
|
||||
|
||||
export function saveSnapshot(design) {
|
||||
ensureDir();
|
||||
const hostname = new URL(design.meta.url).hostname.replace(/^www\./, '');
|
||||
const file = historyFile(hostname);
|
||||
|
||||
let history = [];
|
||||
if (existsSync(file)) {
|
||||
try { history = JSON.parse(readFileSync(file, 'utf-8')); } catch { history = []; }
|
||||
}
|
||||
|
||||
// Compact snapshot — only store key metrics, not full data
|
||||
const snapshot = {
|
||||
timestamp: design.meta.timestamp,
|
||||
url: design.meta.url,
|
||||
colors: {
|
||||
count: design.colors.all.length,
|
||||
primary: design.colors.primary?.hex || null,
|
||||
secondary: design.colors.secondary?.hex || null,
|
||||
accent: design.colors.accent?.hex || null,
|
||||
},
|
||||
typography: {
|
||||
families: design.typography.families.map(f => f.name),
|
||||
scaleCount: design.typography.scale.length,
|
||||
},
|
||||
spacing: {
|
||||
base: design.spacing.base,
|
||||
count: design.spacing.scale.length,
|
||||
},
|
||||
shadows: design.shadows.values.length,
|
||||
radii: design.borders.radii.length,
|
||||
breakpoints: design.breakpoints.length,
|
||||
components: Object.keys(design.components),
|
||||
a11yScore: design.accessibility?.score || null,
|
||||
cssVarCount: Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0),
|
||||
};
|
||||
|
||||
history.push(snapshot);
|
||||
writeFileSync(file, JSON.stringify(history, null, 2), 'utf-8');
|
||||
return { hostname, snapshotCount: history.length, file };
|
||||
}
|
||||
|
||||
export function getHistory(url) {
|
||||
ensureDir();
|
||||
const hostname = new URL(url).hostname.replace(/^www\./, '');
|
||||
const file = historyFile(hostname);
|
||||
if (!existsSync(file)) return [];
|
||||
try { return JSON.parse(readFileSync(file, 'utf-8')); } catch { return []; }
|
||||
}
|
||||
|
||||
export function formatHistoryMarkdown(url, history) {
|
||||
if (history.length === 0) return `No history found for ${url}.\n`;
|
||||
|
||||
const hostname = new URL(url).hostname;
|
||||
const lines = [`# Design History: ${hostname}`, '', `${history.length} snapshots recorded.`, ''];
|
||||
|
||||
lines.push('| Date | Colors | Fonts | Spacing | A11y | CSS Vars |');
|
||||
lines.push('|------|--------|-------|---------|------|----------|');
|
||||
|
||||
for (const snap of history.reverse()) {
|
||||
const date = new Date(snap.timestamp).toLocaleDateString();
|
||||
lines.push(`| ${date} | ${snap.colors.count} (primary: \`${snap.colors.primary}\`) | ${snap.typography.families.join(', ')} | ${snap.spacing.count} vals | ${snap.a11yScore ?? 'n/a'}% | ${snap.cssVarCount} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Detect changes between first and last snapshot
|
||||
if (history.length >= 2) {
|
||||
const first = history[history.length - 1]; // oldest (reversed)
|
||||
const last = history[0]; // newest
|
||||
|
||||
lines.push('## Changes Over Time');
|
||||
lines.push('');
|
||||
if (first.colors.primary !== last.colors.primary) {
|
||||
lines.push(`- **Primary color changed:** \`${first.colors.primary}\` → \`${last.colors.primary}\``);
|
||||
}
|
||||
if (first.typography.families.join(',') !== last.typography.families.join(',')) {
|
||||
lines.push(`- **Fonts changed:** ${first.typography.families.join(', ')} → ${last.typography.families.join(', ')}`);
|
||||
}
|
||||
if (first.colors.count !== last.colors.count) {
|
||||
lines.push(`- **Color count:** ${first.colors.count} → ${last.colors.count}`);
|
||||
}
|
||||
if (first.a11yScore !== last.a11yScore) {
|
||||
lines.push(`- **A11y score:** ${first.a11yScore}% → ${last.a11yScore}%`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user