dembrandt 0.12.5 → 0.12.6
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 +23 -2
- package/lib/formatters/markdown.js +527 -202
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,11 +45,12 @@ Or add to your project's `.mcp.json`:
|
|
|
45
45
|
|
|
46
46
|
## What to expect from extraction?
|
|
47
47
|
|
|
48
|
-
- Colors (semantic, palette, CSS variables)
|
|
48
|
+
- Colors (semantic, palette, CSS variables, gradients)
|
|
49
49
|
- Typography (fonts, sizes, weights, sources)
|
|
50
50
|
- Spacing (margin/padding scales)
|
|
51
51
|
- Borders (radius, widths, styles, colors)
|
|
52
52
|
- Shadows
|
|
53
|
+
- Motion (duration scale, easing curves, hover patterns per component type)
|
|
53
54
|
- Components (buttons, badges, inputs, links)
|
|
54
55
|
- Breakpoints
|
|
55
56
|
- Icons & frameworks
|
|
@@ -134,13 +135,15 @@ The DTCG format is an industry-standard JSON schema that can be consumed by desi
|
|
|
134
135
|
|
|
135
136
|
### DESIGN.md
|
|
136
137
|
|
|
137
|
-
Use `--design-md` to generate a [DESIGN.md](https://stitch.withgoogle.com/docs/design-md) file, a plain-text design system document readable by AI agents.
|
|
138
|
+
Use `--design-md` to generate a [DESIGN.md](https://stitch.withgoogle.com/docs/design-md) file, a plain-text design system document readable by AI agents. The export follows Google's DESIGN.md draft format: YAML design tokens in front matter plus ordered Markdown guidance sections.
|
|
138
139
|
|
|
139
140
|
```bash
|
|
140
141
|
dembrandt example.com --design-md
|
|
141
142
|
# Saves to: output/example.com/DESIGN.md
|
|
142
143
|
```
|
|
143
144
|
|
|
145
|
+
DESIGN.md reports only what Dembrandt observed on the source site. Exact values (colors, typography, spacing, radii, shadows) live in the YAML front matter when available, and the Markdown body adds human-readable context. Sections with no extracted evidence are omitted rather than filled with invented defaults. For example, the elevation section is dropped when the site uses no box-shadow tokens.
|
|
146
|
+
|
|
144
147
|
### WCAG Contrast Analysis
|
|
145
148
|
|
|
146
149
|
Use `--wcag` to check accessibility contrast ratios across the page. Unlike palette-based checkers, dembrandt walks the actual DOM and finds what color is rendered on top of what background — per element.
|
|
@@ -151,6 +154,24 @@ dembrandt stripe.com --wcag
|
|
|
151
154
|
|
|
152
155
|
Returns every text/background pair with contrast ratio and WCAG 2.1 grade (AA, AA-Large, AAA, or fail), sorted by how often each pair appears. Results are shown in terminal and included in JSON output as `wcag`.
|
|
153
156
|
|
|
157
|
+
Also captures **interactive state contrast**: dembrandt simulates hover, focus, and disabled states on buttons, links, and inputs and checks contrast on each state. State pairs are tagged `[hover]`, `[focus]`, or `[disabled]` in output so you can catch contrast failures that only appear on interaction.
|
|
158
|
+
|
|
159
|
+
### Motion Tokens
|
|
160
|
+
|
|
161
|
+
Motion tokens are extracted automatically on every run — no flag needed. Dembrandt analyzes CSS transitions and animations across the page and returns a structured motion profile.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
dembrandt stripe.com
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
- **Duration scale**: all unique animation durations found on the page
|
|
169
|
+
- **Easing curves**: named easing types (ease-out, spring, custom cubic-bezier) with usage counts
|
|
170
|
+
- **Per-context profiles**: motion behavior by component type (button, nav, card, modal, hero)
|
|
171
|
+
- **Hover interaction deltas**: which properties animate on hover (transform, opacity, background, color) and the pattern (scale-up, fade-in, color-shift, slide-y)
|
|
172
|
+
|
|
173
|
+
Motion data is included in JSON output as `motion` and printed in terminal under a dedicated Motion section.
|
|
174
|
+
|
|
154
175
|
### Brand Guide PDF
|
|
155
176
|
|
|
156
177
|
Use `--brand-guide` to generate a printable PDF summarizing the extracted design system: colors, typography, components, and logo on a single document.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DESIGN.md generator
|
|
3
3
|
*
|
|
4
|
-
* Converts dembrandt extraction results into
|
|
5
|
-
*
|
|
4
|
+
* Converts dembrandt extraction results into Google's DESIGN.md draft format:
|
|
5
|
+
* YAML design tokens in front matter plus ordered markdown rationale sections.
|
|
6
6
|
*/
|
|
7
7
|
import { convertColor, deltaE } from '../colors.js';
|
|
8
8
|
|
|
@@ -11,239 +11,570 @@ import { convertColor, deltaE } from '../colors.js';
|
|
|
11
11
|
* @returns {string} DESIGN.md content
|
|
12
12
|
*/
|
|
13
13
|
export function generateDesignMd(result) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
14
|
+
const domain = getDomain(result);
|
|
15
|
+
const name = getName(result, domain);
|
|
16
|
+
const colorRoles = buildColorRoles(result);
|
|
17
|
+
const typographyTokens = buildTypographyTokens(result);
|
|
18
|
+
const spacingTokens = buildSpacingTokens(result);
|
|
19
|
+
const roundedTokens = buildRoundedTokens(result);
|
|
20
|
+
const componentTokens = buildComponentTokens(result, colorRoles, roundedTokens);
|
|
21
|
+
|
|
22
|
+
const frontMatter = compactObject({
|
|
23
|
+
name,
|
|
24
|
+
description: `Design tokens extracted from ${result.url ?? domain}`,
|
|
25
|
+
colors: hasKeys(colorRoles) ? colorRoles : null,
|
|
26
|
+
typography: hasKeys(typographyTokens) ? typographyTokens : null,
|
|
27
|
+
spacing: hasKeys(spacingTokens) ? spacingTokens : null,
|
|
28
|
+
rounded: hasKeys(roundedTokens) ? roundedTokens : null,
|
|
29
|
+
components: hasKeys(componentTokens) ? componentTokens : null,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const sections = [
|
|
33
|
+
'# Design System',
|
|
34
|
+
buildOverviewSection(domain),
|
|
35
|
+
hasKeys(colorRoles) ? buildColorsSection(colorRoles) : null,
|
|
36
|
+
hasKeys(typographyTokens) ? buildTypographySection(result, typographyTokens) : null,
|
|
37
|
+
hasLayoutEvidence(result, spacingTokens) ? buildLayoutSection(result, spacingTokens) : null,
|
|
38
|
+
hasElevationEvidence(result) ? buildElevationSection(result) : null,
|
|
39
|
+
hasKeys(roundedTokens) ? buildShapesSection(roundedTokens) : null,
|
|
40
|
+
hasComponentEvidence(result) ? buildComponentsSection(result) : null,
|
|
41
|
+
].filter(Boolean);
|
|
42
|
+
|
|
43
|
+
return `---\n${toYaml(frontMatter)}---\n\n${sections.join('\n\n')}\n`;
|
|
44
|
+
}
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
function getDomain(result) {
|
|
47
|
+
try {
|
|
48
|
+
return new URL(result.url).hostname.replace('www.', '');
|
|
49
|
+
} catch {
|
|
50
|
+
return result.url ?? 'unknown';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getName(result, domain) {
|
|
55
|
+
return result.siteName?.trim() || domain;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildOverviewSection(domain) {
|
|
59
|
+
return `## Overview\nDesign tokens extracted from ${domain}. The YAML front matter contains machine-readable values observed by Dembrandt when available; the sections below summarize the extracted evidence without redesigning or correcting the source site.`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildColorRoles(result) {
|
|
63
|
+
const semantic = result.colors?.semantic;
|
|
64
|
+
const palette = result.colors?.palette;
|
|
65
|
+
|
|
66
|
+
const allCandidates = new Map();
|
|
67
|
+
const addCandidate = (raw, source) => {
|
|
68
|
+
if (raw == null) return;
|
|
69
|
+
if (isTransparentColor(raw)) return;
|
|
70
|
+
const parsed = convertColor(String(raw));
|
|
71
|
+
if (!parsed) return;
|
|
72
|
+
const hex = normalizeHex(parsed.hex);
|
|
73
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
74
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
75
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
76
|
+
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
77
|
+
const sat = Math.max(r, g, b) - Math.min(r, g, b);
|
|
78
|
+
if (!allCandidates.has(hex)) allCandidates.set(hex, { hex, lum, sat, source });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const highConf = palette?.filter(c => c.confidence === 'high' || c.confidence === 'medium') ?? [];
|
|
82
|
+
for (const c of (highConf.length ? highConf : palette ?? [])) addCandidate(c.normalized || c.color, 'palette');
|
|
83
|
+
for (const btn of result.components?.buttons ?? []) {
|
|
84
|
+
const state = btn.states?.default ?? btn;
|
|
85
|
+
const bg = state.backgroundColor;
|
|
86
|
+
if (bg && bg !== 'transparent' && !isTransparentColor(bg)) addCandidate(bg, 'button');
|
|
87
|
+
}
|
|
88
|
+
for (const link of result.components?.links ?? []) addCandidate(link.color, 'link');
|
|
89
|
+
|
|
90
|
+
const roles = {};
|
|
91
|
+
if (semantic && Object.values(semantic).some(Boolean)) {
|
|
92
|
+
for (const [role, val] of Object.entries(semantic)) {
|
|
93
|
+
const hex = toHex(typeof val === 'string' ? val : val?.color);
|
|
94
|
+
if (hex) roles[sanitizeTokenName(role)] = hex;
|
|
58
95
|
}
|
|
96
|
+
}
|
|
59
97
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (hex) paletteConf.set(hex, confScore[c.confidence] ?? 0);
|
|
68
|
-
}
|
|
98
|
+
if (allCandidates.size) {
|
|
99
|
+
const confScore = { high: 3, medium: 2, low: 1 };
|
|
100
|
+
const paletteConf = new Map();
|
|
101
|
+
for (const c of palette ?? []) {
|
|
102
|
+
const hex = toHex(c.normalized || c.color);
|
|
103
|
+
if (hex) paletteConf.set(hex, confScore[c.confidence] ?? 0);
|
|
104
|
+
}
|
|
69
105
|
|
|
70
|
-
|
|
106
|
+
const ranked = Array.from(allCandidates.values())
|
|
107
|
+
.map(c => ({
|
|
71
108
|
...c,
|
|
72
|
-
// For primary/secondary selection, saturation dominates — a grey with high
|
|
73
|
-
// palette confidence should not beat a vivid brand color from buttons/links.
|
|
74
|
-
// Saturation scaled 0–1, confidence adds a small tiebreaker.
|
|
75
109
|
rank: c.sat * 100 + (paletteConf.get(c.hex) ?? 0),
|
|
76
|
-
}))
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
110
|
+
}))
|
|
111
|
+
.sort((a, b) => b.rank - a.rank);
|
|
112
|
+
|
|
113
|
+
const deduped = [];
|
|
114
|
+
for (const c of ranked) {
|
|
115
|
+
const tooClose = deduped.some(d => deltaE(c.hex, d.hex) < 15);
|
|
116
|
+
if (!tooClose) deduped.push(c);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const used = new Set(Object.values(roles).map(h => h?.toLowerCase()));
|
|
120
|
+
const byRank = [...deduped].sort((a, b) => b.rank - a.rank);
|
|
121
|
+
const byLum = [...deduped].sort((a, b) => a.lum - b.lum);
|
|
122
|
+
|
|
123
|
+
const pick = (arr) => {
|
|
124
|
+
const c = arr.find(x => !used.has(x.hex.toLowerCase()));
|
|
125
|
+
if (c) {
|
|
126
|
+
used.add(c.hex.toLowerCase());
|
|
127
|
+
return c.hex;
|
|
84
128
|
}
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
85
131
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
132
|
+
if (!roles.primary) roles.primary = pick(byRank);
|
|
133
|
+
if (!roles.secondary) roles.secondary = pick(byRank);
|
|
134
|
+
if (!roles.surface) roles.surface = pick([...byLum].reverse());
|
|
135
|
+
if (!roles['on-surface']) roles['on-surface'] = pick(byLum);
|
|
136
|
+
}
|
|
89
137
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
138
|
+
return compactObject({
|
|
139
|
+
primary: roles.primary,
|
|
140
|
+
secondary: roles.secondary,
|
|
141
|
+
tertiary: roles.tertiary ?? roles.accent,
|
|
142
|
+
surface: roles.surface ?? roles.background,
|
|
143
|
+
'on-surface': roles['on-surface'] ?? roles.text,
|
|
144
|
+
error: roles.error,
|
|
145
|
+
}) ?? {};
|
|
146
|
+
}
|
|
95
147
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
148
|
+
function buildTypographyTokens(result) {
|
|
149
|
+
const styles = result.typography?.styles ?? [];
|
|
150
|
+
const tokens = {};
|
|
151
|
+
const usedNames = new Set();
|
|
152
|
+
|
|
153
|
+
for (const [index, style] of styles.entries()) {
|
|
154
|
+
const token = compactObject({
|
|
155
|
+
fontFamily: normalizeFontFamily(style.fontFamily ?? style.family),
|
|
156
|
+
fontSize: normalizeDimension(style.fontSize ?? style.size),
|
|
157
|
+
fontWeight: normalizeFontWeight(style.fontWeight ?? style.weight),
|
|
158
|
+
lineHeight: normalizeLineHeight(style.lineHeight),
|
|
159
|
+
letterSpacing: normalizeDimension(style.letterSpacing),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!token || !token.fontFamily && !token.fontSize) continue;
|
|
163
|
+
|
|
164
|
+
const baseName = typographyTokenName(style, index);
|
|
165
|
+
const name = uniqueTokenName(baseName, usedNames);
|
|
166
|
+
tokens[name] = token;
|
|
167
|
+
}
|
|
101
168
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
secondary: 'Supporting UI, secondary actions',
|
|
105
|
-
surface: 'Page backgrounds',
|
|
106
|
-
'on-surface': 'Primary text',
|
|
107
|
-
background: 'Page backgrounds',
|
|
108
|
-
text: 'Primary text',
|
|
109
|
-
error: 'Validation errors, destructive actions',
|
|
110
|
-
accent: 'Accent highlights, badges',
|
|
111
|
-
};
|
|
169
|
+
return tokens;
|
|
170
|
+
}
|
|
112
171
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
lines.push(`- **${capitalize(role)}** (${hex})${hint ? `: ${hint}` : ''}`);
|
|
118
|
-
}
|
|
172
|
+
function buildSpacingTokens(result) {
|
|
173
|
+
const values = new Set();
|
|
174
|
+
const base = normalizeDimension(result.spacing?.scaleType);
|
|
175
|
+
if (base) values.add(base);
|
|
119
176
|
|
|
120
|
-
|
|
121
|
-
|
|
177
|
+
for (const entry of result.spacing?.commonValues ?? []) {
|
|
178
|
+
const value = normalizeDimension(entry.px ?? entry);
|
|
179
|
+
if (value) values.add(value);
|
|
122
180
|
}
|
|
123
181
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const styles = result.typography?.styles ?? [];
|
|
128
|
-
const families = new Map();
|
|
129
|
-
for (const s of styles) {
|
|
130
|
-
if (s.family && !SKIP.test(s.family) && !families.has(s.family)) families.set(s.family, []);
|
|
131
|
-
if (s.family && !SKIP.test(s.family)) families.get(s.family).push(s);
|
|
132
|
-
}
|
|
182
|
+
const sorted = [...values].sort((a, b) => parseFloat(a) - parseFloat(b)).slice(0, 8);
|
|
183
|
+
const names = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl', 'xxxl', 'xxxxl'];
|
|
184
|
+
const tokens = {};
|
|
133
185
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const roleLabels = ['Headlines', 'Body', 'Labels'];
|
|
139
|
-
let i = 0;
|
|
140
|
-
for (const [family, styleList] of families) {
|
|
141
|
-
const role = roleLabels[i] ?? 'Labels';
|
|
142
|
-
// Summarise weights
|
|
143
|
-
const weights = [...new Set(styleList.map(s => s.weight).filter(Boolean))];
|
|
144
|
-
const weightDesc = humanWeight(weights[0]);
|
|
145
|
-
// Summarise sizes
|
|
146
|
-
const sizes = styleList.map(s => parseFloat(s.size)).filter(Boolean).sort((a, b) => a - b);
|
|
147
|
-
const sizeDesc = sizes.length > 1
|
|
148
|
-
? `${sizes[0]}–${sizes[sizes.length - 1]}px`
|
|
149
|
-
: sizes.length === 1 ? `${sizes[0]}px` : '';
|
|
150
|
-
const parts = [family];
|
|
151
|
-
if (weightDesc) parts.push(weightDesc);
|
|
152
|
-
if (sizeDesc) parts.push(sizeDesc);
|
|
153
|
-
lines.push(`- **${role}**: ${parts.join(', ')}`);
|
|
154
|
-
i++;
|
|
155
|
-
}
|
|
156
|
-
sections.push(lines.join('\n'));
|
|
157
|
-
}
|
|
186
|
+
if (base) tokens.base = base;
|
|
187
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
188
|
+
const name = names[i] ?? `space-${i + 1}`;
|
|
189
|
+
if (tokens[name] !== sorted[i]) tokens[name] = sorted[i];
|
|
158
190
|
}
|
|
159
191
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
192
|
+
return tokens;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildRoundedTokens(result) {
|
|
196
|
+
const radii = result.borderRadius?.values ?? [];
|
|
197
|
+
const values = [...new Set(radii
|
|
198
|
+
.map(entry => normalizeRadius(entry.value))
|
|
199
|
+
.filter(Boolean))]
|
|
200
|
+
.sort((a, b) => parseFloat(a) - parseFloat(b));
|
|
201
|
+
|
|
202
|
+
const tokens = {};
|
|
203
|
+
if (values.includes('0px')) tokens.none = '0px';
|
|
204
|
+
|
|
205
|
+
const nonZero = values.filter(value => value !== '0px' && value !== '9999px').slice(0, 4);
|
|
206
|
+
const names = ['sm', 'md', 'lg', 'xl'];
|
|
207
|
+
for (let i = 0; i < nonZero.length; i++) tokens[names[i]] = nonZero[i];
|
|
208
|
+
if (values.includes('9999px')) tokens.full = '9999px';
|
|
209
|
+
|
|
210
|
+
return tokens;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildComponentTokens(result, colorRoles, roundedTokens) {
|
|
214
|
+
const components = {};
|
|
215
|
+
const button = (result.components?.buttons ?? [])
|
|
216
|
+
.map(btn => btn.states?.default ?? btn)
|
|
217
|
+
.find(btn => btn.backgroundColor && !isTransparentColor(btn.backgroundColor));
|
|
218
|
+
|
|
219
|
+
if (button) {
|
|
220
|
+
const background = tokenReferenceForColor(button.backgroundColor, colorRoles);
|
|
221
|
+
const text = tokenReferenceForColor(button.color ?? button.textColor, colorRoles);
|
|
222
|
+
const rounded = tokenReferenceForRadius(button.borderRadius, roundedTokens);
|
|
223
|
+
|
|
224
|
+
const token = compactObject({
|
|
225
|
+
backgroundColor: background ?? toHex(button.backgroundColor),
|
|
226
|
+
textColor: text ?? toHex(button.color ?? button.textColor),
|
|
227
|
+
rounded: rounded ?? normalizeRadius(button.borderRadius),
|
|
228
|
+
padding: normalizePadding(button.padding),
|
|
229
|
+
height: normalizeDimension(button.height),
|
|
230
|
+
});
|
|
231
|
+
if (token) components['button-observed'] = token;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const input = firstInput(result.components?.inputs);
|
|
235
|
+
if (input) {
|
|
236
|
+
const state = input.states?.default ?? input;
|
|
237
|
+
const token = compactObject({
|
|
238
|
+
backgroundColor: tokenReferenceForColor(state.backgroundColor, colorRoles) ?? toHex(state.backgroundColor),
|
|
239
|
+
textColor: tokenReferenceForColor(state.color ?? state.textColor, colorRoles) ?? toHex(state.color ?? state.textColor),
|
|
240
|
+
rounded: tokenReferenceForRadius(state.borderRadius, roundedTokens) ?? normalizeRadius(state.borderRadius),
|
|
241
|
+
padding: normalizePadding(state.padding),
|
|
242
|
+
});
|
|
243
|
+
if (token) components['input-observed'] = token;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return components;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildColorsSection(colorRoles) {
|
|
250
|
+
const lines = ['## Colors'];
|
|
251
|
+
for (const [role, hex] of Object.entries(colorRoles)) {
|
|
252
|
+
lines.push(`- **${titleize(role)}** (${hex}): Observed color token extracted from the site's palette, semantic CSS, or component styles.`);
|
|
253
|
+
}
|
|
254
|
+
return lines.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildTypographySection(result, typographyTokens) {
|
|
258
|
+
const lines = ['## Typography'];
|
|
259
|
+
const tokenEntries = Object.entries(typographyTokens).slice(0, 6);
|
|
260
|
+
|
|
261
|
+
for (const [name, token] of tokenEntries) {
|
|
262
|
+
const parts = [token.fontFamily, token.fontSize, humanWeight(token.fontWeight)]
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
lines.push(`- **${titleize(name)}**: ${parts.join(', ')}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (result.typography?.sources?.googleFonts?.length) {
|
|
268
|
+
lines.push(`- **Font source**: Google Fonts (${result.typography.sources.googleFonts.join(', ')})`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildLayoutSection(result, spacingTokens) {
|
|
275
|
+
const scale = result.spacing?.scaleType ? `${result.spacing.scaleType} spacing scale` : null;
|
|
276
|
+
const breakpoints = (result.breakpoints ?? []).map(bp => bp.px).filter(Boolean).slice(0, 6);
|
|
277
|
+
const lines = ['## Layout'];
|
|
278
|
+
|
|
279
|
+
if (scale) {
|
|
280
|
+
lines.push(`Observed spacing scale: ${scale}.`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (Object.keys(spacingTokens).length) {
|
|
284
|
+
lines.push(`- **Spacing tokens**: ${Object.entries(spacingTokens).map(([name, value]) => `${name} ${value}`).join(', ')}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (breakpoints.length) {
|
|
288
|
+
lines.push(`- **Responsive breakpoints**: ${breakpoints.join(', ')}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return lines.join('\n');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function buildElevationSection(result) {
|
|
295
|
+
const shadows = result.shadows ?? [];
|
|
296
|
+
const lines = ['## Elevation & Depth'];
|
|
297
|
+
lines.push(`Observed box-shadow styles: ${shadows.slice(0, 3).map(s => s.shadow).join('; ')}`);
|
|
298
|
+
return lines.join('\n');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildShapesSection(roundedTokens) {
|
|
302
|
+
const lines = ['## Shapes'];
|
|
303
|
+
const rounded = Object.entries(roundedTokens).map(([name, value]) => `${name} ${value}`).join(', ');
|
|
304
|
+
lines.push(`Observed rounded-corner tokens: ${rounded}.`);
|
|
305
|
+
return lines.join('\n');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildComponentsSection(result) {
|
|
309
|
+
const lines = ['## Components'];
|
|
310
|
+
|
|
311
|
+
const button = firstButton(result.components?.buttons);
|
|
312
|
+
if (button) {
|
|
313
|
+
const btn = button.states?.default ?? button;
|
|
314
|
+
const parts = [];
|
|
315
|
+
if (btn.borderRadius) {
|
|
316
|
+
parts.push(`radius ${btn.borderRadius}`);
|
|
317
|
+
}
|
|
318
|
+
if (btn.backgroundColor && !isTransparentColor(btn.backgroundColor)) {
|
|
319
|
+
const hex = toHex(btn.backgroundColor);
|
|
320
|
+
parts.push(`background ${hex ?? btn.backgroundColor}`);
|
|
321
|
+
}
|
|
322
|
+
if (btn.color ?? btn.textColor) parts.push(`text ${toHex(btn.color ?? btn.textColor) ?? (btn.color ?? btn.textColor)}`);
|
|
323
|
+
if (btn.padding) parts.push(`padding ${btn.padding}`);
|
|
324
|
+
if (hasVisibleBorder(btn.border)) parts.push(`border ${btn.border}`);
|
|
325
|
+
if (parts.length) {
|
|
326
|
+
lines.push(`- **Buttons**: Observed sample with ${parts.join(', ')}`);
|
|
179
327
|
}
|
|
328
|
+
}
|
|
180
329
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
330
|
+
const input = firstInput(result.components?.inputs);
|
|
331
|
+
if (input) {
|
|
332
|
+
const inp = input.states?.default ?? input;
|
|
333
|
+
const parts = [];
|
|
334
|
+
if (hasVisibleBorder(inp.border)) parts.push(`${inp.border.split(' ').slice(0, 2).join(' ')} border`);
|
|
335
|
+
if (inp.borderRadius) parts.push(`${inp.borderRadius} radius`);
|
|
336
|
+
if (parts.length) {
|
|
337
|
+
lines.push(`- **Inputs**: Observed sample with ${parts.join(', ')}`);
|
|
189
338
|
}
|
|
339
|
+
}
|
|
190
340
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (radii.length) { lines.push(`- **Border radius scale**: ${radii.join(', ')}`); hasContent = true; }
|
|
341
|
+
return lines.length > 1 ? lines.join('\n') : null;
|
|
342
|
+
}
|
|
194
343
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
344
|
+
function toYaml(value, indent = 0) {
|
|
345
|
+
const pad = ' '.repeat(indent);
|
|
346
|
+
let yaml = '';
|
|
347
|
+
|
|
348
|
+
for (const [key, child] of Object.entries(value)) {
|
|
349
|
+
if (child == null) continue;
|
|
350
|
+
if (typeof child === 'object' && !Array.isArray(child)) {
|
|
351
|
+
if (!Object.keys(child).length) continue;
|
|
352
|
+
yaml += `${pad}${yamlKey(key)}:\n${toYaml(child, indent + 1)}`;
|
|
199
353
|
} else {
|
|
200
|
-
|
|
201
|
-
hasContent = true;
|
|
354
|
+
yaml += `${pad}${yamlKey(key)}: ${yamlScalar(child)}\n`;
|
|
202
355
|
}
|
|
356
|
+
}
|
|
203
357
|
|
|
204
|
-
|
|
358
|
+
return yaml;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function yamlKey(key) {
|
|
362
|
+
return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? key : JSON.stringify(key);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function yamlScalar(value) {
|
|
366
|
+
if (typeof value === 'number') return String(value);
|
|
367
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
368
|
+
return JSON.stringify(String(value));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function typographyTokenName(style, index) {
|
|
372
|
+
const contexts = style.contexts ?? (style.context ? [style.context] : []);
|
|
373
|
+
const normalized = contexts.map(c => String(c).toLowerCase());
|
|
374
|
+
|
|
375
|
+
if (normalized.some(c => /^h1$/.test(c))) return 'headline-display';
|
|
376
|
+
if (normalized.some(c => /^h2$/.test(c))) return 'headline-lg';
|
|
377
|
+
if (normalized.some(c => /^h3$/.test(c))) return 'headline-md';
|
|
378
|
+
if (normalized.some(c => /^h[4-6]$/.test(c))) return 'headline-sm';
|
|
379
|
+
if (normalized.some(c => c === 'button')) return 'label-lg';
|
|
380
|
+
if (normalized.some(c => c === 'a')) return 'label-md';
|
|
381
|
+
if (normalized.some(c => c === 'p' || c === 'div')) return 'body-md';
|
|
382
|
+
|
|
383
|
+
return `text-${index + 1}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function uniqueTokenName(baseName, usedNames) {
|
|
387
|
+
let name = sanitizeTokenName(baseName);
|
|
388
|
+
let suffix = 2;
|
|
389
|
+
while (usedNames.has(name)) {
|
|
390
|
+
name = `${sanitizeTokenName(baseName)}-${suffix}`;
|
|
391
|
+
suffix++;
|
|
205
392
|
}
|
|
393
|
+
usedNames.add(name);
|
|
394
|
+
return name;
|
|
395
|
+
}
|
|
206
396
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (radii.length) {
|
|
216
|
-
const hasZero = radii.some(r => r === 0);
|
|
217
|
-
const hasLarge = radii.some(r => r >= 20);
|
|
218
|
-
if (hasZero && hasLarge) donts.push("Don't mix fully rounded and sharp corners in the same view");
|
|
219
|
-
else if (hasLarge) dos.push('Do use rounded corners consistently across interactive elements');
|
|
220
|
-
else if (hasZero) dos.push('Do maintain sharp corners for a precise, technical feel');
|
|
221
|
-
}
|
|
397
|
+
function sanitizeTokenName(name) {
|
|
398
|
+
return String(name)
|
|
399
|
+
.trim()
|
|
400
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
401
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
402
|
+
.replace(/^-+|-+$/g, '')
|
|
403
|
+
.toLowerCase() || 'token';
|
|
404
|
+
}
|
|
222
405
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
406
|
+
function normalizeFontFamily(value) {
|
|
407
|
+
if (!value) return null;
|
|
408
|
+
return String(value).trim().replace(/\s+/g, ' ');
|
|
409
|
+
}
|
|
226
410
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
411
|
+
function normalizeFontWeight(value) {
|
|
412
|
+
if (value == null) return null;
|
|
413
|
+
const parsed = parseInt(value, 10);
|
|
414
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function normalizeLineHeight(value) {
|
|
418
|
+
if (value == null) return null;
|
|
419
|
+
const raw = String(value).trim();
|
|
420
|
+
const dimension = normalizeDimension(raw);
|
|
421
|
+
if (dimension) return dimension;
|
|
422
|
+
const parsed = parseFloat(raw);
|
|
423
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function normalizeDimension(value) {
|
|
427
|
+
if (value == null) return null;
|
|
428
|
+
if (typeof value === 'number') return `${value}px`;
|
|
429
|
+
|
|
430
|
+
const clean = String(value).split('(')[0].trim();
|
|
431
|
+
const match = clean.match(/^(-?\d*\.?\d+)(px|em|rem)$/i);
|
|
432
|
+
if (!match) return null;
|
|
433
|
+
return `${trimNumber(match[1])}${match[2].toLowerCase()}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function normalizeRadius(value) {
|
|
437
|
+
if (value == null) return null;
|
|
438
|
+
const raw = String(value).trim();
|
|
439
|
+
if (raw === '50%' || raw === '100%') return '9999px';
|
|
440
|
+
return normalizeDimension(raw);
|
|
441
|
+
}
|
|
231
442
|
|
|
232
|
-
|
|
233
|
-
|
|
443
|
+
function normalizePadding(value) {
|
|
444
|
+
if (value == null) return null;
|
|
445
|
+
const parts = String(value).trim().split(/\s+/).map(normalizeDimension);
|
|
446
|
+
if (parts.some(part => !part)) return null;
|
|
447
|
+
return parts.join(' ');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function tokenReferenceForColor(raw, colorRoles) {
|
|
451
|
+
const hex = toHex(raw);
|
|
452
|
+
if (!hex) return null;
|
|
453
|
+
const match = Object.entries(colorRoles).find(([, value]) => value.toLowerCase() === hex.toLowerCase());
|
|
454
|
+
return match ? `{colors.${match[0]}}` : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function tokenReferenceForRadius(raw, roundedTokens) {
|
|
458
|
+
const radius = normalizeRadius(raw);
|
|
459
|
+
if (!radius) return null;
|
|
460
|
+
const match = Object.entries(roundedTokens).find(([, value]) => value === radius);
|
|
461
|
+
return match ? `{rounded.${match[0]}}` : null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function hasKeys(object) {
|
|
465
|
+
return Boolean(object && Object.keys(object).length);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function hasLayoutEvidence(result, spacingTokens) {
|
|
469
|
+
return hasKeys(spacingTokens) || (result.breakpoints ?? []).some(bp => bp.px);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function hasElevationEvidence(result) {
|
|
473
|
+
return result.shadows?.length > 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function hasComponentEvidence(result) {
|
|
477
|
+
return Boolean(firstButton(result.components?.buttons) || firstInput(result.components?.inputs));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function firstButton(buttons) {
|
|
481
|
+
if (!Array.isArray(buttons)) return null;
|
|
482
|
+
const normalized = buttons.map(button => button.states?.default ?? button);
|
|
483
|
+
return normalized.find(button =>
|
|
484
|
+
button.backgroundColor && !isTransparentColor(button.backgroundColor) ||
|
|
485
|
+
positiveDimension(button.borderRadius) ||
|
|
486
|
+
meaningfulPadding(button.padding) ||
|
|
487
|
+
hasVisibleBorder(button.border)
|
|
488
|
+
) ?? normalized.find(button =>
|
|
489
|
+
button.backgroundColor ||
|
|
490
|
+
button.color ||
|
|
491
|
+
button.textColor ||
|
|
492
|
+
button.padding ||
|
|
493
|
+
button.borderRadius ||
|
|
494
|
+
hasVisibleBorder(button.border)
|
|
495
|
+
) ?? null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function firstInput(inputs) {
|
|
499
|
+
if (!inputs) return null;
|
|
500
|
+
if (Array.isArray(inputs)) return inputs[0] ?? null;
|
|
501
|
+
return inputs.text?.[0] ?? inputs.search?.[0] ?? Object.values(inputs).flat()[0] ?? null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function compactObject(object) {
|
|
505
|
+
const entries = Object.entries(object).filter(([, value]) => value != null && value !== '');
|
|
506
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function isTransparentColor(value) {
|
|
510
|
+
if (!value) return true;
|
|
511
|
+
const raw = String(value).trim().toLowerCase();
|
|
512
|
+
if (raw === 'transparent') return true;
|
|
513
|
+
|
|
514
|
+
const hexAlpha = raw.match(/^#(?:[0-9a-f]{4}|[0-9a-f]{8})$/i);
|
|
515
|
+
if (hexAlpha) {
|
|
516
|
+
const alpha = raw.length === 5 ? raw[4] + raw[4] : raw.slice(7, 9);
|
|
517
|
+
return alpha === '00';
|
|
234
518
|
}
|
|
235
519
|
|
|
236
|
-
|
|
520
|
+
const functional = raw.match(/^rgba?\((.*)\)$/);
|
|
521
|
+
if (!functional) return false;
|
|
522
|
+
|
|
523
|
+
const body = functional[1].trim();
|
|
524
|
+
const slashParts = body.split('/');
|
|
525
|
+
if (slashParts.length === 2) return isZeroAlpha(slashParts[1]);
|
|
526
|
+
|
|
527
|
+
const commaParts = body.split(',');
|
|
528
|
+
return commaParts.length === 4 && isZeroAlpha(commaParts[3]);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function isZeroAlpha(value) {
|
|
532
|
+
const raw = String(value).trim();
|
|
533
|
+
const parsed = parseFloat(raw);
|
|
534
|
+
return !Number.isNaN(parsed) && parsed === 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function hasVisibleBorder(value) {
|
|
538
|
+
if (!value) return false;
|
|
539
|
+
const raw = String(value).trim().toLowerCase();
|
|
540
|
+
return raw !== 'none' && !raw.includes(' none ') && !raw.startsWith('0px none');
|
|
237
541
|
}
|
|
238
542
|
|
|
239
|
-
function
|
|
240
|
-
if (!
|
|
241
|
-
return
|
|
543
|
+
function meaningfulPadding(value) {
|
|
544
|
+
if (!value) return false;
|
|
545
|
+
return String(value).split(/\s+/).some(part => positiveDimension(part));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function positiveDimension(value) {
|
|
549
|
+
if (!value) return false;
|
|
550
|
+
const match = String(value).match(/^(\d*\.?\d+)px$/);
|
|
551
|
+
return Boolean(match && parseFloat(match[1]) > 0);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function toHex(raw) {
|
|
555
|
+
if (raw == null) return null;
|
|
556
|
+
if (isTransparentColor(raw)) return null;
|
|
557
|
+
const parsed = convertColor(String(raw));
|
|
558
|
+
return parsed ? normalizeHex(parsed.hex) : null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function normalizeHex(hex) {
|
|
562
|
+
return hex.toUpperCase();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function trimNumber(value) {
|
|
566
|
+
return String(parseFloat(value));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function titleize(s) {
|
|
570
|
+
return String(s)
|
|
571
|
+
.replace(/[-_]+/g, ' ')
|
|
572
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
242
573
|
}
|
|
243
574
|
|
|
244
575
|
function humanWeight(w) {
|
|
245
576
|
if (!w) return '';
|
|
246
|
-
const n = parseInt(w);
|
|
577
|
+
const n = parseInt(w, 10);
|
|
247
578
|
if (n <= 300) return 'light';
|
|
248
579
|
if (n <= 400) return 'regular';
|
|
249
580
|
if (n <= 500) return 'medium';
|
|
@@ -251,9 +582,3 @@ function humanWeight(w) {
|
|
|
251
582
|
if (n <= 700) return 'bold';
|
|
252
583
|
return 'extra-bold';
|
|
253
584
|
}
|
|
254
|
-
|
|
255
|
-
function toHex(raw) {
|
|
256
|
-
if (raw == null) return null;
|
|
257
|
-
const parsed = convertColor(String(raw));
|
|
258
|
-
return parsed ? parsed.hex : null;
|
|
259
|
-
}
|