dembrandt 0.9.0 → 0.10.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 CHANGED
@@ -77,6 +77,7 @@ dembrandt bmw.de --dark-mode # Extract colors from dark mode variant
77
77
  dembrandt bmw.de --mobile # Use mobile viewport (390x844, iPhone 12/13/14/15) for responsive analysis
78
78
  dembrandt bmw.de --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
79
79
  dembrandt bmw.de --brand-guide # Generate a brand guide PDF
80
+ dembrandt bmw.de --design-md # Generate a DESIGN.md file for AI agents
80
81
  dembrandt bmw.de --pages 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
81
82
  dembrandt bmw.de --sitemap # Discover pages from sitemap.xml instead of DOM links
82
83
  dembrandt bmw.de --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
@@ -142,6 +143,15 @@ dembrandt stripe.com --dtcg
142
143
 
143
144
  The DTCG format is an industry-standard JSON schema that can be consumed by design tools and token transformation libraries like [Style Dictionary](https://styledictionary.com).
144
145
 
146
+ ### DESIGN.md
147
+
148
+ 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 like Google Stitch.
149
+
150
+ ```bash
151
+ dembrandt stripe.com --design-md
152
+ # Saves to: output/stripe.com/DESIGN.md
153
+ ```
154
+
145
155
  ## Local UI
146
156
 
147
157
  Browse your extracted brands in a visual interface.
package/index.js CHANGED
@@ -15,6 +15,7 @@ import { extractBranding } from "./lib/extractors.js";
15
15
  import { displayResults } from "./lib/display.js";
16
16
  import { toW3CFormat } from "./lib/w3c-exporter.js";
17
17
  import { generatePDF } from "./lib/pdf.js";
18
+ import { generateDesignMd } from "./lib/design-md.js";
18
19
  import { parseSitemap } from "./lib/discovery.js";
19
20
  import { mergeResults } from "./lib/merger.js";
20
21
  import { writeFileSync, mkdirSync } from "fs";
@@ -23,7 +24,7 @@ import { join } from "path";
23
24
  program
24
25
  .name("dembrandt")
25
26
  .description("Extract design tokens from any website")
26
- .version("0.9.0")
27
+ .version("0.10.0")
27
28
  .argument("<url>")
28
29
  .option("--browser <type>", "Browser to use (chromium|firefox)", "chromium")
29
30
  .option("--json-only", "Output raw JSON")
@@ -33,6 +34,7 @@ program
33
34
  .option("--mobile", "Extract from mobile viewport")
34
35
  .option("--slow", "3x longer timeouts for slow-loading sites")
35
36
  .option("--brand-guide", "Export a brand guide PDF")
37
+ .option("--design-md", "Export a DESIGN.md file")
36
38
  .option("--no-sandbox", "Disable browser sandbox (needed for Docker/CI)")
37
39
  .option("--raw-colors", "Include pre-filter raw colors in JSON output")
38
40
  .option("--screenshot <path>", "Save a screenshot of the page")
@@ -242,6 +244,28 @@ program
242
244
  }
243
245
  }
244
246
 
247
+ // Generate DESIGN.md
248
+ if (opts.designMd) {
249
+ try {
250
+ const mdDomain = new URL(url).hostname.replace("www.", "");
251
+ const mdDir = join(process.cwd(), "output", mdDomain);
252
+ mkdirSync(mdDir, { recursive: true });
253
+ const mdPath = join(mdDir, "DESIGN.md");
254
+ writeFileSync(mdPath, generateDesignMd(result));
255
+ console.log(
256
+ chalk.dim(
257
+ `DESIGN.md saved to: ${chalk.hex('#8BE9FD')(
258
+ `output/${mdDomain}/DESIGN.md`
259
+ )}`
260
+ )
261
+ );
262
+ } catch (err) {
263
+ console.log(
264
+ chalk.hex('#FFB86C')(`Could not generate DESIGN.md: ${err.message}`)
265
+ );
266
+ }
267
+ }
268
+
245
269
  // Output to terminal
