dembrandt 0.12.5 → 0.12.7

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.
package/index.js CHANGED
@@ -13,6 +13,7 @@ import ora from "ora";
13
13
  import { chromium, firefox } from "playwright-core";
14
14
  import { extractBranding } from "./lib/extractors/index.js";
15
15
  import { displayResults } from "./lib/formatters/terminal.js";
16
+ import { color } from "./lib/formatters/theme.js";
16
17
  import { toW3CFormat } from "./lib/formatters/w3c.js";
17
18
  import { generatePDF } from "./lib/formatters/pdf.js";
18
19
  import { generateDesignMd } from "./lib/formatters/markdown.js";
@@ -213,6 +214,9 @@ program
213
214
  // Convert to W3C format if requested
214
215
  const outputData = opts.dtcg ? toW3CFormat(result) : result;
215
216
 
217
+ // Collect "saved to" notices and print them after the results below
218
+ const savedNotices = [];
219
+
216
220
  // Save JSON output if --save-output or --dtcg is specified
217
221
  if (opts.saveOutput || opts.dtcg) {
218
222
  try {
@@ -230,16 +234,19 @@ program
230
234
  const filepath = join(outputDir, filename);
231
235
  writeFileSync(filepath, JSON.stringify(outputData, null, 2));
232
236
 
233
- console.log(
237
+ const jsonLabel = opts.dtcg
238
+ ? 'DTCG tokens saved (--dtcg)'
239
+ : 'JSON saved (--save-output)';
240
+ savedNotices.push(
234
241
  chalk.dim(
235
- `💾 JSON saved to: ${chalk.hex('#8BE9FD')(
242
+ `💾 ${jsonLabel}: ${color.info(
236
243
  `output/${domain}/${filename}`
237
244
  )}`
238
245
  )
239
246
  );
240
247
  } catch (err) {
241
248
  console.log(
242
- chalk.hex('#FFB86C')(`⚠ Could not save JSON file: ${err.message}`)
249
+ color.warning(`! Could not save JSON file: ${err.message}`)
243
250
  );
244
251
  }
245
252
  }
@@ -258,9 +265,9 @@ program
258
265
  spinner.start("Generating PDF brand guide...");
259
266
  await generatePDF(result, pdfPath, browser);
260
267
  spinner.stop();
261
- console.log(
268
+ savedNotices.push(
262
269
  chalk.dim(
263
- `PDF saved to: ${chalk.hex('#8BE9FD')(
270
+ `💾 Brand guide PDF saved (--brand-guide): ${color.info(
264
271
  `output/${pdfDomain}/${pdfFilename}`
265
272
  )}`
266
273
  )
@@ -268,7 +275,7 @@ program
268
275
  } catch (err) {
269
276
  spinner.stop();
270
277
  console.log(
271
- chalk.hex('#FFB86C')(`Could not generate PDF: ${err.message}`)
278
+ color.warning(`Could not generate PDF: ${err.message}`)
272
279
  );
273
280
  }
274
281
  }
@@ -281,27 +288,40 @@ program
281
288
  mkdirSync(mdDir, { recursive: true });
282
289
  const mdPath = join(mdDir, "DESIGN.md");
283
290
  writeFileSync(mdPath, generateDesignMd(result));
284
- console.log(
291
+ savedNotices.push(
285
292
  chalk.dim(
286
- `DESIGN.md saved to: ${chalk.hex('#8BE9FD')(
293
+ `💾 DESIGN.md saved (--design-md): ${color.info(
287
294
  `output/${mdDomain}/DESIGN.md`
288
295
  )}`
289
296
  )
290
297
  );
291
298
  } catch (err) {
292
299
  console.log(
293
- chalk.hex('#FFB86C')(`Could not generate DESIGN.md: ${err.message}`)
300
+ color.warning(`Could not generate DESIGN.md: ${err.message}`)
294
301
  );
295
302
  }
296
303
  }
297
304
 
298
305
  // Output to terminal
306
+ const summaryLine =
307
+ color.accent('✨ Analysis summary: ') +
308
+ chalk.dim(
309
+ `${result.colors?.palette?.length ?? 0} colors, ` +
310
+ `${result.typography?.styles?.length ?? 0} text styles, ` +
311
+ `${result.breakpoints?.length ?? 0} breakpoints.`
312
+ );
299
313
  if (opts.jsonOnly) {
300
314
  console.log = originalConsoleLog;
301
315
  console.log(JSON.stringify(outputData, null, 2));
316
+ // Keep stdout pure JSON: summary and notices go to stderr
317
+ console.error(summaryLine);
318
+ for (const notice of savedNotices) console.error(notice);
302
319
  } else {
303
320
  console.log();
304
321
  displayResults(result);
322
+ console.log();
323
+ console.log(summaryLine);
324
+ for (const notice of savedNotices) console.log(notice);
305
325
  }
306
326
  } catch (err) {
307
327
  spinner.fail("Failed");
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { color } from '../formatters/theme.js';
2
3
  import { discoverLinks } from '../discovery.js';
3
4
  import { extractLogo, extractSiteName } from './logo.js';
4
5
  import { extractColors } from './colors.js';
@@ -69,11 +70,11 @@ export async function extractBranding(url, spinner, browser, options = {}) {
69
70
  const initialDomain = new URL(initialUrl).hostname;
70
71
  const finalDomain = new URL(finalUrl).hostname;
71
72
  if (initialDomain !== finalDomain) {
72
- console.log(chalk.hex('#FFB86C')(` Page redirected to different domain:`));
73
+ console.log(color.warning(` ! Page redirected to different domain:`));
73
74
  console.log(chalk.dim(` From: ${initialUrl}`));
74
75
  console.log(chalk.dim(` To: ${finalUrl}`));
75
76
  } else {
76
- console.log(chalk.hex('#8BE9FD')(` Page redirected within same domain:`));
77
+ console.log(color.info(` i Page redirected within same domain:`));
77
78
  console.log(chalk.dim(` From: ${initialUrl}`));
78
79
  console.log(chalk.dim(` To: ${finalUrl}`));
79
80
  }
@@ -81,7 +82,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
81
82
  }
82
83
 
83
84
  spinner.stop();
84
- console.log(chalk.hex('#50FA7B')(` ✓ Page loaded`));
85
+ console.log(color.success(` ✓ Page loaded`));
85
86
 
86
87
  spinner.start("Waiting for body content to render...");
87
88
  try {
@@ -90,10 +91,10 @@ export async function extractBranding(url, spinner, browser, options = {}) {
90
91
  { timeout: (options.navigationTimeout || 20000) * timeoutMultiplier }
91
92
  );
92
93
  spinner.stop();
93
- console.log(chalk.hex('#50FA7B')(` ✓ Body content rendered`));
94
+ console.log(color.success(` ✓ Body content rendered`));
94
95
  } catch {
95
96
  spinner.stop();
96
- console.log(chalk.hex('#FFB86C')(` Body content timeout (continuing)`));
97
+ console.log(color.warning(` ! Body content timeout (continuing)`));
97
98
  timeouts.push('Body content rendering');
98
99
  }
99
100
 
@@ -101,7 +102,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
101
102
  const hydrationTime = 8000 * timeoutMultiplier;
102
103
  await page.waitForTimeout(hydrationTime);
103
104
  spinner.stop();
104
- console.log(chalk.hex('#50FA7B')(` ✓ Hydration complete (${hydrationTime / 1000}s)`));
105
+ console.log(color.success(` ✓ Hydration complete (${hydrationTime / 1000}s)`));
105
106
 
106
107
  spinner.start("Waiting for main content...");
107
108
  try {
@@ -109,10 +110,10 @@ export async function extractBranding(url, spinner, browser, options = {}) {
109
110
  timeout: 10000 * timeoutMultiplier,
110
111
  });
111
112
  spinner.stop();
112
- console.log(chalk.hex('#50FA7B')(` ✓ Main content detected`));
113
+ console.log(color.success(` ✓ Main content detected`));
113
114
  } catch {
114
115
  spinner.stop();
115
- console.log(chalk.hex('#FFB86C')(` Main content selector timeout (continuing)`));
116
+ console.log(color.warning(` ! Main content selector timeout (continuing)`));
116
117
  timeouts.push('Main content selector');
117
118
  }
118
119
 
@@ -131,25 +132,25 @@ export async function extractBranding(url, spinner, browser, options = {}) {
131
132
  window.scrollTo(0, 0);
132
133
  });
133
134
  spinner.stop();
134
- console.log(chalk.hex('#50FA7B')(` ✓ Full page scrolled (lazy content triggered)`));
135
+ console.log(color.success(` ✓ Full page scrolled (lazy content triggered)`));
135
136
 
136
137
  spinner.start("Final content stabilization...");
137
138
  const stabilizationTime = 4000 * timeoutMultiplier;
138
139
  await page.waitForTimeout(stabilizationTime);
139
140
  spinner.stop();
140
- console.log(chalk.hex('#50FA7B')(` ✓ Page fully loaded and stable`));
141
+ console.log(color.success(` ✓ Page fully loaded and stable`));
141
142
 
142
143
  spinner.start("Validating page content...");
143
144
  const contentLength = await page.evaluate(() => document.body.textContent.length);
144
145
  spinner.stop();
145
146
 
146
147
  if (contentLength > 100) {
147
- console.log(chalk.hex('#50FA7B')(` ✓ Content validated: ${contentLength} chars`));
148
+ console.log(color.success(` ✓ Content validated: ${contentLength} chars`));
148
149
  break;
149
150
  }
150
151
 
151
152
  spinner.warn(`Page seems empty (attempt ${attempts}/${maxAttempts}), retrying...`);
152
- console.log(chalk.hex('#FFB86C')(` Content length: ${contentLength} chars (expected >100)`));
153
+ console.log(color.warning(` ! Content length: ${contentLength} chars (expected >100)`));
153
154
  await page.waitForTimeout(3000 * timeoutMultiplier);
154
155
  } catch (err) {
155
156
  if (attempts >= maxAttempts) {
@@ -165,7 +166,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
165
166
  }
166
167
 
167
168
  spinner.stop();
168
- console.log(chalk.hex('#8BE9FD')("\n Extracting design tokens...\n"));
169
+ console.log(color.info("\n Extracting design tokens...\n"));
169
170
 
170
171
  spinner.start("Analyzing design system (16 parallel tasks)...");
171
172
  const [
@@ -207,22 +208,22 @@ export async function extractBranding(url, spinner, browser, options = {}) {
207
208
  ]);
208
209
 
209
210
  spinner.stop();
210
- console.log(colors.palette.length > 0 ? chalk.hex('#50FA7B')(` ✓ Colors: ${colors.palette.length} found`) : chalk.hex('#FFB86C')(` Colors: 0 found`));
211
- console.log(typography.styles.length > 0 ? chalk.hex('#50FA7B')(` ✓ Typography: ${typography.styles.length} styles`) : chalk.hex('#FFB86C')(` Typography: 0 styles`));
212
- console.log(spacing.commonValues.length > 0 ? chalk.hex('#50FA7B')(` ✓ Spacing: ${spacing.commonValues.length} values`) : chalk.hex('#FFB86C')(` Spacing: 0 values`));
213
- console.log(borderRadius.values.length > 0 ? chalk.hex('#50FA7B')(` ✓ Border radius: ${borderRadius.values.length} values`) : chalk.hex('#FFB86C')(` Border radius: 0 values`));
211
+ console.log(colors.palette.length > 0 ? color.success(` ✓ Colors: ${colors.palette.length} found`) : color.warning(` ! Colors: 0 found`));
212
+ console.log(typography.styles.length > 0 ? color.success(` ✓ Typography: ${typography.styles.length} styles`) : color.warning(` ! Typography: 0 styles`));
213
+ console.log(spacing.commonValues.length > 0 ? color.success(` ✓ Spacing: ${spacing.commonValues.length} values`) : color.warning(` ! Spacing: 0 values`));
214
+ console.log(borderRadius.values.length > 0 ? color.success(` ✓ Border radius: ${borderRadius.values.length} values`) : color.warning(` ! Border radius: 0 values`));
214
215
 
215
216
  const bordersTotal = (borders?.combinations?.length || 0);
216
- console.log(bordersTotal > 0 ? chalk.hex('#50FA7B')(` ✓ Borders: ${bordersTotal} combinations`) : chalk.hex('#FFB86C')(` Borders: 0 found`));
217
- console.log(shadows.length > 0 ? chalk.hex('#50FA7B')(` ✓ Shadows: ${shadows.length} found`) : chalk.hex('#FFB86C')(` Shadows: 0 found`));
218
- console.log(buttons.length > 0 ? chalk.hex('#50FA7B')(` ✓ Buttons: ${buttons.length} variants`) : chalk.hex('#FFB86C')(` Buttons: 0 variants`));
219
- console.log(inputs.text?.length > 0 ? chalk.hex('#50FA7B')(` ✓ Inputs: found`) : chalk.hex('#FFB86C')(` Inputs: 0 styles`));
220
- console.log(links.length > 0 ? chalk.hex('#50FA7B')(` ✓ Links: ${links.length} styles`) : chalk.hex('#FFB86C')(` Links: 0 styles`));
221
- console.log(breakpoints.length > 0 ? chalk.hex('#50FA7B')(` ✓ Breakpoints: ${breakpoints.length} detected`) : chalk.hex('#FFB86C')(` Breakpoints: 0 detected`));
222
- console.log(iconSystem.length > 0 ? chalk.hex('#50FA7B')(` ✓ Icon systems: ${iconSystem.length} detected`) : chalk.hex('#FFB86C')(` Icon systems: 0 detected`));
223
- console.log(frameworks.length > 0 ? chalk.hex('#50FA7B')(` ✓ Frameworks: ${frameworks.length} detected`) : chalk.hex('#FFB86C')(` Frameworks: 0 detected`));
224
- console.log(gradients.length > 0 ? chalk.hex('#50FA7B')(` ✓ Gradients: ${gradients.length} found`) : chalk.hex('#8BE9FD')(` · Gradients: 0 found`));
225
- console.log(motion.durations.length > 0 ? chalk.hex('#50FA7B')(` ✓ Motion: ${motion.durations.length} durations, ${motion.easings.length} easings`) : chalk.hex('#8BE9FD')(` · Motion: none detected`));
217
+ console.log(bordersTotal > 0 ? color.success(` ✓ Borders: ${bordersTotal} combinations`) : color.warning(` ! Borders: 0 found`));
218
+ console.log(shadows.length > 0 ? color.success(` ✓ Shadows: ${shadows.length} found`) : color.warning(` ! Shadows: 0 found`));
219
+ console.log(buttons.length > 0 ? color.success(` ✓ Buttons: ${buttons.length} variants`) : color.warning(` ! Buttons: 0 variants`));
220
+ console.log(inputs.text?.length > 0 ? color.success(` ✓ Inputs: found`) : color.warning(` ! Inputs: 0 styles`));
221
+ console.log(links.length > 0 ? color.success(` ✓ Links: ${links.length} styles`) : color.warning(` ! Links: 0 styles`));
222
+ console.log(breakpoints.length > 0 ? color.success(` ✓ Breakpoints: ${breakpoints.length} detected`) : color.warning(` ! Breakpoints: 0 detected`));
223
+ console.log(iconSystem.length > 0 ? color.success(` ✓ Icon systems: ${iconSystem.length} detected`) : color.warning(` ! Icon systems: 0 detected`));
224
+ console.log(frameworks.length > 0 ? color.success(` ✓ Frameworks: ${frameworks.length} detected`) : color.warning(` ! Frameworks: 0 detected`));
225
+ console.log(gradients.length > 0 ? color.success(` ✓ Gradients: ${gradients.length} found`) : color.info(` · Gradients: 0 found`));
226
+ console.log(motion.durations.length > 0 ? color.success(` ✓ Motion: ${motion.durations.length} durations, ${motion.easings.length} easings`) : color.info(` · Motion: none detected`));
226
227
  console.log();
227
228
 
228
229
  // Hover/focus state extraction
@@ -427,8 +428,8 @@ export async function extractBranding(url, spinner, browser, options = {}) {
427
428
 
428
429
  spinner.stop();
429
430
  console.log(hoverFocusColors.length > 0 ?
430
- chalk.hex('#50FA7B')(` ✓ Hover/focus: ${hoverFocusColors.length} state colors found`) :
431
- chalk.hex('#FFB86C')(` Hover/focus: 0 state colors found`));
431
+ color.success(` ✓ Hover/focus: ${hoverFocusColors.length} state colors found`) :
432
+ color.warning(` ! Hover/focus: 0 state colors found`));
432
433
 
433
434
  // Dark mode
434
435
  if (options.darkMode) {
@@ -458,7 +459,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
458
459
  links.push(...darkModeLinks.map((link) => ({ ...link, source: "dark-mode" })));
459
460
 
460
461
  spinner.stop();
461
- console.log(chalk.hex('#50FA7B')(` ✓ Dark mode: +${darkModeColors.palette.length} colors`));
462
+ console.log(color.success(` ✓ Dark mode: +${darkModeColors.palette.length} colors`));
462
463
  }
463
464
 
464
465
  // Mobile viewport
@@ -476,19 +477,19 @@ export async function extractBranding(url, spinner, browser, options = {}) {
476
477
  colors.palette = mergedPalette;
477
478
 
478
479
  spinner.stop();
479
- console.log(chalk.hex('#50FA7B')(` ✓ Mobile: +${mobileColors.palette.length} colors`));
480
+ console.log(color.success(` ✓ Mobile: +${mobileColors.palette.length} colors`));
480
481
  }
481
482
 
482
483
  spinner.stop();
483
484
  console.log();
484
- console.log(chalk.hex('#50FA7B').bold(" Brand extraction complete!"));
485
+ console.log(color.success.bold(" Brand extraction complete!"));
485
486
 
486
487
  if (timeouts.length > 0 && !options.slow) {
487
488
  console.log();
488
- console.log(chalk.hex('#FFB86C')(`⚠ ${timeouts.length} timeout(s) occurred during extraction:`));
489
+ console.log(color.warning(`! ${timeouts.length} timeout(s) occurred during extraction:`));
489
490
  timeouts.forEach(t => console.log(chalk.dim(` • ${t}`)));
490
491
  console.log();
491
- console.log(chalk.hex('#8BE9FD')(`💡 Tip: Try running with ${chalk.bold('--slow')} flag for more reliable results on slow-loading sites`));
492
+ console.log(color.info(`💡 Tip: Try running with ${chalk.bold('--slow')} flag for more reliable results on slow-loading sites`));
492
493
  }
493
494
 
494
495
  let wcag = [];
@@ -531,8 +532,8 @@ export async function extractBranding(url, spinner, browser, options = {}) {
531
532
  const staticPassing = wcag.filter(p => !p.source && p.aa).length;
532
533
  const staticTotal = wcag.filter(p => !p.source).length;
533
534
  const statesFailing = wcag.filter(p => p.source === 'state' && !p.aa).length;
534
- console.log(chalk.hex('#50FA7B')(` ✓ WCAG: ${staticPassing}/${staticTotal} pairs pass AA`) +
535
- (statesFailing ? chalk.hex('#FFB86C')(` · ${statesFailing} state pair(s) fail`) : ''));
535
+ console.log(color.success(` ✓ WCAG: ${staticPassing}/${staticTotal} pairs pass AA`) +
536
+ (statesFailing ? color.warning(` · ${statesFailing} state pair(s) fail`) : ''));
536
537
  } catch {
537
538
  spinner.stop();
538
539
  }