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.
@@ -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, W3C design tokens, Tailwind config, and CSS variables. Use when user says 'extract design', 'get design system', 'design language', 'design tokens', 'what colors does this site use', 'what font does this site use', or '/extract-design'."
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
- ## Process
11
+ ## Prerequisites
12
+
13
+ Ensure `designlang` is available. Install if needed:
14
+
15
+ ```bash
16
+ npm install -g designlang
17
+ ```
13
18
 
14
- 1. **Run the extraction CLI** on the provided URL:
19
+ Or use npx (no install required):
15
20
 
16
21
  ```bash
17
- cd "${CLAUDE_SKILL_DIR}/../.." && node bin/design-extract.js $ARGUMENTS
22
+ npx designlang <url>
18
23
  ```
19
24
 
20
- If dependencies are not installed, run first:
25
+ ## Process
26
+
27
+ 1. **Run the extraction** on the provided URL:
28
+
21
29
  ```bash
22
- cd "${CLAUDE_SKILL_DIR}/../.." && npm install
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 (with hex codes)
43
+ - Primary color palette with hex codes
33
44
  - Font families in use
34
45
  - Spacing system (base unit if detected)
35
- - Number of component patterns found
36
- - Any notable design decisions (shadows, border-radius scale, etc.)
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 `tailwind.config.js` into the user's project
40
- - Import `variables.css` into their stylesheet
41
- - Use `design-tokens.json` for tooling integration
42
- - Use the markdown file as a reference for AI-assisted development
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 describing the full design system |
51
- | `*-design-tokens.json` | W3C Design Tokens format for tooling |
52
- | `*-tailwind.config.js` | Ready-to-use Tailwind CSS theme extension |
53
- | `*-variables.css` | CSS custom properties for direct use |
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
- - `--out <dir>` Output directory (default: `./design-extract-output`)
58
- - `--dark` — Also extract dark mode color scheme
59
- - `--wait <ms>` Wait time after page load for SPAs
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
- return { url, title, light: lightData, dark: darkData };
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
- // 2. Extract CSS custom properties from :root
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 stylesheet, skip */ }
231
+ } catch { /* cross-origin */ }
118
232
  }
119
- } catch { /* no stylesheets accessible */ }
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
- // 3. Extract media queries from stylesheets
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
- // 4. Extract keyframes
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
+ }