designlang 2.0.0 → 4.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/clone.js ADDED
@@ -0,0 +1,218 @@
1
+ // Clone command — generate a working Next.js starter from extracted design
2
+
3
+ import { mkdirSync, writeFileSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ export function generateClone(design, outDir) {
7
+ const projectDir = outDir;
8
+ mkdirSync(join(projectDir, 'src/app'), { recursive: true });
9
+ mkdirSync(join(projectDir, 'public'), { recursive: true });
10
+
11
+ const { colors, typography, spacing, borders, shadows } = design;
12
+
13
+ // Package.json
14
+ writeFileSync(join(projectDir, 'package.json'), JSON.stringify({
15
+ name: `${design.meta.title?.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 40) || 'cloned-design'}-clone`,
16
+ version: '0.1.0',
17
+ private: true,
18
+ scripts: {
19
+ dev: 'next dev',
20
+ build: 'next build',
21
+ start: 'next start',
22
+ },
23
+ dependencies: {
24
+ next: '^15.0.0',
25
+ react: '^19.0.0',
26
+ 'react-dom': '^19.0.0',
27
+ },
28
+ devDependencies: {
29
+ tailwindcss: '^4.0.0',
30
+ '@tailwindcss/postcss': '^4.0.0',
31
+ },
32
+ }, null, 2), 'utf-8');
33
+
34
+ // Globals CSS with extracted design tokens
35
+ const primaryHex = colors.primary?.hex || '#3b82f6';
36
+ const secondaryHex = colors.secondary?.hex || '#8b5cf6';
37
+ const accentHex = colors.accent?.hex || '#f59e0b';
38
+ const bgColor = colors.backgrounds[0] || '#ffffff';
39
+ const textColor = colors.text[0] || '#171717';
40
+ const fontFamily = typography.families[0]?.name || 'Inter';
41
+ const monoFont = typography.families.find(f => f.name.toLowerCase().includes('mono'))?.name || 'monospace';
42
+ const radiusMd = borders.radii.find(r => r.label === 'md')?.value || 8;
43
+ const shadowMd = shadows.values.find(s => s.label === 'md')?.raw || '0 4px 6px rgba(0,0,0,0.1)';
44
+
45
+ const neutrals = colors.neutrals.slice(0, 5);
46
+
47
+ writeFileSync(join(projectDir, 'src/app/globals.css'), `@import "tailwindcss";
48
+
49
+ :root {
50
+ --color-primary: ${primaryHex};
51
+ --color-secondary: ${secondaryHex};
52
+ --color-accent: ${accentHex};
53
+ --color-background: ${bgColor};
54
+ --color-foreground: ${textColor};
55
+ ${neutrals.map((n, i) => ` --color-neutral-${i + 1}: ${n.hex};`).join('\n')}
56
+ --font-sans: '${fontFamily}', system-ui, sans-serif;
57
+ --font-mono: '${monoFont}', monospace;
58
+ --radius: ${radiusMd}px;
59
+ --shadow: ${shadowMd};
60
+ }
61
+
62
+ body {
63
+ background: var(--color-background);
64
+ color: var(--color-foreground);
65
+ font-family: var(--font-sans);
66
+ }
67
+ `, 'utf-8');
68
+
69
+ // Layout
70
+ writeFileSync(join(projectDir, 'src/app/layout.js'), `export const metadata = {
71
+ title: '${(design.meta.title || 'Cloned Design').replace(/'/g, "\\'")}',
72
+ description: 'Design cloned from ${design.meta.url}',
73
+ };
74
+
75
+ export default function RootLayout({ children }) {
76
+ return (
77
+ <html lang="en">
78
+ <head>
79
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
80
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
81
+ <link href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontFamily)}:wght@400;500;600;700&display=swap" rel="stylesheet" />
82
+ </head>
83
+ <body>{children}</body>
84
+ </html>
85
+ );
86
+ }
87
+ `, 'utf-8');
88
+
89
+ // Demo page showcasing the design system
90
+ const headingScale = typography.headings.slice(0, 3);
91
+ const bodySize = typography.body?.size || 16;
92
+ const spacingVals = spacing.scale.slice(0, 8);
93
+
94
+ writeFileSync(join(projectDir, 'src/app/page.js'), `import './globals.css';
95
+
96
+ export default function Home() {
97
+ return (
98
+ <main style={{ maxWidth: '1200px', margin: '0 auto', padding: '48px 24px' }}>
99
+ {/* Hero */}
100
+ <section style={{ textAlign: 'center', padding: '80px 0' }}>
101
+ <h1 style={{
102
+ fontSize: '${headingScale[0]?.size || 48}px',
103
+ fontWeight: ${headingScale[0]?.weight || 700},
104
+ lineHeight: '${headingScale[0]?.lineHeight || '1.1'}',
105
+ color: 'var(--color-foreground)',
106
+ marginBottom: '16px',
107
+ }}>
108
+ Design System Clone
109
+ </h1>
110
+ <p style={{
111
+ fontSize: '${bodySize + 4}px',
112
+ color: 'var(--color-neutral-1)',
113
+ maxWidth: '600px',
114
+ margin: '0 auto 32px',
115
+ }}>
116
+ Extracted from <a href="${design.meta.url}" style={{ color: 'var(--color-primary)' }}>${design.meta.url}</a>
117
+ </p>
118
+ <div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
119
+ <button style={{
120
+ background: 'var(--color-primary)',
121
+ color: '#fff',
122
+ border: 'none',
123
+ padding: '12px 24px',
124
+ borderRadius: 'var(--radius)',
125
+ fontSize: '${bodySize}px',
126
+ fontWeight: 500,
127
+ cursor: 'pointer',
128
+ }}>
129
+ Primary Button
130
+ </button>
131
+ <button style={{
132
+ background: 'transparent',
133
+ color: 'var(--color-foreground)',
134
+ border: '1px solid var(--color-neutral-${Math.min(neutrals.length, 3)})',
135
+ padding: '12px 24px',
136
+ borderRadius: 'var(--radius)',
137
+ fontSize: '${bodySize}px',
138
+ fontWeight: 500,
139
+ cursor: 'pointer',
140
+ }}>
141
+ Secondary Button
142
+ </button>
143
+ </div>
144
+ </section>
145
+
146
+ {/* Color Palette */}
147
+ <section style={{ padding: '48px 0' }}>
148
+ <h2 style={{ fontSize: '${headingScale[1]?.size || 24}px', fontWeight: 600, marginBottom: '24px' }}>Color Palette</h2>
149
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: '12px' }}>
150
+ <div style={{ background: 'var(--color-primary)', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '#fff', fontSize: '12px' }}>Primary<br/>${primaryHex}</div>
151
+ <div style={{ background: 'var(--color-secondary)', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '#fff', fontSize: '12px' }}>Secondary<br/>${secondaryHex}</div>
152
+ <div style={{ background: 'var(--color-accent)', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '#fff', fontSize: '12px' }}>Accent<br/>${accentHex}</div>
153
+ ${neutrals.map((n, i) => ` <div style={{ background: '${n.hex}', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '${n.hsl.l > 50 ? '#000' : '#fff'}', fontSize: '12px' }}>Neutral ${i + 1}<br/>${n.hex}</div>`).join('\n')}
154
+ </div>
155
+ </section>
156
+
157
+ {/* Typography */}
158
+ <section style={{ padding: '48px 0' }}>
159
+ <h2 style={{ fontSize: '${headingScale[1]?.size || 24}px', fontWeight: 600, marginBottom: '24px' }}>Typography</h2>
160
+ ${headingScale.map((h, i) => ` <p style={{ fontSize: '${h.size}px', fontWeight: ${h.weight}, lineHeight: '${h.lineHeight}', marginBottom: '16px' }}>Heading ${i + 1} — ${h.size}px / ${h.weight}</p>`).join('\n')}
161
+ <p style={{ fontSize: '${bodySize}px', lineHeight: '1.6', color: 'var(--color-neutral-1)', marginTop: '24px' }}>
162
+ Body text at ${bodySize}px. This is what most content on the site looks like.
163
+ The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.
164
+ </p>
165
+ </section>
166
+
167
+ {/* Cards */}
168
+ <section style={{ padding: '48px 0' }}>
169
+ <h2 style={{ fontSize: '${headingScale[1]?.size || 24}px', fontWeight: 600, marginBottom: '24px' }}>Cards</h2>
170
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '24px' }}>
171
+ {[1, 2, 3].map(i => (
172
+ <div key={i} style={{
173
+ background: 'var(--color-background)',
174
+ border: '1px solid var(--color-neutral-${Math.min(neutrals.length, 3)})',
175
+ borderRadius: 'var(--radius)',
176
+ padding: '24px',
177
+ boxShadow: 'var(--shadow)',
178
+ }}>
179
+ <h3 style={{ fontSize: '${(headingScale[2]?.size || 18)}px', fontWeight: 600, marginBottom: '8px' }}>Card Title {i}</h3>
180
+ <p style={{ fontSize: '${bodySize}px', color: 'var(--color-neutral-1)', lineHeight: '1.5' }}>
181
+ This card uses the extracted border radius, shadow, and spacing values from the original site.
182
+ </p>
183
+ </div>
184
+ ))}
185
+ </div>
186
+ </section>
187
+
188
+ {/* Footer */}
189
+ <footer style={{ padding: '48px 0', borderTop: '1px solid var(--color-neutral-${Math.min(neutrals.length, 3)})', marginTop: '48px', textAlign: 'center' }}>
190
+ <p style={{ fontSize: '${bodySize - 2}px', color: 'var(--color-neutral-1)' }}>
191
+ Design extracted from ${design.meta.url} with <a href="https://github.com/Manavarya09/design-extract" style={{ color: 'var(--color-primary)' }}>designlang</a>
192
+ </p>
193
+ </footer>
194
+ </main>
195
+ );
196
+ }
197
+ `, 'utf-8');
198
+
199
+ // Next config
200
+ writeFileSync(join(projectDir, 'next.config.mjs'), `/** @type {import('next').NextConfig} */
201
+ const nextConfig = {};
202
+ export default nextConfig;
203
+ `, 'utf-8');
204
+
205
+ // PostCSS config for Tailwind v4
206
+ writeFileSync(join(projectDir, 'postcss.config.mjs'), `const config = {
207
+ plugins: {
208
+ "@tailwindcss/postcss": {},
209
+ },
210
+ };
211
+ export default config;
212
+ `, 'utf-8');
213
+
214
+ return {
215
+ dir: projectDir,
216
+ files: ['package.json', 'src/app/globals.css', 'src/app/layout.js', 'src/app/page.js', 'next.config.mjs', 'postcss.config.mjs'],
217
+ };
218
+ }
package/src/crawler.js CHANGED
@@ -203,6 +203,13 @@ async function extractPageData(page) {
203
203
  animation: cs.animation,
204
204
  display: cs.display,
205
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,
206
213
  });
207
214
  }
208
215
 
@@ -45,6 +45,92 @@ export function extractComponents(computedStyles) {
45
45
  };
46
46
  }
47
47
 
48
+ // Navigation
49
+ const navs = computedStyles.filter(el =>
50
+ el.tag === 'nav' || el.role === 'navigation' ||
51
+ /nav|navbar|header/i.test(el.classList)
52
+ );
53
+ if (navs.length > 0) {
54
+ components.navigation = {
55
+ count: navs.length,
56
+ baseStyle: mostCommonStyle(navs, ['backgroundColor', 'color', 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'position', 'boxShadow']),
57
+ };
58
+ }
59
+
60
+ // Footer
61
+ const footers = computedStyles.filter(el =>
62
+ el.tag === 'footer' || el.role === 'contentinfo' ||
63
+ /footer/i.test(el.classList)
64
+ );
65
+ if (footers.length > 0) {
66
+ components.footer = {
67
+ count: footers.length,
68
+ baseStyle: mostCommonStyle(footers, ['backgroundColor', 'color', 'paddingTop', 'paddingBottom', 'fontSize']),
69
+ };
70
+ }
71
+
72
+ // Modals / Dialogs
73
+ const modals = computedStyles.filter(el =>
74
+ el.tag === 'dialog' || el.role === 'dialog' || el.role === 'alertdialog' ||
75
+ /modal|dialog|overlay|popup/i.test(el.classList)
76
+ );
77
+ if (modals.length > 0) {
78
+ components.modals = {
79
+ count: modals.length,
80
+ baseStyle: mostCommonStyle(modals, ['backgroundColor', 'borderRadius', 'boxShadow', 'paddingTop', 'paddingRight', 'maxWidth']),
81
+ };
82
+ }
83
+
84
+ // Dropdowns / Menus
85
+ const dropdowns = computedStyles.filter(el =>
86
+ el.role === 'menu' || el.role === 'listbox' ||
87
+ /dropdown|menu|popover|combobox/i.test(el.classList)
88
+ );
89
+ if (dropdowns.length > 0) {
90
+ components.dropdowns = {
91
+ count: dropdowns.length,
92
+ baseStyle: mostCommonStyle(dropdowns, ['backgroundColor', 'borderRadius', 'boxShadow', 'borderColor', 'paddingTop']),
93
+ };
94
+ }
95
+
96
+ // Tables
97
+ const tables = computedStyles.filter(el => el.tag === 'table' || el.role === 'table');
98
+ const tableCells = computedStyles.filter(el => ['td', 'th'].includes(el.tag));
99
+ if (tables.length > 0 || tableCells.length > 10) {
100
+ components.tables = {
101
+ count: tables.length,
102
+ cellCount: tableCells.length,
103
+ baseStyle: {
104
+ ...mostCommonStyle(tables, ['borderColor', 'backgroundColor']),
105
+ cellStyle: mostCommonStyle(tableCells, ['paddingTop', 'paddingRight', 'borderColor', 'fontSize']),
106
+ },
107
+ };
108
+ }
109
+
110
+ // Badges / Tags / Pills
111
+ const badges = computedStyles.filter(el =>
112
+ /badge|tag|pill|chip|label/i.test(el.classList) &&
113
+ el.area < 5000 && el.area > 100
114
+ );
115
+ if (badges.length > 0) {
116
+ components.badges = {
117
+ count: badges.length,
118
+ baseStyle: mostCommonStyle(badges, ['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderRadius']),
119
+ };
120
+ }
121
+
122
+ // Avatars
123
+ const avatars = computedStyles.filter(el =>
124
+ /avatar/i.test(el.classList) ||
125
+ (el.tag === 'img' && el.borderRadius === '9999px' && el.area < 10000 && el.area > 400)
126
+ );
127
+ if (avatars.length > 0) {
128
+ components.avatars = {
129
+ count: avatars.length,
130
+ baseStyle: mostCommonStyle(avatars, ['borderRadius', 'backgroundColor']),
131
+ };
132
+ }
133
+
48
134
  return components;
49
135
  }
50
136
 
@@ -0,0 +1,128 @@
1
+ // Interaction state extraction — hover, focus, active styles
2
+
3
+ import { chromium } from 'playwright';
4
+
5
+ export async function captureInteractions(url, options = {}) {
6
+ const { width = 1280, height = 800, wait = 0 } = options;
7
+ const browser = await chromium.launch({ headless: true });
8
+ const context = await browser.newContext({ viewport: { width, height } });
9
+ const page = await context.newPage();
10
+
11
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
12
+ if (wait > 0) await page.waitForTimeout(wait);
13
+ await page.evaluate(() => document.fonts.ready);
14
+
15
+ const results = { buttons: [], links: [], inputs: [] };
16
+
17
+ // Extract button states
18
+ const buttons = await page.$$('button, [role="button"], a[class*="btn"]');
19
+ for (const btn of buttons.slice(0, 10)) {
20
+ try {
21
+ const base = await getStyles(page, btn);
22
+ if (!base || base.display === 'none') continue;
23
+
24
+ // Hover
25
+ await btn.hover();
26
+ await page.waitForTimeout(100);
27
+ const hover = await getStyles(page, btn);
28
+
29
+ // Focus
30
+ await btn.focus();
31
+ await page.waitForTimeout(100);
32
+ const focus = await getStyles(page, btn);
33
+
34
+ const diffs = diffStates(base, hover, focus);
35
+ if (diffs.hasChanges) {
36
+ results.buttons.push({ text: base.text, base: base.styles, ...diffs });
37
+ }
38
+ } catch { /* skip */ }
39
+ }
40
+
41
+ // Extract link states
42
+ const links = await page.$$('a:not([role="button"]):not([class*="btn"])');
43
+ for (const link of links.slice(0, 10)) {
44
+ try {
45
+ const base = await getStyles(page, link);
46
+ if (!base || base.display === 'none') continue;
47
+
48
+ await link.hover();
49
+ await page.waitForTimeout(100);
50
+ const hover = await getStyles(page, link);
51
+
52
+ const diffs = diffStates(base, hover, null);
53
+ if (diffs.hasChanges) {
54
+ results.links.push({ text: base.text, base: base.styles, ...diffs });
55
+ break; // One link pattern is enough
56
+ }
57
+ } catch { /* skip */ }
58
+ }
59
+
60
+ // Extract input states
61
+ const inputs = await page.$$('input[type="text"], input[type="email"], input[type="search"], textarea');
62
+ for (const input of inputs.slice(0, 5)) {
63
+ try {
64
+ const base = await getStyles(page, input);
65
+ if (!base || base.display === 'none') continue;
66
+
67
+ await input.focus();
68
+ await page.waitForTimeout(100);
69
+ const focus = await getStyles(page, input);
70
+
71
+ const diffs = diffStates(base, null, focus);
72
+ if (diffs.hasChanges) {
73
+ results.inputs.push({ base: base.styles, ...diffs });
74
+ break; // One input pattern is enough
75
+ }
76
+ } catch { /* skip */ }
77
+ }
78
+
79
+ await browser.close();
80
+
81
+ return results;
82
+ }
83
+
84
+ async function getStyles(page, element) {
85
+ return element.evaluate(el => {
86
+ const cs = getComputedStyle(el);
87
+ if (cs.display === 'none' || cs.visibility === 'hidden') return null;
88
+ return {
89
+ text: el.textContent?.trim().slice(0, 30) || '',
90
+ display: cs.display,
91
+ styles: {
92
+ color: cs.color,
93
+ backgroundColor: cs.backgroundColor,
94
+ borderColor: cs.borderColor,
95
+ boxShadow: cs.boxShadow,
96
+ transform: cs.transform,
97
+ opacity: cs.opacity,
98
+ outline: cs.outline,
99
+ textDecoration: cs.textDecoration,
100
+ scale: cs.scale,
101
+ },
102
+ };
103
+ });
104
+ }
105
+
106
+ function diffStates(base, hover, focus) {
107
+ const result = { hasChanges: false, hover: {}, focus: {} };
108
+
109
+ if (hover) {
110
+ for (const [prop, val] of Object.entries(hover.styles)) {
111
+ if (val !== base.styles[prop] && val !== 'none' && val !== 'auto') {
112
+ result.hover[prop] = { from: base.styles[prop], to: val };
113
+ result.hasChanges = true;
114
+ }
115
+ }
116
+ }
117
+
118
+ if (focus) {
119
+ for (const [prop, val] of Object.entries(focus.styles)) {
120
+ if (val !== base.styles[prop] && val !== 'none' && val !== 'auto') {
121
+ result.focus[prop] = { from: base.styles[prop], to: val };
122
+ result.hasChanges = true;
123
+ }
124
+ }
125
+ }
126
+
127
+ return result;
128
+ }
@@ -0,0 +1,114 @@
1
+ // Layout extraction — grid, flexbox, container patterns, structural design language
2
+
3
+ export function extractLayout(computedStyles) {
4
+ const containers = [];
5
+ const gridPatterns = [];
6
+ const flexPatterns = [];
7
+ const columnCounts = new Map();
8
+
9
+ for (const el of computedStyles) {
10
+ const isGrid = el.display === 'grid' || el.display === 'inline-grid';
11
+ const isFlex = el.display === 'flex' || el.display === 'inline-flex';
12
+
13
+ if (isGrid) {
14
+ gridPatterns.push({
15
+ tag: el.tag,
16
+ classList: el.classList,
17
+ gridTemplateColumns: el.gridTemplateColumns || 'none',
18
+ gridTemplateRows: el.gridTemplateRows || 'none',
19
+ gap: el.gap,
20
+ area: el.area,
21
+ });
22
+
23
+ // Count column patterns
24
+ const cols = el.gridTemplateColumns;
25
+ if (cols && cols !== 'none') {
26
+ const colCount = cols.split(/\s+/).filter(v => v && v !== 'none').length;
27
+ if (colCount > 0) columnCounts.set(colCount, (columnCounts.get(colCount) || 0) + 1);
28
+ }
29
+ }
30
+
31
+ if (isFlex) {
32
+ flexPatterns.push({
33
+ tag: el.tag,
34
+ classList: el.classList,
35
+ flexDirection: el.flexDirection || 'row',
36
+ flexWrap: el.flexWrap || 'nowrap',
37
+ justifyContent: el.justifyContent || 'normal',
38
+ alignItems: el.alignItems || 'normal',
39
+ gap: el.gap,
40
+ area: el.area,
41
+ });
42
+ }
43
+
44
+ // Detect containers (large centered elements)
45
+ if (el.area > 100000 && el.maxWidth && el.maxWidth !== 'none') {
46
+ containers.push({
47
+ tag: el.tag,
48
+ classList: el.classList,
49
+ maxWidth: el.maxWidth,
50
+ paddingLeft: el.paddingLeft,
51
+ paddingRight: el.paddingRight,
52
+ });
53
+ }
54
+ }
55
+
56
+ // Summarize flex direction usage
57
+ const flexDirections = {};
58
+ for (const f of flexPatterns) {
59
+ const key = `${f.flexDirection}/${f.flexWrap}`;
60
+ flexDirections[key] = (flexDirections[key] || 0) + 1;
61
+ }
62
+
63
+ // Summarize justify/align patterns
64
+ const justifyPatterns = {};
65
+ const alignPatterns = {};
66
+ for (const f of flexPatterns) {
67
+ justifyPatterns[f.justifyContent] = (justifyPatterns[f.justifyContent] || 0) + 1;
68
+ alignPatterns[f.alignItems] = (alignPatterns[f.alignItems] || 0) + 1;
69
+ }
70
+
71
+ // Grid column summary
72
+ const gridColumns = [...columnCounts.entries()]
73
+ .sort((a, b) => b[1] - a[1])
74
+ .map(([cols, count]) => ({ columns: cols, count }));
75
+
76
+ // Container widths
77
+ const containerWidths = [];
78
+ const widthSet = new Set();
79
+ for (const c of containers) {
80
+ if (!widthSet.has(c.maxWidth)) {
81
+ widthSet.add(c.maxWidth);
82
+ containerWidths.push({ maxWidth: c.maxWidth, padding: c.paddingLeft });
83
+ }
84
+ }
85
+
86
+ // Gap values
87
+ const gaps = new Set();
88
+ for (const el of [...gridPatterns, ...flexPatterns]) {
89
+ if (el.gap && el.gap !== 'normal' && el.gap !== '0px') {
90
+ gaps.add(el.gap);
91
+ }
92
+ }
93
+
94
+ return {
95
+ gridCount: gridPatterns.length,
96
+ flexCount: flexPatterns.length,
97
+ gridColumns,
98
+ flexDirections,
99
+ justifyPatterns,
100
+ alignPatterns,
101
+ containerWidths,
102
+ gaps: [...gaps].sort(),
103
+ // Sample grid patterns (top 5 by area)
104
+ topGrids: gridPatterns
105
+ .sort((a, b) => b.area - a.area)
106
+ .slice(0, 5)
107
+ .map(g => ({ columns: g.gridTemplateColumns, rows: g.gridTemplateRows, gap: g.gap })),
108
+ // Sample flex patterns (top 5 by area)
109
+ topFlex: flexPatterns
110
+ .sort((a, b) => b.area - a.area)
111
+ .slice(0, 5)
112
+ .map(f => ({ direction: f.flexDirection, wrap: f.flexWrap, justify: f.justifyContent, align: f.alignItems, gap: f.gap })),
113
+ };
114
+ }