designlang 6.0.0 → 7.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.
Files changed (35) hide show
  1. package/.github/FUNDING.yml +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  5. package/CHANGELOG.md +43 -0
  6. package/README.md +111 -1
  7. package/bin/design-extract.js +88 -2
  8. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +1121 -0
  9. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -0
  10. package/package.json +5 -4
  11. package/src/config.js +23 -0
  12. package/src/crawler.js +116 -0
  13. package/src/extractors/a11y-remediation.js +47 -0
  14. package/src/extractors/component-clusters.js +39 -0
  15. package/src/extractors/css-health.js +151 -0
  16. package/src/extractors/scoring.js +20 -1
  17. package/src/extractors/semantic-regions.js +44 -0
  18. package/src/extractors/stack-fingerprint.js +88 -0
  19. package/src/formatters/_token-ref.js +44 -0
  20. package/src/formatters/agent-rules.js +116 -0
  21. package/src/formatters/android-compose.js +164 -0
  22. package/src/formatters/dtcg-tokens.js +175 -0
  23. package/src/formatters/flutter-dart.js +130 -0
  24. package/src/formatters/ios-swiftui.js +161 -0
  25. package/src/formatters/markdown.js +25 -0
  26. package/src/formatters/wordpress.js +183 -0
  27. package/src/index.js +30 -0
  28. package/src/mcp/resources.js +64 -0
  29. package/src/mcp/server.js +110 -0
  30. package/src/mcp/tools.js +149 -0
  31. package/tests/cli.test.js +50 -0
  32. package/tests/extractors.test.js +131 -0
  33. package/tests/formatters.test.js +232 -0
  34. package/tests/mcp.test.js +68 -0
  35. package/website/app/globals.css +11 -11
