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 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 the DESIGN.md format
5
- * as defined by Google Stitch prose-first, human + AI readable.
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 sections = [];
15
-
16
- const domain = (() => {
17
- try { return new URL(result.url).hostname.replace('www.', ''); } catch { return result.url ?? 'unknown'; }
18
- })();
19
-
20
- // --- Overview ---
21
- sections.push(`# Design System\n\n## Overview\nDesign tokens extracted from ${domain}.`);
22
-
23
- // --- Colors ---
24
- {
25
- const semantic = result.colors?.semantic;
26
- const palette = result.colors?.palette;
27
-
28
- // Collect all candidate colors from palette + buttons + links, normalised to hex
29
- const allCandidates = new Map();
30
- const addCandidate = (raw, source) => {
31
- if (raw == null) return;
32
- const parsed = convertColor(String(raw));
33
- if (!parsed) return;
34
- const hex = parsed.hex; // canonical 6-digit lowercase hex
35
- const r = parseInt(hex.slice(1, 3), 16) / 255;
36
- const g = parseInt(hex.slice(3, 5), 16) / 255;
37
- const b = parseInt(hex.slice(5, 7), 16) / 255;
38
- const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
39
- const sat = Math.max(r, g, b) - Math.min(r, g, b);
40
- if (!allCandidates.has(hex)) allCandidates.set(hex, { hex, lum, sat, source });
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
- const highConf = palette?.filter(c => c.confidence === 'high' || c.confidence === 'medium') ?? [];
44
- for (const c of (highConf.length ? highConf : palette ?? [])) addCandidate(c.normalized || c.color, 'palette');
45
- for (const btn of result.components?.buttons ?? []) {
46
- const bg = btn.states?.default?.backgroundColor;
47
- if (bg && bg !== 'transparent') addCandidate(bg, 'button');
48
- }
49
- for (const link of result.components?.links ?? []) addCandidate(link.color, 'link');
50
-
51
- // Build semantic roles
52
- const roles = {};
53
- if (semantic && Object.values(semantic).some(Boolean)) {
54
- // Extractor already resolved primary/secondary from class names — most authoritative
55
- for (const [role, val] of Object.entries(semantic)) {
56
- if (val) roles[role] = toHex(val) || val;
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
- // Fill any missing roles from candidates, ranked by palette confidence then saturation
61
- if (allCandidates.size) {
62
- // Score candidates: high-confidence palette colors rank above button/link colors
63
- const confScore = { high: 3, medium: 2, low: 1 };
64
- const paletteConf = new Map();
65
- for (const c of palette ?? []) {
66
- const hex = toHex(c.normalized || c.color);
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
- const candidates = Array.from(allCandidates.values()).map(c => ({
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
- // Sort by rank before dedup so the best candidate per cluster is kept, not the first-seen
79
- const ranked = [...candidates].sort((a, b) => b.rank - a.rank);
80
- const deduped = [];
81
- for (const c of ranked) {
82
- const tooClose = deduped.some(d => deltaE(c.hex, d.hex) < 15);
83
- if (!tooClose) deduped.push(c);
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
- const used = new Set(Object.values(roles).map(h => h?.toLowerCase()));
87
- const byRank = [...deduped].sort((a, b) => b.rank - a.rank);
88
- const byLum = [...deduped].sort((a, b) => a.lum - b.lum);
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
- const pick = (arr) => {
91
- const c = arr.find(x => !used.has(x.hex.toLowerCase()));
92
- if (c) { used.add(c.hex.toLowerCase()); return c.hex; }
93
- return null;
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
- if (!roles.primary) { const h = pick(byRank); if (h) roles.primary = h; }
97
- if (!roles.secondary) { const h = pick(byRank); if (h) roles.secondary = h; }
98
- if (!roles.surface) { const h = pick([...byLum].reverse()); if (h) roles.surface = h; }
99
- if (!roles['on-surface']) { const h = pick(byLum); if (h) roles['on-surface'] = h; }
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
- const usageHints = {
103
- primary: 'CTAs, active states, key interactive elements',
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
- const lines = ['## Colors'];
114
- for (const [role, hex] of Object.entries(roles)) {
115
- if (!hex) continue;
116
- const hint = usageHints[role.toLowerCase()] ?? '';
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
- if (lines.length > 1) sections.push(lines.join('\n'));
121
- else sections.push('## Colors\n- **Primary** (#000000): CTAs, active states, key interactive elements\n- **Surface** (#ffffff): Page backgrounds\n- **On-surface** (#000000): Primary text');
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
- // --- Typography ---
125
- {
126
- const SKIP = /^(fontawesome|font.awesome|material.icon|glyphicon|icomoon|dashicons|built_rg|piepie|sans-serif|serif|monospace|cursive|fantasy|-apple-system|system-ui|segoe.ui|helvetica\b|arial|georgia|times|courier)/i;
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
- if (!families.size) {
135
- sections.push('## Typography\n- **Headlines**: System font, semi-bold\n- **Body**: System font, regular, 14–16px');
136
- } else {
137
- const lines = ['## Typography'];
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
- // --- Components ---
161
- {
162
- const lines = ['## Components'];
163
- let hasContent = false;
164
-
165
- const buttons = result.components?.buttons ?? [];
166
- if (buttons.length) {
167
- const btn = buttons[0].states?.default ?? buttons[0];
168
- const parts = [];
169
- if (btn.borderRadius) {
170
- const r = parseFloat(btn.borderRadius);
171
- parts.push(r > 20 ? 'fully rounded' : r > 0 ? `rounded (${btn.borderRadius})` : 'square corners');
172
- }
173
- if (btn.backgroundColor && btn.backgroundColor !== 'transparent') {
174
- const hex = toHex(btn.backgroundColor);
175
- parts.push(`primary uses ${hex ? hex + ' fill' : 'colored fill'}`);
176
- }
177
- if (btn.border && btn.border !== 'none') parts.push('outlined variant available');
178
- if (parts.length) { lines.push(`- **Buttons**: ${capitalize(parts.join(', '))}`); hasContent = true; }
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
- const inputs = result.components?.inputs;
182
- const firstInput = inputs?.text?.[0] ?? (Array.isArray(inputs) ? inputs[0] : null);
183
- if (firstInput) {
184
- const inp = firstInput.states?.default ?? firstInput;
185
- const parts = [];
186
- if (inp.border) parts.push(`${inp.border.split(' ').slice(0, 2).join(' ')} border`);
187
- if (inp.borderRadius) parts.push(`${inp.borderRadius} radius`);
188
- if (parts.length) { lines.push(`- **Inputs**: ${capitalize(parts.join(', '))}`); hasContent = true; }
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
- const radiiAll = result.borderRadius?.values ?? [];
192
- const radii = radiiAll.filter(v => v.value && !v.value.trim().includes(' ')).slice(0, 4).map(v => v.value);
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
- const shadows = result.shadows ?? [];
196
- if (shadows.length) {
197
- lines.push(`- **Elevation**: uses box shadows for depth`);
198
- hasContent = true;
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
- lines.push(`- **Elevation**: flat design, no shadows`);
201
- hasContent = true;
354
+ yaml += `${pad}${yamlKey(key)}: ${yamlScalar(child)}\n`;
202
355
  }
356
+ }
203
357
 
204
- if (hasContent) sections.push(lines.join('\n'));
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
- // --- Do's and Don'ts ---
208
- {
209
- const dos = [];
210
- const donts = [];
211
-
212
- // Infer from border radius
213
- const radiiAll = result.borderRadius?.values ?? [];
214
- const radii = radiiAll.filter(v => v.value && !v.value.trim().includes(' ')).map(v => parseFloat(v.value));
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
- // Infer from colors
224
- dos.push('Do use the primary color sparingly — only for the most important action per screen');
225
- dos.push('Do maintain 4.5:1 contrast ratio for all body text (WCAG AA)');
406
+ function normalizeFontFamily(value) {
407
+ if (!value) return null;
408
+ return String(value).trim().replace(/\s+/g, ' ');
409
+ }
226
410
 
227
- // Infer from shadows
228
- if (!(result.shadows?.length)) {
229
- dos.push('Do convey depth through background and border contrast rather than shadows');
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
- const lines = ['## Do\'s and Don\'ts', ...dos.map(d => `- ${d}`), ...donts.map(d => `- ${d}`)];
233
- sections.push(lines.join('\n'));
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
- return sections.join('\n\n') + '\n';
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 capitalize(s) {
240
- if (!s) return s;
241
- return s.charAt(0).toUpperCase() + s.slice(1);
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.12.5",
3
+ "version": "0.12.6",
4
4
  "description": "Extract design tokens and publicly visible CSS information from any website",
5
5
  "mcpName": "io.github.dembrandt/dembrandt",
6
6
  "main": "index.js",