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.
- package/CHANGELOG.md +35 -0
- package/README.md +17 -0
- package/bin/design-extract.js +5 -1
- package/package.json +1 -1
- package/src/config.js +2 -0
- package/src/crawler.js +35 -6
- package/src/extractors/accessibility.js +44 -1
- package/src/extractors/colors.js +50 -12
- package/src/extractors/scoring.js +49 -30
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
- package/.github/og-preview.png +0 -0
- package/.github/workflows/manavarya-bot.yml +0 -17
- package/chrome-extension/README.md +0 -41
- package/chrome-extension/icons/favicon.svg +0 -7
- package/chrome-extension/icons/icon-128.png +0 -0
- package/chrome-extension/icons/icon-16.png +0 -0
- package/chrome-extension/icons/icon-32.png +0 -0
- package/chrome-extension/icons/icon-48.png +0 -0
- package/chrome-extension/manifest.json +0 -26
- package/chrome-extension/popup.html +0 -167
- package/chrome-extension/popup.js +0 -59
- package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
- package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
- package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
- package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
- package/tests/cli.test.js +0 -84
- package/tests/cookies.test.js +0 -98
- package/tests/extractors.test.js +0 -792
- package/tests/formatters.test.js +0 -709
- package/tests/interaction-states.test.js +0 -62
- package/tests/mcp.test.js +0 -68
- package/tests/modern-css.test.js +0 -104
- package/tests/routes-reconciliation.test.js +0 -120
- package/tests/utils.test.js +0 -413
- package/tests/wide-gamut.test.js +0 -90
- package/website/.claude/launch.json +0 -11
- package/website/AGENTS.md +0 -5
- package/website/CLAUDE.md +0 -1
- package/website/README.md +0 -36
- package/website/app/api/extract/route.js +0 -245
- package/website/app/components/A11ySlider.js +0 -369
- package/website/app/components/Comparison.js +0 -286
- package/website/app/components/CssHealth.js +0 -243
- package/website/app/components/Extractor.js +0 -184
- package/website/app/components/HeroExtractor.js +0 -455
- package/website/app/components/Marginalia.js +0 -3
- package/website/app/components/McpSection.js +0 -223
- package/website/app/components/PlatformTabs.js +0 -250
- package/website/app/components/RegionsComponents.js +0 -429
- package/website/app/components/Rule.js +0 -13
- package/website/app/components/Specimens.js +0 -237
- package/website/app/components/StructuredData.js +0 -144
- package/website/app/components/TokenBrowser.js +0 -344
- package/website/app/components/token-browser-sample.js +0 -65
- package/website/app/globals.css +0 -505
- package/website/app/icon.svg +0 -7
- package/website/app/layout.js +0 -126
- package/website/app/opengraph-image.js +0 -170
- package/website/app/page.js +0 -399
- package/website/app/robots.js +0 -15
- package/website/app/seo-config.js +0 -82
- package/website/app/sitemap.js +0 -18
- package/website/jsconfig.json +0 -7
- package/website/lib/cache.js +0 -73
- package/website/lib/rate-limit.js +0 -30
- package/website/lib/rate-limit.test.js +0 -55
- package/website/lib/specimens.json +0 -86
- package/website/lib/token-helpers.js +0 -70
- package/website/lib/url-safety.js +0 -103
- package/website/lib/url-safety.test.js +0 -116
- package/website/lib/zip-files.js +0 -15
- package/website/next.config.mjs +0 -15
- package/website/package-lock.json +0 -1353
- package/website/package.json +0 -19
- package/website/public/favicon.svg +0 -7
- package/website/public/logo-specimen.svg +0 -76
- package/website/public/mark.svg +0 -12
- 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:
|
package/bin/design-extract.js
CHANGED
|
@@ -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('
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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)) {
|
package/src/extractors/colors.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
}) ||
|
|
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
|
-
//
|
|
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 <=
|
|
11
|
-
else if (colorCount <=
|
|
12
|
-
else if (colorCount <=
|
|
13
|
-
else if (colorCount <=
|
|
14
|
-
else
|
|
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,
|
|
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 <=
|
|
34
|
-
else if (scaleSize <=
|
|
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 =
|
|
45
|
-
else scores.spacingSystem =
|
|
52
|
+
else if (fitRatio >= 0.6) scores.spacingSystem = 85;
|
|
53
|
+
else scores.spacingSystem = 75;
|
|
46
54
|
} else {
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 =
|
|
59
|
-
else if (shadowCount <=
|
|
60
|
-
else if (shadowCount <=
|
|
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 <=
|
|
66
|
-
else if (radiiCount <=
|
|
67
|
-
else if (radiiCount <=
|
|
68
|
-
else
|
|
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:
|
|
105
|
-
typographyConsistency:
|
|
106
|
-
spacingSystem:
|
|
107
|
-
shadowConsistency:
|
|
108
|
-
radiusConsistency:
|
|
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;
|
package/.github/FUNDING.yml
DELETED
|
@@ -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.
|
package/.github/og-preview.png
DELETED
|
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 }}
|