@@ -0,0 +1,150 @@
1
+ # designlang v7.0 — Design Spec
2
+
3
+ **Date:** 2026-04-18
4
+ **Status:** Approved for implementation
5
+ **Author:** Manav Arya Singh
6
+
7
+ ## Theme
8
+
9
+ *The design context layer for AI agents, across every platform.*
10
+
11
+ v7.0 turns designlang from a web-only token extractor into (a) the default MCP/agent context source for AI IDEs, (b) a real multi-platform design system output (web + iOS + Android + Flutter + WordPress), (c) a CSS auditor, not just an extractor.
12
+
13
+ ## Scope — 10 features
14
+
15
+ ### 1. MCP server
16
+ New command: `designlang mcp [--output-dir <path>]`. Launches a stdio MCP server built on `@modelcontextprotocol/sdk`. Exposes:
17
+ - **Resources:** `tokens://primitive`, `tokens://semantic`, `tokens://components`, `regions://map`, `health://score`.
18
+ - **Tools:** `search_tokens(query)`, `find_nearest_color(hex, level: AA|AAA)`, `get_region(name)`, `get_component(name)`, `list_failing_contrast_pairs()`.
19
+ - No live extraction tool (deferred to v7.1 with hosted backend).
20
+
21
+ ### 2. Agent rules emitter
22
+ New flag: `--emit-agent-rules` (and on by default when `--full`). Writes:
23
+ - `.cursor/rules/designlang.mdc` — Cursor project rules referencing tokens.
24
+ - `.claude/skills/designlang/SKILL.md` — Claude Code skill folder with frontmatter.
25
+ - `CLAUDE.md.fragment` — append-ready fragment for root CLAUDE.md.
26
+ - `agents.md` — generic agent prompt.
27
+
28
+ All four files read from the same source-of-truth JSON.
29
+
30
+ ### 3. DTCG strict mode + semantic layer + composite tokens
31
+ Rewrites `*-design-tokens.json` to W3C DTCG v1 format:
32
+ - Every leaf is `{ "$value": ..., "$type": "color|dimension|fontFamily|shadow|...", "$extensions": {...} }`.
33
+ - Two-layer structure: `primitive.*` (raw values) and `semantic.*` (alias references like `"{primitive.color.blue.500}"`).
34
+ - Composite types for `typography`, `shadow`, `border`, `gradient` (single object with family/size/weight/lineHeight, etc.).
35
+ - Auto-infers semantic roles from usage: `semantic.color.action.primary`, `semantic.color.surface.default`, `semantic.text.body`, `semantic.radius.control`, etc.
36
+ - **Default ON.** `--tokens-legacy` preserves pre-v7 shape. Breaking change — documented in CHANGELOG and migration guide.
37
+
38
+ ### 4. Multi-platform emitters
39
+ New flag: `--platforms <csv>` (values: `web,ios,android,flutter,wordpress,all`; default `web`).
40
+ - **iOS (SwiftUI):** `*-ios.swift` with `Color.primaryAction`, `CGFloat` spacing/radius, `Font` registrations.
41
+ - **Android (Compose):** `*-colors.xml`, `*-dimens.xml`, `*-Theme.kt` with Compose `val`s.
42
+ - **Flutter (Dart):** `*-theme.dart` exporting a `ThemeData` + color/typography extensions.
43
+ - **WordPress:** `theme.json` (Gutenberg block theme tokens), `style.css` with CSS custom props, minimal `functions.php` + `index.php`/`templates/index.html` skeleton so output is a drop-in block theme.
44
+
45
+ All emitters consume the DTCG semantic layer, not primitives — they stay consistent.
46
+
47
+ ### 5. Tech-stack + Tailwind fingerprint
48
+ New extractor `stack-fingerprint`:
49
+ - Detects framework from window globals, script URLs, meta tags, DOM signatures (React/Vue/Svelte/Next/Nuxt/Remix/Astro/Shopify/WooCommerce/Webflow/Framer).
50
+ - Detects CSS layer (Tailwind v3/v4, styled-components, CSS Modules, vanilla).
51
+ - Detects analytics, CDN, fonts host, A/B tools.
52
+ - When Tailwind is detected: scrapes class-name frequency, extracts utility classes in use, and emits `*-tailwind-diff.md` (what classes are used, config delta vs. default preset) instead of only a fresh `tailwind.config.js`.
53
+
54
+ ### 6. CSS health audit
55
+ New extractor `css-health`:
56
+ - Runs Playwright `coverage.startCSSCoverage()` → reports unused bytes per stylesheet.
57
+ - Parses all stylesheets for specificity distribution, `!important` count, duplicate declarations, selector-per-rule averages, vendor-prefix and obsolete-property audit.
58
+ - Enumerates `@keyframes` with name/duration/easing/steps.
59
+ - Emits `*-css-health.json` and `*-css-health.md`.
60
+ - Adds three new dimensions to the design score: `cssHealth`, `animationCatalog`, `specificity`. Old score fields remain for backward compat.
61
+
62
+ ### 7. A11y remediation suggestions
63
+ Extends a11y extractor:
64
+ - For each failing WCAG fg/bg pair, searches the extracted palette for the nearest color that passes AA (4.5:1) and AAA (7:1).
65
+ - Emits suggestions in `*-a11y.md` and as fixable entries in `*-design-language.md`.
66
+ - MCP tool `find_nearest_color(hex, level)` reuses this engine.
67
+
68
+ ### 8. Semantic component segmentation
69
+ New extractor `semantic-regions`:
70
+ - Heuristic classifier using ARIA landmarks (`<nav>`, `<main>`, `<header>`, `<footer>`, `role=banner|contentinfo|complementary`), layout properties (sticky header, footer at page end), and class-name hints (`.hero`, `.pricing`, `.cta`).
71
+ - Labels detected regions: `nav`, `hero`, `features`, `pricing`, `testimonials`, `cta`, `footer`, `content`.
72
+ - Emits `*-regions.json` with bounds, computed styles, and region role.
73
+ - MCP tool `get_region(name)` returns the full region block.
74
+
75
+ ### 9. Reusable component detection
76
+ New extractor `component-clusters`:
77
+ - For each DOM element matching button/card/input/badge/tag candidates, computes a structural hash (tag sequence + class pattern) + style vector (flattened computed-style array).
78
+ - Clusters by similarity (structural hash exact, style vector cosine > threshold).
79
+ - Replaces current "one example per component type" with `{ component, instanceCount, variants: [...] }`.
80
+ - Markdown output shows "Button — 24 instances, 2 variants (primary, ghost)" with CSS for each.
81
+
82
+ ### 10. WordPress theme export
83
+ Covered under Feature 4; called out separately because it is a full emitter with file-tree skeleton, not only tokens.
84
+
85
+ ## Architecture changes
86
+
87
+ ### New modules
88
+ - `src/extractors/stack-fingerprint.js`
89
+ - `src/extractors/css-health.js`
90
+ - `src/extractors/semantic-regions.js`
91
+ - `src/extractors/component-clusters.js`
92
+ - `src/extractors/a11y-remediation.js` (or extend existing a11y)
93
+ - `src/formatters/dtcg-tokens.js` (replaces `design-tokens.js` with legacy fallback)
94
+ - `src/formatters/ios-swiftui.js`
95
+ - `src/formatters/android-compose.js`
96
+ - `src/formatters/flutter-dart.js`
97
+ - `src/formatters/wordpress-theme.js`
98
+ - `src/formatters/agent-rules.js`
99
+ - `src/mcp/server.js` + `src/mcp/resources.js` + `src/mcp/tools.js`
100
+
101
+ ### New dependencies
102
+ - `@modelcontextprotocol/sdk` — for MCP server.
103
+ - No other additions.
104
+
105
+ ### CLI surface
106
+ ```
107
+ designlang <url> [...existing flags]
108
+ --platforms <csv> web,ios,android,flutter,wordpress,all (default: web)
109
+ --emit-agent-rules Emit Cursor/Claude Code/generic agent rules
110
+ --tokens-legacy Keep pre-v7 token JSON shape
111
+
112
+ designlang mcp [--output-dir <path>] # NEW: launch MCP server
113
+ ```
114
+
115
+ All existing commands (`apply`, `clone`, `score`, `watch`, `diff`, `brands`, `sync`, `history`) unchanged.
116
+
117
+ ### Backward compatibility
118
+ - Base extraction stays — same file names, same top-level keys.
119
+ - **Breaking:** token JSON shape changes to DTCG. Mitigated by `--tokens-legacy`, loud CHANGELOG, migration section in README.
120
+ - **Additive:** score JSON gains new fields; existing fields preserved.
121
+ - **Additive:** new files only appear with opt-in flags (`--platforms`, `--emit-agent-rules`).
122
+
123
+ ## Testing
124
+
125
+ - Unit tests per extractor module using fixture HTML (existing pattern in `tests/`).
126
+ - Integration test: run full extraction on a local fixture site, snapshot each output file.
127
+ - DTCG output validated against the W3C DTCG JSON schema (vendored).
128
+ - Multi-platform emitters: golden-file tests (check generated Swift/Kotlin/Dart/theme.json against known-good outputs).
129
+ - MCP server: spin up server, run a handful of resource/tool requests over the MCP test harness, assert responses.
130
+
131
+ ## Release plan
132
+
133
+ 1. Implement features in dependency order (see writing-plans phase).
134
+ 2. All tests pass.
135
+ 3. Update README with new sections: MCP, agent rules, multi-platform, CSS health, WordPress.
136
+ 4. Write CHANGELOG.md with breaking-change callout on DTCG + migration snippet.
137
+ 5. Bump `package.json` version `6.0.0` → `7.0.0`.
138
+ 6. `npm publish` + `git tag v7.0.0 && git push --tags`.
139
+ 7. Website update — deferred to Wave 2.
140
+
141
+ ## Out of scope (deferred to v7.1+)
142
+
143
+ - Bidirectional Figma Variables sync.
144
+ - Full JSX/Vue/Svelte component codegen (needs Mitosis-style IR or LLM).
145
+ - Versioned auto-published npm token package with changesets.
146
+ - Hosted shareable reports (needs backend).
147
+ - Hosted "Try it free" web extraction (Wave 2).
148
+ - CMS content-model extraction.
149
+ - Storybook + Chromatic integration.
150
+ - Live clone-to-editable canvas.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "6.0.0",
3
+ "version": "7.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": {
@@ -13,10 +13,11 @@
13
13
  "test": "node --test tests/*.test.js"
14
14
  },
