designlang 1.0.0 → 3.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 +247 -10
- package/package.json +1 -1
- package/skills/extract-design/SKILL.md +52 -25
- package/src/crawler.js +132 -20
- package/src/diff.js +146 -0
- package/src/extractors/accessibility.js +95 -0
- package/src/extractors/interactions.js +128 -0
- package/src/extractors/layout.js +114 -0
- package/src/extractors/responsive.js +132 -0
- package/src/formatters/figma.js +83 -0
- package/src/formatters/markdown.js +183 -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 +15 -0
- package/src/multibrand.js +151 -0
- package/src/sync.js +69 -0
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: extract-design
|
|
3
|
-
description: "Extract the full design language from any website URL. Produces AI-optimized markdown,
|
|
4
|
-
argument-hint: "<url> [--dark] [--out <dir>]"
|
|
3
|
+
description: "Extract the full design language from any website URL. Produces 8 output files including AI-optimized markdown, visual HTML preview, Tailwind config, React theme, shadcn/ui theme, Figma variables, W3C design tokens, and CSS variables. Also runs WCAG accessibility scoring. Use when user says 'extract design', 'get design system', 'design language', 'design tokens', 'what colors/fonts does this site use', or '/extract-design'."
|
|
5
4
|
allowed-tools: Bash, Read, Write, Glob
|
|
6
5
|
---
|
|
7
6
|
|
|
8
7
|
# Extract Design Language
|
|
9
8
|
|
|
10
|
-
Extract the complete design language from any website URL.
|
|
9
|
+
Extract the complete design language from any website URL. Generates 8 output files covering colors, typography, spacing, shadows, components, breakpoints, animations, and accessibility.
|
|
11
10
|
|
|
12
|
-
##
|
|
11
|
+
## Prerequisites
|
|
12
|
+
|
|
13
|
+
Ensure `designlang` is available. Install if needed:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g designlang
|
|
17
|
+
```
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
Or use npx (no install required):
|
|
15
20
|
|
|
16
21
|
```bash
|
|
17
|
-
|
|
22
|
+
npx designlang <url>
|
|
18
23
|
```
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
## Process
|
|
26
|
+
|
|
27
|
+
1. **Run the extraction** on the provided URL:
|
|
28
|
+
|
|
21
29
|
```bash
|
|
22
|
-
|
|
30
|
+
npx designlang <url> --screenshots
|
|
23
31
|
```
|
|
24
32
|
|
|
33
|
+
For multi-page crawling: `npx designlang <url> --depth 3 --screenshots`
|
|
34
|
+
For dark mode: `npx designlang <url> --dark --screenshots`
|
|
35
|
+
|
|
25
36
|
2. **Read the generated markdown file** to understand the design:
|
|
26
37
|
|
|
27
38
|
```bash
|
|
@@ -29,31 +40,47 @@ cat design-extract-output/*-design-language.md
|
|
|
29
40
|
```
|
|
30
41
|
|
|
31
42
|
3. **Present key findings** to the user:
|
|
32
|
-
- Primary color palette
|
|
43
|
+
- Primary color palette with hex codes
|
|
33
44
|
- Font families in use
|
|
34
45
|
- Spacing system (base unit if detected)
|
|
35
|
-
-
|
|
36
|
-
-
|
|
46
|
+
- WCAG accessibility score
|
|
47
|
+
- Component patterns found
|
|
48
|
+
- Notable design decisions (shadows, radii, etc.)
|
|
37
49
|
|
|
38
50
|
4. **Offer next steps:**
|
|
39
|
-
- Copy
|
|
40
|
-
- Import
|
|
41
|
-
-
|
|
42
|
-
-
|
|
51
|
+
- Copy `*-tailwind.config.js` into their project
|
|
52
|
+
- Import `*-variables.css` into their stylesheet
|
|
53
|
+
- Paste `*-shadcn-theme.css` into globals.css for shadcn/ui users
|
|
54
|
+
- Import `*-theme.js` for React/CSS-in-JS projects
|
|
55
|
+
- Import `*-figma-variables.json` into Figma for designer handoff
|
|
56
|
+
- Open `*-preview.html` in a browser for a visual overview
|
|
57
|
+
- Use the markdown file as context for AI-assisted development
|
|
43
58
|
|
|
44
|
-
## Output Files
|
|
45
|
-
|
|
46
|
-
The tool generates 4 files in the output directory:
|
|
59
|
+
## Output Files (8)
|
|
47
60
|
|
|
48
61
|
| File | Purpose |
|
|
49
62
|
|------|---------|
|
|
50
|
-
| `*-design-language.md` | AI-optimized markdown
|
|
51
|
-
| `*-
|
|
52
|
-
| `*-
|
|
53
|
-
| `*-
|
|
63
|
+
| `*-design-language.md` | AI-optimized markdown — the full design system for LLMs |
|
|
64
|
+
| `*-preview.html` | Visual HTML report with swatches, type scale, shadows, a11y |
|
|
65
|
+
| `*-design-tokens.json` | W3C Design Tokens format |
|
|
66
|
+
| `*-tailwind.config.js` | Ready-to-use Tailwind CSS theme |
|
|
67
|
+
| `*-variables.css` | CSS custom properties |
|
|
68
|
+
| `*-figma-variables.json` | Figma Variables import format |
|
|
69
|
+
| `*-theme.js` | React/CSS-in-JS theme object |
|
|
70
|
+
| `*-shadcn-theme.css` | shadcn/ui theme CSS variables |
|
|
71
|
+
|
|
72
|
+
## Additional Commands
|
|
73
|
+
|
|
74
|
+
- **Compare two sites:** `npx designlang diff <urlA> <urlB>`
|
|
75
|
+
- **View history:** `npx designlang history <url>`
|
|
54
76
|
|
|
55
77
|
## Options
|
|
56
78
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
79
|
+
| Flag | Description |
|
|
80
|
+
|------|-------------|
|
|
81
|
+
| `--out <dir>` | Output directory (default: `./design-extract-output`) |
|
|
82
|
+
| `--dark` | Also extract dark mode color scheme |
|
|
83
|
+
| `--depth <n>` | Crawl N internal pages for site-wide extraction |
|
|
84
|
+
| `--screenshots` | Capture component screenshots (buttons, cards, nav) |
|
|
85
|
+
| `--wait <ms>` | Wait time after page load for SPAs |
|
|
86
|
+
| `--framework <type>` | Generate only specific theme (`react` or `shadcn`) |
|
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,
|
|
@@ -95,12 +203,18 @@ async function extractPageData(page) {
|
|
|
95
203
|
animation: cs.animation,
|
|
96
204
|
display: cs.display,
|
|
97
205
|
position: cs.position,
|
|
206
|
+
flexDirection: cs.flexDirection,
|
|
207
|
+
flexWrap: cs.flexWrap,
|
|
208
|
+
justifyContent: cs.justifyContent,
|
|
209
|
+
alignItems: cs.alignItems,
|
|
210
|
+
gridTemplateColumns: cs.gridTemplateColumns,
|
|
211
|
+
gridTemplateRows: cs.gridTemplateRows,
|
|
212
|
+
maxWidth: cs.maxWidth,
|
|
98
213
|
});
|
|
99
214
|
}
|
|
100
215
|
|
|
101
|
-
//
|
|
216
|
+
// CSS custom properties
|
|
102
217
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
103
|
-
// Get all custom properties by iterating stylesheets
|
|
104
218
|
try {
|
|
105
219
|
for (const sheet of document.styleSheets) {
|
|
106
220
|
try {
|
|
@@ -114,12 +228,10 @@ async function extractPageData(page) {
|
|
|
114
228
|
}
|
|
115
229
|
}
|
|
116
230
|
}
|
|
117
|
-
} catch { /* cross-origin
|
|
231
|
+
} catch { /* cross-origin */ }
|
|
118
232
|
}
|
|
119
|
-
} catch { /* no
|
|
233
|
+
} catch { /* no access */ }
|
|
120
234
|
|
|
121
|
-
// Also get any custom properties from the computed style
|
|
122
|
-
// (fallback for CSS-in-JS that sets vars on :root)
|
|
123
235
|
for (let i = 0; i < rootStyles.length; i++) {
|
|
124
236
|
const prop = rootStyles[i];
|
|
125
237
|
if (prop.startsWith('--') && !results.cssVariables[prop]) {
|
|
@@ -127,7 +239,7 @@ async function extractPageData(page) {
|
|
|
127
239
|
}
|
|
128
240
|
}
|
|
129
241
|
|
|
130
|
-
//
|
|
242
|
+
// Media queries
|
|
131
243
|
try {
|
|
132
244
|
for (const sheet of document.styleSheets) {
|
|
133
245
|
try {
|
|
@@ -140,7 +252,7 @@ async function extractPageData(page) {
|
|
|
140
252
|
}
|
|
141
253
|
} catch { /* no access */ }
|
|
142
254
|
|
|
143
|
-
//
|
|
255
|
+
// Keyframes
|
|
144
256
|
try {
|
|
145
257
|
for (const sheet of document.styleSheets) {
|
|
146
258
|
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
|
+
}
|