designlang 8.0.0 → 9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [9.0.0] — 2026-04-21
4
+
5
+ **The Motion & Voice release.** Six new capabilities that push designlang past "extract the paint" and into "extract the *feel*, the *anatomy*, and the *voice*." No competing tool does any of these. All work ships with tests (282/282 passing).
6
+
7
+ ### Added — extraction
8
+
9
+ - **Motion language extractor** (`src/extractors/motion.js`) — easings are classified into families (`ease-in`, `ease-in-out`, `ease-out`, `linear`, `steps`, `spring`, `custom`) via cubic-bezier geometry, durations are bucketed into a named scale (`instant`/`xs`/`sm`/`md`/`lg`/`xl`/`xxl`), spring/overshoot cubic-beziers are surfaced, scroll-linked animation usage is detected via `animation-timeline` / `view-timeline-name` / `scroll-timeline-name`, and each `@keyframes` rule is classified by kind (`slide-x`, `slide-y`, `fade`, `reveal`, `rotate`, `scale`, `pulse`, `custom`). A one-word `feel` fingerprint (`springy`/`responsive`/`smooth`/`mechanical`/`mixed`) summarizes the whole system.
10
+ - **Motion tokens formatter** (`src/formatters/motion-tokens.js`) — emits `*-motion-tokens.json` in a DTCG-flavored shape with `$type: duration` / `$type: cubicBezier`.
11
+ - **Component Anatomy v2** (`src/extractors/component-anatomy.js`) — groups components by variant-class hints, infers slot roles (icon / label / badge / heading / media / footer), builds a variant × size × state matrix, captures sample button labels, and emits typed React stubs via `formatAnatomyStubs`. Output: `*-anatomy.tsx`.
12
+ - **Brand voice extractor** (`src/extractors/voice.js`) — classifies tone (friendly / formal / technical / playful / neutral) from lexical markers, picks pronoun posture (`we→you`, `you-only`, `we-only`, `third-person`), detects heading style, top CTA verbs, and microcopy patterns. Output: `*-voice.json`.
13
+ - **Crawler extensions** (`src/crawler.js`) — per-element `animation-timeline`, view/scroll timeline names; per-candidate `text`, `slots[]`, `disabled`, `variantHint`, `sizeHint` to feed anatomy + voice.
14
+
15
+ ### Added — new commands
16
+
17
+ - **`designlang lint <file>`** — audits DTCG / flat-JSON / CSS-vars token files for color sprawl, spacing-scale drift, radius/shadow bloat, and WCAG AA fg/bg contrast. Exits non-zero on `error`-level findings. CI-ready.
18
+ - **`designlang drift <url> --tokens <file>`** — compares local tokens against a live site, reports `in-sync` / `minor-drift` / `notable-drift` / `major-drift` with a drift ratio. `--fail-on <level>` controls CI exit code.
19
+ - **`designlang visual-diff <before> <after>`** — single-file HTML side-by-side report with embedded base64 screenshots, file-size deltas, and a changed-color-tokens table.
20
+
21
+ ### Added — markdown output
22
+
23
+ Three new sections in `*-design-language.md`: **Motion Language**, **Component Anatomy**, **Brand Voice**.
24
+
25
+ ### Changed
26
+
27
+ - Default extraction now writes **11+ files** (up from 8): `*-motion-tokens.json`, `*-anatomy.tsx` (when candidates exist), `*-voice.json`.
28
+ - `bin/design-extract.js` version → `9.0.0`.
29
+ - `package.json` — description refreshed; new keywords: `motion`, `animation`, `component-anatomy`, `brand-voice`, `token-lint`, `visual-diff`.
30
+ - README: "What's New in v9" hero block, new feature sections 24-29, new CLI entries (`lint`, `drift`, `visual-diff`).
31
+
32
+ ### Tests
33
+
34
+ - New `tests/v9-features.test.js` — 7 suites, 21 assertions across motion, anatomy, voice, and lint.
35
+ - Full suite: **282/282 passing**.
36
+
3
37
  ## [8.0.0] — 2026-04-20
4
38
 
5
39
  A credibility-and-distribution release. Three reliability bugs that hurt trust on real sites are fixed; three DX flags close the most-requested CLI gaps; five new surfaces (VS Code, Raycast, Figma, GitHub Actions, MCP registry) ship alongside.
package/README.md CHANGED
@@ -15,9 +15,20 @@
15
15
  <img src="designlang.png" alt="designlang in action" width="100%">
16
16
  </p>
17
17
 