15
15
  "dependencies": {
16
- "playwright": "^1.42.0",
17
- "commander": "^12.0.0",
16
+ "@modelcontextprotocol/sdk": "^1.29.0",
18
17
  "chalk": "^5.3.0",
19
- "ora": "^8.0.0"
18
+ "commander": "^12.0.0",
19
+ "ora": "^8.0.0",
20
+ "playwright": "^1.42.0"
20
21
  },
21
22
  "engines": {
22
23
  "node": ">=20"
package/src/config.js CHANGED
@@ -32,5 +32,28 @@ export function mergeConfig(cliOpts, config) {
32
32
  cookie: cliOpts.cookie || config.cookies,
33
33
  header: cliOpts.header || config.headers,
34
34
  out: cliOpts.out || config.out || './design-extract-output',
35
+ tokensLegacy: cliOpts.tokensLegacy || config.tokensLegacy || false,
36
+ platforms: parsePlatforms(cliOpts.platforms ?? config.platforms ?? 'web'),
37
+ emitAgentRules: cliOpts.emitAgentRules || config.emitAgentRules || false,
35
38
  };
36
39
  }
40
+
41
+ // Normalize the --platforms value into an array. Accepts comma-separated strings
42
+ // or an existing array. Expands "all" to the full list. Always ensures "web"
43
+ // remains included (v7.0: --platforms is additive, web is not disabled).
44
+ export function parsePlatforms(value) {
45
+ const KNOWN = ['web', 'ios', 'android', 'flutter', 'wordpress'];
46
+ let list;
47
+ if (Array.isArray(value)) list = value.slice();
48
+ else if (typeof value === 'string') list = value.split(',').map((s) => s.trim()).filter(Boolean);
49
+ else list = ['web'];
50
+
51
+ const expanded = new Set();
52
+ for (const item of list) {
53
+ const v = item.toLowerCase();
54
+ if (v === 'all') KNOWN.forEach((k) => expanded.add(k));
55
+ else if (KNOWN.includes(v)) expanded.add(v);
56
+ }
57
+ expanded.add('web'); // web is always emitted in v7.0
58
+ return KNOWN.filter((k) => expanded.has(k));
59
+ }
package/src/crawler.js CHANGED
@@ -43,14 +43,36 @@ export async function crawlPage(url, options = {}) {
43
43
  }