246
270
  if (opts.jsonOnly) {
247
271
  console.log = originalConsoleLog;
package/lib/colors.js CHANGED
@@ -243,6 +243,27 @@ export function convertColor(colorString) {
243
243
  g = parseInt(rgbaMatch[2]);
244
244
  b = parseInt(rgbaMatch[3]);
245
245
  a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : undefined;
246
+ } else if (/^hsla?\(/.test(colorString)) {
247
+ // Parse hsl/hsla
248
+ const hslMatch = colorString.match(/hsla?\(([\d.]+),\s*([\d.]+)%?,\s*([\d.]+)%?(?:,\s*([\d.]+))?\)/);
249
+ if (!hslMatch) return null;
250
+ const h = parseFloat(hslMatch[1]) / 360;
251
+ const s = parseFloat(hslMatch[2]) / 100;
252
+ const l = parseFloat(hslMatch[3]) / 100;
253
+ a = hslMatch[4] ? parseFloat(hslMatch[4]) : undefined;
254
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
255
+ const p = 2 * l - q;
256
+ const hue2rgb = (p, q, t) => {
257
+ if (t < 0) t += 1;
258
+ if (t > 1) t -= 1;
259
+ if (t < 1/6) return p + (q - p) * 6 * t;
260
+ if (t < 1/2) return q;
261
+ if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
262
+ return p;
263
+ };
264
+ r = Math.round(hue2rgb(p, q, h + 1/3) * 255);
265
+ g = Math.round(hue2rgb(p, q, h) * 255);
266
+ b = Math.round(hue2rgb(p, q, h - 1/3) * 255);
246
267
  } else {
247
268
  // Try hex
248
269
  const rgb = hexToRgb(colorString);
@@ -0,0 +1,259 @@
1
+ /**
2
+ * DESIGN.md generator
3
+ *
4
+ * Converts dembrandt extraction results into the DESIGN.md format
5
+ * as defined by Google Stitch — prose-first, human + AI readable.
6
+ */
7
+ import { convertColor, deltaE } from './colors.js';
8
+
9
+ /**
10
+ * @param {object} result - dembrandt extraction result
11
+ * @returns {string} DESIGN.md content
12
+ */
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
+ };
42
+
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
+ }
58
+ }
59
+
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
+ }
69
+
70
+ const candidates = Array.from(allCandidates.values()).map(c => ({
71
+ ...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
+ 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);
84
+ }
85
+
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);
89
+
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
+ };
95
+
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
+ }
101
+
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
+ };
112
+
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
+ }
119
+
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');
122
+ }
123
+
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
+ }
133
+
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
+ }
158
+ }
159
+
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; }
179
+ }
180
+
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; }
189
+ }
190
+
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; }
194
+
195
+ const shadows = result.shadows ?? [];
196
+ if (shadows.length) {
197
+ lines.push(`- **Elevation**: uses box shadows for depth`);
198
+ hasContent = true;
199
+ } else {
200
+ lines.push(`- **Elevation**: flat design, no shadows`);
201
+ hasContent = true;
202
+ }
203
+
204
+ if (hasContent) sections.push(lines.join('\n'));
205
+ }
206
+
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
+ }
222
+
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)');
226
+
227
+ // Infer from shadows
228
+ if (!(result.shadows?.length)) {
229
+ dos.push('Do convey depth through background and border contrast rather than shadows');
230
+ }
231
+
232
+ const lines = ['## Do\'s and Don\'ts', ...dos.map(d => `- ${d}`), ...donts.map(d => `- ${d}`)];
233
+ sections.push(lines.join('\n'));
234
+ }
235
+
236
+ return sections.join('\n\n') + '\n';
237
+ }
238
+
239
+ function capitalize(s) {
240
+ if (!s) return s;
241
+ return s.charAt(0).toUpperCase() + s.slice(1);
242
+ }
243
+
244
+ function humanWeight(w) {
245
+ if (!w) return '';
246
+ const n = parseInt(w);
247
+ if (n <= 300) return 'light';
248
+ if (n <= 400) return 'regular';
249
+ if (n <= 500) return 'medium';
250
+ if (n <= 600) return 'semi-bold';
251
+ if (n <= 700) return 'bold';
252
+ return 'extra-bold';
253
+ }
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/mcp-server.js CHANGED
@@ -31,6 +31,8 @@ const nullSpinner = {
31
31
  stop() { return this; },
32
32
  succeed(msg) { return this; },
33
33
  fail(msg) { return this; },
34
+ warn(msg) { return this; },
35
+ info(msg) { return this; },
34
36
  };
35
37
 
36
38
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Extract design tokens and brand assets from any website",
5
5
  "mcpName": "io.github.dembrandt/dembrandt",
6
6
  "main": "index.js",