18
- **designlang** crawls any website with a headless browser, extracts every computed style from the live DOM, and generates **8 output files** — including an AI-optimized markdown file, visual HTML preview, Tailwind config, React theme, shadcn/ui theme, Figma variables, W3C design tokens, and CSS custom properties.
18
+ [![designlang on npm](https://pkgfolio.vercel.app/embed/pkg/designlang?v=2)](https://www.npmjs.com/package/designlang)
19
19
 
20
- But unlike every other tool out there, it also extracts **layout patterns** (grids, flexbox, containers), captures **responsive behavior** across 4 breakpoints, records **interaction states** (hover, focus, active), scores **WCAG accessibility**, and lets you **compare multiple brands** or **sync live sites to local tokens**.
20
+ **designlang** crawls any website with a headless browser, extracts every computed style from the live DOM, and generates **11+ output files** including an AI-optimized markdown file, visual HTML preview, Tailwind config, React theme, shadcn/ui theme, Figma variables, W3C design tokens, CSS custom properties, **motion tokens**, **typed component anatomy stubs**, and a **brand voice** summary.
21
+
22
+ But unlike every other tool out there, it also extracts **layout patterns** (grids, flexbox, containers), **motion language** (durations, easings, springs, scroll-linked animations), **component anatomy** (slots, variant × size × state matrices), **brand voice** (tone, CTA verbs, heading style), captures **responsive behavior** across 4 breakpoints, records **interaction states** (hover, focus, active), scores **WCAG accessibility**, lints your own token files, and lets you **drift-check a codebase against a live site**, **visual-diff two URLs**, **compare multiple brands**, or **sync live sites to local tokens**.
23
+
24
+ ## What's New in v9 — The Motion & Voice Release
25
+
26
+ - **Motion Language** — durations bucketed into semantic tokens (`instant`/`xs`/`sm`/`md`/`lg`/`xl`), easings classified into families (ease-out, spring-overshoot, steps), scroll-linked animation detection (`animation-timeline`, `view-timeline-name`), keyframe kind classification (slide / fade / reveal / rotate / scale / pulse), and a `feel` fingerprint — *springy*, *responsive*, *smooth*, *mechanical*, or *mixed*.
27
+ - **Component Anatomy v2** — every component cluster is now an *anatomy tree* with slots (label, icon, badge, heading, media), variant × size × state matrices, and an emitted `*-anatomy.tsx` file of typed React stubs you can wire into your design system.
28
+ - **Brand Voice** — extracts tone (friendly / formal / technical / playful / neutral), pronoun posture (`we→you` / `you-only` / `we-only` / `third-person`), heading style (Title Case / Sentence case / all-lowercase), top CTA verbs, and a microcopy inventory. Feeds LLMs the *voice*, not just the paint.
29
+ - **`designlang lint`** — audit your own `design-tokens.json` (DTCG or flat) or `variables.css` for color sprawl, spacing-scale drift, radius/shadow bloat, and WCAG fg/bg contrast fails. Exits non-zero on errors — CI-ready.
30
+ - **`designlang drift`** — point at a live site, pass your local token file, and get a verdict: `in-sync` / `minor-drift` / `notable-drift` / `major-drift`. Integrates cleanly with the existing GitHub Action.
31
+ - **`designlang visual-diff`** — capture two URLs side-by-side and emit a single-file HTML report with component screenshots, file-size deltas, and changed color tokens. No heavy pixel-diff dependencies — runs in pure Node + Playwright.
21
32
 
22
33
  ## Quick Start
23
34
 
@@ -31,7 +42,7 @@ Get everything at once:
31
42
  npx designlang https://stripe.com --full
32
43
  ```
33
44
 
34
- ## What You Get (8 Files)
45
+ ## What You Get (11+ Files)
35
46
 
36
47
  | File | What it is |
37
48
  |------|------------|
@@ -43,6 +54,9 @@ npx designlang https://stripe.com --full
43
54
  | `*-figma-variables.json` | Figma Variables import (with dark mode support) |
44
55
  | `*-theme.js` | React/CSS-in-JS theme (Chakra, Stitches, Vanilla Extract) |
45
56
  | `*-shadcn-theme.css` | shadcn/ui globals.css variables |
57
+ | `*-motion-tokens.json` | **(v9)** Motion tokens — durations, easings, springs, scroll-linked flag |
58
+ | `*-anatomy.tsx` | **(v9)** Typed React stubs for every detected component + variants |
59
+ | `*-voice.json` | **(v9)** Brand voice fingerprint — tone, CTA verbs, heading style |
46
60
 
47
61
  The markdown output has **19 sections**: Color Palette, Typography, Spacing, Border Radii, Box Shadows, CSS Custom Properties, Breakpoints, Transitions & Animations, Component Patterns (with full CSS snippets), Layout System, Responsive Design, Interaction States, Accessibility (WCAG 2.1), Gradients, Z-Index Map, SVG Icons, Font Files, Image Style Patterns, and Quick Start.
48
62
 
@@ -319,7 +333,112 @@ A Manifest-v3 popup lives in [`chrome-extension/`](chrome-extension/). One click
319
333
  - **Install:** toggle developer mode at `chrome://extensions`, click *Load unpacked*, pick the `chrome-extension/` folder.
320
334
  - **Firefox + Edge** work with the same MV3 manifest.
321
335
 
322
- ### 23. Better Auth + Network Control (NEW in v7.1)
336
+ ### 24. Motion Language (NEW in v9)
337
+
338
+ Extracts the full motion fingerprint, not just transition strings:
339
+
340
+ ```bash
341
+ designlang https://linear.app
342
+ # emits linear-app-motion-tokens.json
343
+ ```
344
+
345
+ ```
346
+ Motion: feel = springy, 2 spring easings, scroll-linked = yes
347
+ Durations: instant (80ms), xs (150ms), sm (220ms), md (380ms)
348
+ Easings: ease-out (61%), spring-overshoot (18%), ease-in-out (21%)
349
+ Keyframes: fade-up (slide-y, used 18x), scale-in (reveal, used 4x)
350
+ ```
351
+
352
+ ### 25. Component Anatomy v2 (NEW in v9)
353
+
354
+ Every detected component becomes an anatomy tree with typed React stubs:
355
+
356
+ ```bash
357
+ designlang https://stripe.com
358
+ # emits stripe-com-anatomy.tsx
359
+ ```
360
+
361
+ ```tsx
362
+ export interface ButtonProps {
363
+ variant?: 'primary' | 'secondary' | 'ghost';
364
+ size?: 'sm' | 'md' | 'lg';
365
+ disabled?: boolean;
366
+ leadingIcon?: React.ReactNode;
367
+ badge?: React.ReactNode;
368
+ children?: React.ReactNode;
369
+ }
370
+ ```
371
+
372
+ ### 26. Brand Voice (NEW in v9)
373
+
374
+ Pulls the voice alongside the visual:
375
+
376
+ ```bash
377
+ designlang https://vercel.com
378
+ # emits vercel-com-voice.json + a Brand Voice section in the markdown
379
+ ```
380
+
381
+ ```
382
+ Tone: technical · Pronoun: we→you · Headings: Sentence case (tight)
383
+ Top CTA verbs: start (14), get (8), deploy (5), try (3)
384
+ Sample headings:
385
+ > Develop. Preview. Ship.
386
+ > The React framework for the web.
387
+ ```
388
+
389
+ ### 27. `designlang lint` — Token Quality Linter (NEW in v9)
390
+
391
+ Audit your own token file with the same rules the scorer runs against live sites:
392
+
393
+ ```bash
394
+ designlang lint ./src/tokens/design-tokens.json
395
+ ```
396
+
397
+ ```
398
+ Score: 74/100 Grade: C Tokens: 126
399
+
400
+ colorDiscipline ██████████████░░░░░░ 72
401
+ spacingSystem ████████████████░░░░ 84
402
+ borderRadii ████████████░░░░░░░░ 60
403
+ shadows ██████████░░░░░░░░░░ 50
404
+ accessibility █████████████████░░░ 88
405
+
406
+ WARN [color-sprawl] 3 near-duplicate color pair(s) within 8 RGB units
407
+ ERROR [contrast-wcag-aa] 2 fg/bg pair(s) fail WCAG AA (4.5:1)
408
+ ```
409
+
410
+ Exits non-zero on any `error`-level finding — drop into CI.
411
+
412
+ ### 28. `designlang drift` — Codebase ↔ Live Site Sync Check (NEW in v9)
413
+
414
+ Point at a deployed site, pass your local tokens, and get a verdict:
415
+
416
+ ```bash
417
+ designlang drift https://yourapp.com --tokens ./src/tokens.json --tolerance 8
418
+ ```
419
+
420
+ ```
421
+ Verdict: notable-drift (drift ratio: 0.24)
422
+
423
+ | token | local | nearest live | Δ |
424
+ |----------------|----------|--------------------|----|
425
+ | color.primary | #4338CA | #5B4CF5 (primary) | 22 |
426
+ | color.border | #D4D4D8 | #E5E5EA (surface) | 18 |
427
+ ```
428
+
429
+ Configurable `--fail-on <level>` for CI: `minor-drift` / `notable-drift` / `major-drift`.
430
+
431
+ ### 29. `designlang visual-diff` — Two-URL Side-by-Side (NEW in v9)
432
+
433
+ Capture screenshots + token deltas for two URLs in a single self-contained HTML report:
434
+
435
+ ```bash
436
+ designlang visual-diff https://staging.app.com https://app.com
437
+ ```
438
+
439
+ Emits `visual-diff-<timestamp>.html` with embedded images (base64), file-size deltas, and a changed-color-tokens table. Nothing else to serve — just open the file.
440
+
441
+ ### 30. Better Auth + Network Control (v7.1)
323
442
 
324
443
  Extracting from authenticated, self-signed, or non-default environments now takes one flag:
325
444
 
@@ -407,15 +526,18 @@ Options:
407
526
  --verbose Detailed progress output
408
527
 
409
528
  Commands:
410
- apply <url> Extract and apply design directly to your project
411
- clone <url> Generate a working Next.js starter from extracted design
412
- score <url> Rate design quality (7 categories, A-F, bar chart)
413
- watch <url> Monitor for design changes on interval
414
- diff <urlA> <urlB> Compare two sites' design languages
415
- brands <urls...> Multi-brand comparison matrix
416
- sync <url> Sync local tokens with live site
417
- history <url> View design change history
418
- mcp Launch stdio MCP server (--output-dir <dir>)
529
+ apply <url> Extract and apply design directly to your project
530
+ clone <url> Generate a working Next.js starter from extracted design
531
+ score <url> Rate design quality (7 categories, A-F, bar chart)
532
+ watch <url> Monitor for design changes on interval
533
+ diff <urlA> <urlB> Compare two sites' design languages
534
+ brands <urls...> Multi-brand comparison matrix
535
+ sync <url> Sync local tokens with live site
536
+ history <url> View design change history
537
+ mcp Launch stdio MCP server (--output-dir <dir>)
538
+ lint <file> (v9) Audit a local token file (.json/.css) — CI-ready
539
+ drift <url> --tokens <file> (v9) Check local tokens for drift against a live site
540
+ visual-diff <before> <after> (v9) Side-by-side HTML diff of two URLs
419
541
  ```
420
542
 
421
543
  ## Example Output
@@ -506,3 +628,5 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). PRs welcome!
506
628
  ## License
507
629
 
508
630
  [MIT](LICENSE) - Manav Arya Singh
631
+
632
+
@@ -48,7 +48,7 @@ const program = new Command();
48
48
  program
49
49
  .name('designlang')
50
50
  .description('Extract the complete design language from any website')
51
- .version('8.0.0');
51
+ .version('9.0.0');
52
52
 
53
53
  // ── Main command: extract ──────────────────────────────────────
54
54
  program
@@ -221,6 +221,15 @@ program
221
221
  };
222
222
  files.push({ name: `${prefix}-mcp.json`, content: JSON.stringify(mcpPayload, null, 2), label: 'MCP companion' });
223
223
 
224
+ // v9: motion tokens + component anatomy stubs + voice
225
+ const { formatMotionTokens } = await import('../src/formatters/motion-tokens.js');
226
+ const { formatAnatomyStubs } = await import('../src/extractors/component-anatomy.js');
227
+ files.push({ name: `${prefix}-motion-tokens.json`, content: formatMotionTokens(design.motion), label: 'Motion Tokens' });
228
+ if ((design.componentAnatomy || []).length) {
229
+ files.push({ name: `${prefix}-anatomy.tsx`, content: formatAnatomyStubs(design.componentAnatomy), label: 'Component Anatomy (stubs)' });
230
+ }
231
+ files.push({ name: `${prefix}-voice.json`, content: JSON.stringify(design.voice || {}, null, 2), label: 'Brand Voice' });
232
+
224
233
  for (const file of files) {
225
234
  writeFileSync(join(outDir, file.name), file.content, 'utf-8');
226
235
  }
@@ -804,6 +813,86 @@ program
804
813
  }
805
814
  });
806
815
 
816
+ // ── Token lint (v9) ────────────────────────────────────────
817
+ program
818
+ .command('lint <file>')
819
+ .description('Audit a local token file (.json / .css) for color sprawl, scale drift, contrast fails')
820
+ .option('--json', 'emit machine-readable JSON')
821
+ .action(async (file, opts) => {
822
+ try {
823
+ const { lintTokens } = await import('../src/lint.js');
824
+ const r = lintTokens(resolve(file));
825
+ if (opts.json) { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); return; }
826
+ console.log('');
827
+ console.log(chalk.bold(` designlang lint — ${file}`));
828
+ console.log(` Score: ${chalk.bold(r.score + '/100')} Grade: ${chalk.bold(r.grade)} Tokens: ${r.tokenCount}`);
829
+ console.log('');
830
+ for (const [k, v] of Object.entries(r.scorecard)) {
831
+ const bar = '█'.repeat(Math.round(v / 5)) + '░'.repeat(20 - Math.round(v / 5));
832
+ console.log(` ${k.padEnd(20)} ${bar} ${v}`);
833
+ }
834
+ console.log('');
835
+ for (const f of r.findings) {
836
+ const color = f.severity === 'error' ? chalk.red : f.severity === 'warn' ? chalk.yellow : chalk.cyan;
837
+ console.log(` ${color(f.severity.toUpperCase())} [${f.rule}] ${f.message}`);
838
+ }
839
+ if (!r.findings.length) console.log(chalk.green(' ✓ no issues found'));
840
+ console.log('');
841
+ process.exit(r.findings.some(f => f.severity === 'error') ? 1 : 0);
842
+ } catch (err) {
843
+ process.stderr.write(chalk.red(`\n Error: ${err.message}\n\n`));
844
+ process.exit(1);
845
+ }
846
+ });
847
+
848
+ // ── Drift (v9) ─────────────────────────────────────────────
849
+ program
850
+ .command('drift <url>')
851
+ .description('Compare local tokens against a live site and report drift (CI-friendly)')
852
+ .requiredOption('--tokens <file>', 'local tokens file (.json or .css)')
853
+ .option('--tolerance <n>', 'color distance tolerance (0-50)', parseInt, 8)
854
+ .option('--fail-on <level>', 'exit non-zero on: minor-drift | notable-drift | major-drift', 'notable-drift')
855
+ .option('--json', 'emit machine-readable JSON')
856
+ .action(async (url, opts) => {
857
+ if (!url.startsWith('http')) url = `https://${url}`;
858
+ validateUrl(url);
859
+ try {
860
+ const { checkDrift, formatDriftMarkdown } = await import('../src/drift.js');
861
+ const r = await checkDrift(url, { tokens: resolve(opts.tokens), tolerance: opts.tolerance });
862
+ if (opts.json) { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); }
863
+ else { console.log('\n' + formatDriftMarkdown(r) + '\n'); }
864
+ const order = ['in-sync', 'minor-drift', 'notable-drift', 'major-drift'];
865
+ if (order.indexOf(r.verdict) >= order.indexOf(opts.failOn)) process.exit(1);
866
+ } catch (err) {
867
+ process.stderr.write(chalk.red(`\n Error: ${err.message}\n\n`));
868
+ process.exit(1);
869
+ }
870
+ });
871
+
872
+ // ── Visual diff (v9) ───────────────────────────────────────
873
+ program
874
+ .command('visual-diff <before> <after>')
875
+ .description('Side-by-side HTML diff of two URLs with screenshots + token changes')
876
+ .option('-o, --out <dir>', 'output directory', './design-extract-output')
877
+ .action(async (before, after, opts) => {
878
+ if (!before.startsWith('http')) before = `https://${before}`;
879
+ if (!after.startsWith('http')) after = `https://${after}`;
880
+ validateUrl(before); validateUrl(after);
881
+ const spinner = ora('Capturing before + after').start();
882
+ try {
883
+ const { visualDiff, formatVisualDiffHtml } = await import('../src/visual-diff.js');
884
+ const r = await visualDiff({ beforeUrl: before, afterUrl: after });
885
+ const html = formatVisualDiffHtml(r);
886
+ mkdirSync(resolve(opts.out), { recursive: true });
887
+ const path = join(resolve(opts.out), `visual-diff-${Date.now()}.html`);
888
+ writeFileSync(path, html, 'utf8');
889
+ spinner.succeed(`Visual diff written → ${path}`);
890
+ } catch (err) {
891
+ spinner.fail(err.message);
892
+ process.exit(1);
893
+ }
894
+ });
895
+
807
896
  // ── MCP server command ─────────────────────────────────────