44
44
  const page = await context.newPage();
45
45
 
46
+ // Start CSS coverage for css-health audit. Not supported on all targets —
47
+ // fail gracefully and set empty coverage if the API is unavailable.
48
+ let cssCoverageAvailable = true;
49
+ try {
50
+ await page.coverage.startCSSCoverage();
51
+ } catch { cssCoverageAvailable = false; }
52
+
46
53
  await gotoWithRetry(page, url, { waitUntil: 'domcontentloaded', timeout: 30000 });
47
54
  // Wait for network to settle — but don't hang on sites with persistent connections
48
55
  await page.waitForLoadState('networkidle').catch(() => {});
49
56
  if (wait > 0) await page.waitForTimeout(wait);
50
57
  await page.evaluate(() => document.fonts.ready).catch(() => {});
51
58
 
59
+ // Capture CSS coverage after the page has settled.
60
+ let cssCoverage = [];
61
+ if (cssCoverageAvailable) {
62
+ try {
63
+ const raw = await page.coverage.stopCSSCoverage();
64
+ cssCoverage = raw.map(c => ({
65
+ url: c.url,
66
+ text: c.text,
67
+ totalBytes: (c.text || '').length,
68
+ ranges: c.ranges || [],
69
+ }));
70
+ } catch { cssCoverage = []; }
71
+ }
72
+
52
73
  const title = await page.title();
53
74
  const lightData = await extractPageData(page, ignore);
75
+ lightData.cssCoverage = cssCoverage;
54
76
 
55
77
  // Component screenshots
56
78
  let componentScreenshots = {};
@@ -326,6 +348,100 @@ async function extractPageData(page, ignoreSelectors) {
326
348
  }
327
349
  } catch { /* no access */ }
328
350
 
