designlang 7.2.0 → 8.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 (81) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +17 -0
  3. package/bin/design-extract.js +5 -1
  4. package/package.json +1 -1
  5. package/src/config.js +2 -0
  6. package/src/crawler.js +35 -6
  7. package/src/extractors/accessibility.js +44 -1
  8. package/src/extractors/colors.js +50 -12
  9. package/src/extractors/scoring.js +49 -30
  10. package/.github/FUNDING.yml +0 -1
  11. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  12. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  13. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
  14. package/.github/og-preview.png +0 -0
  15. package/.github/workflows/manavarya-bot.yml +0 -17
  16. package/chrome-extension/README.md +0 -41
  17. package/chrome-extension/icons/favicon.svg +0 -7
  18. package/chrome-extension/icons/icon-128.png +0 -0
  19. package/chrome-extension/icons/icon-16.png +0 -0
  20. package/chrome-extension/icons/icon-32.png +0 -0
  21. package/chrome-extension/icons/icon-48.png +0 -0
  22. package/chrome-extension/manifest.json +0 -26
  23. package/chrome-extension/popup.html +0 -167
  24. package/chrome-extension/popup.js +0 -59
  25. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
  26. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
  27. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
  28. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
  29. package/tests/cli.test.js +0 -84
  30. package/tests/cookies.test.js +0 -98
  31. package/tests/extractors.test.js +0 -792
  32. package/tests/formatters.test.js +0 -709
  33. package/tests/interaction-states.test.js +0 -62
  34. package/tests/mcp.test.js +0 -68
  35. package/tests/modern-css.test.js +0 -104
  36. package/tests/routes-reconciliation.test.js +0 -120
  37. package/tests/utils.test.js +0 -413
  38. package/tests/wide-gamut.test.js +0 -90
  39. package/website/.claude/launch.json +0 -11
  40. package/website/AGENTS.md +0 -5
  41. package/website/CLAUDE.md +0 -1
  42. package/website/README.md +0 -36
  43. package/website/app/api/extract/route.js +0 -245
  44. package/website/app/components/A11ySlider.js +0 -369
  45. package/website/app/components/Comparison.js +0 -286
  46. package/website/app/components/CssHealth.js +0 -243
  47. package/website/app/components/Extractor.js +0 -184
  48. package/website/app/components/HeroExtractor.js +0 -455
  49. package/website/app/components/Marginalia.js +0 -3
  50. package/website/app/components/McpSection.js +0 -223
  51. package/website/app/components/PlatformTabs.js +0 -250
  52. package/website/app/components/RegionsComponents.js +0 -429
  53. package/website/app/components/Rule.js +0 -13
  54. package/website/app/components/Specimens.js +0 -237
  55. package/website/app/components/StructuredData.js +0 -144
  56. package/website/app/components/TokenBrowser.js +0 -344
  57. package/website/app/components/token-browser-sample.js +0 -65
  58. package/website/app/globals.css +0 -505
  59. package/website/app/icon.svg +0 -7
  60. package/website/app/layout.js +0 -126
  61. package/website/app/opengraph-image.js +0 -170
  62. package/website/app/page.js +0 -399
  63. package/website/app/robots.js +0 -15
  64. package/website/app/seo-config.js +0 -82
  65. package/website/app/sitemap.js +0 -18
  66. package/website/jsconfig.json +0 -7
  67. package/website/lib/cache.js +0 -73
  68. package/website/lib/rate-limit.js +0 -30
  69. package/website/lib/rate-limit.test.js +0 -55
  70. package/website/lib/specimens.json +0 -86
  71. package/website/lib/token-helpers.js +0 -70
  72. package/website/lib/url-safety.js +0 -103
  73. package/website/lib/url-safety.test.js +0 -116
  74. package/website/lib/zip-files.js +0 -15
  75. package/website/next.config.mjs +0 -15
  76. package/website/package-lock.json +0 -1353
  77. package/website/package.json +0 -19
  78. package/website/public/favicon.svg +0 -7
  79. package/website/public/logo-specimen.svg +0 -76
  80. package/website/public/mark.svg +0 -12
  81. package/website/public/site.webmanifest +0 -13
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [8.0.0] — 2026-04-20
4
+
5
+ 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.
6
+
7
+ ### Reliability
8
+
9
+ - **Brand / primary color detection rewritten** — the extractor now ranks chromatic clusters by `interactiveBg × 100 + saturation × 2 + log(usage)` and requires either HSL saturation > 25 or an interactive-bg hit to qualify as chromatic. Previously the extractor picked the most-counted color, which on neutral-heavy sites like Linear meant the "Primary" was a pale gray (`#d0d6e0`). v8 correctly picks Linear's lime CTA (`#e4f222`) and Stripe's purple (`#533afd`). `src/extractors/colors.js`.
10
+ - **Accessibility scoring defused** — the crawler now emits a `hasText` boolean per element (a direct text-node child with visible characters), and the WCAG extractor filters out decorative glyph wrappers, transparent/overlay pairs, and non-text containers. Linear's WCAG score moved from 25% (171 failing pairs) to 83% (1 failing pair). `src/extractors/accessibility.js`, `src/crawler.js`.
11
+ - **Design-system score recalibrated** — thresholds for color count, shadow count, border-radii count, typography weight, and type-scale size were re-fit against ground-truth sites (Stripe, Linear, Vercel, GitHub, Apple). `cssHealth` is now weighted in the overall (8/100). Linear 47→76, Stripe 81→88, Apple 83→86, Vercel 64→76. `src/extractors/scoring.js`.
12
+
13
+ ### Added
14
+
15
+ - **`--selector <css>`** — scopes extraction to a DOM subtree (e.g. `designlang https://stripe.com --selector "footer"`). Stripe full-page extraction drops from 2,409 elements to 112 when scoped to the footer. Falls back to the full document if the selector is invalid or empty.
16
+ - **`--system-chrome`** — forces Playwright to use the locally installed Chrome (`channel: 'chrome'`) instead of the ~150 MB bundled Chromium, for faster `npx` first-runs in environments that already have Chrome.
17
+ - **`--json` output mode** — full extraction payload written to stdout (suppresses progress UI) for piping into other tools. This was a partial implementation in v7; v8 makes it first-class and adds it to the CLI reference.
18
+ - **VS Code extension** (`vscode-extension/`) — `designlang: Extract design from URL` and `designlang: Extract and inject into workspace` commands.
19
+ - **Raycast extension** (`raycast-extension/`) — Extract, Score, and "Copy CLI command for URL" commands.
20
+ - **Figma plugin** (`figma-plugin/`) — URL or paste-JSON → Figma Variables collections (MV for Figma's `figma.variables` API, with multi-mode support).
21
+ - **GitHub Action** (`github-action/`) — "Design regression guard": runs `designlang` on a URL, diffs tokens vs a committed baseline, and comments the delta on the pull request. Optional `fail-on-change`.
22
+ - **Smithery + MCP registry** (`smithery.yaml`, `smithery.dockerfile`, `docs/MCP-REGISTRY.md`) — one-command install in Smithery; checklist for the official MCP registry, Cursor, and Claude Desktop.
23
+ - **Chrome Web Store + Firefox + Edge listing prep** (`chrome-extension/PRIVACY.md`, `chrome-extension/STORE_LISTING.md`) — privacy policy and store copy.
24
+ - **README hero demo tape** (`docs/demo.tape`) — VHS script that renders an animated terminal GIF into `website/public/demo.gif`.
25
+ - **Launch kit** (`docs/LAUNCH.md`) — Product Hunt / Show HN / Twitter copy + day-of checklist.
26
+
27
+ ### Changed
28
+
29
+ - README: hero image now references the animated demo (with static PNG fallback), adds an "Install everywhere" table covering all surfaces, documents `--selector`, `--system-chrome`, and `--json`.
30
+ - `.npmignore`: excludes all companion-surface directories (`vscode-extension/`, `raycast-extension/`, `figma-plugin/`, `github-action/`, `chrome-extension/`, `website/`) and test fixtures so the npm tarball stays small — each surface publishes to its own registry.
31
+ - `bin/design-extract.js`: reports `8.0.0` from `--version`.
32
+ - `src/config.js`: whitelists `selector` and `systemChrome` from CLI/config.
33
+
34
+ ### Thanks
35
+
36
+ - To everyone who flagged that Linear's primary was coming out as light gray — that single complaint drove the brand-color rewrite.
37
+
3
38
  ## [7.2.0] — 2026-04-19
4
39
 
5
40
  ### Added
package/README.md CHANGED
@@ -396,6 +396,9 @@ Options:
396
396
  --header <headers...> Custom headers (name:value)
397
397
  --user-agent <ua> Override the browser User-Agent string
398
398
  --insecure Ignore HTTPS/SSL certificate errors (self-signed, dev, proxies)
399
+ --selector <css> Only extract from elements matching this CSS selector (e.g. ".pricing-card")
400
+ --system-chrome Use the system Chrome install instead of the bundled Chromium (skips 150MB download)
401
+ --json Print full extraction as JSON to stdout (for piping into other tools)
399
402
  --framework <type> Only generate specific theme (react, shadcn)
400
403
  --platforms <csv> Additional platforms: web,ios,android,flutter,wordpress,all (additive)
401
404
  --emit-agent-rules Emit Cursor / Claude Code / CLAUDE.md / agents.md rule files
@@ -468,6 +471,20 @@ Running `designlang https://vercel.com --full`:
468
471
  5. **Score** — Accessibility extractor calculates WCAG contrast ratios for all color pairs
469
472
  6. **Capture** — Optional: screenshots, responsive viewport crawling, interaction state recording
470
473
 
474
+ ## Install Everywhere
475
+
476
+ designlang ships surfaces beyond the CLI:
477
+
478
+ | Surface | Path | Description |
479
+ |---------|------|-------------|
480
+ | **CLI** | `npx designlang <url>` | Main entry point. |
481
+ | **VS Code extension** | [`vscode-extension/`](vscode-extension/) | "Extract design from URL" command + auto-inject into workspace. |
482
+ | **Raycast extension** | [`raycast-extension/`](raycast-extension/) | Extract, score, and "copy CLI command" from Raycast. |
483
+ | **Figma plugin** | [`figma-plugin/`](figma-plugin/) | Paste a URL inside Figma, get a full Variables collection. |
484
+ | **GitHub Action** | [`github-action/`](github-action/) | "Design regression guard" — diffs tokens on every PR and comments. |
485
+ | **Chrome extension** | [`chrome-extension/`](chrome-extension/) | One-click handoff from any tab (MV3, `activeTab` only). |
486
+ | **MCP server** | `npx designlang mcp` | Exposes the extracted design as MCP resources + tools for Cursor, Claude Code, Windsurf, etc. See [`docs/MCP-REGISTRY.md`](docs/MCP-REGISTRY.md). |
487
+
471
488
  ## Agent Skill
472
489
 
473
490
  Works with **Claude Code, Cursor, Codex, and 40+ AI coding agents** via the skills ecosystem:
@@ -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('6.0.0');
51
+ .version('8.0.0');
52
52
 
53
53
  // ── Main command: extract ──────────────────────────────────────
54
54
  program
@@ -72,6 +72,8 @@ program
72
72
  .option('--user-agent <ua>', 'override the browser User-Agent string')
73
73
  .option('--insecure', 'ignore HTTPS/SSL certificate errors (self-signed, dev, proxies)')
74
74
  .option('--ignore <selectors...>', 'CSS selectors to remove before extraction')
75
+ .option('--selector <css>', 'only extract design from elements matching this CSS selector (e.g. ".pricing-card")')
76
+ .option('--system-chrome', 'use the system Chrome install instead of the bundled Chromium (skips the 150MB Playwright download)')
75
77
  .option('--tokens-legacy', 'Emit pre-v7 flat token JSON (backward compat)')
76
78
  .option('--platforms <csv>', 'Additional platforms: web,ios,android,flutter,wordpress,all (web is always emitted)', 'web')
77
79
  .option('--emit-agent-rules', 'Emit Cursor/Claude Code/generic agent rules')
@@ -155,6 +157,8 @@ program
155
157
  insecure: merged.insecure || false,
156
158
  userAgent: merged.userAgent,
157
159
  deepInteract: merged.deepInteract || merged.full,
160
+ selector: merged.selector,
161
+ channel: merged.systemChrome ? 'chrome' : undefined,
158
162
  });