808
897
  program
809
898
  .command('mcp')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "8.0.0",
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.",
3
+ "version": "9.0.0",
4
+ "description": "Extract the complete design language from any website — colors, typography, spacing, shadows, motion, component anatomy, and brand voice. Outputs AI-optimized markdown, W3C design tokens, motion tokens, typed component stubs, Tailwind config, and more.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "designlang": "./bin/design-extract.js"
@@ -38,7 +38,13 @@
38
38
  "vue",
39
39
  "svelte",
40
40
  "json",
41
- "ci-cd"
41
+ "ci-cd",
42
+ "motion",
43
+ "animation",
44
+ "component-anatomy",
45
+ "brand-voice",
46
+ "token-lint",
47
+ "visual-diff"
42
48
  ],
43
49
  "author": "masyv",
44
50
  "license": "MIT"
package/src/crawler.js CHANGED
@@ -559,6 +559,11 @@ async function extractPageData(page, ignoreSelectors, scopeSelector) {
559
559
  zIndex: cs.zIndex,
560
560
  transition: cs.transition,
561
561
  animation: cs.animation,
562
+ animationTimeline: cs.animationTimeline || cs.getPropertyValue('animation-timeline') || '',
563
+ animationRangeStart: cs.getPropertyValue('animation-range-start') || '',
564
+ animationRangeEnd: cs.getPropertyValue('animation-range-end') || '',
565
+ viewTimelineName: cs.getPropertyValue('view-timeline-name') || '',
566
+ scrollTimelineName: cs.getPropertyValue('scroll-timeline-name') || '',
562
567
  display: cs.display,
563
568
  position: cs.position,
564
569
  flexDirection: cs.flexDirection,
@@ -757,10 +762,25 @@ async function extractPageData(page, ignoreSelectors, scopeSelector) {
757
762
  parseFloat(cs.fontSize) || 0,
758
763
  parseFloat(cs.fontWeight) || 0,
759
764
  ];
765
+ const text = ((el.innerText || el.textContent || '') + '').trim().slice(0, 160);
766
+ const slots = Array.from(el.children).slice(0, 8).map(c => {
767
+ const tagName = c.tagName.toLowerCase();
768
+ let role = 'content';
769
+ if (tagName === 'svg' || tagName === 'img' || c.querySelector?.('svg,img')) role = 'icon';
770
+ else if (/badge|pill|tag|chip/i.test(c.className || '')) role = 'badge';
771
+ else if (/h[1-6]/.test(tagName) || /title|heading/i.test(c.className || '')) role = 'heading';
772
+ else if (/description|subtitle|text|body/i.test(c.className || '')) role = 'text';
773
+ return { tag: tagName, role, text: ((c.innerText || c.textContent || '') + '').trim().slice(0, 80) };
774
+ });
760
775
  results.componentCandidates.push({
761
776
  kind,
762
777
  structuralHash: structuralHashOf(el),
763
778
  styleVector,
779
+ text,
780
+ slots,
781
+ disabled: el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true',
782
+ variantHint: (cls.match(/\b(primary|secondary|tertiary|ghost|outline|solid|destructive|danger|success|warning|link|subtle)\b/) || [])[1] || '',
783
+ sizeHint: (cls.match(/\b(xs|sm|md|lg|xl|small|medium|large)\b/) || [])[1] || '',
764
784
  css: {
765
785
  background: cs.backgroundColor,
766
786
  color: cs.color,
package/src/drift.js ADDED
@@ -0,0 +1,137 @@
1
+ // designlang drift <url> --tokens <file>
2
+ // Compares local project tokens against the live site and reports what's drifted.
3
+ // Designed for CI/CD: exits non-zero when drift exceeds the tolerance budget.
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { extname } from 'path';
7
+ import { extractDesignLanguage } from './index.js';
8
+
9
+ function flattenDtcg(obj, prefix = '', out = {}) {
10
+ for (const [k, v] of Object.entries(obj || {})) {
11
+ if (k.startsWith('$')) continue;
12
+ if (v && typeof v === 'object') {
13
+ if ('$value' in v) {
14
+ out[prefix ? `${prefix}.${k}` : k] = v.$value;
15
+ } else {
16
+ flattenDtcg(v, prefix ? `${prefix}.${k}` : k, out);
17
+ }
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+
23
+ function loadLocalTokens(file) {
24
+ const raw = readFileSync(file, 'utf8');
25
+ const ext = extname(file);
26
+ if (ext === '.json') {
27
+ const j = JSON.parse(raw);
28
+ const flat = flattenDtcg(j);
29
+ if (Object.keys(flat).length) return flat;
30
+ const out = {};
31
+ for (const [group, entries] of Object.entries(j)) {
32
+ if (!entries || typeof entries !== 'object') continue;
33
+ for (const [k, v] of Object.entries(entries)) out[`${group}.${k}`] = String(v);
34
+ }
35
+ return out;
36
+ }
37
+ if (ext === '.css') {
38
+ const out = {};
39
+ for (const m of raw.matchAll(/--([\w-]+):\s*([^;]+);/g)) out[m[1]] = m[2].trim();
40
+ return out;
41
+ }
42
+ throw new Error(`Unsupported token file: ${ext}`);
43
+ }
44
+
45
+ function hexToRgb(h) {
46
+ const m = h.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
47
+ return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
48
+ }
49
+
50
+ function colorDistance(a, b) {
51
+ const ra = hexToRgb(a), rb = hexToRgb(b);
52
+ if (!ra || !rb) return Infinity;
53
+ return Math.sqrt(ra.reduce((s, v, i) => s + (v - rb[i]) ** 2, 0));
54
+ }
55
+
56
+ function findNearest(value, palette) {
57
+ if (!/^#[\da-f]{6}$/i.test(value)) return null;
58
+ let best = { distance: Infinity, token: null };
59
+ for (const p of palette) {
60
+ if (!/^#[\da-f]{6}$/i.test(p.hex || '')) continue;
61
+ const d = colorDistance(value, p.hex);
62
+ if (d < best.distance) best = { distance: d, token: p };
63
+ }
64
+ return best;
65
+ }
66
+
67
+ export async function checkDrift(url, { tokens: tokensFile, tolerance = 8, options = {} } = {}) {
68
+ const local = loadLocalTokens(tokensFile);
69
+ const design = await extractDesignLanguage(url, options);
70
+ const livePalette = design.colors?.all || [];
71
+
72
+ const drifted = [];
73
+ const matched = [];
74
+ const unknown = [];
75
+
76
+ for (const [name, value] of Object.entries(local)) {
77
+ if (!/^#[\da-f]{3,8}$/i.test(String(value))) continue; // only color tokens for now
78
+ const hex = value.length === 4 ? '#' + value.slice(1).split('').map(c => c + c).join('') : value;
79
+ const nearest = findNearest(hex, livePalette);
80
+ if (!nearest || nearest.distance === Infinity) { unknown.push({ name, value: hex }); continue; }
81
+ if (nearest.distance > tolerance) {
82
+ drifted.push({
83
+ token: name,
84
+ local: hex,
85
+ nearestLive: nearest.token.hex,
86
+ distance: Math.round(nearest.distance),
87
+ role: nearest.token.role || 'unknown',
88
+ });
89
+ } else {
90
+ matched.push({ token: name, local: hex, liveMatch: nearest.token.hex, distance: Math.round(nearest.distance) });
91
+ }
92
+ }
93
+
94
+ const driftRatio = drifted.length / Math.max(1, drifted.length + matched.length);
95
+ const verdict = driftRatio === 0 ? 'in-sync' : driftRatio < 0.15 ? 'minor-drift' : driftRatio < 0.4 ? 'notable-drift' : 'major-drift';
96
+
97
+ return {
98
+ url,
99
+ tokensFile,
100
+ tolerance,
101
+ verdict,
102
+ driftRatio: +driftRatio.toFixed(3),
103
+ drifted,
104
+ matched,
105
+ unknown,
106
+ summary: {
107
+ total: drifted.length + matched.length,
108
+ drifted: drifted.length,
109
+ matched: matched.length,
110
+ unknown: unknown.length,
111
+ },
112
+ };
113
+ }
114
+
115
+ export function formatDriftMarkdown(r) {
116
+ const lines = [
117
+ `# designlang drift report`,
118
+ ``,
119
+ `**Live site:** ${r.url}`,
120
+ `**Local tokens:** ${r.tokensFile}`,
121
+ `**Verdict:** ${r.verdict} (drift ratio: ${r.driftRatio})`,
122
+ ``,
123
+ `| metric | count |`,
124
+ `|---|---|`,
125
+ `| total color tokens | ${r.summary.total} |`,
126
+ `| matched | ${r.summary.matched} |`,
127
+ `| drifted | ${r.summary.drifted} |`,
128
+ `| unknown | ${r.summary.unknown} |`,
129
+ ``,
130
+ ];
131
+ if (r.drifted.length) {
132
+ lines.push(`## Drifted tokens`, ``, `| token | local | nearest live | Δ |`, `|---|---|---|---|`);
133
+ for (const d of r.drifted) lines.push(`| \`${d.token}\` | \`${d.local}\` | \`${d.nearestLive}\` (${d.role}) | ${d.distance} |`);
134
+ lines.push('');
135
+ }
136
+ return lines.join('\n');
137
+ }
@@ -0,0 +1,123 @@
1
+ // Component Anatomy v2 — builds per-kind anatomy trees, variant × state matrices,
2
+ // and typed prop surfaces that downstream generators can turn into stubs.
3
+
4
+ const KNOWN_VARIANTS = ['primary', 'secondary', 'tertiary', 'ghost', 'outline', 'solid', 'destructive', 'danger', 'success', 'warning', 'link', 'subtle'];
5
+ const KNOWN_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'small', 'medium', 'large'];
6
+
7
+ function slotFingerprint(slots = []) {
8
+ return slots.map(s => s.role).join('>');
9
+ }
10
+
11
+ function inferSlots(slots = [], kind) {
12
+ const roles = new Set(slots.map(s => s.role));
13
+ if (kind === 'button') {
14
+ return {
15
+ label: true,
16
+ icon: roles.has('icon'),
17
+ badge: roles.has('badge'),
18
+ };
19
+ }
20
+ if (kind === 'card') {
21
+ return {
22
+ heading: roles.has('heading'),
23
+ description: roles.has('text'),
24
+ media: roles.has('icon'),
25
+ footer: slots.length > 3,
26
+ };
27
+ }
28
+ if (kind === 'input') {
29
+ return { leading: roles.has('icon'), trailing: false };
30
+ }
31
+ return {};
32
+ }
33
+
34
+ function dominant(arr) {
35
+ const counts = {};
36
+ for (const v of arr) counts[v] = (counts[v] || 0) + 1;
37
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
38
+ }
39
+
40
+ export function extractComponentAnatomy(candidates = []) {
41
+ const byKind = {};
42
+ for (const c of candidates) {
43
+ const k = c.kind || 'other';
44
+ (byKind[k] ||= []).push(c);
45
+ }
46
+
47
+ const anatomies = [];
48
+ for (const [kind, items] of Object.entries(byKind)) {
49
+ if (items.length < 2) continue;
50
+
51
+ // Group variants by explicit class hint, fall back to style vector.
52
+ const variantGroups = {};
53
+ for (const it of items) {
54
+ const v = it.variantHint || 'default';
55
+ (variantGroups[v] ||= []).push(it);
56
+ }
57
+
58
+ const variants = Object.entries(variantGroups).map(([name, vs]) => {
59
+ const sizes = {};
60
+ for (const it of vs) {
61
+ const sz = it.sizeHint || 'default';
62
+ (sizes[sz] ||= []).push(it);
63
+ }
64
+ return {
65
+ name,
66
+ count: vs.length,
67
+ states: {
68
+ default: { count: vs.filter(v => !v.disabled).length, css: vs.find(v => !v.disabled)?.css || null },
69
+ disabled: { count: vs.filter(v => v.disabled).length, css: vs.find(v => v.disabled)?.css || null },
70
+ },
71
+ sizes: Object.entries(sizes).map(([sName, sItems]) => ({ name: sName, count: sItems.length, css: sItems[0].css })),
72
+ sampleText: vs.slice(0, 5).map(v => v.text).filter(Boolean),
73
+ };
74
+ }).sort((a, b) => b.count - a.count);
75
+
76
+ const slotSignatures = items.map(i => slotFingerprint(i.slots));
77
+ const anatomy = {
78
+ kind,
79
+ totalInstances: items.length,
80
+ slots: inferSlots(items[0].slots, kind),
81
+ dominantSlotShape: dominant(slotSignatures),
82
+ variants,
83
+ props: {
84
+ variant: Object.keys(variantGroups).filter(v => v !== 'default'),
85
+ size: [...new Set(items.map(i => i.sizeHint).filter(Boolean))],
86
+ disabled: items.some(i => i.disabled),
87
+ },
88
+ };
89
+ anatomies.push(anatomy);
90
+ }
91
+
92
+ return anatomies.sort((a, b) => b.totalInstances - a.totalInstances);
93
+ }
94
+
95
+ // Emit TypeScript-flavored React stub for the anatomy — includes variant/size props + slot children.
96
+ export function formatAnatomyStubs(anatomies = []) {
97
+ const lines = [
98
+ "// Auto-generated by designlang — component anatomy v2.",
99
+ "// Scaffolds. Wire into your token system; not a runtime library.",
100
+ "",
101
+ "import * as React from 'react';",
102
+ "",
103
+ ];
104
+ for (const a of anatomies) {
105
+ const Name = a.kind.charAt(0).toUpperCase() + a.kind.slice(1);
106
+ const variantUnion = (a.props.variant.length ? a.props.variant : ['default']).map(v => `'${v}'`).join(' | ');
107
+ const sizeUnion = (a.props.size.length ? a.props.size : ['md']).map(v => `'${v}'`).join(' | ');
108
+ lines.push(`export interface ${Name}Props {`);
109
+ lines.push(` variant?: ${variantUnion};`);
110
+ lines.push(` size?: ${sizeUnion};`);
111
+ if (a.props.disabled) lines.push(` disabled?: boolean;`);
112
+ if (a.slots.icon) lines.push(` leadingIcon?: React.ReactNode;`);
113
+ if (a.slots.badge) lines.push(` badge?: React.ReactNode;`);
114
+ lines.push(` children?: React.ReactNode;`);
115
+ lines.push(`}`);
116
+ lines.push(``);
117
+ lines.push(`export function ${Name}({ variant = '${a.props.variant[0] || 'default'}', size = 'md', ...rest }: ${Name}Props) {`);
118
+ lines.push(` return React.createElement('${a.kind === 'input' ? 'input' : a.kind === 'link' ? 'a' : a.kind === 'card' ? 'div' : 'button'}', { 'data-variant': variant, 'data-size': size, ...rest });`);
119
+ lines.push(`}`);
120
+ lines.push(``);
121
+ }
122
+ return lines.join('\n');
123
+ }