351
+ // Component clusters (v7): per-element features for similarity-based grouping.
352
+ function colorToChannels(str) {
353
+ if (!str) return [0, 0, 0, 0];
354
+ const m = String(str).match(/rgba?\(([^)]+)\)/i);
355
+ if (!m) return [0, 0, 0, 0];
356
+ const parts = m[1].split(',').map(s => parseFloat(s));
357
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] === undefined ? 1 : parts[3]];
358
+ }
359
+ function structuralHashOf(el) {
360
+ const parts = [el.tagName.toLowerCase()];
361
+ for (const c of el.children) {
362
+ parts.push(c.tagName.toLowerCase());
363
+ }
364
+ return parts.slice(0, 6).join('>');
365
+ }
366
+ const candidateSelector = 'button, a[role="button"], .btn, [class*="button"], input[type="text"], input[type="email"], input[type="search"], textarea, [class*="card"]';
367
+ results.componentCandidates = [];
368
+ const seenCandidates = new Set();
369
+ for (const el of document.querySelectorAll(candidateSelector)) {
370
+ if (results.componentCandidates.length >= 300) break;
371
+ const rect = el.getBoundingClientRect();
372
+ if (rect.width < 4 || rect.height < 4) continue;
373
+ if (seenCandidates.has(el)) continue;
374
+ seenCandidates.add(el);
375
+ const cs = getComputedStyle(el);
376
+ const tag = el.tagName.toLowerCase();
377
+ let kind = 'other';
378
+ const cls = typeof el.className === 'string' ? el.className.toLowerCase() : '';
379
+ if (tag === 'button' || el.getAttribute('role') === 'button' || /\bbtn\b|button/.test(cls)) kind = 'button';
380
+ else if (tag === 'input' || tag === 'textarea') kind = 'input';
381
+ else if (tag === 'a') kind = 'link';
382
+ else if (/card/.test(cls)) kind = 'card';
383
+ const bg = colorToChannels(cs.backgroundColor);
384
+ const fg = colorToChannels(cs.color);
385
+ const styleVector = [
386
+ parseFloat(cs.paddingTop) || 0,
387
+ parseFloat(cs.paddingRight) || 0,
388
+ parseFloat(cs.paddingBottom) || 0,
389
+ parseFloat(cs.paddingLeft) || 0,
390
+ bg[0], bg[1], bg[2], bg[3] * 255,
391
+ fg[0], fg[1], fg[2], fg[3] * 255,
392
+ parseFloat(cs.borderTopLeftRadius) || 0,
393
+ parseFloat(cs.borderWidth) || 0,
394
+ parseFloat(cs.fontSize) || 0,
395
+ parseFloat(cs.fontWeight) || 0,
396
+ ];
397
+ results.componentCandidates.push({
398
+ kind,
399
+ structuralHash: structuralHashOf(el),
400
+ styleVector,
401
+ css: {
402
+ background: cs.backgroundColor,
403
+ color: cs.color,
404
+ padding: `${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft}`,
405
+ borderRadius: cs.borderTopLeftRadius,
406
+ border: `${cs.borderWidth} ${cs.borderStyle} ${cs.borderColor}`,
407
+ fontSize: cs.fontSize,
408
+ fontWeight: cs.fontWeight,
409
+ },
410
+ });
411
+ }
412
+
413
+ // Semantic regions (v7): landmark + heading + bounds data for classifier.
414
+ results.sections = Array.from(document.querySelectorAll(
415
+ 'header, nav, main, section, footer, aside, [role="banner"], [role="contentinfo"], [role="complementary"], [role="navigation"]'
416
+ )).slice(0, 100).map(el => {
417
+ const r = el.getBoundingClientRect();
418
+ return {
419
+ tag: el.tagName.toLowerCase(),
420
+ role: el.getAttribute('role') || '',
421
+ className: typeof el.className === 'string' ? el.className : '',
422
+ id: el.id || '',
423
+ text: (el.innerText || '').slice(0, 2000),
424
+ headings: Array.from(el.querySelectorAll('h1,h2,h3')).slice(0, 5).map(h => h.innerText || ''),
425
+ buttonCount: el.querySelectorAll('button, a[role="button"], .btn, [class*="button"]').length,
426
+ cardCount: el.querySelectorAll('article, li, [class*="card"], [class*="item"]').length,
427
+ bounds: { x: r.x, y: r.y, w: r.width, h: r.height },
428
+ };
429
+ });
430
+
431
+ // Stack fingerprint signals (v7)
432
+ results.stack = {
433
+ scripts: Array.from(document.scripts).map(s => s.src).filter(Boolean).slice(0, 50),
434
+ metas: Array.from(document.querySelectorAll('meta[name],meta[property]'))
435
+ .map(m => ({ name: m.name || m.getAttribute('property'), content: m.content }))
436
+ .slice(0, 50),
437
+ classNameSample: Array.from(document.querySelectorAll('[class]'))
438
+ .slice(0, 500)
439
+ .map(e => typeof e.className === 'string' ? e.className : '')
440
+ .filter(Boolean),
441
+ windowGlobals: ['React', 'Vue', '__NEXT_DATA__', '__NUXT__', '___gatsby', '_remixContext', 'Shopify', 'wp']
442
+ .filter(k => typeof window[k] !== 'undefined'),
443
+ };
444
+
329
445
  // SVG icons
330
446
  results.icons = [];
