designlang 7.2.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +154 -13
- package/bin/design-extract.js +94 -1
- package/package.json +9 -3
- package/src/config.js +2 -0
- package/src/crawler.js +55 -6
- package/src/drift.js +137 -0
- package/src/extractors/accessibility.js +44 -1
- package/src/extractors/colors.js +50 -12
- package/src/extractors/component-anatomy.js +123 -0
- package/src/extractors/motion.js +184 -0
- package/src/extractors/scoring.js +49 -30
- package/src/extractors/voice.js +96 -0
- package/src/formatters/markdown.js +88 -0
- package/src/formatters/motion-tokens.js +22 -0
- package/src/index.js +14 -0
- package/src/lint.js +198 -0
- package/src/visual-diff.js +116 -0
- 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/src/drift.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// designlang drift <url> --tokens <file>
|
|
2
|
+
// Compares local project tokens against the live site and reports what's drifted.
|
|
3
|
+
// Designed for CI/CD: exits non-zero when drift exceeds the tolerance budget.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { extname } from 'path';
|
|
7
|
+
import { extractDesignLanguage } from './index.js';
|
|
8
|
+
|
|
9
|
+
function flattenDtcg(obj, prefix = '', out = {}) {
|
|
10
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
11
|
+
if (k.startsWith('$')) continue;
|
|
12
|
+
if (v && typeof v === 'object') {
|
|
13
|
+
if ('$value' in v) {
|
|
14
|
+
out[prefix ? `${prefix}.${k}` : k] = v.$value;
|
|
15
|
+
} else {
|
|
16
|
+
flattenDtcg(v, prefix ? `${prefix}.${k}` : k, out);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadLocalTokens(file) {
|
|
24
|
+
const raw = readFileSync(file, 'utf8');
|
|
25
|
+
const ext = extname(file);
|
|
26
|
+
if (ext === '.json') {
|
|
27
|
+
const j = JSON.parse(raw);
|
|
28
|
+
const flat = flattenDtcg(j);
|
|
29
|
+
if (Object.keys(flat).length) return flat;
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [group, entries] of Object.entries(j)) {
|
|
32
|
+
if (!entries || typeof entries !== 'object') continue;
|
|
33
|
+
for (const [k, v] of Object.entries(entries)) out[`${group}.${k}`] = String(v);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
if (ext === '.css') {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const m of raw.matchAll(/--([\w-]+):\s*([^;]+);/g)) out[m[1]] = m[2].trim();
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`Unsupported token file: ${ext}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hexToRgb(h) {
|
|
46
|
+
const m = h.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
|
47
|
+
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function colorDistance(a, b) {
|
|
51
|
+
const ra = hexToRgb(a), rb = hexToRgb(b);
|
|
52
|
+
if (!ra || !rb) return Infinity;
|
|
53
|
+
return Math.sqrt(ra.reduce((s, v, i) => s + (v - rb[i]) ** 2, 0));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findNearest(value, palette) {
|
|
57
|
+
if (!/^#[\da-f]{6}$/i.test(value)) return null;
|
|
58
|
+
let best = { distance: Infinity, token: null };
|
|
59
|
+
for (const p of palette) {
|
|
60
|
+
if (!/^#[\da-f]{6}$/i.test(p.hex || '')) continue;
|
|
61
|
+
const d = colorDistance(value, p.hex);
|
|
62
|
+
if (d < best.distance) best = { distance: d, token: p };
|
|
63
|
+
}
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function checkDrift(url, { tokens: tokensFile, tolerance = 8, options = {} } = {}) {
|
|
68
|
+
const local = loadLocalTokens(tokensFile);
|
|
69
|
+
const design = await extractDesignLanguage(url, options);
|
|
70
|
+
const livePalette = design.colors?.all || [];
|
|
71
|
+
|
|
72
|
+
const drifted = [];
|
|
73
|
+
const matched = [];
|
|
74
|
+
const unknown = [];
|
|
75
|
+
|
|
76
|
+
for (const [name, value] of Object.entries(local)) {
|
|
77
|
+
if (!/^#[\da-f]{3,8}$/i.test(String(value))) continue; // only color tokens for now
|
|
78
|
+
const hex = value.length === 4 ? '#' + value.slice(1).split('').map(c => c + c).join('') : value;
|
|
79
|
+
const nearest = findNearest(hex, livePalette);
|
|
80
|
+
if (!nearest || nearest.distance === Infinity) { unknown.push({ name, value: hex }); continue; }
|
|
81
|
+
if (nearest.distance > tolerance) {
|
|
82
|
+
drifted.push({
|
|
83
|
+
token: name,
|
|
84
|
+
local: hex,
|
|
85
|
+
nearestLive: nearest.token.hex,
|
|
86
|
+
distance: Math.round(nearest.distance),
|
|
87
|
+
role: nearest.token.role || 'unknown',
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
matched.push({ token: name, local: hex, liveMatch: nearest.token.hex, distance: Math.round(nearest.distance) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const driftRatio = drifted.length / Math.max(1, drifted.length + matched.length);
|
|
95
|
+
const verdict = driftRatio === 0 ? 'in-sync' : driftRatio < 0.15 ? 'minor-drift' : driftRatio < 0.4 ? 'notable-drift' : 'major-drift';
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
url,
|
|
99
|
+
tokensFile,
|
|
100
|
+
tolerance,
|
|
101
|
+
verdict,
|
|
102
|
+
driftRatio: +driftRatio.toFixed(3),
|
|
103
|
+
drifted,
|
|
104
|
+
matched,
|
|
105
|
+
unknown,
|
|
106
|
+
summary: {
|
|
107
|
+
total: drifted.length + matched.length,
|
|
108
|
+
drifted: drifted.length,
|
|
109
|
+
matched: matched.length,
|
|
110
|
+
unknown: unknown.length,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function formatDriftMarkdown(r) {
|
|
116
|
+
const lines = [
|
|
117
|
+
`# designlang drift report`,
|
|
118
|
+
``,
|
|
119
|
+
`**Live site:** ${r.url}`,
|
|
120
|
+
`**Local tokens:** ${r.tokensFile}`,
|
|
121
|
+
`**Verdict:** ${r.verdict} (drift ratio: ${r.driftRatio})`,
|
|
122
|
+
``,
|
|
123
|
+
`| metric | count |`,
|
|
124
|
+
`|---|---|`,
|
|
125
|
+
`| total color tokens | ${r.summary.total} |`,
|
|
126
|
+
`| matched | ${r.summary.matched} |`,
|
|
127
|
+
`| drifted | ${r.summary.drifted} |`,
|
|
128
|
+
`| unknown | ${r.summary.unknown} |`,
|
|
129
|
+
``,
|
|
130
|
+
];
|
|
131
|
+
if (r.drifted.length) {
|
|
132
|
+
lines.push(`## Drifted tokens`, ``, `| token | local | nearest live | Δ |`, `|---|---|---|---|`);
|
|
133
|
+
for (const d of r.drifted) lines.push(`| \`${d.token}\` | \`${d.local}\` | \`${d.nearestLive}\` (${d.role}) | ${d.distance} |`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
@@ -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,
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Component Anatomy v2 — builds per-kind anatomy trees, variant × state matrices,
|
|
2
|
+
// and typed prop surfaces that downstream generators can turn into stubs.
|
|
3
|
+
|
|
4
|
+
const KNOWN_VARIANTS = ['primary', 'secondary', 'tertiary', 'ghost', 'outline', 'solid', 'destructive', 'danger', 'success', 'warning', 'link', 'subtle'];
|
|
5
|
+
const KNOWN_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'small', 'medium', 'large'];
|
|
6
|
+
|
|
7
|
+
function slotFingerprint(slots = []) {
|
|
8
|
+
return slots.map(s => s.role).join('>');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function inferSlots(slots = [], kind) {
|
|
12
|
+
const roles = new Set(slots.map(s => s.role));
|
|
13
|
+
if (kind === 'button') {
|
|
14
|
+
return {
|
|
15
|
+
label: true,
|
|
16
|
+
icon: roles.has('icon'),
|
|
17
|
+
badge: roles.has('badge'),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (kind === 'card') {
|
|
21
|
+
return {
|
|
22
|
+
heading: roles.has('heading'),
|
|
23
|
+
description: roles.has('text'),
|
|
24
|
+
media: roles.has('icon'),
|
|
25
|
+
footer: slots.length > 3,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (kind === 'input') {
|
|
29
|
+
return { leading: roles.has('icon'), trailing: false };
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dominant(arr) {
|
|
35
|
+
const counts = {};
|
|
36
|
+
for (const v of arr) counts[v] = (counts[v] || 0) + 1;
|
|
37
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function extractComponentAnatomy(candidates = []) {
|
|
41
|
+
const byKind = {};
|
|
42
|
+
for (const c of candidates) {
|
|
43
|
+
const k = c.kind || 'other';
|
|
44
|
+
(byKind[k] ||= []).push(c);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const anatomies = [];
|
|
48
|
+
for (const [kind, items] of Object.entries(byKind)) {
|
|
49
|
+
if (items.length < 2) continue;
|
|
50
|
+
|
|
51
|
+
// Group variants by explicit class hint, fall back to style vector.
|
|
52
|
+
const variantGroups = {};
|
|
53
|
+
for (const it of items) {
|
|
54
|
+
const v = it.variantHint || 'default';
|
|
55
|
+
(variantGroups[v] ||= []).push(it);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const variants = Object.entries(variantGroups).map(([name, vs]) => {
|
|
59
|
+
const sizes = {};
|
|
60
|
+
for (const it of vs) {
|
|
61
|
+
const sz = it.sizeHint || 'default';
|
|
62
|
+
(sizes[sz] ||= []).push(it);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
count: vs.length,
|
|
67
|
+
states: {
|
|
68
|
+
default: { count: vs.filter(v => !v.disabled).length, css: vs.find(v => !v.disabled)?.css || null },
|
|
69
|
+
disabled: { count: vs.filter(v => v.disabled).length, css: vs.find(v => v.disabled)?.css || null },
|
|
70
|
+
},
|
|
71
|
+
sizes: Object.entries(sizes).map(([sName, sItems]) => ({ name: sName, count: sItems.length, css: sItems[0].css })),
|
|
72
|
+
sampleText: vs.slice(0, 5).map(v => v.text).filter(Boolean),
|
|
73
|
+
};
|
|
74
|
+
}).sort((a, b) => b.count - a.count);
|
|
75
|
+
|
|
76
|
+
const slotSignatures = items.map(i => slotFingerprint(i.slots));
|
|
77
|
+
const anatomy = {
|
|
78
|
+
kind,
|
|
79
|
+
totalInstances: items.length,
|
|
80
|
+
slots: inferSlots(items[0].slots, kind),
|
|
81
|
+
dominantSlotShape: dominant(slotSignatures),
|
|
82
|
+
variants,
|
|
83
|
+
props: {
|
|
84
|
+
variant: Object.keys(variantGroups).filter(v => v !== 'default'),
|
|
85
|
+
size: [...new Set(items.map(i => i.sizeHint).filter(Boolean))],
|
|
86
|
+
disabled: items.some(i => i.disabled),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
anatomies.push(anatomy);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return anatomies.sort((a, b) => b.totalInstances - a.totalInstances);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Emit TypeScript-flavored React stub for the anatomy — includes variant/size props + slot children.
|
|
96
|
+
export function formatAnatomyStubs(anatomies = []) {
|
|
97
|
+
const lines = [
|
|
98
|
+
"// Auto-generated by designlang — component anatomy v2.",
|
|
99
|
+
"// Scaffolds. Wire into your token system; not a runtime library.",
|
|
100
|
+
"",
|
|
101
|
+
"import * as React from 'react';",
|
|
102
|
+
"",
|
|
103
|
+
];
|
|
104
|
+
for (const a of anatomies) {
|
|
105
|
+
const Name = a.kind.charAt(0).toUpperCase() + a.kind.slice(1);
|
|
106
|
+
const variantUnion = (a.props.variant.length ? a.props.variant : ['default']).map(v => `'${v}'`).join(' | ');
|
|
107
|
+
const sizeUnion = (a.props.size.length ? a.props.size : ['md']).map(v => `'${v}'`).join(' | ');
|
|
108
|
+
lines.push(`export interface ${Name}Props {`);
|
|
109
|
+
lines.push(` variant?: ${variantUnion};`);
|
|
110
|
+
lines.push(` size?: ${sizeUnion};`);
|
|
111
|
+
if (a.props.disabled) lines.push(` disabled?: boolean;`);
|
|
112
|
+
if (a.slots.icon) lines.push(` leadingIcon?: React.ReactNode;`);
|
|
113
|
+
if (a.slots.badge) lines.push(` badge?: React.ReactNode;`);
|
|
114
|
+
lines.push(` children?: React.ReactNode;`);
|
|
115
|
+
lines.push(`}`);
|
|
116
|
+
lines.push(``);
|
|
117
|
+
lines.push(`export function ${Name}({ variant = '${a.props.variant[0] || 'default'}', size = 'md', ...rest }: ${Name}Props) {`);
|
|
118
|
+
lines.push(` return React.createElement('${a.kind === 'input' ? 'input' : a.kind === 'link' ? 'a' : a.kind === 'card' ? 'div' : 'button'}', { 'data-variant': variant, 'data-size': size, ...rest });`);
|
|
119
|
+
lines.push(`}`);
|
|
120
|
+
lines.push(``);
|
|
121
|
+
}
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Motion v2 — rich motion language extraction.
|
|
2
|
+
// Classifies easings into semantic families, detects springs/bounces,
|
|
3
|
+
// catches scroll/view-timeline usage, and emits motion tokens (duration, easing, spring).
|
|
4
|
+
|
|
5
|
+
const MS = v => {
|
|
6
|
+
if (!v) return 0;
|
|
7
|
+
const m = String(v).match(/(-?\d+\.?\d*)(m?s)?/);
|
|
8
|
+
if (!m) return 0;
|
|
9
|
+
const n = parseFloat(m[1]);
|
|
10
|
+
return m[2] === 's' ? n * 1000 : n;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const DURATION_NAMES = [
|
|
14
|
+
{ max: 80, name: 'instant' },
|
|
15
|
+
{ max: 150, name: 'xs' },
|
|
16
|
+
{ max: 250, name: 'sm' },
|
|
17
|
+
{ max: 400, name: 'md' },
|
|
18
|
+
{ max: 700, name: 'lg' },
|
|
19
|
+
{ max: 1200, name: 'xl' },
|
|
20
|
+
{ max: Infinity, name: 'xxl' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function nameDuration(ms) {
|
|
24
|
+
return DURATION_NAMES.find(d => ms <= d.max).name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classifyCubicBezier(raw) {
|
|
28
|
+
const m = raw.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/);
|
|
29
|
+
if (!m) return { family: 'custom', raw };
|
|
30
|
+
const [x1, y1, x2, y2] = m.slice(1).map(Number);
|
|
31
|
+
if (y1 < 0 || y2 > 1 || y2 < 0 || y1 > 1) return { family: 'spring', raw, overshoot: true };
|
|
32
|
+
if (x1 < 0.2 && x2 > 0.8) return { family: 'ease-in-out', raw };
|
|
33
|
+
if (x1 < 0.2 && y1 < 0.2) return { family: 'ease-out', raw };
|
|
34
|
+
if (x2 > 0.8 && y2 > 0.8) return { family: 'ease-in', raw };
|
|
35
|
+
if (y1 < x1 && y2 < x2) return { family: 'ease-in', raw };
|
|
36
|
+
if (y1 > x1 && y2 > x2) return { family: 'ease-out', raw };
|
|
37
|
+
return { family: 'custom', raw };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function classifyEasing(raw) {
|
|
41
|
+
if (!raw) return { family: 'linear', raw };
|
|
42
|
+
if (raw === 'linear') return { family: 'linear', raw };
|
|
43
|
+
if (raw === 'ease' || raw === 'ease-in-out') return { family: 'ease-in-out', raw };
|
|
44
|
+
if (raw === 'ease-in') return { family: 'ease-in', raw };
|
|
45
|
+
if (raw === 'ease-out') return { family: 'ease-out', raw };
|
|
46
|
+
if (/cubic-bezier/.test(raw)) return classifyCubicBezier(raw);
|
|
47
|
+
if (/steps/.test(raw)) return { family: 'steps', raw };
|
|
48
|
+
return { family: 'custom', raw };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBounceKeyframe(kf) {
|
|
52
|
+
if (!kf.steps || kf.steps.length < 3) return false;
|
|
53
|
+
const first = kf.steps.find(s => s.offset === '0%' || s.offset === 'from');
|
|
54
|
+
const last = kf.steps.find(s => s.offset === '100%' || s.offset === 'to');
|
|
55
|
+
if (!first || !last) return false;
|
|
56
|
+
return first.style.replace(/\s+/g, '') === last.style.replace(/\s+/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function keyframeKind(kf) {
|
|
60
|
+
const props = new Set();
|
|
61
|
+
const values = [];
|
|
62
|
+
for (const step of kf.steps || []) {
|
|
63
|
+
for (const part of (step.style || '').split(';')) {
|
|
64
|
+
const [p, v] = part.split(':').map(s => (s || '').trim());
|
|
65
|
+
if (p) props.add(p);
|
|
66
|
+
if (v) values.push(v);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const has = p => props.has(p);
|
|
70
|
+
const anyValue = re => values.some(v => re.test(v));
|
|
71
|
+
if (has('transform') && anyValue(/translate/i)) {
|
|
72
|
+
if (anyValue(/translateY\(-?\d/)) return 'slide-y';
|
|
73
|
+
if (anyValue(/translateX\(-?\d/)) return 'slide-x';
|
|
74
|
+
return 'slide';
|
|
75
|
+
}
|
|
76
|
+
if (has('opacity') && !has('transform')) return 'fade';
|
|
77
|
+
if (has('opacity') && has('transform')) return 'reveal';
|
|
78
|
+
if (anyValue(/rotate/)) return 'rotate';
|
|
79
|
+
if (anyValue(/scale/)) return 'scale';
|
|
80
|
+
if (isBounceKeyframe(kf)) return 'pulse';
|
|
81
|
+
return 'custom';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function extractMotion(computedStyles, keyframes = []) {
|
|
85
|
+
const transitions = new Set();
|
|
86
|
+
const easingRaw = new Set();
|
|
87
|
+
const durations = [];
|
|
88
|
+
const animationRefs = new Map();
|
|
89
|
+
const transitionedProps = {};
|
|
90
|
+
const scrollSignals = new Set();
|
|
91
|
+
let animatingElements = 0;
|
|
92
|
+
|
|
93
|
+
for (const el of computedStyles) {
|
|
94
|
+
let isAnimating = false;
|
|
95
|
+
if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
|
|
96
|
+
transitions.add(el.transition);
|
|
97
|
+
isAnimating = true;
|
|
98
|
+
for (const m of el.transition.matchAll(/(?<![(\d])(\d+\.?\d*m?s)(?![)\w])/g)) durations.push(MS(m[1]));
|
|
99
|
+
for (const m of el.transition.matchAll(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]+\)|steps\([^)]+\))/g)) easingRaw.add(m[1]);
|
|
100
|
+
for (const part of el.transition.split(',')) {
|
|
101
|
+
const prop = part.trim().split(/\s+/)[0];
|
|
102
|
+
if (prop && prop !== 'all') transitionedProps[prop] = (transitionedProps[prop] || 0) + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
|
|
106
|
+
const nameMatch = el.animation.match(/([a-zA-Z_][\w-]*)\s*$/) || el.animation.match(/^([a-zA-Z_][\w-]*)/);
|
|
107
|
+
if (nameMatch) {
|
|
108
|
+
const name = nameMatch[1];
|
|
109
|
+
if (name !== 'none' && name !== 'running' && name !== 'paused') {
|
|
110
|
+
animationRefs.set(name, (animationRefs.get(name) || 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
isAnimating = true;
|
|
114
|
+
}
|
|
115
|
+
if (el.animationTimeline && el.animationTimeline !== 'auto' && el.animationTimeline !== 'none' && el.animationTimeline !== '') {
|
|
116
|
+
scrollSignals.add(el.animationTimeline);
|
|
117
|
+
}
|
|
118
|
+
if (el.viewTimelineName) scrollSignals.add(`view:${el.viewTimelineName}`);
|
|
119
|
+
if (el.scrollTimelineName) scrollSignals.add(`scroll:${el.scrollTimelineName}`);
|
|
120
|
+
if (isAnimating) animatingElements++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const uniqueDurations = [...new Set(durations.filter(d => d > 0))].sort((a, b) => a - b);
|
|
124
|
+
const durationTokens = uniqueDurations.map(ms => ({
|
|
125
|
+
name: nameDuration(ms),
|
|
126
|
+
ms,
|
|
127
|
+
css: ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`,
|
|
128
|
+
}));
|
|
129
|
+
// dedupe by name — keep first (smallest) per bucket
|
|
130
|
+
const seenName = new Set();
|
|
131
|
+
const namedDurations = [];
|
|
132
|
+
for (const t of durationTokens) {
|
|
133
|
+
if (seenName.has(t.name)) continue;
|
|
134
|
+
seenName.add(t.name);
|
|
135
|
+
namedDurations.push(t);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const easings = [...easingRaw].map(e => ({ ...classifyEasing(e), count: computedStyles.filter(c => (c.transition || '').includes(e)).length }));
|
|
139
|
+
const springs = easings.filter(e => e.family === 'spring');
|
|
140
|
+
|
|
141
|
+
const enrichedKeyframes = (keyframes || []).map(kf => ({
|
|
142
|
+
name: kf.name,
|
|
143
|
+
steps: kf.steps,
|
|
144
|
+
kind: keyframeKind(kf),
|
|
145
|
+
isBounce: isBounceKeyframe(kf),
|
|
146
|
+
used: animationRefs.has(kf.name),
|
|
147
|
+
usageCount: animationRefs.get(kf.name) || 0,
|
|
148
|
+
propertiesAnimated: [...new Set((kf.steps || []).flatMap(s => (s.style || '').split(';').map(d => d.split(':')[0].trim()).filter(Boolean)))],
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const transitionTop = Object.entries(transitionedProps).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([property, count]) => ({ property, count }));
|
|
152
|
+
|
|
153
|
+
// Motion language fingerprint — what does this site's motion *feel* like?
|
|
154
|
+
const totalUses = easings.reduce((s, e) => s + e.count, 0);
|
|
155
|
+
const share = family => easings.filter(e => e.family === family).reduce((s, e) => s + e.count, 0) / (totalUses || 1);
|
|
156
|
+
const feel = springs.length > 0
|
|
157
|
+
? 'springy'
|
|
158
|
+
: share('ease-out') > 0.5
|
|
159
|
+
? 'responsive'
|
|
160
|
+
: share('ease-in-out') > 0.5
|
|
161
|
+
? 'smooth'
|
|
162
|
+
: share('linear') > 0.5
|
|
163
|
+
? 'mechanical'
|
|
164
|
+
: 'mixed';
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
durations: namedDurations,
|
|
168
|
+
easings,
|
|
169
|
+
springs,
|
|
170
|
+
keyframes: enrichedKeyframes,
|
|
171
|
+
transitionedProperties: transitionTop,
|
|
172
|
+
scrollLinked: {
|
|
173
|
+
present: scrollSignals.size > 0,
|
|
174
|
+
signals: [...scrollSignals],
|
|
175
|
+
},
|
|
176
|
+
stats: {
|
|
177
|
+
animatingElements,
|
|
178
|
+
transitionCount: transitions.size,
|
|
179
|
+
keyframeCount: enrichedKeyframes.length,
|
|
180
|
+
keyframeUnused: enrichedKeyframes.filter(k => !k.used).length,
|
|
181
|
+
},
|
|
182
|
+
feel,
|
|
183
|
+
};
|
|
184
|
+
}
|