designlang 2.0.0 → 4.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/README.md +138 -77
- package/bin/design-extract.js +272 -2
- package/designlang.png +0 -0
- package/package.json +1 -1
- package/skills/extract-design/SKILL.md +52 -25
- package/src/clone.js +218 -0
- package/src/crawler.js +7 -0
- package/src/extractors/components.js +86 -0
- package/src/extractors/interactions.js +128 -0
- package/src/extractors/layout.js +114 -0
- package/src/extractors/responsive.js +132 -0
- package/src/extractors/scoring.js +132 -0
- package/src/formatters/markdown.js +178 -0
- package/src/index.js +13 -0
- package/src/multibrand.js +151 -0
- package/src/sync.js +69 -0
- package/src/watch.js +47 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Responsive multi-breakpoint capture — extract at multiple viewports and diff
|
|
2
|
+
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
|
|
5
|
+
const VIEWPORTS = [
|
|
6
|
+
{ name: 'mobile', width: 375, height: 812 },
|
|
7
|
+
{ name: 'tablet', width: 768, height: 1024 },
|
|
8
|
+
{ name: 'desktop', width: 1280, height: 800 },
|
|
9
|
+
{ name: 'wide', width: 1920, height: 1080 },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export async function captureResponsive(url, options = {}) {
|
|
13
|
+
const { wait = 0 } = options;
|
|
14
|
+
const browser = await chromium.launch({ headless: true });
|
|
15
|
+
|
|
16
|
+
const snapshots = [];
|
|
17
|
+
|
|
18
|
+
for (const vp of VIEWPORTS) {
|
|
19
|
+
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height } });
|
|
20
|
+
const page = await context.newPage();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
|
|
24
|
+
if (wait > 0) await page.waitForTimeout(wait);
|
|
25
|
+
await page.evaluate(() => document.fonts.ready);
|
|
26
|
+
|
|
27
|
+
const data = await page.evaluate(() => {
|
|
28
|
+
const body = document.body;
|
|
29
|
+
const cs = getComputedStyle(body);
|
|
30
|
+
const html = document.documentElement;
|
|
31
|
+
const htmlCs = getComputedStyle(html);
|
|
32
|
+
|
|
33
|
+
// Collect key metrics at this viewport
|
|
34
|
+
const headings = {};
|
|
35
|
+
for (let i = 1; i <= 3; i++) {
|
|
36
|
+
const h = document.querySelector(`h1, h2, h3`.split(',')[i - 1]);
|
|
37
|
+
if (h) {
|
|
38
|
+
const hcs = getComputedStyle(h);
|
|
39
|
+
headings[`h${i}`] = { fontSize: hcs.fontSize, lineHeight: hcs.lineHeight };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Body font size
|
|
44
|
+
const bodyFontSize = cs.fontSize;
|
|
45
|
+
|
|
46
|
+
// Navigation visibility
|
|
47
|
+
const nav = document.querySelector('nav, [role="navigation"]');
|
|
48
|
+
const navVisible = nav ? getComputedStyle(nav).display !== 'none' : false;
|
|
49
|
+
const navHeight = nav ? nav.getBoundingClientRect().height : 0;
|
|
50
|
+
|
|
51
|
+
// Count visible grid/flex containers
|
|
52
|
+
let gridCount = 0, flexCount = 0;
|
|
53
|
+
const allEls = document.querySelectorAll('*');
|
|
54
|
+
const sample = Array.from(allEls).slice(0, 2000);
|
|
55
|
+
for (const el of sample) {
|
|
56
|
+
const d = getComputedStyle(el).display;
|
|
57
|
+
if (d === 'grid' || d === 'inline-grid') gridCount++;
|
|
58
|
+
if (d === 'flex' || d === 'inline-flex') flexCount++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Count columns in grids
|
|
62
|
+
const grids = document.querySelectorAll('*');
|
|
63
|
+
let maxColumns = 0;
|
|
64
|
+
for (const el of Array.from(grids).slice(0, 1000)) {
|
|
65
|
+
const gcs = getComputedStyle(el);
|
|
66
|
+
if (gcs.display === 'grid' && gcs.gridTemplateColumns && gcs.gridTemplateColumns !== 'none') {
|
|
67
|
+
const cols = gcs.gridTemplateColumns.split(/\s+/).length;
|
|
68
|
+
if (cols > maxColumns) maxColumns = cols;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check for hamburger menu
|
|
73
|
+
const hamburger = document.querySelector('[class*="hamburger"], [class*="menu-toggle"], [aria-label*="menu"], button[class*="mobile"]');
|
|
74
|
+
const hasHamburger = hamburger ? getComputedStyle(hamburger).display !== 'none' : false;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
bodyFontSize,
|
|
78
|
+
headings,
|
|
79
|
+
navVisible,
|
|
80
|
+
navHeight,
|
|
81
|
+
gridCount,
|
|
82
|
+
flexCount,
|
|
83
|
+
maxColumns,
|
|
84
|
+
hasHamburger,
|
|
85
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
snapshots.push({ ...vp, ...data });
|
|
90
|
+
} catch {
|
|
91
|
+
snapshots.push({ ...vp, error: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await context.close();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await browser.close();
|
|
98
|
+
|
|
99
|
+
// Build responsive map — what changes between breakpoints
|
|
100
|
+
const changes = [];
|
|
101
|
+
for (let i = 1; i < snapshots.length; i++) {
|
|
102
|
+
const prev = snapshots[i - 1];
|
|
103
|
+
const curr = snapshots[i];
|
|
104
|
+
if (prev.error || curr.error) continue;
|
|
105
|
+
|
|
106
|
+
const diffs = [];
|
|
107
|
+
if (prev.bodyFontSize !== curr.bodyFontSize) {
|
|
108
|
+
diffs.push({ property: 'Body font size', from: prev.bodyFontSize, to: curr.bodyFontSize });
|
|
109
|
+
}
|
|
110
|
+
if (prev.headings?.h1?.fontSize !== curr.headings?.h1?.fontSize) {
|
|
111
|
+
diffs.push({ property: 'H1 size', from: prev.headings?.h1?.fontSize || 'n/a', to: curr.headings?.h1?.fontSize || 'n/a' });
|
|
112
|
+
}
|
|
113
|
+
if (prev.navVisible !== curr.navVisible) {
|
|
114
|
+
diffs.push({ property: 'Nav visibility', from: prev.navVisible ? 'visible' : 'hidden', to: curr.navVisible ? 'visible' : 'hidden' });
|
|
115
|
+
}
|
|
116
|
+
if (prev.hasHamburger !== curr.hasHamburger) {
|
|
117
|
+
diffs.push({ property: 'Hamburger menu', from: prev.hasHamburger ? 'shown' : 'hidden', to: curr.hasHamburger ? 'shown' : 'hidden' });
|
|
118
|
+
}
|
|
119
|
+
if (prev.maxColumns !== curr.maxColumns) {
|
|
120
|
+
diffs.push({ property: 'Max grid columns', from: String(prev.maxColumns), to: String(curr.maxColumns) });
|
|
121
|
+
}
|
|
122
|
+
if (Math.abs(prev.scrollHeight - curr.scrollHeight) > 200) {
|
|
123
|
+
diffs.push({ property: 'Page height', from: `${prev.scrollHeight}px`, to: `${curr.scrollHeight}px` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (diffs.length > 0) {
|
|
127
|
+
changes.push({ from: prev.name, to: curr.name, breakpoint: `${prev.width}px → ${curr.width}px`, diffs });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { viewports: snapshots.filter(s => !s.error), changes };
|
|
132
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Design system scoring — rate consistency and quality
|
|
2
|
+
|
|
3
|
+
export function scoreDesignSystem(design) {
|
|
4
|
+
const scores = {};
|
|
5
|
+
const issues = [];
|
|
6
|
+
|
|
7
|
+
// 1. Color discipline (0-100)
|
|
8
|
+
// Fewer unique colors = more disciplined
|
|
9
|
+
const colorCount = design.colors.all.length;
|
|
10
|
+
if (colorCount <= 8) scores.colorDiscipline = 100;
|
|
11
|
+
else if (colorCount <= 15) scores.colorDiscipline = 85;
|
|
12
|
+
else if (colorCount <= 25) scores.colorDiscipline = 70;
|
|
13
|
+
else if (colorCount <= 40) scores.colorDiscipline = 50;
|
|
14
|
+
else { scores.colorDiscipline = 30; issues.push(`${colorCount} unique colors detected — consider consolidating to a tighter palette`); }
|
|
15
|
+
|
|
16
|
+
if (!design.colors.primary) {
|
|
17
|
+
scores.colorDiscipline -= 15;
|
|
18
|
+
issues.push('No clear primary brand color detected');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 2. Typography consistency (0-100)
|
|
22
|
+
const fontCount = design.typography.families.length;
|
|
23
|
+
if (fontCount <= 2) scores.typographyConsistency = 100;
|
|
24
|
+
else if (fontCount <= 3) scores.typographyConsistency = 80;
|
|
25
|
+
else { scores.typographyConsistency = 50; issues.push(`${fontCount} font families — consider limiting to 2 (heading + body)`); }
|
|
26
|
+
|
|
27
|
+
const weightCount = design.typography.weights.length;
|
|
28
|
+
if (weightCount <= 3) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
|
|
29
|
+
else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
|
|
30
|
+
else { scores.typographyConsistency -= 15; issues.push(`${weightCount} font weights in use — consider standardizing to 3 (regular, medium, bold)`); }
|
|
31
|
+
|
|
32
|
+
const scaleSize = design.typography.scale.length;
|
|
33
|
+
if (scaleSize <= 6) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
|
|
34
|
+
else if (scaleSize <= 10) scores.typographyConsistency = Math.min(scores.typographyConsistency, 85);
|
|
35
|
+
else { scores.typographyConsistency -= 10; issues.push(`${scaleSize} distinct font sizes — consider a tighter type scale`); }
|
|
36
|
+
|
|
37
|
+
// 3. Spacing system (0-100)
|
|
38
|
+
if (design.spacing.base) {
|
|
39
|
+
scores.spacingSystem = 90;
|
|
40
|
+
// Check how many values fit the base
|
|
41
|
+
const fittingValues = design.spacing.scale.filter(v => v % design.spacing.base === 0).length;
|
|
42
|
+
const fitRatio = fittingValues / design.spacing.scale.length;
|
|
43
|
+
if (fitRatio >= 0.8) scores.spacingSystem = 100;
|
|
44
|
+
else if (fitRatio >= 0.6) scores.spacingSystem = 80;
|
|
45
|
+
else scores.spacingSystem = 65;
|
|
46
|
+
} else {
|
|
47
|
+
scores.spacingSystem = 40;
|
|
48
|
+
issues.push('No consistent spacing base unit detected — values appear arbitrary');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (design.spacing.scale.length > 20) {
|
|
52
|
+
scores.spacingSystem -= 15;
|
|
53
|
+
issues.push(`${design.spacing.scale.length} unique spacing values — too many one-off values`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 4. Shadow consistency (0-100)
|
|
57
|
+
const shadowCount = design.shadows.values.length;
|
|
58
|
+
if (shadowCount === 0) scores.shadowConsistency = 80; // no shadows is fine
|
|
59
|
+
else if (shadowCount <= 4) scores.shadowConsistency = 100;
|
|
60
|
+
else if (shadowCount <= 8) scores.shadowConsistency = 75;
|
|
61
|
+
else { scores.shadowConsistency = 50; issues.push(`${shadowCount} unique shadows — consider a 3-level elevation scale (sm/md/lg)`); }
|
|
62
|
+
|
|
63
|
+
// 5. Border radius consistency (0-100)
|
|
64
|
+
const radiiCount = design.borders.radii.length;
|
|
65
|
+
if (radiiCount <= 3) scores.radiusConsistency = 100;
|
|
66
|
+
else if (radiiCount <= 5) scores.radiusConsistency = 85;
|
|
67
|
+
else if (radiiCount <= 8) scores.radiusConsistency = 65;
|
|
68
|
+
else { scores.radiusConsistency = 40; issues.push(`${radiiCount} unique border radii — standardize to 3-4 values`); }
|
|
69
|
+
|
|
70
|
+
// 6. Accessibility (from existing extractor)
|
|
71
|
+
scores.accessibility = design.accessibility?.score || 0;
|
|
72
|
+
if (design.accessibility?.failCount > 0) {
|
|
73
|
+
issues.push(`${design.accessibility.failCount} WCAG contrast failures`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 7. CSS variable usage (0-100)
|
|
77
|
+
const varCount = Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0);
|
|
78
|
+
if (varCount >= 20) scores.tokenization = 100;
|
|
79
|
+
else if (varCount >= 10) scores.tokenization = 75;
|
|
80
|
+
else if (varCount >= 1) scores.tokenization = 50;
|
|
81
|
+
else { scores.tokenization = 20; issues.push('No CSS custom properties found — design is not tokenized'); }
|
|
82
|
+
|
|
83
|
+
// Overall score (weighted average)
|
|
84
|
+
const weights = {
|
|
85
|
+
colorDiscipline: 20,
|
|
86
|
+
typographyConsistency: 20,
|
|
87
|
+
spacingSystem: 20,
|
|
88
|
+
shadowConsistency: 10,
|
|
89
|
+
radiusConsistency: 10,
|
|
90
|
+
accessibility: 15,
|
|
91
|
+
tokenization: 5,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let totalWeight = 0;
|
|
95
|
+
let weightedSum = 0;
|
|
96
|
+
for (const [key, weight] of Object.entries(weights)) {
|
|
97
|
+
if (scores[key] !== undefined) {
|
|
98
|
+
weightedSum += Math.max(0, scores[key]) * weight;
|
|
99
|
+
totalWeight += weight;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const overall = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0;
|
|
104
|
+
|
|
105
|
+
// Grade
|
|
106
|
+
let grade;
|
|
107
|
+
if (overall >= 90) grade = 'A';
|
|
108
|
+
else if (overall >= 80) grade = 'B';
|
|
109
|
+
else if (overall >= 70) grade = 'C';
|
|
110
|
+
else if (overall >= 60) grade = 'D';
|
|
111
|
+
else grade = 'F';
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
overall,
|
|
115
|
+
grade,
|
|
116
|
+
scores,
|
|
117
|
+
issues,
|
|
118
|
+
strengths: getStrengths(scores),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getStrengths(scores) {
|
|
123
|
+
const strengths = [];
|
|
124
|
+
if (scores.colorDiscipline >= 85) strengths.push('Tight, disciplined color palette');
|
|
125
|
+
if (scores.typographyConsistency >= 85) strengths.push('Consistent typography system');
|
|
126
|
+
if (scores.spacingSystem >= 85) strengths.push('Well-defined spacing scale');
|
|
127
|
+
if (scores.shadowConsistency >= 85) strengths.push('Clean elevation system');
|
|
128
|
+
if (scores.radiusConsistency >= 85) strengths.push('Consistent border radii');
|
|
129
|
+
if (scores.accessibility >= 90) strengths.push('Strong accessibility compliance');
|
|
130
|
+
if (scores.tokenization >= 75) strengths.push('Good CSS variable tokenization');
|
|
131
|
+
return strengths;
|
|
132
|
+
}
|
|
@@ -259,6 +259,153 @@ export function formatMarkdown(design) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// ── Layout ──
|
|
263
|
+
if (design.layout) {
|
|
264
|
+
const l = design.layout;
|
|
265
|
+
lines.push('## Layout System');
|
|
266
|
+
lines.push('');
|
|
267
|
+
lines.push(`**${l.gridCount} grid containers** and **${l.flexCount} flex containers** detected.`);
|
|
268
|
+
lines.push('');
|
|
269
|
+
|
|
270
|
+
if (l.containerWidths.length > 0) {
|
|
271
|
+
lines.push('### Container Widths');
|
|
272
|
+
lines.push('');
|
|
273
|
+
lines.push('| Max Width | Padding |');
|
|
274
|
+
lines.push('|-----------|---------|');
|
|
275
|
+
for (const c of l.containerWidths) {
|
|
276
|
+
lines.push(`| ${c.maxWidth} | ${c.padding} |`);
|
|
277
|
+
}
|
|
278
|
+
lines.push('');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (l.gridColumns.length > 0) {
|
|
282
|
+
lines.push('### Grid Column Patterns');
|
|
283
|
+
lines.push('');
|
|
284
|
+
lines.push('| Columns | Usage Count |');
|
|
285
|
+
lines.push('|---------|-------------|');
|
|
286
|
+
for (const g of l.gridColumns) {
|
|
287
|
+
lines.push(`| ${g.columns}-column | ${g.count}x |`);
|
|
288
|
+
}
|
|
289
|
+
lines.push('');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (l.topGrids.length > 0) {
|
|
293
|
+
lines.push('### Grid Templates');
|
|
294
|
+
lines.push('');
|
|
295
|
+
lines.push('```css');
|
|
296
|
+
for (const g of l.topGrids) {
|
|
297
|
+
lines.push(`grid-template-columns: ${g.columns};`);
|
|
298
|
+
if (g.gap !== 'normal' && g.gap !== '0px') lines.push(`gap: ${g.gap};`);
|
|
299
|
+
}
|
|
300
|
+
lines.push('```');
|
|
301
|
+
lines.push('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (Object.keys(l.flexDirections).length > 0) {
|
|
305
|
+
lines.push('### Flex Patterns');
|
|
306
|
+
lines.push('');
|
|
307
|
+
lines.push('| Direction/Wrap | Count |');
|
|
308
|
+
lines.push('|----------------|-------|');
|
|
309
|
+
for (const [pattern, count] of Object.entries(l.flexDirections)) {
|
|
310
|
+
lines.push(`| ${pattern} | ${count}x |`);
|
|
311
|
+
}
|
|
312
|
+
lines.push('');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (l.gaps.length > 0) {
|
|
316
|
+
lines.push(`**Gap values:** ${l.gaps.map(g => `\`${g}\``).join(', ')}`);
|
|
317
|
+
lines.push('');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Responsive ──
|
|
322
|
+
if (design.responsive) {
|
|
323
|
+
const r = design.responsive;
|
|
324
|
+
lines.push('## Responsive Design');
|
|
325
|
+
lines.push('');
|
|
326
|
+
|
|
327
|
+
if (r.viewports.length > 0) {
|
|
328
|
+
lines.push('### Viewport Snapshots');
|
|
329
|
+
lines.push('');
|
|
330
|
+
lines.push('| Viewport | Body Font | Nav Visible | Max Columns | Hamburger | Page Height |');
|
|
331
|
+
lines.push('|----------|-----------|-------------|-------------|-----------|-------------|');
|
|
332
|
+
for (const vp of r.viewports) {
|
|
333
|
+
lines.push(`| ${vp.name} (${vp.width}px) | ${vp.bodyFontSize} | ${vp.navVisible ? 'Yes' : 'No'} | ${vp.maxColumns} | ${vp.hasHamburger ? 'Yes' : 'No'} | ${vp.scrollHeight}px |`);
|
|
334
|
+
}
|
|
335
|
+
lines.push('');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (r.changes.length > 0) {
|
|
339
|
+
lines.push('### Breakpoint Changes');
|
|
340
|
+
lines.push('');
|
|
341
|
+
for (const change of r.changes) {
|
|
342
|
+
lines.push(`**${change.breakpoint}** (${change.from} → ${change.to}):`);
|
|
343
|
+
for (const d of change.diffs) {
|
|
344
|
+
lines.push(`- ${d.property}: \`${d.from}\` → \`${d.to}\``);
|
|
345
|
+
}
|
|
346
|
+
lines.push('');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Interaction States ──
|
|
352
|
+
if (design.interactions) {
|
|
353
|
+
const hasContent = design.interactions.buttons.length > 0 || design.interactions.links.length > 0 || design.interactions.inputs.length > 0;
|
|
354
|
+
if (hasContent) {
|
|
355
|
+
lines.push('## Interaction States');
|
|
356
|
+
lines.push('');
|
|
357
|
+
|
|
358
|
+
if (design.interactions.buttons.length > 0) {
|
|
359
|
+
lines.push('### Button States');
|
|
360
|
+
lines.push('');
|
|
361
|
+
for (const btn of design.interactions.buttons.slice(0, 3)) {
|
|
362
|
+
lines.push(`**"${btn.text}"**`);
|
|
363
|
+
if (Object.keys(btn.hover).length > 0) {
|
|
364
|
+
lines.push('```css');
|
|
365
|
+
lines.push('/* Hover */');
|
|
366
|
+
for (const [prop, val] of Object.entries(btn.hover)) {
|
|
367
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
368
|
+
}
|
|
369
|
+
lines.push('```');
|
|
370
|
+
}
|
|
371
|
+
if (Object.keys(btn.focus).length > 0) {
|
|
372
|
+
lines.push('```css');
|
|
373
|
+
lines.push('/* Focus */');
|
|
374
|
+
for (const [prop, val] of Object.entries(btn.focus)) {
|
|
375
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
376
|
+
}
|
|
377
|
+
lines.push('```');
|
|
378
|
+
}
|
|
379
|
+
lines.push('');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (design.interactions.links.length > 0) {
|
|
384
|
+
lines.push('### Link Hover');
|
|
385
|
+
lines.push('');
|
|
386
|
+
const link = design.interactions.links[0];
|
|
387
|
+
lines.push('```css');
|
|
388
|
+
for (const [prop, val] of Object.entries(link.hover)) {
|
|
389
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
390
|
+
}
|
|
391
|
+
lines.push('```');
|
|
392
|
+
lines.push('');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (design.interactions.inputs.length > 0) {
|
|
396
|
+
lines.push('### Input Focus');
|
|
397
|
+
lines.push('');
|
|
398
|
+
const input = design.interactions.inputs[0];
|
|
399
|
+
lines.push('```css');
|
|
400
|
+
for (const [prop, val] of Object.entries(input.focus)) {
|
|
401
|
+
lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
|
|
402
|
+
}
|
|
403
|
+
lines.push('```');
|
|
404
|
+
lines.push('');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
262
409
|
// ── Accessibility ──
|
|
263
410
|
if (design.accessibility) {
|
|
264
411
|
const a = design.accessibility;
|
|
@@ -322,6 +469,37 @@ export function formatMarkdown(design) {
|
|
|
322
469
|
}
|
|
323
470
|
}
|
|
324
471
|
|
|
472
|
+
// ── Design Score ──
|
|
473
|
+
if (design.score) {
|
|
474
|
+
const s = design.score;
|
|
475
|
+
lines.push('## Design System Score');
|
|
476
|
+
lines.push('');
|
|
477
|
+
lines.push(`**Overall: ${s.overall}/100 (Grade: ${s.grade})**`);
|
|
478
|
+
lines.push('');
|
|
479
|
+
lines.push('| Category | Score |');
|
|
480
|
+
lines.push('|----------|-------|');
|
|
481
|
+
if (s.scores.colorDiscipline !== undefined) lines.push(`| Color Discipline | ${s.scores.colorDiscipline}/100 |`);
|
|
482
|
+
if (s.scores.typographyConsistency !== undefined) lines.push(`| Typography Consistency | ${s.scores.typographyConsistency}/100 |`);
|
|
483
|
+
if (s.scores.spacingSystem !== undefined) lines.push(`| Spacing System | ${s.scores.spacingSystem}/100 |`);
|
|
484
|
+
if (s.scores.shadowConsistency !== undefined) lines.push(`| Shadow Consistency | ${s.scores.shadowConsistency}/100 |`);
|
|
485
|
+
if (s.scores.radiusConsistency !== undefined) lines.push(`| Border Radius Consistency | ${s.scores.radiusConsistency}/100 |`);
|
|
486
|
+
if (s.scores.accessibility !== undefined) lines.push(`| Accessibility | ${s.scores.accessibility}/100 |`);
|
|
487
|
+
if (s.scores.tokenization !== undefined) lines.push(`| CSS Tokenization | ${s.scores.tokenization}/100 |`);
|
|
488
|
+
lines.push('');
|
|
489
|
+
|
|
490
|
+
if (s.strengths.length > 0) {
|
|
491
|
+
lines.push('**Strengths:** ' + s.strengths.join(', '));
|
|
492
|
+
lines.push('');
|
|
493
|
+
}
|
|
494
|
+
if (s.issues.length > 0) {
|
|
495
|
+
lines.push('**Issues:**');
|
|
496
|
+
for (const issue of s.issues) {
|
|
497
|
+
lines.push(`- ${issue}`);
|
|
498
|
+
}
|
|
499
|
+
lines.push('');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
325
503
|
// ── Quick Start ──
|
|
326
504
|
lines.push('## Quick Start');
|
|
327
505
|
lines.push('');
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { extractBreakpoints } from './extractors/breakpoints.js';
|
|
|
9
9
|
import { extractAnimations } from './extractors/animations.js';
|
|
10
10
|
import { extractComponents } from './extractors/components.js';
|
|
11
11
|
import { extractAccessibility } from './extractors/accessibility.js';
|
|
12
|
+
import { extractLayout } from './extractors/layout.js';
|
|
13
|
+
import { scoreDesignSystem } from './extractors/scoring.js';
|
|
12
14
|
|
|
13
15
|
export async function extractDesignLanguage(url, options = {}) {
|
|
14
16
|
const rawData = await crawlPage(url, options);
|
|
@@ -32,7 +34,9 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
32
34
|
animations: extractAnimations(styles, rawData.light.keyframes),
|
|
33
35
|
components: extractComponents(styles),
|
|
34
36
|
accessibility: extractAccessibility(styles),
|
|
37
|
+
layout: extractLayout(styles),
|
|
35
38
|
componentScreenshots: rawData.componentScreenshots || {},
|
|
39
|
+
score: null, // populated below
|
|
36
40
|
};
|
|
37
41
|
|
|
38
42
|
if (rawData.dark) {
|
|
@@ -42,6 +46,8 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
42
46
|
};
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
design.score = scoreDesignSystem(design);
|
|
50
|
+
|
|
45
51
|
return design;
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -55,3 +61,10 @@ export { formatFigma } from './formatters/figma.js';
|
|
|
55
61
|
export { formatReactTheme, formatShadcnTheme } from './formatters/theme.js';
|
|
56
62
|
export { diffDesigns, formatDiffMarkdown, formatDiffHtml } from './diff.js';
|
|
57
63
|
export { saveSnapshot, getHistory, formatHistoryMarkdown } from './history.js';
|
|
64
|
+
export { captureResponsive } from './extractors/responsive.js';
|
|
65
|
+
export { captureInteractions } from './extractors/interactions.js';
|
|
66
|
+
export { syncDesign } from './sync.js';
|
|
67
|
+
export { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from './multibrand.js';
|
|
68
|
+
export { generateClone } from './clone.js';
|
|
69
|
+
export { scoreDesignSystem } from './extractors/scoring.js';
|
|
70
|
+
export { watchSite } from './watch.js';
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Multi-brand N-site comparison matrix
|
|
2
|
+
|
|
3
|
+
import { extractDesignLanguage } from './index.js';
|
|
4
|
+
|
|
5
|
+
export async function compareBrands(urls, options = {}) {
|
|
6
|
+
const brands = [];
|
|
7
|
+
|
|
8
|
+
for (const url of urls) {
|
|
9
|
+
const normalized = url.startsWith('http') ? url : `https://${url}`;
|
|
10
|
+
try {
|
|
11
|
+
const design = await extractDesignLanguage(normalized, options);
|
|
12
|
+
const hostname = new URL(normalized).hostname.replace(/^www\./, '');
|
|
13
|
+
brands.push({ url: normalized, hostname, design });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
brands.push({ url: normalized, hostname: url, error: err.message });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return brands;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatBrandMatrix(brands) {
|
|
23
|
+
const lines = [];
|
|
24
|
+
const valid = brands.filter(b => !b.error);
|
|
25
|
+
|
|
26
|
+
lines.push('# Multi-Brand Design Comparison');
|
|
27
|
+
lines.push('');
|
|
28
|
+
lines.push(`Comparing ${valid.length} brands.`);
|
|
29
|
+
lines.push('');
|
|
30
|
+
|
|
31
|
+
// Overview table
|
|
32
|
+
const headers = ['Property', ...valid.map(b => b.hostname)];
|
|
33
|
+
lines.push(`| ${headers.join(' | ')} |`);
|
|
34
|
+
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
|
|
35
|
+
|
|
36
|
+
// Primary color
|
|
37
|
+
lines.push(`| Primary Color | ${valid.map(b => `\`${b.design.colors.primary?.hex || 'none'}\``).join(' | ')} |`);
|
|
38
|
+
// Secondary color
|
|
39
|
+
lines.push(`| Secondary Color | ${valid.map(b => `\`${b.design.colors.secondary?.hex || 'none'}\``).join(' | ')} |`);
|
|
40
|
+
// Fonts
|
|
41
|
+
lines.push(`| Fonts | ${valid.map(b => b.design.typography.families.map(f => f.name).join(', ') || 'none').join(' | ')} |`);
|
|
42
|
+
// Color count
|
|
43
|
+
lines.push(`| Color Count | ${valid.map(b => b.design.colors.all.length).join(' | ')} |`);
|
|
44
|
+
// Spacing base
|
|
45
|
+
lines.push(`| Spacing Base | ${valid.map(b => b.design.spacing.base ? `${b.design.spacing.base}px` : 'none').join(' | ')} |`);
|
|
46
|
+
// A11y score
|
|
47
|
+
lines.push(`| A11y Score | ${valid.map(b => b.design.accessibility ? `${b.design.accessibility.score}%` : 'n/a').join(' | ')} |`);
|
|
48
|
+
// Shadows
|
|
49
|
+
lines.push(`| Shadows | ${valid.map(b => b.design.shadows.values.length).join(' | ')} |`);
|
|
50
|
+
// Radii
|
|
51
|
+
lines.push(`| Border Radii | ${valid.map(b => b.design.borders.radii.length).join(' | ')} |`);
|
|
52
|
+
// CSS vars
|
|
53
|
+
lines.push(`| CSS Variables | ${valid.map(b => Object.values(b.design.variables).reduce((s, v) => s + Object.keys(v).length, 0)).join(' | ')} |`);
|
|
54
|
+
// Components
|
|
55
|
+
lines.push(`| Components | ${valid.map(b => Object.keys(b.design.components).join(', ') || 'none').join(' | ')} |`);
|
|
56
|
+
|
|
57
|
+
lines.push('');
|
|
58
|
+
|
|
59
|
+
// Color overlap matrix
|
|
60
|
+
lines.push('## Color Overlap');
|
|
61
|
+
lines.push('');
|
|
62
|
+
if (valid.length >= 2) {
|
|
63
|
+
for (let i = 0; i < valid.length; i++) {
|
|
64
|
+
for (let j = i + 1; j < valid.length; j++) {
|
|
65
|
+
const colorsA = new Set(valid[i].design.colors.all.map(c => c.hex));
|
|
66
|
+
const colorsB = new Set(valid[j].design.colors.all.map(c => c.hex));
|
|
67
|
+
const shared = [...colorsA].filter(c => colorsB.has(c));
|
|
68
|
+
lines.push(`**${valid[i].hostname} vs ${valid[j].hostname}:** ${shared.length} shared colors${shared.length > 0 ? ` (${shared.slice(0, 5).map(c => `\`${c}\``).join(', ')})` : ''}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
lines.push('');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Font comparison
|
|
75
|
+
lines.push('## Typography Comparison');
|
|
76
|
+
lines.push('');
|
|
77
|
+
const allFonts = new Set();
|
|
78
|
+
for (const b of valid) {
|
|
79
|
+
for (const f of b.design.typography.families) allFonts.add(f.name);
|
|
80
|
+
}
|
|
81
|
+
lines.push(`| Font | ${valid.map(b => b.hostname).join(' | ')} |`);
|
|
82
|
+
lines.push(`| --- | ${valid.map(() => '---').join(' | ')} |`);
|
|
83
|
+
for (const font of allFonts) {
|
|
84
|
+
lines.push(`| ${font} | ${valid.map(b => b.design.typography.families.some(f => f.name === font) ? 'Yes' : '-').join(' | ')} |`);
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
|
|
88
|
+
// Errors
|
|
89
|
+
const errored = brands.filter(b => b.error);
|
|
90
|
+
if (errored.length > 0) {
|
|
91
|
+
lines.push('## Errors');
|
|
92
|
+
lines.push('');
|
|
93
|
+
for (const b of errored) {
|
|
94
|
+
lines.push(`- **${b.url}**: ${b.error}`);
|
|
95
|
+
}
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatBrandMatrixHtml(brands) {
|
|
103
|
+
const valid = brands.filter(b => !b.error);
|
|
104
|
+
|
|
105
|
+
const swatchCell = (hex) => hex
|
|
106
|
+
? `<td><span style="display:inline-block;width:16px;height:16px;border-radius:4px;background:${hex};border:1px solid #333;vertical-align:middle;margin-right:6px"></span><code>${hex}</code></td>`
|
|
107
|
+
: `<td>-</td>`;
|
|
108
|
+
|
|
109
|
+
return `<!DOCTYPE html>
|
|
110
|
+
<html><head><meta charset="UTF-8"><title>Multi-Brand Comparison</title>
|
|
111
|
+
<style>
|
|
112
|
+
* { margin:0; padding:0; box-sizing:border-box; }
|
|
113
|
+
body { font-family:-apple-system,sans-serif; background:#0a0a0a; color:#e5e5e5; padding:40px; }
|
|
114
|
+
h1 { font-size:28px; color:#fff; margin-bottom:24px; }
|
|
115
|
+
h2 { font-size:18px; color:#fff; margin:32px 0 12px; }
|
|
116
|
+
table { width:100%; border-collapse:collapse; margin:12px 0; }
|
|
117
|
+
th { text-align:left; padding:10px 12px; background:#141414; color:#888; font-size:12px; text-transform:uppercase; letter-spacing:0.05em; border-bottom:1px solid #222; }
|
|
118
|
+
td { padding:10px 12px; border-bottom:1px solid #1a1a1a; font-size:13px; }
|
|
119
|
+
tr:hover td { background:#111; }
|
|
120
|
+
code { background:#1e1e2e; padding:2px 6px; border-radius:4px; font-size:12px; color:#a78bfa; }
|
|
121
|
+
.score-good { color:#22c55e; } .score-warn { color:#eab308; } .score-bad { color:#ef4444; }
|
|
122
|
+
</style></head><body>
|
|
123
|
+
<h1>Multi-Brand Design Comparison</h1>
|
|
124
|
+
<p style="color:#666;margin-bottom:24px">${valid.length} brands analyzed</p>
|
|
125
|
+
|
|
126
|
+
<table>
|
|
127
|
+
<tr><th>Property</th>${valid.map(b => `<th>${b.hostname}</th>`).join('')}</tr>
|
|
128
|
+
<tr><td>Primary Color</td>${valid.map(b => swatchCell(b.design.colors.primary?.hex)).join('')}</tr>
|
|
129
|
+
<tr><td>Secondary Color</td>${valid.map(b => swatchCell(b.design.colors.secondary?.hex)).join('')}</tr>
|
|
130
|
+
<tr><td>Fonts</td>${valid.map(b => `<td>${b.design.typography.families.map(f => `<code>${f.name}</code>`).join(' ')}</td>`).join('')}</tr>
|
|
131
|
+
<tr><td>Colors</td>${valid.map(b => `<td>${b.design.colors.all.length}</td>`).join('')}</tr>
|
|
132
|
+
<tr><td>Spacing Base</td>${valid.map(b => `<td>${b.design.spacing.base ? b.design.spacing.base + 'px' : '-'}</td>`).join('')}</tr>
|
|
133
|
+
<tr><td>A11y Score</td>${valid.map(b => {
|
|
134
|
+
const s = b.design.accessibility?.score;
|
|
135
|
+
const cls = s >= 80 ? 'score-good' : s >= 50 ? 'score-warn' : 'score-bad';
|
|
136
|
+
return `<td class="${cls}">${s ?? 'n/a'}%</td>`;
|
|
137
|
+
}).join('')}</tr>
|
|
138
|
+
<tr><td>Shadows</td>${valid.map(b => `<td>${b.design.shadows.values.length}</td>`).join('')}</tr>
|
|
139
|
+
<tr><td>Border Radii</td>${valid.map(b => `<td>${b.design.borders.radii.length}</td>`).join('')}</tr>
|
|
140
|
+
<tr><td>CSS Variables</td>${valid.map(b => `<td>${Object.values(b.design.variables).reduce((s, v) => s + Object.keys(v).length, 0)}</td>`).join('')}</tr>
|
|
141
|
+
</table>
|
|
142
|
+
|
|
143
|
+
<h2>Full Color Palettes</h2>
|
|
144
|
+
${valid.map(b => `
|
|
145
|
+
<h3 style="color:#888;font-size:14px;margin:16px 0 8px">${b.hostname}</h3>
|
|
146
|
+
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
|
147
|
+
${b.design.colors.all.slice(0, 15).map(c => `<div style="width:32px;height:32px;border-radius:6px;background:${c.hex};border:1px solid #333" title="${c.hex}"></div>`).join('')}
|
|
148
|
+
</div>`).join('')}
|
|
149
|
+
|
|
150
|
+
</body></html>`;
|
|
151
|
+
}
|