331
447
  for (const svg of document.querySelectorAll('svg')) {
@@ -0,0 +1,47 @@
1
+ // A11y remediation: for each failing fg/bg pair, propose the nearest palette
2
+ // color that passes the requested WCAG rule.
3
+
4
+ function toRgb(hex) {
5
+ const h = String(hex || '').replace('#', '');
6
+ const n = h.length === 3 ? h.split('').map(x => x + x).join('') : h;
7
+ const i = parseInt(n, 16);
8
+ if (Number.isNaN(i)) return [0, 0, 0];
9
+ return [(i >> 16) & 255, (i >> 8) & 255, i & 255];
10
+ }
11
+
12
+ function relLum([r, g, b]) {
13
+ const f = c => {
14
+ const s = c / 255;
15
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
16
+ };
17
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
18
+ }
19
+
20
+ function contrast(a, b) {
21
+ const la = relLum(toRgb(a));
22
+ const lb = relLum(toRgb(b));
23
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
24
+ }
25
+
26
+ const THRESHOLDS = { 'AA-normal': 4.5, 'AA-large': 3, 'AAA-normal': 7, 'AAA-large': 4.5 };
27
+
28
+ export function remediateFailingPairs(failing = [], palette = []) {
29
+ return failing.map(p => {
30
+ const target = THRESHOLDS[p.rule] || 4.5;
31
+ let best = null;
32
+ for (const candidate of palette) {
33
+ if (!candidate) continue;
34
+ const newRatio = contrast(candidate, p.bg);
35
+ if (newRatio >= target && (!best || newRatio > best.newRatio)) {
36
+ best = {
37
+ replace: 'fg',
38
+ color: candidate,
39
+ newRatio: Math.round(newRatio * 100) / 100,
40
+ };
41
+ }
42
+ }
43
+ return { ...p, suggestion: best };
44
+ });
45
+ }
46
+
47
+ export { contrast as _contrast };
@@ -0,0 +1,39 @@
1
+ // Cluster similar component instances by structuralHash + style vector cosine similarity.
2
+
3
+ function cosine(a = [], b = []) {
4
+ const n = Math.min(a.length, b.length);
5
+ let dot = 0, na = 0, nb = 0;
6
+ for (let i = 0; i < n; i++) {
7
+ dot += a[i] * b[i];
8
+ na += a[i] * a[i];
9
+ nb += b[i] * b[i];
10
+ }
11
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : (na === nb ? 1 : 0);
12
+ }
13
+
14
+ export function clusterComponents(elements = [], { threshold = 0.95 } = {}) {
15
+ const byKind = {};
16
+ for (const el of elements) {
17
+ const key = `${el.kind}|${el.structuralHash}`;
18
+ (byKind[key] ||= []).push(el);
19
+ }
20
+ const out = [];
21
+ for (const group of Object.values(byKind)) {
22
+ const variants = [];
23
+ for (const el of group) {
24
+ const match = variants.find(v => cosine(v.example.styleVector || [], el.styleVector || []) >= threshold);
25
+ if (match) {
26
+ match.instanceCount++;
27
+ } else {
28
+ variants.push({ example: el, instanceCount: 1 });
29
+ }
30
+ }
31
+ out.push({
32
+ kind: group[0].kind,
33
+ structuralHash: group[0].structuralHash,
34
+ instanceCount: group.length,
35
+ variants: variants.map(v => ({ css: v.example.css, instanceCount: v.instanceCount })),
36
+ });
37
+ }
38
+ return out;
39
+ }
@@ -0,0 +1,151 @@
1
+ // CSS health audit — operates on page.coverage.stopCSSCoverage() output
2
+ // serialized as [{ url, text, totalBytes, ranges:[{start,end}] }].
3
+
4
+ function countUsed(ranges = []) {
5
+ let used = 0;
6
+ for (const r of ranges) used += Math.max(0, (r.end || 0) - (r.start || 0));
7
+ return used;
8
+ }
9
+
10
+ function countImportant(text) {
11
+ const m = text.match(/!important/g);
12
+ return m ? m.length : 0;
13
+ }
14
+
15
+ function countDuplicates(text) {
16
+ // Count prop:value pairs that appear >=2x across the sheet.
17
+ const seen = new Map();
18
+ const re = /([\w-]+)\s*:\s*([^;{}!]+)(\s*!important)?\s*;?/g;
19
+ for (const m of text.matchAll(re)) {
20
+ const key = `${m[1].trim()}:${m[2].trim()}`;
21
+ seen.set(key, (seen.get(key) || 0) + 1);
22
+ }
23
+ let dup = 0;
24
+ for (const n of seen.values()) if (n >= 2) dup += (n - 1);
25
+ return dup;
26
+ }
27
+
28
+ function countVendorPrefixes(text) {
29
+ return {
30
+ webkit: (text.match(/-webkit-/g) || []).length,
31
+ moz: (text.match(/-moz-/g) || []).length,
32
+ ms: (text.match(/-ms-/g) || []).length,
33
+ o: (text.match(/(^|[^-\w])-o-/g) || []).length,
34
+ };
35
+ }
36
+
37
+ function extractKeyframes(text) {
38
+ const out = [];
39
+ const re = /@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g;
40
+ for (const m of text.matchAll(re)) {
41
+ const body = m[2];
42
+ const steps = (body.match(/(\d+%|from|to)\s*\{/g) || []).length;
43
+ out.push({ name: m[1], steps });
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function specificityFor(selector) {
49
+ // Simple WCAG-ish triple: ids, classes+attrs+pseudo-classes, types
50
+ const ids = (selector.match(/#[\w-]+/g) || []).length;
51
+ const classes = (selector.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]+\))?/g) || []).length;
52
+ const types = (selector.match(/(?:^|[\s>+~,])([a-z][\w-]*)/gi) || []).length;
53
+ return [ids, classes, types];
54
+ }
55
+
56
+ function specificityDistribution(text) {
57
+ const triples = [];
58
+ const re = /([^{}]+)\{([^}]*)\}/g;
59
+ for (const m of text.matchAll(re)) {
60
+ const selectorList = m[1];
61
+ for (const sel of selectorList.split(',').map(s => s.trim()).filter(Boolean)) {
62
+ if (sel.startsWith('@')) continue;
63
+ triples.push(specificityFor(sel));
64
+ }
65
+ }
66
+ if (triples.length === 0) {
67
+ return { max: [0, 0, 0], average: [0, 0, 0], count: 0 };
68
+ }
69
+ let max = [0, 0, 0];
70
+ let sum = [0, 0, 0];
71
+ for (const t of triples) {
72
+ for (let i = 0; i < 3; i++) {
73
+ sum[i] += t[i];
74
+ if (t[i] > max[i]) max[i] = t[i];
75
+ }
76
+ }
77
+ const avg = sum.map(v => Math.round((v / triples.length) * 100) / 100);
78
+ return { max, average: avg, count: triples.length };
79
+ }
80
+
81
+ export function extractCssHealth(coverage = []) {
82
+ const sheets = [];
83
+ let totalBytes = 0;
84
+ let usedBytes = 0;
85
+ let importantCount = 0;
86
+ let duplicates = 0;
87
+ const vendorPrefixes = { webkit: 0, moz: 0, ms: 0, o: 0 };
88
+ const keyframes = [];
89
+ let specMax = [0, 0, 0];
90
+ let specSumWeighted = [0, 0, 0];
91
+ let specCount = 0;
92
+
93
+ for (const c of coverage) {
94
+ const text = c.text || '';
95
+ const sheetTotal = typeof c.totalBytes === 'number' ? c.totalBytes : text.length;
96
+ const sheetUsed = countUsed(c.ranges);
97
+ const sheetUnused = Math.max(0, sheetTotal - sheetUsed);
98
+ sheets.push({
99
+ url: c.url || '',
100
+ totalBytes: sheetTotal,
101
+ usedBytes: sheetUsed,
102
+ unusedBytes: sheetUnused,
103
+ unusedPercent: sheetTotal ? Math.round((sheetUnused / sheetTotal) * 100) : 0,
104
+ });
105
+ totalBytes += sheetTotal;
106
+ usedBytes += sheetUsed;
107
+
108
+ importantCount += countImportant(text);
109
+ duplicates += countDuplicates(text);
110
+
111
+ const vp = countVendorPrefixes(text);
112
+ vendorPrefixes.webkit += vp.webkit;
113
+ vendorPrefixes.moz += vp.moz;
114
+ vendorPrefixes.ms += vp.ms;
115
+ vendorPrefixes.o += vp.o;
116
+
117
+ keyframes.push(...extractKeyframes(text));
118
+
119
+ const spec = specificityDistribution(text);
120
+ for (let i = 0; i < 3; i++) {
121
+ if (spec.max[i] > specMax[i]) specMax[i] = spec.max[i];
122
+ specSumWeighted[i] += spec.average[i] * spec.count;
123
+ }
124
+ specCount += spec.count;
125
+ }
126
+
127
+ const unusedBytes = Math.max(0, totalBytes - usedBytes);
128
+ const unusedPercent = totalBytes ? Math.round((unusedBytes / totalBytes) * 100) : 0;
129
+ const specAvg = specCount > 0
130
+ ? specSumWeighted.map(v => Math.round((v / specCount) * 100) / 100)
131
+ : [0, 0, 0];
132
+
133
+ const issues = [];
134
+ if (importantCount > 0) issues.push(`${importantCount} !important rule${importantCount > 1 ? 's' : ''}`);
135
+ if (duplicates > 0) issues.push(`${duplicates} duplicate declaration${duplicates > 1 ? 's' : ''}`);
136
+ if (unusedPercent >= 50) issues.push(`${unusedPercent}% unused CSS`);
137
+
138
+ return {
139
+ sheets,
140
+ totalBytes,
141
+ usedBytes,
142
+ unusedBytes,
143
+ unusedPercent,
144
+ importantCount,
145
+ duplicates,
146
+ vendorPrefixes,
147
+ keyframes,
148
+ specificity: { max: specMax, average: specAvg, count: specCount },
149
+ issues,
150
+ };
151
+ }
@@ -73,7 +73,26 @@ export function scoreDesignSystem(design) {
73
73
  issues.push(`${design.accessibility.failCount} WCAG contrast failures`);
74
74
  }
75
75
 
76
- // 7. CSS variable usage (0-100)
76
+ // 7. CSS health (0-100) — additive; does not affect existing weights.
77
+ if (design.cssHealth) {
78
+ const ch = design.cssHealth;
79
+ let h = 100;
80
+ if (ch.unusedPercent >= 70) h -= 30;
81
+ else if (ch.unusedPercent >= 50) h -= 20;
82
+ else if (ch.unusedPercent >= 30) h -= 10;
83
+ if (ch.importantCount >= 20) h -= 20;
84
+ else if (ch.importantCount >= 5) h -= 10;
85
+ else if (ch.importantCount >= 1) h -= 5;
86
+ if (ch.duplicates >= 20) h -= 15;
87
+ else if (ch.duplicates >= 5) h -= 8;
88
+ else if (ch.duplicates >= 1) h -= 3;
89
+ scores.cssHealth = Math.max(0, h);
90
+ if (ch.importantCount >= 5) issues.push(`${ch.importantCount} !important rules — prefer specificity over overrides`);
91
+ if (ch.unusedPercent >= 50) issues.push(`${ch.unusedPercent}% of CSS is unused — consider purging`);
92
+ if (ch.duplicates >= 5) issues.push(`${ch.duplicates} duplicate CSS declarations`);
93
+ }
94
+
95
+ // 8. CSS variable usage (0-100)
77
96
  const varCount = Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0);
