designlang 2.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/bin/design-extract.js +124 -2
- package/package.json +1 -1
- package/skills/extract-design/SKILL.md +52 -25
- package/src/crawler.js +7 -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/markdown.js +147 -0
- package/src/index.js +6 -0
- package/src/multibrand.js +151 -0
- package/src/sync.js +69 -0
package/bin/design-extract.js
CHANGED
|
@@ -15,6 +15,10 @@ import { formatFigma } from '../src/formatters/figma.js';
|
|
|
15
15
|
import { formatReactTheme, formatShadcnTheme } from '../src/formatters/theme.js';
|
|
16
16
|
import { diffDesigns, formatDiffMarkdown, formatDiffHtml } from '../src/diff.js';
|
|
17
17
|
import { saveSnapshot, getHistory, formatHistoryMarkdown } from '../src/history.js';
|
|
18
|
+
import { captureResponsive } from '../src/extractors/responsive.js';
|
|
19
|
+
import { captureInteractions } from '../src/extractors/interactions.js';
|
|
20
|
+
import { syncDesign } from '../src/sync.js';
|
|
21
|
+
import { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from '../src/multibrand.js';
|
|
18
22
|
import { nameFromUrl } from '../src/utils.js';
|
|
19
23
|
|
|
20
24
|
const program = new Command();
|
|
@@ -22,7 +26,7 @@ const program = new Command();
|
|
|
22
26
|
program
|
|
23
27
|
.name('designlang')
|
|
24
28
|
.description('Extract the complete design language from any website')
|
|
25
|
-
.version('
|
|
29
|
+
.version('3.0.0');
|
|
26
30
|
|
|
27
31
|
// ── Main command: extract ──────────────────────────────────────
|
|
28
32
|
program
|
|
@@ -36,6 +40,9 @@ program
|
|
|
36
40
|
.option('--depth <n>', 'number of internal pages to also crawl', parseInt, 0)
|
|
37
41
|
.option('--screenshots', 'capture component screenshots')
|
|
38
42
|
.option('--framework <type>', 'generate framework theme (react, shadcn)')
|
|
43
|
+
.option('--responsive', 'capture design at multiple breakpoints')
|
|
44
|
+
.option('--interactions', 'capture hover/focus/active states')
|
|
45
|
+
.option('--full', 'enable all extra captures (screenshots, responsive, interactions)')
|
|
39
46
|
.option('--no-history', 'skip saving to history')
|
|
40
47
|
.option('--verbose', 'show detailed progress')
|
|
41
48
|
.action(async (url, opts) => {
|
|
@@ -58,10 +65,22 @@ program
|
|
|
58
65
|
wait: opts.wait,
|
|
59
66
|
dark: opts.dark,
|
|
60
67
|
depth: opts.depth,
|
|
61
|
-
screenshots: opts.screenshots,
|
|
68
|
+
screenshots: opts.screenshots || opts.full,
|
|
62
69
|
outDir,
|
|
63
70
|
});
|
|
64
71
|
|
|
72
|
+
// Responsive capture
|
|
73
|
+
if (opts.responsive || opts.full) {
|
|
74
|
+
spinner.text = 'Capturing responsive breakpoints...';
|
|
75
|
+
design.responsive = await captureResponsive(url, { wait: opts.wait });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Interaction state capture
|
|
79
|
+
if (opts.interactions || opts.full) {
|
|
80
|
+
spinner.text = 'Capturing interaction states...';
|
|
81
|
+
design.interactions = await captureInteractions(url, { width: opts.width, height: parseInt(opts.height) || 800, wait: opts.wait });
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
spinner.text = 'Generating outputs...';
|
|
66
85
|
mkdirSync(outDir, { recursive: true });
|
|
67
86
|
|
|
@@ -125,6 +144,17 @@ program
|
|
|
125
144
|
console.log(` ${chalk.gray('Breakpoints:')} ${design.breakpoints.length} breakpoints`);
|
|
126
145
|
console.log(` ${chalk.gray('Components:')} ${Object.keys(design.components).length} patterns detected`);
|
|
127
146
|
console.log(` ${chalk.gray('CSS Vars:')} ${Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0)} custom properties`);
|
|
147
|
+
if (design.layout) {
|
|
148
|
+
console.log(` ${chalk.gray('Layout:')} ${design.layout.gridCount} grids, ${design.layout.flexCount} flex containers`);
|
|
149
|
+
}
|
|
150
|
+
if (design.responsive) {
|
|
151
|
+
console.log(` ${chalk.gray('Responsive:')} ${design.responsive.viewports.length} viewports, ${design.responsive.changes.length} breakpoint changes`);
|
|
152
|
+
}
|
|
153
|
+
if (design.interactions) {
|
|
154
|
+
const ic = design.interactions;
|
|
155
|
+
const total = ic.buttons.length + ic.links.length + ic.inputs.length;
|
|
156
|
+
console.log(` ${chalk.gray('Interactions:')} ${total} state changes captured`);
|
|
157
|
+
}
|
|
128
158
|
|
|
129
159
|
// Accessibility summary
|
|
130
160
|
if (design.accessibility) {
|
|
@@ -216,4 +246,96 @@ program
|
|
|
216
246
|
console.log(formatHistoryMarkdown(url, history));
|
|
217
247
|
});
|
|
218
248
|
|
|
249
|
+
// ── Brands command (multi-site comparison) ─────────────────
|
|
250
|
+
program
|
|
251
|
+
.command('brands <urls...>')
|
|
252
|
+
.description('Compare design languages across multiple brands')
|
|
253
|
+
.option('-o, --out <dir>', 'output directory', './design-brands-output')
|
|
254
|
+
.action(async (urls, opts) => {
|
|
255
|
+
console.log('');
|
|
256
|
+
console.log(chalk.bold(' designlang brands'));
|
|
257
|
+
console.log(chalk.gray(` Comparing ${urls.length} sites`));
|
|
258
|
+
console.log('');
|
|
259
|
+
|
|
260
|
+
const spinner = ora(`Extracting ${urls.length} sites...`).start();
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const brands = await compareBrands(urls);
|
|
264
|
+
|
|
265
|
+
const outDir = resolve(opts.out);
|
|
266
|
+
mkdirSync(outDir, { recursive: true });
|
|
267
|
+
|
|
268
|
+
const md = formatBrandMatrix(brands);
|
|
269
|
+
const html = formatBrandMatrixHtml(brands);
|
|
270
|
+
|
|
271
|
+
writeFileSync(join(outDir, 'brands.md'), md, 'utf-8');
|
|
272
|
+
writeFileSync(join(outDir, 'brands.html'), html, 'utf-8');
|
|
273
|
+
|
|
274
|
+
spinner.succeed('Brand comparison complete!');
|
|
275
|
+
console.log('');
|
|
276
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan('brands.md')} — Markdown matrix`);
|
|
277
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan('brands.html')} — Visual matrix`);
|
|
278
|
+
console.log('');
|
|
279
|
+
console.log(chalk.gray(` Saved to ${outDir}`));
|
|
280
|
+
|
|
281
|
+
// Quick summary
|
|
282
|
+
const valid = brands.filter(b => !b.error);
|
|
283
|
+
for (const b of valid) {
|
|
284
|
+
console.log(` ${chalk.cyan(b.hostname)}: ${b.design.colors.all.length} colors, ${b.design.typography.families.map(f => f.name).join(', ')}, ${b.design.accessibility?.score ?? '?'}% a11y`);
|
|
285
|
+
}
|
|
286
|
+
console.log('');
|
|
287
|
+
|
|
288
|
+
} catch (err) {
|
|
289
|
+
spinner.fail('Brand comparison failed');
|
|
290
|
+
console.error(chalk.red(`\n ${err.message}\n`));
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── Sync command ────────────────────────────────────────────
|
|
296
|
+
program
|
|
297
|
+
.command('sync <url>')
|
|
298
|
+
.description('Sync local design tokens with a live website')
|
|
299
|
+
.option('-o, --out <dir>', 'directory with token files to update', '.')
|
|
300
|
+
.action(async (url, opts) => {
|
|
301
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
302
|
+
|
|
303
|
+
console.log('');
|
|
304
|
+
console.log(chalk.bold(' designlang sync'));
|
|
305
|
+
console.log(chalk.gray(` ${url}`));
|
|
306
|
+
console.log('');
|
|
307
|
+
|
|
308
|
+
const spinner = ora('Extracting current design...').start();
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const result = await syncDesign(url, { out: resolve(opts.out) });
|
|
312
|
+
|
|
313
|
+
if (result.isFirstRun) {
|
|
314
|
+
spinner.succeed('First sync — baseline saved.');
|
|
315
|
+
} else if (result.changes.length === 0) {
|
|
316
|
+
spinner.succeed('No design changes detected.');
|
|
317
|
+
} else {
|
|
318
|
+
spinner.succeed(`${result.changes.length} design changes detected!`);
|
|
319
|
+
console.log('');
|
|
320
|
+
for (const c of result.changes) {
|
|
321
|
+
console.log(` ${chalk.yellow('≠')} ${c.property}: ${c.from} → ${c.to}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (result.updatedFiles.length > 0) {
|
|
326
|
+
console.log('');
|
|
327
|
+
console.log(chalk.bold(' Updated files:'));
|
|
328
|
+
for (const f of result.updatedFiles) {
|
|
329
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(f)}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
console.log('');
|
|
333
|
+
|
|
334
|
+
} catch (err) {
|
|
335
|
+
spinner.fail('Sync failed');
|
|
336
|
+
console.error(chalk.red(`\n ${err.message}\n`));
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
219
341
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Extract the complete design language from any website — colors, typography, spacing, shadows, and more. Outputs AI-optimized markdown, W3C design tokens, Tailwind config, and CSS variables.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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
|
@@ -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
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Responsive multi-breakpoint capture — extract at multiple viewports and diff
|
|
2
|
+
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
|
|
5
|
+
const VIEWPORTS = [
|
|
6
|
+
{ name: 'mobile', width: 375, height: 812 },
|
|
7
|
+
{ name: 'tablet', width: 768, height: 1024 },
|
|
8
|
+
{ name: 'desktop', width: 1280, height: 800 },
|
|
9
|
+
{ name: 'wide', width: 1920, height: 1080 },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export async function captureResponsive(url, options = {}) {
|
|
13
|
+
const { wait = 0 } = options;
|
|
14
|
+
const browser = await chromium.launch({ headless: true });
|
|
15
|
+
|
|
16
|
+
const snapshots = [];
|
|
17
|
+
|
|
18
|
+
for (const vp of VIEWPORTS) {
|
|
19
|
+
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height } });
|
|
20
|
+
const page = await context.newPage();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
|
|
24
|
+
if (wait > 0) await page.waitForTimeout(wait);
|
|
25
|
+
await page.evaluate(() => document.fonts.ready);
|
|
26
|
+
|
|
27
|
+
const data = await page.evaluate(() => {
|
|
28
|
+
const body = document.body;
|
|
29
|
+
const cs = getComputedStyle(body);
|
|
30
|
+
const html = document.documentElement;
|
|
31
|
+
const htmlCs = getComputedStyle(html);
|
|
32
|
+
|
|
33
|
+
// Collect key metrics at this viewport
|
|
34
|
+
const headings = {};
|
|
35
|
+
for (let i = 1; i <= 3; i++) {
|
|
36
|
+
const h = document.querySelector(`h1, h2, h3`.split(',')[i - 1]);
|
|
37
|
+
if (h) {
|
|
38
|
+
const hcs = getComputedStyle(h);
|
|
39
|
+
headings[`h${i}`] = { fontSize: hcs.fontSize, lineHeight: hcs.lineHeight };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Body font size
|
|
44
|
+
const bodyFontSize = cs.fontSize;
|
|
45
|
+
|
|
46
|
+
// Navigation visibility
|
|
47
|
+
const nav = document.querySelector('nav, [role="navigation"]');
|
|
48
|
+
const navVisible = nav ? getComputedStyle(nav).display !== 'none' : false;
|
|
49
|
+
const navHeight = nav ? nav.getBoundingClientRect().height : 0;
|
|
50
|
+
|
|
51
|
+
// Count visible grid/flex containers
|
|
52
|
+
let gridCount = 0, flexCount = 0;
|
|
53
|
+
const allEls = document.querySelectorAll('*');
|
|
54
|
+
const sample = Array.from(allEls).slice(0, 2000);
|
|
55
|
+
for (const el of sample) {
|
|
56
|
+
const d = getComputedStyle(el).display;
|
|
57
|
+
if (d === 'grid' || d === 'inline-grid') gridCount++;
|
|
58
|
+
if (d === 'flex' || d === 'inline-flex') flexCount++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Count columns in grids
|
|
62
|
+
const grids = document.querySelectorAll('*');
|
|
63
|
+
let maxColumns = 0;
|
|
64
|
+
for (const el of Array.from(grids).slice(0, 1000)) {
|
|
65
|
+
const gcs = getComputedStyle(el);
|
|
66
|
+
if (gcs.display === 'grid' && gcs.gridTemplateColumns && gcs.gridTemplateColumns !== 'none') {
|
|
67
|
+
const cols = gcs.gridTemplateColumns.split(/\s+/).length;
|
|
68
|
+
if (cols > maxColumns) maxColumns = cols;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check for hamburger menu
|
|
73
|
+
const hamburger = document.querySelector('[class*="hamburger"], [class*="menu-toggle"], [aria-label*="menu"], button[class*="mobile"]');
|
|
74
|
+
const hasHamburger = hamburger ? getComputedStyle(hamburger).display !== 'none' : false;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
bodyFontSize,
|
|
78
|
+
headings,
|
|
79
|
+
navVisible,
|
|
80
|
+
navHeight,
|
|
81
|
+
gridCount,
|
|
82
|
+
flexCount,
|
|
83
|
+
maxColumns,
|
|
84
|
+
hasHamburger,
|
|
85
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
snapshots.push({ ...vp, ...data });
|
|
90
|
+
} catch {
|
|
91
|
+
snapshots.push({ ...vp, error: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await context.close();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await browser.close();
|
|
98
|
+
|
|
99
|
+
// Build responsive map — what changes between breakpoints
|
|
100
|
+
const changes = [];
|
|
101
|
+
for (let i = 1; i < snapshots.length; i++) {
|
|
102
|
+
const prev = snapshots[i - 1];
|
|
103
|
+
const curr = snapshots[i];
|
|
104
|
+
if (prev.error || curr.error) continue;
|
|
105
|
+
|
|
106
|
+
const diffs = [];
|
|
107
|
+
if (prev.bodyFontSize !== curr.bodyFontSize) {
|
|
108
|
+
diffs.push({ property: 'Body font size', from: prev.bodyFontSize, to: curr.bodyFontSize });
|
|
109
|
+
}
|
|
110
|
+
if (prev.headings?.h1?.fontSize !== curr.headings?.h1?.fontSize) {
|
|
111
|
+
diffs.push({ property: 'H1 size', from: prev.headings?.h1?.fontSize || 'n/a', to: curr.headings?.h1?.fontSize || 'n/a' });
|
|
112
|
+
}
|
|
113
|
+
if (prev.navVisible !== curr.navVisible) {
|
|
114
|
+
diffs.push({ property: 'Nav visibility', from: prev.navVisible ? 'visible' : 'hidden', to: curr.navVisible ? 'visible' : 'hidden' });
|
|
115
|
+
}
|
|
116
|
+
if (prev.hasHamburger !== curr.hasHamburger) {
|
|
117
|
+
diffs.push({ property: 'Hamburger menu', from: prev.hasHamburger ? 'shown' : 'hidden', to: curr.hasHamburger ? 'shown' : 'hidden' });
|
|
118
|
+
}
|
|
119
|
+
if (prev.maxColumns !== curr.maxColumns) {
|
|
120
|
+
diffs.push({ property: 'Max grid columns', from: String(prev.maxColumns), to: String(curr.maxColumns) });
|
|
121
|
+
}
|
|
122
|
+
if (Math.abs(prev.scrollHeight - curr.scrollHeight) > 200) {
|
|
123
|
+
diffs.push({ property: 'Page height', from: `${prev.scrollHeight}px`, to: `${curr.scrollHeight}px` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (diffs.length > 0) {
|
|
127
|
+
changes.push({ from: prev.name, to: curr.name, breakpoint: `${prev.width}px → ${curr.width}px`, diffs });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { viewports: snapshots.filter(s => !s.error), changes };
|
|
132
|
+
}
|
|
@@ -259,6 +259,153 @@ export function formatMarkdown(design) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// ── Layout ──
|
|
263
|
+
if (design.layout) {
|
|
264
|
+
const l = design.layout;
|
|
265
|
+
lines.push('## Layout System');
|
|
266
|
+
lines.push('');
|
|
267
|
+
lines.push(`**${l.gridCount} grid containers** and **${l.flexCount} flex containers** detected.`);
|
|
268
|
+
lines.push('');
|
|
269
|
+
|
|
270
|
+
if (l.containerWidths.length > 0) {
|
|
271
|
+
lines.push('### Container Widths');
|
|
272
|
+
lines.push('');
|
|
273
|
+
lines.push('| Max Width | Padding |');
|
|
274
|
+
lines.push('|-----------|---------|');
|
|
275
|
+
for (const c of l.containerWidths) {
|
|
276
|
+
lines.push(`| ${c.maxWidth} | ${c.padding} |`);
|
|
277
|
+
}
|
|
278
|
+
lines.push('');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (l.gridColumns.length > 0) {
|
|
282
|
+
lines.push('### Grid Column Patterns');
|
|
283
|
+
lines.push('');
|
|
284
|
+
lines.push('| Columns | Usage Count |');
|
|
285
|
+
lines.push('|---------|-------------|');
|
|
286
|
+
for (const g of l.gridColumns) {
|
|
287
|
+
lines.push(`| ${g.columns}-column | ${g.count}x |`);
|
|
288
|
+
}
|
|
289
|
+
lines.push('');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (l.topGrids.length > 0) {
|
|
293
|
+
lines.push('### Grid Templates');
|
|
294
|
+
lines.push('');
|
|
295
|
+
lines.push('```css');
|
|
296
|
+
for (const g of l.topGrids) {
|
|
297
|
+
lines.push(`grid-template-columns: ${g.columns};`);
|
|
298
|
+
if (g.gap !== 'normal' && g.gap !== '0px') lines.push(`gap: ${g.gap};`);
|
|
299
|
+
}
|
|
300
|
+
lines.push('```');
|
|
301
|
+
lines.push('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (Object.keys(l.flexDirections).length > 0) {
|
|
305
|
+
lines.push('### Flex Patterns');
|
|
306
|
+
lines.push('');
|
|
307
|
+
lines.push('| Direction/Wrap | Count |');
|
|
308
|
+
lines.push('|----------------|-------|');
|
|
309
|
+
for (const [pattern, count] of Object.entries(l.flexDirections)) {
|
|
310
|
+
lines.push(`| ${pattern} | ${count}x |`);
|
|
311
|
+
}
|
|
312
|
+
lines.push('');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (l.gaps.length > 0) {
|
|
316
|
+
lines.push(`**Gap values:** ${l.gaps.map(g => `\`${g}\``).join(', ')}`);
|
|
317
|
+
lines.push('');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Responsive ──
|
|
322
|
+
if (design.responsive) {
|
|
323
|
+
const r = design.responsive;
|
|
324
|
+
lines.push('## Responsive Design');
|
|
325
|
+
lines.push('');
|
|
326
|
+
|
|
327
|
+
if (r.viewports.length > 0) {
|
|
328
|
+
lines.push('### Viewport Snapshots');
|
|
329
|
+
lines.push('');
|
|
330
|
+
lines.push('| Viewport | Body Font | Nav Visible | Max Columns | Hamburger | Page Height |');
|
|
331
|
+
lines.push('|----------|-----------|-------------|-------------|-----------|-------------|');
|
|
332
|
+
for (const vp of r.viewports) {
|
|
333
|
+
lines.push(`| ${vp.name} (${vp.width}px) | ${vp.bodyFontSize} | ${vp.navVisible ? 'Yes' : 'No'} | ${vp.maxColumns} | ${vp.hasHamburger ? 'Yes' : 'No'} | ${vp.scrollHeight}px |`);
|
|
334
|
+
}
|
|
335
|
+
lines.push('');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (r.changes.length > 0) {
|
|
339
|
+
lines.push('### Breakpoint Changes');
|
|
340
|
+
lines.push('');
|
|
341
|
+
for (const change of r.changes) {
|
|
342
|
+
lines.push(`**${change.breakpoint}** (${change.from} → ${change.to}):`);
|
|
343
|
+
for (const d of change.diffs) {
|
|
344
|
+
lines.push(`- ${d.property}: \`${d.from}\` → \`${d.to}\``);
|
|
345
|
+
}
|
|
346
|
+
lines.push('');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Interaction States ──
|
|
352
|
+
if (design.interactions) {
|
|
353
|
+
const hasContent = design.interactions.buttons.length > 0 || design.interactions.links.length > 0 || design.interactions.inputs.length > 0;
|
|
354
|
+
if (hasContent) {
|
|
355
|
+
lines.push('## Interaction States');
|
|
356
|
+
lines.push('');
|
|
357
|
+
|
|
358
|
+
if (design.interactions.buttons.length > 0) {
|
|
359
|
+
lines.push('### Button States');
|
|
360
|
+
lines.push('');
|
|
361
|
+
for (const btn of design.interactions.buttons.slice(0, 3)) {
|
|
362
|
+
lines.push(`**"${btn.text}"**`);
|
|
363
|
+
if (Object.keys(btn.hover).length > 0) {
|
|
364
|
+
lines.push('```css');
|
|
365
|
+
lines.push('/* Hover */');
|
|
366
|
+
for (const [prop, val] of Object.entries(btn.hover)) {
|
|
367
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
368
|
+
}
|
|
369
|
+
lines.push('```');
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(btn.focus).length > 0) {
|
|
372
|
+
lines.push('```css');
|
|
373
|
+
lines.push('/* Focus */');
|
|
374
|
+
for (const [prop, val] of Object.entries(btn.focus)) {
|
|
375
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
376
|
+
}
|
|
377
|
+
lines.push('```');
|
|
378
|
+
}
|
|
379
|
+
lines.push('');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (design.interactions.links.length > 0) {
|
|
384
|
+
lines.push('### Link Hover');
|
|
385
|
+
lines.push('');
|
|
386
|
+
const link = design.interactions.links[0];
|
|
387
|
+
lines.push('```css');
|
|
388
|
+
for (const [prop, val] of Object.entries(link.hover)) {
|
|
389
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
390
|
+
}
|
|
391
|
+
lines.push('```');
|
|
392
|
+
lines.push('');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (design.interactions.inputs.length > 0) {
|
|
396
|
+
lines.push('### Input Focus');
|
|
397
|
+
lines.push('');
|
|
398
|
+
const input = design.interactions.inputs[0];
|
|
399
|
+
lines.push('```css');
|
|
400
|
+
for (const [prop, val] of Object.entries(input.focus)) {
|
|
401
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
402
|
+
}
|
|
403
|
+
lines.push('```');
|
|
404
|
+
lines.push('');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
262
409
|
// ── Accessibility ──
|
|
263
410
|
if (design.accessibility) {
|
|
264
411
|
const a = design.accessibility;
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { extractBreakpoints } from './extractors/breakpoints.js';
|
|
|
9
9
|
import { extractAnimations } from './extractors/animations.js';
|
|
10
10
|
import { extractComponents } from './extractors/components.js';
|
|
11
11
|
import { extractAccessibility } from './extractors/accessibility.js';
|
|
12
|
+
import { extractLayout } from './extractors/layout.js';
|
|
12
13
|
|
|
13
14
|
export async function extractDesignLanguage(url, options = {}) {
|
|
14
15
|
const rawData = await crawlPage(url, options);
|
|
@@ -32,6 +33,7 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
32
33
|
animations: extractAnimations(styles, rawData.light.keyframes),
|
|
33
34
|
components: extractComponents(styles),
|
|
34
35
|
accessibility: extractAccessibility(styles),
|
|
36
|
+
layout: extractLayout(styles),
|
|
35
37
|
componentScreenshots: rawData.componentScreenshots || {},
|
|
36
38
|
};
|
|
37
39
|
|
|
@@ -55,3 +57,7 @@ export { formatFigma } from './formatters/figma.js';
|
|
|
55
57
|
export { formatReactTheme, formatShadcnTheme } from './formatters/theme.js';
|
|
56
58
|
export { diffDesigns, formatDiffMarkdown, formatDiffHtml } from './diff.js';
|
|
57
59
|
export { saveSnapshot, getHistory, formatHistoryMarkdown } from './history.js';
|
|
60
|
+
export { captureResponsive } from './extractors/responsive.js';
|
|
61
|
+
export { captureInteractions } from './extractors/interactions.js';
|
|
62
|
+
export { syncDesign } from './sync.js';
|
|
63
|
+
export { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from './multibrand.js';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Multi-brand N-site comparison matrix
|
|
2
|
+
|
|
3
|
+
import { extractDesignLanguage } from './index.js';
|
|
4
|
+
|
|
5
|
+
export async function compareBrands(urls, options = {}) {
|
|
6
|
+
const brands = [];
|
|
7
|
+
|
|
8
|
+
for (const url of urls) {
|
|
9
|
+
const normalized = url.startsWith('http') ? url : `https://${url}`;
|
|
10
|
+
try {
|
|
11
|
+
const design = await extractDesignLanguage(normalized, options);
|
|
12
|
+
const hostname = new URL(normalized).hostname.replace(/^www\./, '');
|
|
13
|
+
brands.push({ url: normalized, hostname, design });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
brands.push({ url: normalized, hostname: url, error: err.message });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return brands;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatBrandMatrix(brands) {
|
|
23
|
+
const lines = [];
|
|
24
|
+
const valid = brands.filter(b => !b.error);
|
|
25
|
+
|
|
26
|
+
lines.push('# Multi-Brand Design Comparison');
|
|
27
|
+
lines.push('');
|
|
28
|
+
lines.push(`Comparing ${valid.length} brands.`);
|
|
29
|
+
lines.push('');
|
|
30
|
+
|
|
31
|
+
// Overview table
|
|
32
|
+
const headers = ['Property', ...valid.map(b => b.hostname)];
|
|
33
|
+
lines.push(`| ${headers.join(' | ')} |`);
|
|
34
|
+
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
|
|
35
|
+
|
|
36
|
+
// Primary color
|
|
37
|
+
lines.push(`| Primary Color | ${valid.map(b => `\`${b.design.colors.primary?.hex || 'none'}\``).join(' | ')} |`);
|
|
38
|
+
// Secondary color
|
|
39
|
+
lines.push(`| Secondary Color | ${valid.map(b => `\`${b.design.colors.secondary?.hex || 'none'}\``).join(' | ')} |`);
|
|
40
|
+
// Fonts
|
|
41
|
+
lines.push(`| Fonts | ${valid.map(b => b.design.typography.families.map(f => f.name).join(', ') || 'none').join(' | ')} |`);
|
|
42
|
+
// Color count
|
|
43
|
+
lines.push(`| Color Count | ${valid.map(b => b.design.colors.all.length).join(' | ')} |`);
|
|
44
|
+
// Spacing base
|
|
45
|
+
lines.push(`| Spacing Base | ${valid.map(b => b.design.spacing.base ? `${b.design.spacing.base}px` : 'none').join(' | ')} |`);
|
|
46
|
+
// A11y score
|
|
47
|
+
lines.push(`| A11y Score | ${valid.map(b => b.design.accessibility ? `${b.design.accessibility.score}%` : 'n/a').join(' | ')} |`);
|
|
48
|
+
// Shadows
|
|
49
|
+
lines.push(`| Shadows | ${valid.map(b => b.design.shadows.values.length).join(' | ')} |`);
|
|
50
|
+
// Radii
|
|
51
|
+
lines.push(`| Border Radii | ${valid.map(b => b.design.borders.radii.length).join(' | ')} |`);
|
|
52
|
+
// CSS vars
|
|
53
|
+
lines.push(`| CSS Variables | ${valid.map(b => Object.values(b.design.variables).reduce((s, v) => s + Object.keys(v).length, 0)).join(' | ')} |`);
|
|
54
|
+
// Components
|
|
55
|
+
lines.push(`| Components | ${valid.map(b => Object.keys(b.design.components).join(', ') || 'none').join(' | ')} |`);
|
|
56
|
+
|
|
57
|
+
lines.push('');
|
|
58
|
+
|
|
59
|
+
// Color overlap matrix
|
|
60
|
+
lines.push('## Color Overlap');
|
|
61
|
+
lines.push('');
|
|
62
|
+
if (valid.length >= 2) {
|
|
63
|
+
for (let i = 0; i < valid.length; i++) {
|
|
64
|
+
for (let j = i + 1; j < valid.length; j++) {
|
|
65
|
+
const colorsA = new Set(valid[i].design.colors.all.map(c => c.hex));
|
|
66
|
+
const colorsB = new Set(valid[j].design.colors.all.map(c => c.hex));
|
|
67
|
+
const shared = [...colorsA].filter(c => colorsB.has(c));
|
|
68
|
+
lines.push(`**${valid[i].hostname} vs ${valid[j].hostname}:** ${shared.length} shared colors${shared.length > 0 ? ` (${shared.slice(0, 5).map(c => `\`${c}\``).join(', ')})` : ''}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
lines.push('');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Font comparison
|
|
75
|
+
lines.push('## Typography Comparison');
|
|
76
|
+
lines.push('');
|
|
77
|
+
const allFonts = new Set();
|
|
78
|
+
for (const b of valid) {
|
|
79
|
+
for (const f of b.design.typography.families) allFonts.add(f.name);
|
|
80
|
+
}
|
|
81
|
+
lines.push(`| Font | ${valid.map(b => b.hostname).join(' | ')} |`);
|
|
82
|
+
lines.push(`| --- | ${valid.map(() => '---').join(' | ')} |`);
|
|
83
|
+
for (const font of allFonts) {
|
|
84
|
+
lines.push(`| ${font} | ${valid.map(b => b.design.typography.families.some(f => f.name === font) ? 'Yes' : '-').join(' | ')} |`);
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
|
|
88
|
+
// Errors
|
|
89
|
+
const errored = brands.filter(b => b.error);
|
|
90
|
+
if (errored.length > 0) {
|
|
91
|
+
lines.push('## Errors');
|
|
92
|
+
lines.push('');
|
|
93
|
+
for (const b of errored) {
|
|
94
|
+
lines.push(`- **${b.url}**: ${b.error}`);
|
|
95
|
+
}
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatBrandMatrixHtml(brands) {
|
|
103
|
+
const valid = brands.filter(b => !b.error);
|
|
104
|
+
|
|
105
|
+
const swatchCell = (hex) => hex
|
|
106
|
+
? `<td><span style="display:inline-block;width:16px;height:16px;border-radius:4px;background:${hex};border:1px solid #333;vertical-align:middle;margin-right:6px"></span><code>${hex}</code></td>`
|
|
107
|
+
: `<td>-</td>`;
|
|
108
|
+
|
|
109
|
+
return `<!DOCTYPE html>
|
|
110
|
+
<html><head><meta charset="UTF-8"><title>Multi-Brand Comparison</title>
|
|
111
|
+
<style>
|
|
112
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
113
|
+
body { font-family:-apple-system,sans-serif; background:#0a0a0a; color:#e5e5e5; padding:40px; }
|
|
114
|
+
h1 { font-size:28px; color:#fff; margin-bottom:24px; }
|
|
115
|
+
h2 { font-size:18px; color:#fff; margin:32px 0 12px; }
|
|
116
|
+
table { width:100%; border-collapse:collapse; margin:12px 0; }
|
|
117
|
+
th { text-align:left; padding:10px 12px; background:#141414; color:#888; font-size:12px; text-transform:uppercase; letter-spacing:0.05em; border-bottom:1px solid #222; }
|
|
118
|
+
td { padding:10px 12px; border-bottom:1px solid #1a1a1a; font-size:13px; }
|
|
119
|
+
tr:hover td { background:#111; }
|
|
120
|
+
code { background:#1e1e2e; padding:2px 6px; border-radius:4px; font-size:12px; color:#a78bfa; }
|
|
121
|
+
.score-good { color:#22c55e; } .score-warn { color:#eab308; } .score-bad { color:#ef4444; }
|
|
122
|
+
</style></head><body>
|
|
123
|
+
<h1>Multi-Brand Design Comparison</h1>
|
|
124
|
+
<p style="color:#666;margin-bottom:24px">${valid.length} brands analyzed</p>
|
|
125
|
+
|
|
126
|
+
<table>
|
|
127
|
+
<tr><th>Property</th>${valid.map(b => `<th>${b.hostname}</th>`).join('')}</tr>
|
|
128
|
+
<tr><td>Primary Color</td>${valid.map(b => swatchCell(b.design.colors.primary?.hex)).join('')}</tr>
|
|
129
|
+
<tr><td>Secondary Color</td>${valid.map(b => swatchCell(b.design.colors.secondary?.hex)).join('')}</tr>
|
|
130
|
+
<tr><td>Fonts</td>${valid.map(b => `<td>${b.design.typography.families.map(f => `<code>${f.name}</code>`).join(' ')}</td>`).join('')}</tr>
|
|
131
|
+
<tr><td>Colors</td>${valid.map(b => `<td>${b.design.colors.all.length}</td>`).join('')}</tr>
|
|
132
|
+
<tr><td>Spacing Base</td>${valid.map(b => `<td>${b.design.spacing.base ? b.design.spacing.base + 'px' : '-'}</td>`).join('')}</tr>
|
|
133
|
+
<tr><td>A11y Score</td>${valid.map(b => {
|
|
134
|
+
const s = b.design.accessibility?.score;
|
|
135
|
+
const cls = s >= 80 ? 'score-good' : s >= 50 ? 'score-warn' : 'score-bad';
|
|
136
|
+
return `<td class="${cls}">${s ?? 'n/a'}%</td>`;
|
|
137
|
+
}).join('')}</tr>
|
|
138
|
+
<tr><td>Shadows</td>${valid.map(b => `<td>${b.design.shadows.values.length}</td>`).join('')}</tr>
|
|
139
|
+
<tr><td>Border Radii</td>${valid.map(b => `<td>${b.design.borders.radii.length}</td>`).join('')}</tr>
|
|
140
|
+
<tr><td>CSS Variables</td>${valid.map(b => `<td>${Object.values(b.design.variables).reduce((s, v) => s + Object.keys(v).length, 0)}</td>`).join('')}</tr>
|
|
141
|
+
</table>
|
|
142
|
+
|
|
143
|
+
<h2>Full Color Palettes</h2>
|
|
144
|
+
${valid.map(b => `
|
|
145
|
+
<h3 style="color:#888;font-size:14px;margin:16px 0 8px">${b.hostname}</h3>
|
|
146
|
+
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
|
147
|
+
${b.design.colors.all.slice(0, 15).map(c => `<div style="width:32px;height:32px;border-radius:6px;background:${c.hex};border:1px solid #333" title="${c.hex}"></div>`).join('')}
|
|
148
|
+
</div>`).join('')}
|
|
149
|
+
|
|
150
|
+
</body></html>`;
|
|
151
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Sync command — watch a live site and auto-update local tokens when design changes
|
|
2
|
+
|
|
3
|
+
import { extractDesignLanguage } from './index.js';
|
|
4
|
+
import { formatTokens } from './formatters/tokens.js';
|
|
5
|
+
import { formatTailwind } from './formatters/tailwind.js';
|
|
6
|
+
import { formatCssVars } from './formatters/css-vars.js';
|
|
7
|
+
import { saveSnapshot, getHistory } from './history.js';
|
|
8
|
+
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
export async function syncDesign(url, options = {}) {
|
|
12
|
+
const { out = '.', interval = 3600000 } = options; // default 1 hour
|
|
13
|
+
|
|
14
|
+
const current = await extractDesignLanguage(url, options);
|
|
15
|
+
const history = getHistory(url);
|
|
16
|
+
const previous = history.length > 1 ? history[history.length - 2] : null;
|
|
17
|
+
|
|
18
|
+
const changes = [];
|
|
19
|
+
|
|
20
|
+
if (previous) {
|
|
21
|
+
// Detect changes
|
|
22
|
+
if (previous.colors.primary !== current.colors.primary?.hex) {
|
|
23
|
+
changes.push({ type: 'color', property: 'primary', from: previous.colors.primary, to: current.colors.primary?.hex });
|
|
24
|
+
}
|
|
25
|
+
if (previous.colors.secondary !== current.colors.secondary?.hex) {
|
|
26
|
+
changes.push({ type: 'color', property: 'secondary', from: previous.colors.secondary, to: current.colors.secondary?.hex });
|
|
27
|
+
}
|
|
28
|
+
if (previous.typography.families.join(',') !== current.typography.families.map(f => f.name).join(',')) {
|
|
29
|
+
changes.push({ type: 'typography', property: 'fonts', from: previous.typography.families.join(', '), to: current.typography.families.map(f => f.name).join(', ') });
|
|
30
|
+
}
|
|
31
|
+
if (previous.colors.count !== current.colors.all.length) {
|
|
32
|
+
changes.push({ type: 'color', property: 'count', from: String(previous.colors.count), to: String(current.colors.all.length) });
|
|
33
|
+
}
|
|
34
|
+
if (previous.a11yScore !== current.accessibility?.score) {
|
|
35
|
+
changes.push({ type: 'accessibility', property: 'score', from: `${previous.a11yScore}%`, to: `${current.accessibility?.score}%` });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Save snapshot
|
|
40
|
+
saveSnapshot(current);
|
|
41
|
+
|
|
42
|
+
// Update local files
|
|
43
|
+
const updates = [];
|
|
44
|
+
|
|
45
|
+
const tokensPath = join(out, 'design-tokens.json');
|
|
46
|
+
if (existsSync(tokensPath)) {
|
|
47
|
+
writeFileSync(tokensPath, formatTokens(current), 'utf-8');
|
|
48
|
+
updates.push('design-tokens.json');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tailwindPath = join(out, 'tailwind.config.js');
|
|
52
|
+
if (existsSync(tailwindPath)) {
|
|
53
|
+
writeFileSync(tailwindPath, formatTailwind(current), 'utf-8');
|
|
54
|
+
updates.push('tailwind.config.js');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cssPath = join(out, 'variables.css');
|
|
58
|
+
if (existsSync(cssPath)) {
|
|
59
|
+
writeFileSync(cssPath, formatCssVars(current), 'utf-8');
|
|
60
|
+
updates.push('variables.css');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
changes,
|
|
65
|
+
updatedFiles: updates,
|
|
66
|
+
isFirstRun: !previous,
|
|
67
|
+
design: current,
|
|
68
|
+
};
|
|
69
|
+
}
|