designlang 5.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
- package/CHANGELOG.md +43 -0
- package/README.md +177 -6
- package/bin/design-extract.js +302 -92
- package/docs/superpowers/plans/2026-04-18-designlang-v7.md +1121 -0
- package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -0
- package/package.json +13 -7
- package/src/config.js +59 -0
- package/src/crawler.js +297 -95
- package/src/extractors/a11y-remediation.js +47 -0
- package/src/extractors/animations.js +37 -5
- package/src/extractors/borders.js +40 -5
- package/src/extractors/component-clusters.js +39 -0
- package/src/extractors/components.js +77 -1
- package/src/extractors/css-health.js +151 -0
- package/src/extractors/gradients.js +25 -5
- package/src/extractors/scoring.js +20 -1
- package/src/extractors/semantic-regions.js +44 -0
- package/src/extractors/shadows.js +60 -17
- package/src/extractors/spacing.js +31 -2
- package/src/extractors/stack-fingerprint.js +88 -0
- package/src/extractors/variables.js +20 -1
- package/src/formatters/_token-ref.js +44 -0
- package/src/formatters/agent-rules.js +116 -0
- package/src/formatters/android-compose.js +164 -0
- package/src/formatters/dtcg-tokens.js +175 -0
- package/src/formatters/figma.js +66 -47
- package/src/formatters/flutter-dart.js +130 -0
- package/src/formatters/ios-swiftui.js +161 -0
- package/src/formatters/markdown.js +25 -0
- package/src/formatters/preview.js +65 -22
- package/src/formatters/svelte-theme.js +40 -0
- package/src/formatters/tailwind.js +57 -4
- package/src/formatters/theme.js +134 -0
- package/src/formatters/vue-theme.js +44 -0
- package/src/formatters/wordpress.js +267 -0
- package/src/history.js +8 -1
- package/src/index.js +76 -20
- package/src/mcp/resources.js +64 -0
- package/src/mcp/server.js +110 -0
- package/src/mcp/tools.js +149 -0
- package/src/utils.js +68 -0
- package/tests/cli.test.js +84 -0
- package/tests/extractors.test.js +792 -0
- package/tests/formatters.test.js +709 -0
- package/tests/mcp.test.js +68 -0
- package/tests/utils.test.js +413 -0
- package/website/app/globals.css +11 -11
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Cluster similar component instances by structuralHash + style vector cosine similarity.
|
|
2
|
+
|
|
3
|
+
function cosine(a = [], b = []) {
|
|
4
|
+
const n = Math.min(a.length, b.length);
|
|
5
|
+
let dot = 0, na = 0, nb = 0;
|
|
6
|
+
for (let i = 0; i < n; i++) {
|
|
7
|
+
dot += a[i] * b[i];
|
|
8
|
+
na += a[i] * a[i];
|
|
9
|
+
nb += b[i] * b[i];
|
|
10
|
+
}
|
|
11
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : (na === nb ? 1 : 0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function clusterComponents(elements = [], { threshold = 0.95 } = {}) {
|
|
15
|
+
const byKind = {};
|
|
16
|
+
for (const el of elements) {
|
|
17
|
+
const key = `${el.kind}|${el.structuralHash}`;
|
|
18
|
+
(byKind[key] ||= []).push(el);
|
|
19
|
+
}
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const group of Object.values(byKind)) {
|
|
22
|
+
const variants = [];
|
|
23
|
+
for (const el of group) {
|
|
24
|
+
const match = variants.find(v => cosine(v.example.styleVector || [], el.styleVector || []) >= threshold);
|
|
25
|
+
if (match) {
|
|
26
|
+
match.instanceCount++;
|
|
27
|
+
} else {
|
|
28
|
+
variants.push({ example: el, instanceCount: 1 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
out.push({
|
|
32
|
+
kind: group[0].kind,
|
|
33
|
+
structuralHash: group[0].structuralHash,
|
|
34
|
+
instanceCount: group.length,
|
|
35
|
+
variants: variants.map(v => ({ css: v.example.css, instanceCount: v.instanceCount })),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
export function extractComponents(computedStyles) {
|
|
2
2
|
const components = {};
|
|
3
3
|
|
|
4
|
-
// Buttons
|
|
4
|
+
// Buttons — with variant detection
|
|
5
5
|
const buttons = computedStyles.filter(el =>
|
|
6
6
|
el.tag === 'button' || el.role === 'button' ||
|
|
7
7
|
(el.tag === 'a' && /btn|button/i.test(el.classList))
|
|
8
8
|
);
|
|
9
9
|
if (buttons.length > 0) {
|
|
10
|
+
// Group by background color to detect variants
|
|
11
|
+
const bgGroups = new Map();
|
|
12
|
+
for (const btn of buttons) {
|
|
13
|
+
const bg = btn.backgroundColor || 'transparent';
|
|
14
|
+
if (!bgGroups.has(bg)) bgGroups.set(bg, []);
|
|
15
|
+
bgGroups.get(bg).push(btn);
|
|
16
|
+
}
|
|
17
|
+
const variants = [...bgGroups.entries()]
|
|
18
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
19
|
+
.map(([bg, group], i) => {
|
|
20
|
+
let variant = 'default';
|
|
21
|
+
if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') variant = 'ghost';
|
|
22
|
+
else if (i === 0) variant = 'primary';
|
|
23
|
+
else if (i === 1) variant = 'secondary';
|
|
24
|
+
else variant = `variant-${i + 1}`;
|
|
25
|
+
return { variant, backgroundColor: bg, count: group.length, style: mostCommonStyle(group, ['color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderRadius']) };
|
|
26
|
+
});
|
|
10
27
|
components.buttons = {
|
|
11
28
|
count: buttons.length,
|
|
12
29
|
baseStyle: mostCommonStyle(buttons, ['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderRadius']),
|
|
30
|
+
variants,
|
|
13
31
|
};
|
|
14
32
|
}
|
|
15
33
|
|
|
@@ -131,6 +149,64 @@ export function extractComponents(computedStyles) {
|
|
|
131
149
|
};
|
|
132
150
|
}
|
|
133
151
|
|
|
152
|
+
// Tabs
|
|
153
|
+
const tabs = computedStyles.filter(el =>
|
|
154
|
+
el.role === 'tab' || /\btab\b/i.test(el.classList)
|
|
155
|
+
);
|
|
156
|
+
if (tabs.length > 0) {
|
|
157
|
+
components.tabs = {
|
|
158
|
+
count: tabs.length,
|
|
159
|
+
baseStyle: mostCommonStyle(tabs, ['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderColor', 'borderRadius']),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Accordions
|
|
164
|
+
const accordions = computedStyles.filter(el =>
|
|
165
|
+
/accordion/i.test(el.classList) ||
|
|
166
|
+
(el.tag === 'summary') ||
|
|
167
|
+
(el.tag === 'details')
|
|
168
|
+
);
|
|
169
|
+
if (accordions.length > 0) {
|
|
170
|
+
components.accordions = {
|
|
171
|
+
count: accordions.length,
|
|
172
|
+
baseStyle: mostCommonStyle(accordions, ['backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingRight', 'borderColor']),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Tooltips
|
|
177
|
+
const tooltips = computedStyles.filter(el =>
|
|
178
|
+
el.role === 'tooltip' || /tooltip/i.test(el.classList)
|
|
179
|
+
);
|
|
180
|
+
if (tooltips.length > 0) {
|
|
181
|
+
components.tooltips = {
|
|
182
|
+
count: tooltips.length,
|
|
183
|
+
baseStyle: mostCommonStyle(tooltips, ['backgroundColor', 'color', 'fontSize', 'borderRadius', 'paddingTop', 'paddingRight', 'boxShadow']),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Progress bars
|
|
188
|
+
const progressBars = computedStyles.filter(el =>
|
|
189
|
+
el.tag === 'progress' || el.role === 'progressbar' || /progress/i.test(el.classList)
|
|
190
|
+
);
|
|
191
|
+
if (progressBars.length > 0) {
|
|
192
|
+
components.progressBars = {
|
|
193
|
+
count: progressBars.length,
|
|
194
|
+
baseStyle: mostCommonStyle(progressBars, ['backgroundColor', 'color', 'borderRadius', 'fontSize']),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Switches / Toggles
|
|
199
|
+
const switches = computedStyles.filter(el =>
|
|
200
|
+
el.role === 'switch' ||
|
|
201
|
+
/switch|toggle/i.test(el.classList)
|
|
202
|
+
);
|
|
203
|
+
if (switches.length > 0) {
|
|
204
|
+
components.switches = {
|
|
205
|
+
count: switches.length,
|
|
206
|
+
baseStyle: mostCommonStyle(switches, ['backgroundColor', 'borderRadius', 'borderColor']),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
134
210
|
// Generate CSS snippets for each component
|
|
135
211
|
for (const [type, data] of Object.entries(components)) {
|
|
136
212
|
if (data.baseStyle) {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// CSS health audit — operates on page.coverage.stopCSSCoverage() output
|
|
2
|
+
// serialized as [{ url, text, totalBytes, ranges:[{start,end}] }].
|
|
3
|
+
|
|
4
|
+
function countUsed(ranges = []) {
|
|
5
|
+
let used = 0;
|
|
6
|
+
for (const r of ranges) used += Math.max(0, (r.end || 0) - (r.start || 0));
|
|
7
|
+
return used;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function countImportant(text) {
|
|
11
|
+
const m = text.match(/!important/g);
|
|
12
|
+
return m ? m.length : 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function countDuplicates(text) {
|
|
16
|
+
// Count prop:value pairs that appear >=2x across the sheet.
|
|
17
|
+
const seen = new Map();
|
|
18
|
+
const re = /([\w-]+)\s*:\s*([^;{}!]+)(\s*!important)?\s*;?/g;
|
|
19
|
+
for (const m of text.matchAll(re)) {
|
|
20
|
+
const key = `${m[1].trim()}:${m[2].trim()}`;
|
|
21
|
+
seen.set(key, (seen.get(key) || 0) + 1);
|
|
22
|
+
}
|
|
23
|
+
let dup = 0;
|
|
24
|
+
for (const n of seen.values()) if (n >= 2) dup += (n - 1);
|
|
25
|
+
return dup;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function countVendorPrefixes(text) {
|
|
29
|
+
return {
|
|
30
|
+
webkit: (text.match(/-webkit-/g) || []).length,
|
|
31
|
+
moz: (text.match(/-moz-/g) || []).length,
|
|
32
|
+
ms: (text.match(/-ms-/g) || []).length,
|
|
33
|
+
o: (text.match(/(^|[^-\w])-o-/g) || []).length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractKeyframes(text) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const re = /@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g;
|
|
40
|
+
for (const m of text.matchAll(re)) {
|
|
41
|
+
const body = m[2];
|
|
42
|
+
const steps = (body.match(/(\d+%|from|to)\s*\{/g) || []).length;
|
|
43
|
+
out.push({ name: m[1], steps });
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function specificityFor(selector) {
|
|
49
|
+
// Simple WCAG-ish triple: ids, classes+attrs+pseudo-classes, types
|
|
50
|
+
const ids = (selector.match(/#[\w-]+/g) || []).length;
|
|
51
|
+
const classes = (selector.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]+\))?/g) || []).length;
|
|
52
|
+
const types = (selector.match(/(?:^|[\s>+~,])([a-z][\w-]*)/gi) || []).length;
|
|
53
|
+
return [ids, classes, types];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function specificityDistribution(text) {
|
|
57
|
+
const triples = [];
|
|
58
|
+
const re = /([^{}]+)\{([^}]*)\}/g;
|
|
59
|
+
for (const m of text.matchAll(re)) {
|
|
60
|
+
const selectorList = m[1];
|
|
61
|
+
for (const sel of selectorList.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
62
|
+
if (sel.startsWith('@')) continue;
|
|
63
|
+
triples.push(specificityFor(sel));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (triples.length === 0) {
|
|
67
|
+
return { max: [0, 0, 0], average: [0, 0, 0], count: 0 };
|
|
68
|
+
}
|
|
69
|
+
let max = [0, 0, 0];
|
|
70
|
+
let sum = [0, 0, 0];
|
|
71
|
+
for (const t of triples) {
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
sum[i] += t[i];
|
|
74
|
+
if (t[i] > max[i]) max[i] = t[i];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const avg = sum.map(v => Math.round((v / triples.length) * 100) / 100);
|
|
78
|
+
return { max, average: avg, count: triples.length };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function extractCssHealth(coverage = []) {
|
|
82
|
+
const sheets = [];
|
|
83
|
+
let totalBytes = 0;
|
|
84
|
+
let usedBytes = 0;
|
|
85
|
+
let importantCount = 0;
|
|
86
|
+
let duplicates = 0;
|
|
87
|
+
const vendorPrefixes = { webkit: 0, moz: 0, ms: 0, o: 0 };
|
|
88
|
+
const keyframes = [];
|
|
89
|
+
let specMax = [0, 0, 0];
|
|
90
|
+
let specSumWeighted = [0, 0, 0];
|
|
91
|
+
let specCount = 0;
|
|
92
|
+
|
|
93
|
+
for (const c of coverage) {
|
|
94
|
+
const text = c.text || '';
|
|
95
|
+
const sheetTotal = typeof c.totalBytes === 'number' ? c.totalBytes : text.length;
|
|
96
|
+
const sheetUsed = countUsed(c.ranges);
|
|
97
|
+
const sheetUnused = Math.max(0, sheetTotal - sheetUsed);
|
|
98
|
+
sheets.push({
|
|
99
|
+
url: c.url || '',
|
|
100
|
+
totalBytes: sheetTotal,
|
|
101
|
+
usedBytes: sheetUsed,
|
|
102
|
+
unusedBytes: sheetUnused,
|
|
103
|
+
unusedPercent: sheetTotal ? Math.round((sheetUnused / sheetTotal) * 100) : 0,
|
|
104
|
+
});
|
|
105
|
+
totalBytes += sheetTotal;
|
|
106
|
+
usedBytes += sheetUsed;
|
|
107
|
+
|
|
108
|
+
importantCount += countImportant(text);
|
|
109
|
+
duplicates += countDuplicates(text);
|
|
110
|
+
|
|
111
|
+
const vp = countVendorPrefixes(text);
|
|
112
|
+
vendorPrefixes.webkit += vp.webkit;
|
|
113
|
+
vendorPrefixes.moz += vp.moz;
|
|
114
|
+
vendorPrefixes.ms += vp.ms;
|
|
115
|
+
vendorPrefixes.o += vp.o;
|
|
116
|
+
|
|
117
|
+
keyframes.push(...extractKeyframes(text));
|
|
118
|
+
|
|
119
|
+
const spec = specificityDistribution(text);
|
|
120
|
+
for (let i = 0; i < 3; i++) {
|
|
121
|
+
if (spec.max[i] > specMax[i]) specMax[i] = spec.max[i];
|
|
122
|
+
specSumWeighted[i] += spec.average[i] * spec.count;
|
|
123
|
+
}
|
|
124
|
+
specCount += spec.count;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const unusedBytes = Math.max(0, totalBytes - usedBytes);
|
|
128
|
+
const unusedPercent = totalBytes ? Math.round((unusedBytes / totalBytes) * 100) : 0;
|
|
129
|
+
const specAvg = specCount > 0
|
|
130
|
+
? specSumWeighted.map(v => Math.round((v / specCount) * 100) / 100)
|
|
131
|
+
: [0, 0, 0];
|
|
132
|
+
|
|
133
|
+
const issues = [];
|
|
134
|
+
if (importantCount > 0) issues.push(`${importantCount} !important rule${importantCount > 1 ? 's' : ''}`);
|
|
135
|
+
if (duplicates > 0) issues.push(`${duplicates} duplicate declaration${duplicates > 1 ? 's' : ''}`);
|
|
136
|
+
if (unusedPercent >= 50) issues.push(`${unusedPercent}% unused CSS`);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
sheets,
|
|
140
|
+
totalBytes,
|
|
141
|
+
usedBytes,
|
|
142
|
+
unusedBytes,
|
|
143
|
+
unusedPercent,
|
|
144
|
+
importantCount,
|
|
145
|
+
duplicates,
|
|
146
|
+
vendorPrefixes,
|
|
147
|
+
keyframes,
|
|
148
|
+
specificity: { max: specMax, average: specAvg, count: specCount },
|
|
149
|
+
issues,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -6,7 +6,9 @@ export function extractGradients(styles) {
|
|
|
6
6
|
const bg = el.backgroundImage;
|
|
7
7
|
if (!bg || !bg.includes('gradient')) continue;
|
|
8
8
|
const rawGradients = splitGradients(bg);
|
|
9
|
-
for (
|
|
9
|
+
for (let raw of rawGradients) {
|
|
10
|
+
// Normalize vendor prefixes
|
|
11
|
+
raw = raw.replace(/-(webkit|moz)-/g, '');
|
|
10
12
|
if (seen.has(raw)) continue;
|
|
11
13
|
seen.add(raw);
|
|
12
14
|
gradients.push(parseGradient(raw));
|
|
@@ -58,15 +60,33 @@ function parseGradient(raw) {
|
|
|
58
60
|
let direction = null;
|
|
59
61
|
let stopArgs = args;
|
|
60
62
|
const first = args[0] || '';
|
|
61
|
-
if (/^(to |from |\d+deg|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
|
|
63
|
+
if (/^(to |from |\d+(\.\d+)?(deg|grad|rad|turn)|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
|
|
62
64
|
direction = first;
|
|
63
65
|
stopArgs = args.slice(1);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const stops = stopArgs.map(s => {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
69
|
+
// Match position only if it's outside parentheses (not inside rgb/hsl)
|
|
70
|
+
// Position is a percentage or length at the end, after the color value
|
|
71
|
+
const trimmed = s.trim();
|
|
72
|
+
// Check if trailing value is outside any parens
|
|
73
|
+
const lastParen = trimmed.lastIndexOf(')');
|
|
74
|
+
const trailing = lastParen >= 0 ? trimmed.slice(lastParen + 1).trim() : trimmed;
|
|
75
|
+
const posMatch = trailing.match(/([\d.]+(%|px|em|rem|vw|vh)?)$/);
|
|
76
|
+
let position = null;
|
|
77
|
+
let color = trimmed;
|
|
78
|
+
if (posMatch && posMatch[0] !== trimmed) {
|
|
79
|
+
// Position found after the color function closes
|
|
80
|
+
position = posMatch[0];
|
|
81
|
+
color = trimmed.slice(0, trimmed.length - trailing.length + trailing.indexOf(posMatch[0])).trim();
|
|
82
|
+
} else if (lastParen < 0) {
|
|
83
|
+
// No parens — simple color like "red 50%"
|
|
84
|
+
const simplePos = trimmed.match(/\s+([\d.]+(%|px|em|rem|vw|vh)?)$/);
|
|
85
|
+
if (simplePos) {
|
|
86
|
+
position = simplePos[1];
|
|
87
|
+
color = trimmed.slice(0, simplePos.index).trim();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
70
90
|
return { color, position };
|
|
71
91
|
});
|
|
72
92
|
|
|
@@ -73,7 +73,26 @@ export function scoreDesignSystem(design) {
|
|
|
73
73
|
issues.push(`${design.accessibility.failCount} WCAG contrast failures`);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// 7. CSS
|
|
76
|
+
// 7. CSS health (0-100) — additive; does not affect existing weights.
|
|
77
|
+
if (design.cssHealth) {
|
|
78
|
+
const ch = design.cssHealth;
|
|
79
|
+
let h = 100;
|
|
80
|
+
if (ch.unusedPercent >= 70) h -= 30;
|
|
81
|
+
else if (ch.unusedPercent >= 50) h -= 20;
|
|
82
|
+
else if (ch.unusedPercent >= 30) h -= 10;
|
|
83
|
+
if (ch.importantCount >= 20) h -= 20;
|
|
84
|
+
else if (ch.importantCount >= 5) h -= 10;
|
|
85
|
+
else if (ch.importantCount >= 1) h -= 5;
|
|
86
|
+
if (ch.duplicates >= 20) h -= 15;
|
|
87
|
+
else if (ch.duplicates >= 5) h -= 8;
|
|
88
|
+
else if (ch.duplicates >= 1) h -= 3;
|
|
89
|
+
scores.cssHealth = Math.max(0, h);
|
|
90
|
+
if (ch.importantCount >= 5) issues.push(`${ch.importantCount} !important rules — prefer specificity over overrides`);
|
|
91
|
+
if (ch.unusedPercent >= 50) issues.push(`${ch.unusedPercent}% of CSS is unused — consider purging`);
|
|
92
|
+
if (ch.duplicates >= 5) issues.push(`${ch.duplicates} duplicate CSS declarations`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 8. CSS variable usage (0-100)
|
|
77
96
|
const varCount = Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0);
|
|
78
97
|
if (varCount >= 20) scores.tokenization = 100;
|
|
79
98
|
else if (varCount >= 10) scores.tokenization = 75;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Classify page sections: nav/hero/features/pricing/testimonials/cta/footer/sidebar/content.
|
|
2
|
+
|
|
3
|
+
const KW = {
|
|
4
|
+
pricing: /\b(\$\s*\d|per\s?month|\/mo\b|pricing|free|billed)/i,
|
|
5
|
+
testimonials: /(customer|review|testimonial|said|"|")/i,
|
|
6
|
+
features: /(feature|benefit|why|what you get)/i,
|
|
7
|
+
cta: /(get started|sign up|try free|start now|request demo|contact sales)/i,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function classify(s) {
|
|
11
|
+
const role = (s.role || '').toLowerCase();
|
|
12
|
+
const tag = (s.tag || '').toLowerCase();
|
|
13
|
+
if (tag === 'nav' || role === 'navigation') return 'nav';
|
|
14
|
+
if (tag === 'header' || role === 'banner') return 'nav';
|
|
15
|
+
if (tag === 'footer' || role === 'contentinfo') return 'footer';
|
|
16
|
+
if (tag === 'aside' || role === 'complementary') return 'sidebar';
|
|
17
|
+
|
|
18
|
+
const cls = (s.className || '').toLowerCase();
|
|
19
|
+
const id = (s.id || '').toLowerCase();
|
|
20
|
+
const blob = `${cls} ${id}`;
|
|
21
|
+
const text = s.text || '';
|
|
22
|
+
const headings = s.headings || [];
|
|
23
|
+
|
|
24
|
+
if (/hero/.test(blob)) return 'hero';
|
|
25
|
+
if (/pricing/.test(blob) || KW.pricing.test(text)) return 'pricing';
|
|
26
|
+
if (/testimonial|review/.test(blob) || KW.testimonials.test(text)) return 'testimonials';
|
|
27
|
+
if (/features?|grid/.test(blob) && s.cardCount >= 3) return 'features';
|
|
28
|
+
if (KW.features.test(text) && s.cardCount >= 3) return 'features';
|
|
29
|
+
if (s.buttonCount <= 2 && headings.length && text.length < 400 && KW.cta.test(text)) return 'cta';
|
|
30
|
+
if (headings.length === 1 && s.buttonCount >= 1 && s.bounds && s.bounds.h > 300) return 'hero';
|
|
31
|
+
return 'content';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractSemanticRegions(sections = []) {
|
|
35
|
+
return sections.map(s => ({
|
|
36
|
+
role: classify(s),
|
|
37
|
+
tag: s.tag,
|
|
38
|
+
bounds: s.bounds,
|
|
39
|
+
heading: (s.headings && s.headings[0]) || null,
|
|
40
|
+
buttonCount: s.buttonCount || 0,
|
|
41
|
+
cardCount: s.cardCount || 0,
|
|
42
|
+
className: s.className || null,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
@@ -1,27 +1,70 @@
|
|
|
1
1
|
export function extractShadows(computedStyles) {
|
|
2
2
|
const shadowSet = new Set();
|
|
3
|
+
const textShadowSet = new Set();
|
|
3
4
|
|
|
4
5
|
for (const el of computedStyles) {
|
|
5
6
|
if (el.boxShadow && el.boxShadow !== 'none') {
|
|
6
7
|
shadowSet.add(el.boxShadow);
|
|
7
8
|
}
|
|
9
|
+
if (el.textShadow && el.textShadow !== 'none') {
|
|
10
|
+
textShadowSet.add(el.textShadow);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const values = [...shadowSet].map(raw => parseShadow(raw));
|
|
15
|
+
values.sort((a, b) => a.visualWeight - b.visualWeight);
|
|
16
|
+
|
|
17
|
+
const textShadows = [...textShadowSet].map(raw => parseShadow(raw));
|
|
18
|
+
textShadows.sort((a, b) => a.visualWeight - b.visualWeight);
|
|
19
|
+
|
|
20
|
+
return { values, textShadows };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseShadow(raw) {
|
|
24
|
+
const inset = raw.includes('inset');
|
|
25
|
+
const cleaned = raw.replace(/\binset\b/g, '').trim();
|
|
26
|
+
|
|
27
|
+
// Extract color (rgb/rgba/hsl/hsla or named/hex) — find the non-numeric portion
|
|
28
|
+
let color = '';
|
|
29
|
+
let numericPart = cleaned;
|
|
30
|
+
// Match color functions first (they may contain commas/numbers)
|
|
31
|
+
const colorFnMatch = cleaned.match(/(rgba?\([^)]+\)|hsla?\([^)]+\))/);
|
|
32
|
+
if (colorFnMatch) {
|
|
33
|
+
color = colorFnMatch[1];
|
|
34
|
+
numericPart = cleaned.replace(color, '').trim();
|
|
35
|
+
} else {
|
|
36
|
+
// Try hex or named color at start or end
|
|
37
|
+
const hexMatch = cleaned.match(/(#[0-9a-fA-F]{3,8})/);
|
|
38
|
+
if (hexMatch) {
|
|
39
|
+
color = hexMatch[1];
|
|
40
|
+
numericPart = cleaned.replace(color, '').trim();
|
|
41
|
+
} else {
|
|
42
|
+
// Named color — typically last or first token that isn't a length
|
|
43
|
+
const tokens = cleaned.split(/\s+/);
|
|
44
|
+
const colorToken = tokens.find(t => !/^-?[\d.]+px$/.test(t) && !/^[\d.]+$/.test(t));
|
|
45
|
+
if (colorToken) {
|
|
46
|
+
color = colorToken;
|
|
47
|
+
numericPart = cleaned.replace(colorToken, '').trim();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
8
50
|
}
|
|
9
51
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
// Parse numeric values (offset-x, offset-y, blur, spread)
|
|
53
|
+
const nums = numericPart.match(/-?[\d.]+px/g)?.map(n => parseFloat(n)) || [];
|
|
54
|
+
const offsetX = nums[0] || 0;
|
|
55
|
+
const offsetY = nums[1] || 0;
|
|
56
|
+
const blur = nums[2] || 0;
|
|
57
|
+
const spread = nums[3] || 0;
|
|
58
|
+
|
|
59
|
+
// Visual weight = distance + blur
|
|
60
|
+
const visualWeight = Math.sqrt(offsetX * offsetX + offsetY * offsetY) + blur;
|
|
61
|
+
|
|
62
|
+
let label = 'none';
|
|
63
|
+
if (visualWeight > 0 && visualWeight <= 3) label = 'xs';
|
|
64
|
+
else if (visualWeight <= 8) label = 'sm';
|
|
65
|
+
else if (visualWeight <= 16) label = 'md';
|
|
66
|
+
else if (visualWeight <= 32) label = 'lg';
|
|
67
|
+
else if (visualWeight > 32) label = 'xl';
|
|
68
|
+
|
|
69
|
+
return { raw, offsetX, offsetY, blur, spread, color, inset, visualWeight: Math.round(visualWeight * 100) / 100, label };
|
|
27
70
|
}
|
|
@@ -1,4 +1,33 @@
|
|
|
1
|
-
import { parseCSSValue,
|
|
1
|
+
import { parseCSSValue, detectScale } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
function naturalBreakCluster(values) {
|
|
4
|
+
if (values.length <= 1) return values;
|
|
5
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
6
|
+
if (sorted.length <= 2) return sorted;
|
|
7
|
+
|
|
8
|
+
// Compute gaps between consecutive values
|
|
9
|
+
const gaps = [];
|
|
10
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
11
|
+
gaps.push(sorted[i] - sorted[i - 1]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Find median gap
|
|
15
|
+
const sortedGaps = [...gaps].sort((a, b) => a - b);
|
|
16
|
+
const medianGap = sortedGaps[Math.floor(sortedGaps.length / 2)];
|
|
17
|
+
|
|
18
|
+
// Split into clusters at gaps larger than the median
|
|
19
|
+
const clusters = [[sorted[0]]];
|
|
20
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
21
|
+
if (gaps[i - 1] > medianGap) {
|
|
22
|
+
clusters.push([sorted[i]]);
|
|
23
|
+
} else {
|
|
24
|
+
clusters[clusters.length - 1].push(sorted[i]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Use the first (smallest) value in each cluster as representative
|
|
29
|
+
return clusters.map(c => c[0]);
|
|
30
|
+
}
|
|
2
31
|
|
|
3
32
|
export function extractSpacing(computedStyles) {
|
|
4
33
|
const allValues = new Set();
|
|
@@ -13,7 +42,7 @@ export function extractSpacing(computedStyles) {
|
|
|
13
42
|
}
|
|
14
43
|
|
|
15
44
|
const sorted = [...allValues].sort((a, b) => a - b);
|
|
16
|
-
const clustered =
|
|
45
|
+
const clustered = naturalBreakCluster(sorted);
|
|
17
46
|
const { base, scale } = detectScale(clustered);
|
|
18
47
|
|
|
19
48
|
// Build named tokens
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Framework + CSS layer + analytics fingerprint.
|
|
2
|
+
// Pure function over signals collected by the crawler.
|
|
3
|
+
|
|
4
|
+
const FRAMEWORK_BY_GLOBAL = {
|
|
5
|
+
'__NEXT_DATA__': 'next',
|
|
6
|
+
'__NUXT__': 'nuxt',
|
|
7
|
+
'___gatsby': 'gatsby',
|
|
8
|
+
'_remixContext': 'remix',
|
|
9
|
+
'React': 'react',
|
|
10
|
+
'Vue': 'vue',
|
|
11
|
+
'Shopify': 'shopify',
|
|
12
|
+
'wp': 'wordpress',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const SCRIPT_PATTERNS = [
|
|
16
|
+
[/_next\/static/, 'next'],
|
|
17
|
+
[/\/nuxt\//, 'nuxt'],
|
|
18
|
+
[/\/astro\//, 'astro'],
|
|
19
|
+
[/\/sveltekit\//, 'sveltekit'],
|
|
20
|
+
[/shopify\./, 'shopify'],
|
|
21
|
+
[/wp-(content|includes)/, 'wordpress'],
|
|
22
|
+
[/webflow\.com/, 'webflow'],
|
|
23
|
+
[/framerusercontent/, 'framer'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const ANALYTICS = {
|
|
27
|
+
gtag: /googletagmanager\.com|google-analytics/,
|
|
28
|
+
plausible: /plausible\.io/,
|
|
29
|
+
posthog: /posthog\.com/,
|
|
30
|
+
segment: /segment\.(io|com)/,
|
|
31
|
+
mixpanel: /mixpanel/,
|
|
32
|
+
amplitude: /amplitude/,
|
|
33
|
+
hotjar: /hotjar/,
|
|
34
|
+
vercelInsights: /\/_vercel\/insights/,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const TAILWIND_UTIL = /(^|\s)(flex|grid|block|inline|hidden|text-(xs|sm|base|lg|xl|\d+xl)|text-(gray|slate|zinc|red|blue|green|amber|neutral|stone)-\d+|bg-(gray|slate|zinc|red|blue|green|amber|neutral|stone)-\d+|p[xy]?-\d+|m[xy]?-\d+|gap-\d+|rounded(-\w+)?|shadow(-\w+)?|items-(start|center|end|baseline|stretch)|justify-(start|center|end|between|around|evenly)|grid-cols-\d+|col-span-\d+)(\s|$)/;
|
|
38
|
+
|
|
39
|
+
function detectFramework(signals) {
|
|
40
|
+
for (const g of signals.windowGlobals || []) {
|
|
41
|
+
if (FRAMEWORK_BY_GLOBAL[g]) return FRAMEWORK_BY_GLOBAL[g];
|
|
42
|
+
}
|
|
43
|
+
for (const s of signals.scripts || []) {
|
|
44
|
+
for (const [re, name] of SCRIPT_PATTERNS) if (re.test(s)) return name;
|
|
45
|
+
}
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detectTailwind(signals) {
|
|
50
|
+
const classes = (signals.classNameSample || []).filter(c => typeof c === 'string');
|
|
51
|
+
const hits = classes.filter(c => TAILWIND_UTIL.test(c));
|
|
52
|
+
if (hits.length < Math.max(5, classes.length * 0.1)) return null;
|
|
53
|
+
const utilFreq = new Map();
|
|
54
|
+
for (const c of hits) {
|
|
55
|
+
for (const u of c.split(/\s+/).filter(Boolean)) {
|
|
56
|
+
utilFreq.set(u, (utilFreq.get(u) || 0) + 1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const topUtilities = [...utilFreq.entries()]
|
|
60
|
+
.sort((a, b) => b[1] - a[1])
|
|
61
|
+
.slice(0, 100)
|
|
62
|
+
.map(([u, n]) => ({ utility: u, count: n }));
|
|
63
|
+
return { detected: true, utilities: topUtilities };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectAnalytics(signals) {
|
|
67
|
+
const found = [];
|
|
68
|
+
for (const [name, re] of Object.entries(ANALYTICS)) {
|
|
69
|
+
if ((signals.scripts || []).some(s => re.test(s))) found.push(name);
|
|
70
|
+
}
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function extractStackFingerprint(signals = {}) {
|
|
75
|
+
const framework = detectFramework(signals);
|
|
76
|
+
const tailwind = detectTailwind(signals);
|
|
77
|
+
const css = { layer: tailwind ? 'tailwind' : 'unknown', tailwind: tailwind || null };
|
|
78
|
+
return {
|
|
79
|
+
framework,
|
|
80
|
+
css,
|
|
81
|
+
analytics: detectAnalytics(signals),
|
|
82
|
+
detectedFrom: {
|
|
83
|
+
globalCount: (signals.windowGlobals || []).length,
|
|
84
|
+
scriptCount: (signals.scripts || []).length,
|
|
85
|
+
classSampleSize: (signals.classNameSample || []).length,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -18,5 +18,24 @@ export function extractVariables(cssVariables) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Build dependency map: which variables reference other variables
|
|
22
|
+
const dependencies = {};
|
|
23
|
+
for (const [name, value] of Object.entries(cssVariables)) {
|
|
24
|
+
const refs = [...value.matchAll(/var\((--[\w-]+)/g)].map(m => m[1]);
|
|
25
|
+
if (refs.length > 0) {
|
|
26
|
+
dependencies[name] = refs;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Semantic grouping by name patterns
|
|
31
|
+
const semantic = { success: {}, warning: {}, error: {}, info: {} };
|
|
32
|
+
for (const [name, value] of Object.entries(cssVariables)) {
|
|
33
|
+
const lower = name.toLowerCase();
|
|
34
|
+
if (/success|green|valid|positive/.test(lower)) semantic.success[name] = value;
|
|
35
|
+
else if (/warning|warn|yellow|caution|amber/.test(lower)) semantic.warning[name] = value;
|
|
36
|
+
else if (/error|danger|destructive|red|invalid|negative/.test(lower)) semantic.error[name] = value;
|
|
37
|
+
else if (/info|informati|blue|notice/.test(lower)) semantic.info[name] = value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { ...categories, dependencies, semantic };
|
|
22
41
|
}
|