78
97
  if (varCount >= 20) scores.tokenization = 100;
79
98
  else if (varCount >= 10) scores.tokenization = 75;
@@ -0,0 +1,44 @@
1
+ // Classify page sections: nav/hero/features/pricing/testimonials/cta/footer/sidebar/content.
2
+
3
+ const KW = {
4
+ pricing: /\b(\$\s*\d|per\s?month|\/mo\b|pricing|free|billed)/i,
5
+ testimonials: /(customer|review|testimonial|said|"|")/i,
6
+ features: /(feature|benefit|why|what you get)/i,
7
+ cta: /(get started|sign up|try free|start now|request demo|contact sales)/i,
8
+ };
9
+
10
+ function classify(s) {
11
+ const role = (s.role || '').toLowerCase();
12
+ const tag = (s.tag || '').toLowerCase();
13
+ if (tag === 'nav' || role === 'navigation') return 'nav';
14
+ if (tag === 'header' || role === 'banner') return 'nav';
15
+ if (tag === 'footer' || role === 'contentinfo') return 'footer';
16
+ if (tag === 'aside' || role === 'complementary') return 'sidebar';
17
+
18
+ const cls = (s.className || '').toLowerCase();
19
+ const id = (s.id || '').toLowerCase();
20
+ const blob = `${cls} ${id}`;
21
+ const text = s.text || '';
22
+ const headings = s.headings || [];
23
+
24
+ if (/hero/.test(blob)) return 'hero';
25
+ if (/pricing/.test(blob) || KW.pricing.test(text)) return 'pricing';
26
+ if (/testimonial|review/.test(blob) || KW.testimonials.test(text)) return 'testimonials';
27
+ if (/features?|grid/.test(blob) && s.cardCount >= 3) return 'features';
28
+ if (KW.features.test(text) && s.cardCount >= 3) return 'features';
29
+ if (s.buttonCount <= 2 && headings.length && text.length < 400 && KW.cta.test(text)) return 'cta';
30
+ if (headings.length === 1 && s.buttonCount >= 1 && s.bounds && s.bounds.h > 300) return 'hero';
31
+ return 'content';
32
+ }
33
+
34
+ export function extractSemanticRegions(sections = []) {
35
+ return sections.map(s => ({
36
+ role: classify(s),
37
+ tag: s.tag,
38
+ bounds: s.bounds,
39
+ heading: (s.headings && s.headings[0]) || null,
40
+ buttonCount: s.buttonCount || 0,
41
+ cardCount: s.cardCount || 0,
42
+ className: s.className || null,
43
+ }));
44
+ }