designlang 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -83
- package/bin/design-extract.js +126 -11
- package/package.json +1 -1
- package/src/crawler.js +125 -20
- package/src/diff.js +146 -0
- package/src/extractors/accessibility.js +95 -0
- package/src/formatters/figma.js +83 -0
- package/src/formatters/markdown.js +36 -1
- package/src/formatters/preview.js +237 -0
- package/src/formatters/theme.js +128 -0
- package/src/history.js +103 -0
- package/src/index.js +9 -0
package/src/crawler.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
2
4
|
|
|
3
5
|
const MAX_ELEMENTS = 5000;
|
|
4
6
|
|
|
5
7
|
export async function crawlPage(url, options = {}) {
|
|
6
|
-
const { width = 1280, height = 800, wait = 0, dark = false } = options;
|
|
8
|
+
const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '' } = options;
|
|
7
9
|
|
|
8
10
|
const browser = await chromium.launch({ headless: true });
|
|
9
11
|
const context = await browser.newContext({
|
|
@@ -14,12 +16,32 @@ export async function crawlPage(url, options = {}) {
|
|
|
14
16
|
|
|
15
17
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
16
18
|
if (wait > 0) await page.waitForTimeout(wait);
|
|
17
|
-
|
|
18
|
-
// Wait for fonts to load
|
|
19
19
|
await page.evaluate(() => document.fonts.ready);
|
|
20
20
|
|
|
21
|
+
const title = await page.title();
|
|
21
22
|
const lightData = await extractPageData(page);
|
|
22
23
|
|
|
24
|
+
// Component screenshots
|
|
25
|
+
let componentScreenshots = {};
|
|
26
|
+
if (screenshots && outDir) {
|
|
27
|
+
componentScreenshots = await captureComponentScreenshots(page, outDir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Multi-page crawl: discover internal links and extract from them
|
|
31
|
+
let additionalPages = [];
|
|
32
|
+
if (depth > 0) {
|
|
33
|
+
const internalLinks = await discoverInternalLinks(page, url, depth);
|
|
34
|
+
for (const link of internalLinks) {
|
|
35
|
+
try {
|
|
36
|
+
await page.goto(link, { waitUntil: 'networkidle', timeout: 20000 });
|
|
37
|
+
await page.evaluate(() => document.fonts.ready);
|
|
38
|
+
const pageData = await extractPageData(page);
|
|
39
|
+
additionalPages.push({ url: link, data: pageData });
|
|
40
|
+
} catch { /* skip failed pages */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Dark mode extraction
|
|
23
45
|
let darkData = null;
|
|
24
46
|
if (dark) {
|
|
25
47
|
await context.close();
|
|
@@ -32,12 +54,104 @@ export async function crawlPage(url, options = {}) {
|
|
|
32
54
|
await darkPage.evaluate(() => document.fonts.ready);
|
|
33
55
|
darkData = await extractPageData(darkPage);
|
|
34
56
|
await darkContext.close();
|
|
57
|
+
} else {
|
|
58
|
+
await context.close();
|
|
35
59
|
}
|
|
36
60
|
|
|
37
|
-
const title = await page.title();
|
|
38
61
|
await browser.close();
|
|
39
62
|
|
|
40
|
-
|
|
63
|
+
// Merge additional page data into light data
|
|
64
|
+
if (additionalPages.length > 0) {
|
|
65
|
+
lightData.computedStyles = mergeStyles(lightData.computedStyles, additionalPages);
|
|
66
|
+
for (const ap of additionalPages) {
|
|
67
|
+
Object.assign(lightData.cssVariables, ap.data.cssVariables);
|
|
68
|
+
lightData.mediaQueries.push(...ap.data.mediaQueries);
|
|
69
|
+
lightData.keyframes.push(...ap.data.keyframes);
|
|
70
|
+
}
|
|
71
|
+
// Deduplicate media queries and keyframes
|
|
72
|
+
lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
|
|
73
|
+
const seenKf = new Set();
|
|
74
|
+
lightData.keyframes = lightData.keyframes.filter(kf => {
|
|
75
|
+
if (seenKf.has(kf.name)) return false;
|
|
76
|
+
seenKf.add(kf.name);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
url, title,
|
|
83
|
+
light: lightData,
|
|
84
|
+
dark: darkData,
|
|
85
|
+
pagesAnalyzed: 1 + additionalPages.length,
|
|
86
|
+
componentScreenshots,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mergeStyles(primary, additionalPages) {
|
|
91
|
+
// Add styles from additional pages, capping total
|
|
92
|
+
const all = [...primary];
|
|
93
|
+
for (const ap of additionalPages) {
|
|
94
|
+
if (all.length >= MAX_ELEMENTS * 2) break;
|
|
95
|
+
all.push(...ap.data.computedStyles);
|
|
96
|
+
}
|
|
97
|
+
return all;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function discoverInternalLinks(page, baseUrl, maxLinks) {
|
|
101
|
+
const base = new URL(baseUrl);
|
|
102
|
+
const links = await page.evaluate((hostname) => {
|
|
103
|
+
return Array.from(document.querySelectorAll('a[href]'))
|
|
104
|
+
.map(a => a.href)
|
|
105
|
+
.filter(href => {
|
|
106
|
+
try {
|
|
107
|
+
const u = new URL(href);
|
|
108
|
+
return u.hostname === hostname && !href.includes('#') && !href.match(/\.(png|jpg|jpeg|gif|svg|pdf|zip|mp4|mp3)$/i);
|
|
109
|
+
} catch { return false; }
|
|
110
|
+
});
|
|
111
|
+
}, base.hostname);
|
|
112
|
+
|
|
113
|
+
// Deduplicate and limit
|
|
114
|
+
const unique = [...new Set(links)].filter(l => l !== baseUrl);
|
|
115
|
+
return unique.slice(0, Math.min(maxLinks * 3, 15)); // crawl up to 15 pages max
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function captureComponentScreenshots(page, outDir) {
|
|
119
|
+
const screenshotDir = join(outDir, 'screenshots');
|
|
120
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
121
|
+
|
|
122
|
+
const result = {};
|
|
123
|
+
|
|
124
|
+
// Find representative elements for each component type
|
|
125
|
+
const selectors = [
|
|
126
|
+
{ name: 'button', selector: 'button:not(:empty), a[role="button"], [class*="btn"]:not(:empty)', label: 'Buttons' },
|
|
127
|
+
{ name: 'card', selector: '[class*="card"]:not(:empty)', label: 'Cards' },
|
|
128
|
+
{ name: 'input', selector: 'input[type="text"], input[type="email"], input[type="search"], textarea', label: 'Inputs' },
|
|
129
|
+
{ name: 'nav', selector: 'nav, [role="navigation"]', label: 'Navigation' },
|
|
130
|
+
{ name: 'hero', selector: '[class*="hero"], section:first-of-type', label: 'Hero Section' },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
for (const { name, selector, label } of selectors) {
|
|
134
|
+
try {
|
|
135
|
+
const el = await page.$(selector);
|
|
136
|
+
if (el) {
|
|
137
|
+
const box = await el.boundingBox();
|
|
138
|
+
if (box && box.width > 20 && box.height > 10) {
|
|
139
|
+
const path = join(screenshotDir, `${name}.png`);
|
|
140
|
+
await el.screenshot({ path });
|
|
141
|
+
result[name] = { path: `screenshots/${name}.png`, label };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch { /* skip if screenshot fails */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Full page screenshot
|
|
148
|
+
try {
|
|
149
|
+
const fullPath = join(screenshotDir, 'full-page.png');
|
|
150
|
+
await page.screenshot({ path: fullPath, fullPage: true });
|
|
151
|
+
result.fullPage = { path: 'screenshots/full-page.png', label: 'Full Page' };
|
|
152
|
+
} catch { /* skip */ }
|
|
153
|
+
|
|
154
|
+
return result;
|
|
41
155
|
}
|
|
42
156
|
|
|
43
157
|
async function extractPageData(page) {
|
|
@@ -49,7 +163,6 @@ async function extractPageData(page) {
|
|
|
49
163
|
keyframes: [],
|
|
50
164
|
};
|
|
51
165
|
|
|
52
|
-
// 1. Walk all elements and collect computed styles
|
|
53
166
|
const allElements = document.querySelectorAll('*');
|
|
54
167
|
const elements = allElements.length > maxElements
|
|
55
168
|
? Array.from(allElements).slice(0, maxElements)
|
|
@@ -60,16 +173,11 @@ async function extractPageData(page) {
|
|
|
60
173
|
const tag = el.tagName.toLowerCase();
|
|
61
174
|
const classList = Array.from(el.classList).join(' ');
|
|
62
175
|
const role = el.getAttribute('role') || '';
|
|
63
|
-
|
|
64
|
-
// Get bounding rect for area estimation
|
|
65
176
|
const rect = el.getBoundingClientRect();
|
|
66
177
|
const area = rect.width * rect.height;
|
|
67
178
|
|
|
68
179
|
results.computedStyles.push({
|
|
69
|
-
tag,
|
|
70
|
-
classList,
|
|
71
|
-
role,
|
|
72
|
-
area,
|
|
180
|
+
tag, classList, role, area,
|
|
73
181
|
color: cs.color,
|
|
74
182
|
backgroundColor: cs.backgroundColor,
|
|
75
183
|
backgroundImage: cs.backgroundImage,
|
|
@@ -98,9 +206,8 @@ async function extractPageData(page) {
|
|
|
98
206
|
});
|
|
99
207
|
}
|
|
100
208
|
|
|
101
|
-
//
|
|
209
|
+
// CSS custom properties
|
|
102
210
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
103
|
-
// Get all custom properties by iterating stylesheets
|
|
104
211
|
try {
|
|
105
212
|
for (const sheet of document.styleSheets) {
|
|
106
213
|
try {
|
|
@@ -114,12 +221,10 @@ async function extractPageData(page) {
|
|
|
114
221
|
}
|
|
115
222
|
}
|
|
116
223
|
}
|
|
117
|
-
} catch { /* cross-origin
|
|
224
|
+
} catch { /* cross-origin */ }
|
|
118
225
|
}
|
|
119
|
-
} catch { /* no
|
|
226
|
+
} catch { /* no access */ }
|
|
120
227
|
|
|
121
|
-
// Also get any custom properties from the computed style
|
|
122
|
-
// (fallback for CSS-in-JS that sets vars on :root)
|
|
123
228
|
for (let i = 0; i < rootStyles.length; i++) {
|
|
124
229
|
const prop = rootStyles[i];
|
|
125
230
|
if (prop.startsWith('--') && !results.cssVariables[prop]) {
|
|
@@ -127,7 +232,7 @@ async function extractPageData(page) {
|
|
|
127
232
|
}
|
|
128
233
|
}
|
|
129
234
|
|
|
130
|
-
//
|
|
235
|
+
// Media queries
|
|
131
236
|
try {
|
|
132
237
|
for (const sheet of document.styleSheets) {
|
|
133
238
|
try {
|
|
@@ -140,7 +245,7 @@ async function extractPageData(page) {
|
|
|
140
245
|
}
|
|
141
246
|
} catch { /* no access */ }
|
|
142
247
|
|
|
143
|
-
//
|
|
248
|
+
// Keyframes
|
|
144
249
|
try {
|
|
145
250
|
for (const sheet of document.styleSheets) {
|
|
146
251
|
try {
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Design diff engine — compare two design systems
|
|
2
|
+
|
|
3
|
+
export function diffDesigns(designA, designB) {
|
|
4
|
+
const diff = { urlA: designA.meta.url, urlB: designB.meta.url, sections: [] };
|
|
5
|
+
|
|
6
|
+
// Color diff
|
|
7
|
+
const colorDiff = {
|
|
8
|
+
name: 'Colors',
|
|
9
|
+
onlyA: [], onlyB: [], shared: [], changed: [],
|
|
10
|
+
};
|
|
11
|
+
const hexesA = new Set(designA.colors.all.map(c => c.hex));
|
|
12
|
+
const hexesB = new Set(designB.colors.all.map(c => c.hex));
|
|
13
|
+
for (const h of hexesA) { if (!hexesB.has(h)) colorDiff.onlyA.push(h); }
|
|
14
|
+
for (const h of hexesB) { if (!hexesA.has(h)) colorDiff.onlyB.push(h); }
|
|
15
|
+
for (const h of hexesA) { if (hexesB.has(h)) colorDiff.shared.push(h); }
|
|
16
|
+
|
|
17
|
+
// Primary color comparison
|
|
18
|
+
if (designA.colors.primary && designB.colors.primary && designA.colors.primary.hex !== designB.colors.primary.hex) {
|
|
19
|
+
colorDiff.changed.push({ property: 'primary', a: designA.colors.primary.hex, b: designB.colors.primary.hex });
|
|
20
|
+
}
|
|
21
|
+
if (designA.colors.secondary && designB.colors.secondary && designA.colors.secondary.hex !== designB.colors.secondary.hex) {
|
|
22
|
+
colorDiff.changed.push({ property: 'secondary', a: designA.colors.secondary.hex, b: designB.colors.secondary.hex });
|
|
23
|
+
}
|
|
24
|
+
diff.sections.push(colorDiff);
|
|
25
|
+
|
|
26
|
+
// Typography diff
|
|
27
|
+
const typeDiff = { name: 'Typography', onlyA: [], onlyB: [], shared: [], changed: [] };
|
|
28
|
+
const fontsA = new Set(designA.typography.families.map(f => f.name));
|
|
29
|
+
const fontsB = new Set(designB.typography.families.map(f => f.name));
|
|
30
|
+
for (const f of fontsA) { if (!fontsB.has(f)) typeDiff.onlyA.push(f); }
|
|
31
|
+
for (const f of fontsB) { if (!fontsA.has(f)) typeDiff.onlyB.push(f); }
|
|
32
|
+
for (const f of fontsA) { if (fontsB.has(f)) typeDiff.shared.push(f); }
|
|
33
|
+
diff.sections.push(typeDiff);
|
|
34
|
+
|
|
35
|
+
// Spacing diff
|
|
36
|
+
const spaceDiff = { name: 'Spacing', changed: [] };
|
|
37
|
+
if (designA.spacing.base !== designB.spacing.base) {
|
|
38
|
+
spaceDiff.changed.push({ property: 'base unit', a: `${designA.spacing.base}px`, b: `${designB.spacing.base}px` });
|
|
39
|
+
}
|
|
40
|
+
spaceDiff.countA = designA.spacing.scale.length;
|
|
41
|
+
spaceDiff.countB = designB.spacing.scale.length;
|
|
42
|
+
diff.sections.push(spaceDiff);
|
|
43
|
+
|
|
44
|
+
// Accessibility diff
|
|
45
|
+
if (designA.accessibility && designB.accessibility) {
|
|
46
|
+
diff.sections.push({
|
|
47
|
+
name: 'Accessibility',
|
|
48
|
+
changed: [{ property: 'WCAG score', a: `${designA.accessibility.score}%`, b: `${designB.accessibility.score}%` }],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Component diff
|
|
53
|
+
const compDiff = { name: 'Components', onlyA: [], onlyB: [], shared: [] };
|
|
54
|
+
const compsA = new Set(Object.keys(designA.components));
|
|
55
|
+
const compsB = new Set(Object.keys(designB.components));
|
|
56
|
+
for (const c of compsA) { if (!compsB.has(c)) compDiff.onlyA.push(c); }
|
|
57
|
+
for (const c of compsB) { if (!compsA.has(c)) compDiff.onlyB.push(c); }
|
|
58
|
+
for (const c of compsA) { if (compsB.has(c)) compDiff.shared.push(c); }
|
|
59
|
+
diff.sections.push(compDiff);
|
|
60
|
+
|
|
61
|
+
return diff;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatDiffMarkdown(diff) {
|
|
65
|
+
const lines = [];
|
|
66
|
+
lines.push(`# Design Comparison`);
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(`| | Site A | Site B |`);
|
|
69
|
+
lines.push(`|---|--------|--------|`);
|
|
70
|
+
lines.push(`| URL | ${diff.urlA} | ${diff.urlB} |`);
|
|
71
|
+
lines.push('');
|
|
72
|
+
|
|
73
|
+
for (const section of diff.sections) {
|
|
74
|
+
lines.push(`## ${section.name}`);
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
if (section.changed && section.changed.length > 0) {
|
|
78
|
+
lines.push('### Differences');
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push('| Property | Site A | Site B |');
|
|
81
|
+
lines.push('|----------|--------|--------|');
|
|
82
|
+
for (const c of section.changed) {
|
|
83
|
+
lines.push(`| ${c.property} | \`${c.a}\` | \`${c.b}\` |`);
|
|
84
|
+
}
|
|
85
|
+
lines.push('');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (section.onlyA && section.onlyA.length > 0) {
|
|
89
|
+
lines.push(`**Only in Site A:** ${section.onlyA.map(v => `\`${v}\``).join(', ')}`);
|
|
90
|
+
lines.push('');
|
|
91
|
+
}
|
|
92
|
+
if (section.onlyB && section.onlyB.length > 0) {
|
|
93
|
+
lines.push(`**Only in Site B:** ${section.onlyB.map(v => `\`${v}\``).join(', ')}`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
}
|
|
96
|
+
if (section.shared && section.shared.length > 0) {
|
|
97
|
+
lines.push(`**Shared:** ${section.shared.map(v => `\`${v}\``).join(', ')}`);
|
|
98
|
+
lines.push('');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatDiffHtml(diff) {
|
|
106
|
+
return `<!DOCTYPE html>
|
|
107
|
+
<html><head><meta charset="UTF-8"><title>Design Comparison</title>
|
|
108
|
+
<style>
|
|
109
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
110
|
+
body { font-family:-apple-system,sans-serif; background:#0a0a0a; color:#e5e5e5; padding:40px; }
|
|
111
|
+
h1 { font-size:32px; color:#fff; margin-bottom:24px; }
|
|
112
|
+
h2 { font-size:20px; color:#fff; margin:32px 0 16px; border-bottom:1px solid #222; padding-bottom:8px; }
|
|
113
|
+
.urls { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:32px; }
|
|
114
|
+
.url-card { background:#141414; border:1px solid #222; border-radius:12px; padding:16px; }
|
|
115
|
+
.url-card h3 { font-size:12px; color:#666; margin-bottom:4px; }
|
|
116
|
+
.url-card a { color:#3b82f6; font-size:14px; }
|
|
117
|
+
.diff-row { display:grid; grid-template-columns:120px 1fr 1fr; gap:12px; padding:10px 16px; border-radius:8px; margin-bottom:4px; }
|
|
118
|
+
.diff-row:nth-child(odd) { background:#111; }
|
|
119
|
+
.diff-label { color:#888; font-size:13px; }
|
|
120
|
+
.diff-val { font-family:monospace; font-size:13px; }
|
|
121
|
+
.swatch-inline { display:inline-block; width:14px; height:14px; border-radius:3px; vertical-align:middle; margin-right:6px; border:1px solid #333; }
|
|
122
|
+
.only-a { color:#f97316; } .only-b { color:#8b5cf6; } .shared { color:#22c55e; }
|
|
123
|
+
.tag { display:inline-block; font-size:12px; padding:2px 8px; border-radius:4px; margin:2px; }
|
|
124
|
+
.tag-a { background:#f9731620; color:#f97316; }
|
|
125
|
+
.tag-b { background:#8b5cf620; color:#8b5cf6; }
|
|
126
|
+
.tag-shared { background:#22c55e20; color:#22c55e; }
|
|
127
|
+
</style></head><body>
|
|
128
|
+
<h1>Design Comparison</h1>
|
|
129
|
+
<div class="urls">
|
|
130
|
+
<div class="url-card"><h3>Site A</h3><a href="${diff.urlA}">${diff.urlA}</a></div>
|
|
131
|
+
<div class="url-card"><h3>Site B</h3><a href="${diff.urlB}">${diff.urlB}</a></div>
|
|
132
|
+
</div>
|
|
133
|
+
${diff.sections.map(s => `
|
|
134
|
+
<h2>${s.name}</h2>
|
|
135
|
+
${s.changed && s.changed.length > 0 ? s.changed.map(c => `
|
|
136
|
+
<div class="diff-row">
|
|
137
|
+
<span class="diff-label">${c.property}</span>
|
|
138
|
+
<span class="diff-val">${c.a.startsWith('#') ? `<span class="swatch-inline" style="background:${c.a}"></span>` : ''}${c.a}</span>
|
|
139
|
+
<span class="diff-val">${c.b.startsWith('#') ? `<span class="swatch-inline" style="background:${c.b}"></span>` : ''}${c.b}</span>
|
|
140
|
+
</div>`).join('') : ''}
|
|
141
|
+
${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>` : ''}
|
|
142
|
+
${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>` : ''}
|
|
143
|
+
${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>` : ''}
|
|
144
|
+
`).join('')}
|
|
145
|
+
</body></html>`;
|
|
146
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { parseColor, rgbToHex } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
// WCAG 2.1 relative luminance
|
|
4
|
+
function luminance({ r, g, b }) {
|
|
5
|
+
const [rs, gs, bs] = [r, g, b].map(c => {
|
|
6
|
+
c = c / 255;
|
|
7
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
8
|
+
});
|
|
9
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function contrastRatio(c1, c2) {
|
|
13
|
+
const l1 = luminance(c1);
|
|
14
|
+
const l2 = luminance(c2);
|
|
15
|
+
const lighter = Math.max(l1, l2);
|
|
16
|
+
const darker = Math.min(l1, l2);
|
|
17
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wcagLevel(ratio, isLargeText) {
|
|
21
|
+
if (isLargeText) {
|
|
22
|
+
if (ratio >= 4.5) return 'AAA';
|
|
23
|
+
if (ratio >= 3) return 'AA';
|
|
24
|
+
return 'FAIL';
|
|
25
|
+
}
|
|
26
|
+
if (ratio >= 7) return 'AAA';
|
|
27
|
+
if (ratio >= 4.5) return 'AA';
|
|
28
|
+
return 'FAIL';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractAccessibility(computedStyles) {
|
|
32
|
+
const pairs = new Map(); // "fg|bg" -> { fg, bg, count, elements }
|
|
33
|
+
|
|
34
|
+
for (const el of computedStyles) {
|
|
35
|
+
const fg = parseColor(el.color);
|
|
36
|
+
const bg = parseColor(el.backgroundColor);
|
|
37
|
+
if (!fg || !bg || bg.a === 0) continue;
|
|
38
|
+
|
|
39
|
+
const fgHex = rgbToHex(fg);
|
|
40
|
+
const bgHex = rgbToHex(bg);
|
|
41
|
+
const key = `${fgHex}|${bgHex}`;
|
|
42
|
+
|
|
43
|
+
if (!pairs.has(key)) {
|
|
44
|
+
pairs.set(key, { fg, bg, fgHex, bgHex, count: 0, tags: new Set(), fontSize: null });
|
|
45
|
+
}
|
|
46
|
+
const pair = pairs.get(key);
|
|
47
|
+
pair.count++;
|
|
48
|
+
pair.tags.add(el.tag);
|
|
49
|
+
// Track font size for large text determination
|
|
50
|
+
const size = parseFloat(el.fontSize);
|
|
51
|
+
if (!pair.fontSize || size > pair.fontSize) pair.fontSize = size;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const results = [];
|
|
55
|
+
let passCount = 0;
|
|
56
|
+
let failCount = 0;
|
|
57
|
+
|
|
58
|
+
for (const [, pair] of pairs) {
|
|
59
|
+
if (pair.fgHex === pair.bgHex) continue; // skip same color pairs
|
|
60
|
+
const ratio = contrastRatio(pair.fg, pair.bg);
|
|
61
|
+
const isLargeText = pair.fontSize >= 18 || (pair.fontSize >= 14 && pair.tags.has('b'));
|
|
62
|
+
const level = wcagLevel(ratio, isLargeText);
|
|
63
|
+
|
|
64
|
+
if (level === 'FAIL') failCount += pair.count;
|
|
65
|
+
else passCount += pair.count;
|
|
66
|
+
|
|
67
|
+
results.push({
|
|
68
|
+
foreground: pair.fgHex,
|
|
69
|
+
background: pair.bgHex,
|
|
70
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
71
|
+
level,
|
|
72
|
+
isLargeText,
|
|
73
|
+
count: pair.count,
|
|
74
|
+
elements: [...pair.tags].slice(0, 5),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sort: failures first, then by count
|
|
79
|
+
results.sort((a, b) => {
|
|
80
|
+
if (a.level === 'FAIL' && b.level !== 'FAIL') return -1;
|
|
81
|
+
if (b.level === 'FAIL' && a.level !== 'FAIL') return 1;
|
|
82
|
+
return b.count - a.count;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const total = passCount + failCount;
|
|
86
|
+
const score = total > 0 ? Math.round((passCount / total) * 100) : 100;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
score,
|
|
90
|
+
passCount,
|
|
91
|
+
failCount,
|
|
92
|
+
totalPairs: results.length,
|
|
93
|
+
pairs: results.slice(0, 50), // top 50 pairs
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Figma Variables JSON format (compatible with Figma Variables import)
|
|
2
|
+
export function formatFigma(design) {
|
|
3
|
+
const variables = [];
|
|
4
|
+
|
|
5
|
+
// Colors
|
|
6
|
+
if (design.colors.primary) {
|
|
7
|
+
variables.push(colorVar('color/primary', design.colors.primary.hex));
|
|
8
|
+
}
|
|
9
|
+
if (design.colors.secondary) {
|
|
10
|
+
variables.push(colorVar('color/secondary', design.colors.secondary.hex));
|
|
11
|
+
}
|
|
12
|
+
if (design.colors.accent) {
|
|
13
|
+
variables.push(colorVar('color/accent', design.colors.accent.hex));
|
|
14
|
+
}
|
|
15
|
+
for (let i = 0; i < design.colors.neutrals.length && i < 10; i++) {
|
|
16
|
+
variables.push(colorVar(`color/neutral/${i * 100 || 50}`, design.colors.neutrals[i].hex));
|
|
17
|
+
}
|
|
18
|
+
for (let i = 0; i < design.colors.backgrounds.length; i++) {
|
|
19
|
+
variables.push(colorVar(`color/background/${i === 0 ? 'default' : i}`, design.colors.backgrounds[i]));
|
|
20
|
+
}
|
|
21
|
+
for (let i = 0; i < design.colors.text.length && i < 5; i++) {
|
|
22
|
+
variables.push(colorVar(`color/text/${i === 0 ? 'default' : i}`, design.colors.text[i]));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Spacing
|
|
26
|
+
for (const v of design.spacing.scale.slice(0, 20)) {
|
|
27
|
+
variables.push({ name: `spacing/${v}`, type: 'FLOAT', value: v, scopes: ['GAP', 'ALL_SCOPES'] });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Border radius
|
|
31
|
+
for (const r of design.borders.radii) {
|
|
32
|
+
variables.push({ name: `radius/${r.label}`, type: 'FLOAT', value: r.value, scopes: ['CORNER_RADIUS'] });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Font sizes
|
|
36
|
+
for (const s of design.typography.scale.slice(0, 12)) {
|
|
37
|
+
variables.push({ name: `fontSize/${s.size}`, type: 'FLOAT', value: s.size, scopes: ['FONT_SIZE'] });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const collection = {
|
|
41
|
+
name: `Design Language — ${design.meta.title || 'Extracted'}`,
|
|
42
|
+
modes: [{ name: 'Default', variables }],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Add dark mode if available
|
|
46
|
+
if (design.darkMode) {
|
|
47
|
+
const darkVars = [];
|
|
48
|
+
const dc = design.darkMode.colors;
|
|
49
|
+
if (dc.primary) darkVars.push(colorVar('color/primary', dc.primary.hex));
|
|
50
|
+
if (dc.secondary) darkVars.push(colorVar('color/secondary', dc.secondary.hex));
|
|
51
|
+
for (let i = 0; i < dc.neutrals.length && i < 10; i++) {
|
|
52
|
+
darkVars.push(colorVar(`color/neutral/${i * 100 || 50}`, dc.neutrals[i].hex));
|
|
53
|
+
}
|
|
54
|
+
for (let i = 0; i < dc.backgrounds.length; i++) {
|
|
55
|
+
darkVars.push(colorVar(`color/background/${i === 0 ? 'default' : i}`, dc.backgrounds[i]));
|
|
56
|
+
}
|
|
57
|
+
for (let i = 0; i < dc.text.length && i < 5; i++) {
|
|
58
|
+
darkVars.push(colorVar(`color/text/${i === 0 ? 'default' : i}`, dc.text[i]));
|
|
59
|
+
}
|
|
60
|
+
collection.modes.push({ name: 'Dark', variables: darkVars });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return JSON.stringify(collection, null, 2);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function colorVar(name, hex) {
|
|
67
|
+
const rgb = hexToRgb(hex);
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
type: 'COLOR',
|
|
71
|
+
value: { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255, a: 1 },
|
|
72
|
+
scopes: ['ALL_SCOPES'],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hexToRgb(hex) {
|
|
77
|
+
const h = hex.replace('#', '');
|
|
78
|
+
return {
|
|
79
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
80
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
81
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -7,7 +7,7 @@ export function formatMarkdown(design) {
|
|
|
7
7
|
lines.push(`# Design Language: ${meta.title || 'Unknown Site'}`);
|
|
8
8
|
lines.push('');
|
|
9
9
|
lines.push(`> Extracted from \`${meta.url}\` on ${new Date(meta.timestamp).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`);
|
|
10
|
-
lines.push(`> ${meta.elementCount} elements analyzed`);
|
|
10
|
+
lines.push(`> ${meta.elementCount} elements analyzed${meta.pagesAnalyzed > 1 ? ` across ${meta.pagesAnalyzed} pages` : ''}`);
|
|
11
11
|
lines.push('');
|
|
12
12
|
lines.push('This document describes the complete design language of the website. It is structured for AI/LLM consumption — use it to faithfully recreate the visual design in any framework.');
|
|
13
13
|
lines.push('');
|
|
@@ -259,6 +259,41 @@ export function formatMarkdown(design) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// ── Accessibility ──
|
|
263
|
+
if (design.accessibility) {
|
|
264
|
+
const a = design.accessibility;
|
|
265
|
+
lines.push('## Accessibility (WCAG 2.1)');
|
|
266
|
+
lines.push('');
|
|
267
|
+
lines.push(`**Overall Score: ${a.score}%** — ${a.passCount} passing, ${a.failCount} failing color pairs`);
|
|
268
|
+
lines.push('');
|
|
269
|
+
|
|
270
|
+
if (a.pairs.length > 0) {
|
|
271
|
+
const failures = a.pairs.filter(p => p.level === 'FAIL');
|
|
272
|
+
if (failures.length > 0) {
|
|
273
|
+
lines.push('### Failing Color Pairs');
|
|
274
|
+
lines.push('');
|
|
275
|
+
lines.push('| Foreground | Background | Ratio | Level | Used On |');
|
|
276
|
+
lines.push('|------------|------------|-------|-------|---------|');
|
|
277
|
+
for (const p of failures.slice(0, 15)) {
|
|
278
|
+
lines.push(`| \`${p.foreground}\` | \`${p.background}\` | ${p.ratio}:1 | ${p.level} | ${p.elements.join(', ')} (${p.count}x) |`);
|
|
279
|
+
}
|
|
280
|
+
lines.push('');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const passes = a.pairs.filter(p => p.level !== 'FAIL');
|
|
284
|
+
if (passes.length > 0) {
|
|
285
|
+
lines.push('### Passing Color Pairs');
|
|
286
|
+
lines.push('');
|
|
287
|
+
lines.push('| Foreground | Background | Ratio | Level |');
|
|
288
|
+
lines.push('|------------|------------|-------|-------|');
|
|
289
|
+
for (const p of passes.slice(0, 10)) {
|
|
290
|
+
lines.push(`| \`${p.foreground}\` | \`${p.background}\` | ${p.ratio}:1 | ${p.level} |`);
|
|
291
|
+
}
|
|
292
|
+
lines.push('');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
262
297
|
// ── Dark Mode ──
|
|
263
298
|
if (design.darkMode) {
|
|
264
299
|
lines.push('## Dark Mode');
|