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 +10 -0
- package/index.js +25 -1
- package/lib/colors.js +21 -0
- package/lib/design-md.js +259 -0
- package/mcp-server.js +2 -0
- package/package.json +1 -1
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.
|
|
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);
|
package/lib/design-md.js
ADDED
|
@@ -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