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.
@@ -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('2.0.0');
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": "2.0.0",
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, 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
@@ -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
+ }