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:
Manavarya09
2026-04-15 14:21:25 +04:00
parent 2a2fb16ee6
commit db7fe344aa
7 changed files with 917 additions and 20 deletions
+125 -20
View File
@@ -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
View File
@@ -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>`;
}
+95
View File
@@ -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
};
}
+83
View File
@@ -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),
};
}
+237
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+128
View File
@@ -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
View File
@@ -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');
}