159
163
 
160
164
  // Responsive capture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "7.2.0",
3
+ "version": "8.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": {
package/src/config.js CHANGED
@@ -39,6 +39,8 @@ export function mergeConfig(cliOpts, config) {
39
39
  cookieFile: cliOpts.cookieFile || config.cookieFile,
40
40
  insecure: cliOpts.insecure || config.insecure || false,
41
41
  userAgent: cliOpts.userAgent || config.userAgent,
42
+ selector: cliOpts.selector || config.selector,
43
+ systemChrome: cliOpts.systemChrome || config.systemChrome || false,
42
44
  };
43
45
  }
44
46
 
package/src/crawler.js CHANGED
@@ -24,6 +24,8 @@ export async function crawlPage(url, options = {}) {
24
24
  insecure = false,
25
25
  userAgent,
26
26
  deepInteract = false,
27
+ selector,
28
+ channel,
27
29
  } = options;
28
30
 
29
31
  const launchArgs = [
@@ -39,6 +41,9 @@ export async function crawlPage(url, options = {}) {
39
41
  const browser = await chromium.launch({
40
42
  headless: true,
41
43
  ...(executablePath && { executablePath }),
44
+ // channel: 'chrome' forces Playwright to use the system Chrome install
45
+ // instead of the 150MB bundled Chromium — see --system-chrome.
46
+ ...(channel && { channel }),
42
47
  args: launchArgs,
43
48
  });
44
49
  try {
@@ -97,7 +102,7 @@ export async function crawlPage(url, options = {}) {
97
102
  interactState = await runInteractionPass(page).catch(() => null);
98
103
  }
99
104
 
100
- const lightData = await extractPageData(page, ignore);
105
+ const lightData = await extractPageData(page, ignore, selector);
101
106
  lightData.cssCoverage = cssCoverage;
102
107
  if (interactState) lightData.interactState = interactState;
103
108
 
@@ -388,8 +393,8 @@ async function runInteractionPass(page) {
388
393
  return state;
389
394
  }
390
395
 
391
- async function extractPageData(page, ignoreSelectors) {
392
- const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
396
+ async function extractPageData(page, ignoreSelectors, scopeSelector) {
397
+ const data = await page.evaluate(({ maxElements, ignoreSelectors, scopeSelector }) => {
393
398
  // Remove ignored elements before extraction
394
399
  if (ignoreSelectors && ignoreSelectors.length > 0) {
395
400
  for (const sel of ignoreSelectors) {
@@ -420,7 +425,23 @@ async function extractPageData(page, ignoreSelectors) {
420
425
  }
421
426
  return collected;
422
427
  }
423
- const elements = collectElements(document, []);
428
+
429
+ // If --selector was provided, scope element collection to the matching
430
+ // subtrees only. Falls back to the full document if the selector is
431
+ // invalid or returns no matches.
432
+ let scopeRoots = [document];
433
+ if (scopeSelector) {
434
+ try {
435
+ const matches = Array.from(document.querySelectorAll(scopeSelector));
436
+ if (matches.length > 0) scopeRoots = matches;
437
+ } catch { /* invalid selector → use document */ }
438
+ }
439
+ const elements = [];
440
+ for (const root of scopeRoots) {
441
+ if (root !== document && root.nodeType === 1) elements.push(root);
442
+ collectElements(root, elements);
443
+ if (elements.length >= maxElements) break;
444
+ }
424
445
 
425
446
  // Build a lightweight index: stylesheet URL + their top selectors.
426
447
  // Used to attribute each element's primary source stylesheet.
@@ -502,8 +523,16 @@ async function extractPageData(page, ignoreSelectors) {
502
523
  sourceAttrBudget--;
503
524
  }
504
525
 
526
+ // hasText: at least one direct text-node child with visible characters —
527
+ // lets downstream extractors filter decorative spans/divs out of WCAG
528
+ // contrast accounting.
529
+ let hasText = false;
530
+ for (const node of el.childNodes) {
531
+ if (node.nodeType === 3 && node.textContent && node.textContent.trim()) { hasText = true; break; }
532
+ }
533
+
505
534
  results.computedStyles.push({
506
- tag, classList, role, area,
535
+ tag, classList, role, area, hasText,
507
536
  color: cs.color,
508
537
  backgroundColor: cs.backgroundColor,
509
538
  backgroundImage: cs.backgroundImage,
@@ -840,7 +869,7 @@ async function extractPageData(page, ignoreSelectors) {
840
869
  }
841
870
 
842
871
  return results;
843
- }, { maxElements: MAX_ELEMENTS, ignoreSelectors: ignoreSelectors || [] });
872
+ }, { maxElements: MAX_ELEMENTS, ignoreSelectors: ignoreSelectors || [], scopeSelector: scopeSelector || null });
844
873
 
845
874
  // Fetch and parse cross-origin stylesheets
846
875
  if (data.crossOriginSheets && data.crossOriginSheets.length > 0) {
@@ -28,16 +28,59 @@ function wcagLevel(ratio, isLargeText) {
28
28
  return 'FAIL';
29
29
  }
30
30
 
31
+ // Tags where "foreground vs background" contrast is *not* a WCAG text concern —
32
+ // SVG/icon glyphs, media, form primitives, and structural containers without
33
+ // direct text. Filtering these removes the overlay/decorative false-positives
34
+ // that used to crater scores on dark-themed sites.
35
+ const NON_TEXT_TAGS = new Set([
36
+ 'svg', 'path', 'circle', 'rect', 'polygon', 'polyline', 'line', 'ellipse',
37
+ 'use', 'defs', 'g', 'clippath', 'mask', 'filter', 'symbol', 'stop', 'lineargradient', 'radialgradient',
38
+ 'img', 'picture', 'video', 'audio', 'canvas', 'iframe', 'source', 'track',
39
+ 'br', 'hr', 'wbr',
40
+ 'input', 'select', 'textarea', 'progress', 'meter', 'option', 'optgroup',
41
+ 'script', 'style', 'link', 'meta', 'head', 'html', 'body',
42
+ 'main', 'section', 'article', 'aside', 'header', 'footer', 'nav',
43
+ 'div', 'figure', 'form', 'fieldset', 'ul', 'ol', 'dl',
44
+ ]);
45
+
46
+ const TEXT_BEARING_TAGS = new Set([
47
+ 'p', 'a', 'button', 'label', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
48
+ 'td', 'th', 'code', 'pre', 'em', 'strong', 'small', 'b', 'i', 'u',
49
+ 'time', 'summary', 'figcaption', 'blockquote', 'q', 'mark', 'cite', 'abbr',
50
+ 'dt', 'dd', 'kbd', 'samp', 'var', 'sub', 'sup', 'del', 'ins', 'caption', 'legend',
51
+ // span is a high-noise/high-signal tag — it wraps both real text and
52
+ // decorative glyphs. Include it but require an explicit background (the
53
+ // opacity filter downstream still removes the decorative transparent ones).
54
+ 'span',
55
+ ]);
56
+
57
+ function isContrastRelevant(el) {
58
+ const tag = (el.tag || '').toLowerCase();
59
+ if (NON_TEXT_TAGS.has(tag)) return false;
60
+ if (!TEXT_BEARING_TAGS.has(tag)) return false;
61
+ // If the crawler captured hasText, trust it — filters decorative
62
+ // span/link/button wrappers that hold no real glyphs. If hasText wasn't
63
+ // captured (older fixtures, unit tests) fall back to inclusion.
64
+ if (el.hasText === false) return false;
65
+ return true;
66
+ }
67
+
31
68
  export function extractAccessibility(computedStyles) {
32
69
  const pairs = new Map(); // "fg|bg" -> { fg, bg, count, elements }
33
70
 
34
71
  for (const el of computedStyles) {
72
+ if (!isContrastRelevant(el)) continue;
73
+
35
74
  const fg = parseColor(el.color);
36
75
  const bg = parseColor(el.backgroundColor);
37
- if (!fg || !bg || bg.a === 0) continue;
76
+ if (!fg || !bg) continue;
77
+ // Skip transparent/semi-transparent — real contrast depends on the parent
78
+ // stack which we don't composite. Counting these as "fails" is noise.
79
+ if (bg.a < 0.9 || fg.a < 0.9) continue;
38
80
 
39
81
  const fgHex = rgbToHex(fg);
40
82
  const bgHex = rgbToHex(bg);
83
+ if (fgHex === bgHex) continue;
41
84
  const key = `${fgHex}|${bgHex}`;
42
85
 
43
86
  if (!pairs.has(key)) {
@@ -1,25 +1,39 @@
1
- import { parseColor, rgbToHex, rgbToHsl, clusterColors, isSaturated } from '../utils.js';
1
+ import { parseColor, rgbToHex, rgbToHsl, clusterColors, isSaturated, colorDistance } from '../utils.js';
2
+
3
+ const INTERACTIVE_TAGS = new Set(['a', 'button']);
4
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab']);
5
+ const INTERACTIVE_CLASS_RE = /\b(btn|button|cta|primary|action)\b/i;
6
+
7
+ function isInteractive(el) {
8
+ if (!el) return false;
9
+ if (INTERACTIVE_TAGS.has(el.tag)) return true;
10
+ if (el.role && INTERACTIVE_ROLES.has(el.role)) return true;
11
+ if (el.classList && INTERACTIVE_CLASS_RE.test(el.classList)) return true;
12
+ return false;
13
+ }
2
14
 
3
15
  export function extractColors(computedStyles) {
4
- const colorMap = new Map(); // hex -> { hex, parsed, count, contexts: Set }
16
+ const colorMap = new Map(); // hex -> { hex, parsed, count, contexts: Set, interactiveBg: number }
5
17
 
6
- function addColor(value, context) {
18
+ function addColor(value, context, { interactive = false } = {}) {
7
19
  const parsed = parseColor(value);
8
20
  if (!parsed || parsed.a === 0) return;
9
21
  const hex = rgbToHex(parsed);
10
22
  if (!colorMap.has(hex)) {
11
- colorMap.set(hex, { hex, parsed, count: 0, contexts: new Set() });
23
+ colorMap.set(hex, { hex, parsed, count: 0, contexts: new Set(), interactiveBg: 0 });
12
24
  }
13
25
  const entry = colorMap.get(hex);
14
26
  entry.count++;
15
27
  entry.contexts.add(context);
28
+ if (interactive && context === 'background') entry.interactiveBg++;
16
29
  }
17
30
 
18
31
  const gradients = new Set();
19
32
 
20
33
  for (const el of computedStyles) {
34
+ const interactive = isInteractive(el);
21
35
  addColor(el.color, 'text');
22
- addColor(el.backgroundColor, 'background');
36
+ addColor(el.backgroundColor, 'background', { interactive });
23
37
  addColor(el.borderColor, 'border');
24
38
 
25
39
  if (el.backgroundImage && el.backgroundImage !== 'none' && el.backgroundImage.includes('gradient')) {
@@ -30,12 +44,21 @@ export function extractColors(computedStyles) {
30
44
  const allColors = Array.from(colorMap.values());
31
45
  const clusters = clusterColors(allColors, 15);
32
46
 
33
- // Classify roles
47
+ // Aggregate interactive-bg score per cluster (sum across members)
48
+ for (const cluster of clusters) {
49
+ cluster.interactiveBg = cluster.members.reduce((s, m) => s + (m.interactiveBg || 0), 0);
50
+ const { s: sat, l: lit } = rgbToHsl(cluster.representative);
51
+ cluster.saturation = sat;
52
+ cluster.lightness = lit;
53
+ }
54
+
55
+ // Classify roles — tighten chromatic threshold so pale grays (hsl sat < 25) don't qualify
34
56
  const neutrals = [];
35
57
  const chromatic = [];
36
58
 
37
59
  for (const cluster of clusters) {
38
- if (isSaturated(cluster.representative)) {
60
+ const chromaticEnough = cluster.saturation > 25 && cluster.lightness > 5 && cluster.lightness < 95;
61
+ if (chromaticEnough || (isSaturated(cluster.representative) && cluster.interactiveBg > 0)) {
39
62
  chromatic.push(cluster);
40
63
  } else {
41
64
  neutrals.push(cluster);
@@ -61,12 +84,27 @@ export function extractColors(computedStyles) {
61
84
  }
62
85
  }
63
86
 
64
- const primary = chromatic[0] || null;
65
- const secondary = chromatic[1] || null;
66
- const accent = chromatic.find(c => {
67
- const pct = c.count / allColors.reduce((s, a) => s + a.count, 0);
87
+ // Rank chromatic clusters by brand-likelihood:
88
+ // interactiveBg carries the most signal (it's a CTA color)
89
+ // saturation comes next (brand colors are usually punchy)
90
+ // raw usage count is a weak tiebreaker (avoids neutral-heavy sites dominating)
91
+ function brandScore(c) {
92
+ return c.interactiveBg * 100 + c.saturation * 2 + Math.log10(Math.max(1, c.count));
93
+ }
94
+ const ranked = [...chromatic].sort((a, b) => brandScore(b) - brandScore(a));
95
+
96
+ const primary = ranked[0] || null;
97
+ // secondary: distinct hue from primary
98
+ const secondary = ranked.find(c => {
99
+ if (!primary || c === primary) return false;
100
+ return colorDistance(c.representative, primary.representative) > 60;
101
+ }) || ranked[1] || null;
102
+ // accent: sparse chromatic, prefers background context
103
+ const accent = ranked.find(c => {
104
+ if (c === primary || c === secondary) return false;
105
+ const pct = c.count / Math.max(1, allColors.reduce((s, a) => s + a.count, 0));
68
106
  return pct < 0.05 && c.members.some(m => m.contexts.has('background'));
69
- }) || chromatic[2] || null;
107
+ }) || ranked.find(c => c !== primary && c !== secondary) || null;
70
108
 
71
109
  return {
72
110
  primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count } : null,
@@ -4,14 +4,16 @@ export function scoreDesignSystem(design) {
4
4
  const scores = {};
5
5
  const issues = [];
6
6
 
7
- // 1. Color discipline (0-100)
8
- // Fewer unique colors = more disciplined
7
+ // 1. Color discipline (0-100) — calibrated against real production sites
8
+ // (Linear, Stripe, Vercel, GitHub, Apple) which commonly ship 20–50 colors
9
+ // once you include hover/disabled/alpha variants.
9
10
  const colorCount = design.colors.all.length;
10
- if (colorCount <= 8) scores.colorDiscipline = 100;
11
- else if (colorCount <= 15) scores.colorDiscipline = 85;
12
- else if (colorCount <= 25) scores.colorDiscipline = 70;
13
- else if (colorCount <= 40) scores.colorDiscipline = 50;
14
- else { scores.colorDiscipline = 30; issues.push(`${colorCount} unique colors detected consider consolidating to a tighter palette`); }
11
+ if (colorCount <= 12) scores.colorDiscipline = 100;
12
+ else if (colorCount <= 25) scores.colorDiscipline = 92;
13
+ else if (colorCount <= 40) scores.colorDiscipline = 80;
14
+ else if (colorCount <= 60) scores.colorDiscipline = 65;
15
+ else if (colorCount <= 100) scores.colorDiscipline = 50;
16
+ else { scores.colorDiscipline = 35; issues.push(`${colorCount} unique colors detected — consider consolidating to a tighter palette`); }
15
17
 
16
18
  if (!design.colors.primary) {
17
19
  scores.colorDiscipline -= 15;
@@ -26,46 +28,62 @@ export function scoreDesignSystem(design) {
26
28
 
27
29
  const weightCount = design.typography.weights.length;
28
30
  if (weightCount <= 3) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
29
- else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
31
+ else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 90);
32
+ else if (weightCount <= 7) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
30
33
  else { scores.typographyConsistency -= 15; issues.push(`${weightCount} font weights in use — consider standardizing to 3 (regular, medium, bold)`); }
31
34
 
35
+ // Type-scale count — variable-font sites and component-rich pages commonly
36
+ // ship 12–18 sizes (nav, body, caption, h1–h6, stat, pill, etc).
32
37
  const scaleSize = design.typography.scale.length;
33
- if (scaleSize <= 6) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
34
- else if (scaleSize <= 10) scores.typographyConsistency = Math.min(scores.typographyConsistency, 85);
38
+ if (scaleSize <= 8) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
39
+ else if (scaleSize <= 14) scores.typographyConsistency = Math.min(scores.typographyConsistency, 92);
40
+ else if (scaleSize <= 20) scores.typographyConsistency = Math.min(scores.typographyConsistency, 82);
35
41
  else { scores.typographyConsistency -= 10; issues.push(`${scaleSize} distinct font sizes — consider a tighter type scale`); }
36
42
 
37
- // 3. Spacing system (0-100)
43
+ // 3. Spacing system (0-100) — detectScale() only tries 2/4/6/8 as bases and
44
+ // fails on sites whose computed-style spacing has line-height/SVG noise mixed
45
+ // in. Don't penalise too hard when a base isn't pinned but tokenization is
46
+ // healthy — the design still has a system, we just can't name it.
38
47
  if (design.spacing.base) {
39
48
  scores.spacingSystem = 90;
40
- // Check how many values fit the base
41
49
  const fittingValues = design.spacing.scale.filter(v => v % design.spacing.base === 0).length;
42
50
  const fitRatio = fittingValues / design.spacing.scale.length;
43
51
  if (fitRatio >= 0.8) scores.spacingSystem = 100;
44
- else if (fitRatio >= 0.6) scores.spacingSystem = 80;
45
- else scores.spacingSystem = 65;
52
+ else if (fitRatio >= 0.6) scores.spacingSystem = 85;
53
+ else scores.spacingSystem = 75;
46
54
  } else {
47
- scores.spacingSystem = 40;
48
- issues.push('No consistent spacing base unit detected values appear arbitrary');
55
+ // Fallback: soft penalty if the site still ships design tokens (CSS vars).
56
+ const varCount = Object.values(design.variables || {}).reduce((s, v) => s + Object.keys(v || {}).length, 0);
57
+ scores.spacingSystem = varCount >= 20 ? 70 : 55;
58
+ if (varCount < 20) issues.push('No consistent spacing base unit detected — values appear arbitrary');
49
59
  }
50
60
 
51
- if (design.spacing.scale.length > 20) {
61
+ // Spacing value count — real sites commonly ship 25–40 distinct spacings
62
+ // (component padding, gap, stacked layout rhythm). Only penalise above 35.
63
+ if (design.spacing.scale.length > 50) {
52
64
  scores.spacingSystem -= 15;
53
65
  issues.push(`${design.spacing.scale.length} unique spacing values — too many one-off values`);
66
+ } else if (design.spacing.scale.length > 35) {
67
+ scores.spacingSystem -= 5;
54
68
  }
55
69
 
56
- // 4. Shadow consistency (0-100)
70
+ // 4. Shadow consistency (0-100) — calibrated; real sites routinely ship
71
+ // 10–20 shadows once hover/focus/elevation variants are counted.
57
72
  const shadowCount = design.shadows.values.length;
58
- if (shadowCount === 0) scores.shadowConsistency = 80; // no shadows is fine
59
- else if (shadowCount <= 4) scores.shadowConsistency = 100;
60
- else if (shadowCount <= 8) scores.shadowConsistency = 75;
73
+ if (shadowCount === 0) scores.shadowConsistency = 85;
74
+ else if (shadowCount <= 5) scores.shadowConsistency = 100;
75
+ else if (shadowCount <= 10) scores.shadowConsistency = 90;
76
+ else if (shadowCount <= 18) scores.shadowConsistency = 78;
77
+ else if (shadowCount <= 28) scores.shadowConsistency = 62;
61
78
  else { scores.shadowConsistency = 50; issues.push(`${shadowCount} unique shadows — consider a 3-level elevation scale (sm/md/lg)`); }
62
79
 
63
80
  // 5. Border radius consistency (0-100)
64
81
  const radiiCount = design.borders.radii.length;
65
- if (radiiCount <= 3) scores.radiusConsistency = 100;
66
- else if (radiiCount <= 5) scores.radiusConsistency = 85;
67
- else if (radiiCount <= 8) scores.radiusConsistency = 65;
68
- else { scores.radiusConsistency = 40; issues.push(`${radiiCount} unique border radii standardize to 3-4 values`); }
82
+ if (radiiCount <= 4) scores.radiusConsistency = 100;
83
+ else if (radiiCount <= 7) scores.radiusConsistency = 90;
84
+ else if (radiiCount <= 10) scores.radiusConsistency = 80;
85
+ else if (radiiCount <= 15) scores.radiusConsistency = 65;
86
+ else { scores.radiusConsistency = 45; issues.push(`${radiiCount} unique border radii — standardize to 3-4 values`); }
69
87
 
70
88
  // 6. Accessibility (from existing extractor)
71
89
  scores.accessibility = design.accessibility?.score || 0;
@@ -101,13 +119,14 @@ export function scoreDesignSystem(design) {
101
119
 
102
120
  // Overall score (weighted average)
103
121
  const weights = {
104
- colorDiscipline: 20,
105
- typographyConsistency: 20,
106
- spacingSystem: 20,
107
- shadowConsistency: 10,
108
- radiusConsistency: 10,
122
+ colorDiscipline: 18,
123
+ typographyConsistency: 18,
124
+ spacingSystem: 18,
125
+ shadowConsistency: 9,
126
+ radiusConsistency: 9,
109
127
  accessibility: 15,
110
128
  tokenization: 5,
129
+ cssHealth: 8,
111
130
  };
112
131
 
113
132
  let totalWeight = 0;
@@ -1 +0,0 @@
1
- github: [Manavarya09]
@@ -1,62 +0,0 @@
1
- name: Bug report
2
- description: Something in designlang is broken or produces wrong output.
3
- title: "[Bug]: "
4
- labels: ["bug"]
5
- body:
6
- - type: input
7
- id: url
8
- attributes:
9
- label: URL extracted
10
- description: The site you ran designlang against.
11
- placeholder: https://example.com
12
- validations:
13
- required: true
14
- - type: input
15
- id: command
16
- attributes:
17
- label: Command used
18
- description: The exact command you ran.
19
- placeholder: npx designlang https://example.com --full
20
- validations:
21
- required: true
22
- - type: textarea
23
- id: expected
24
- attributes:
25
- label: What you expected
26
- validations:
27
- required: true
28
- - type: textarea
29
- id: actual
30
- attributes:
31
- label: What actually happened
32
- description: Include stack traces, log output, or relevant snippets from output files.
33
- validations:
34
- required: true
35
- - type: input
36
- id: version
37
- attributes:
38
- label: designlang version
39
- description: Output of `npx designlang --version` (or the version in package.json)
40
- placeholder: 7.0.0
41
- validations:
42
- required: true
43
- - type: input
44
- id: node
45
- attributes:
46
- label: Node.js version
47
- description: Output of `node --version`
48
- placeholder: v20.11.0
49
- validations:
50
- required: true
51
- - type: input
52
- id: os
53
- attributes:
54
- label: OS
55
- placeholder: macOS 14.5 / Ubuntu 22.04 / Windows 11
56
- validations:
57
- required: true
58
- - type: textarea
59
- id: extra
60
- attributes:
61
- label: Anything else
62
- description: Screenshots, env variables, network constraints, auth requirements, etc.
@@ -1,8 +0,0 @@
1
- blank_issues_enabled: false
2
- contact_links:
3
- - name: Question or discussion
4
- url: https://github.com/Manavarya09/design-extract/discussions
5
- about: Ask a question, share an extraction, or discuss ideas.
6
- - name: designlang website
7
- url: https://website-five-lime-65.vercel.app
8
- about: Read more about what designlang does.
@@ -1,28 +0,0 @@
1
- name: Feature request
2
- description: Suggest a new extraction, output format, or CLI capability.
3
- title: "[Feature]: "
4
- labels: ["enhancement"]
5
- body:
6
- - type: textarea
7
- id: problem
8
- attributes:
9
- label: Problem
10
- description: What are you trying to do that designlang doesn't support today?
11
- validations:
12
- required: true
13
- - type: textarea
14
- id: proposal
15
- attributes:
16
- label: Proposal
17
- description: What should designlang do? CLI flag, command, new extractor, new output format?
18
- validations:
19
- required: true
20
- - type: textarea
21
- id: alternatives
22
- attributes:
23
- label: Alternatives you've considered
24
- - type: textarea
25
- id: context
26
- attributes:
27
- label: Additional context
28
- description: Example sites, example outputs, links to similar tools, screenshots.
Binary file
@@ -1,17 +0,0 @@
1
- # Managed by bot-manavarya/reviewer — edits will be overwritten.
2
- name: manavarya-bot review
3
-
4
- on:
5
- pull_request:
6
- types: [opened, synchronize, reopened, ready_for_review]
7
-
8
- jobs:
9
- review:
10
- if: ${{ github.event.pull_request.draft == false }}
11
- uses: bot-manavarya/reviewer/.github/workflows/review.yml@main
12
- with:
13
- provider: 'gemini'
14
- model: 'gemini-2.5-flash'
15
- secrets:
16
- bot-token: ${{ secrets.MANAVARYA_BOT_TOKEN }}
17
- gemini-api-key: ${{ secrets.GEMINI_API_KEY }}