dembrandt 0.12.0 → 0.12.2

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
@@ -71,6 +71,7 @@ dembrandt example.com --sitemap # Discover pages from sitemap.xml instead
71
71
  dembrandt example.com --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
72
72
  dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
73
73
  dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
74
+ dembrandt example.com --wcag # WCAG 2.1 contrast analysis — real DOM pairs, AA/AAA grades
74
75
  ```
75
76
 
76
77
  Default: formatted terminal display only. Use `--save-output` to persist results as JSON files. Browser automatically retries in visible mode if headless extraction fails.
@@ -140,6 +141,16 @@ dembrandt example.com --design-md
140
141
  # Saves to: output/example.com/DESIGN.md
141
142
  ```
142
143
 
144
+ ### WCAG Contrast Analysis
145
+
146
+ 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.
147
+
148
+ ```bash
149
+ dembrandt stripe.com --wcag
150
+ ```
151
+
152
+ 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
+
143
154
  ### Brand Guide PDF
144
155
 
145
156
  Use `--brand-guide` to generate a printable PDF summarizing the extracted design system: colors, typography, components, and logo on a single document.
@@ -180,6 +191,64 @@ Opens http://localhost:5173 with API on port 3002.
180
191
 
181
192
  Extractions are performed via CLI (`dembrandt <url> --save-output`) and automatically appear in the UI.
182
193
 
194
+ ## Recipes
195
+
196
+ **Quick brand scan**
197
+ ```bash
198
+ dembrandt stripe.com
199
+ ```
200
+
201
+ **Compare two sites**
202
+ ```bash
203
+ dembrandt stripe.com --save-output
204
+ dembrandt braintree.com --save-output
205
+ # Compare output/stripe.com and output/braintree.com side by side
206
+ ```
207
+
208
+ **Multi-page audit** — get a fuller picture across the whole site
209
+ ```bash
210
+ dembrandt stripe.com --pages 10 --sitemap --save-output
211
+ ```
212
+
213
+ **Spot-check a value** — verify a specific token fast
214
+ ```bash
215
+ dembrandt stripe.com --json-only | grep -i "border-radius"
216
+ ```
217
+
218
+ **Export for Tailwind** — get spacing and color values into your config
219
+ ```bash
220
+ dembrandt stripe.com --dtcg --save-output
221
+ # Use the .tokens.json with Style Dictionary to generate tailwind.config.js
222
+ ```
223
+
224
+ **Export for Tokens Studio / Figma**
225
+ ```bash
226
+ dembrandt stripe.com --dtcg --save-output
227
+ # Import the .tokens.json directly into Tokens Studio
228
+ ```
229
+
230
+ **Generate DESIGN.md for your AI agent**
231
+ ```bash
232
+ dembrandt stripe.com --design-md
233
+ # Point your agent at the output DESIGN.md
234
+ ```
235
+
236
+ **Accessibility audit** — check contrast on any live URL
237
+ ```bash
238
+ dembrandt stripe.com --wcag
239
+ ```
240
+
241
+ **Regression baseline** — snapshot now, catch drift later
242
+ ```bash
243
+ dembrandt myapp.com --save-output --dtcg
244
+ # Store output as baseline, re-run after deploys and diff
245
+ ```
246
+
247
+ **CI / headless environments**
248
+ ```bash
249
+ dembrandt myapp.com --no-sandbox --save-output
250
+ ```
251
+
183
252
  ## Use Cases
184
253
 
185
254
  - Design system documentation
package/index.js CHANGED
@@ -11,21 +11,25 @@ import { program } from "commander";
11
11
  import chalk from "chalk";
12
12
  import ora from "ora";
13
13
  import { chromium, firefox } from "playwright-core";
14
- import { extractBranding } from "./lib/extractors.js";
15
- import { displayResults } from "./lib/display.js";
16
- import { toW3CFormat } from "./lib/w3c-exporter.js";
17
- import { generatePDF } from "./lib/pdf.js";
18
- import { generateDesignMd } from "./lib/design-md.js";
14
+ import { extractBranding } from "./lib/extractors/index.js";
15
+ import { displayResults } from "./lib/formatters/terminal.js";
16
+ import { toW3CFormat } from "./lib/formatters/w3c.js";
17
+ import { generatePDF } from "./lib/formatters/pdf.js";
18
+ import { generateDesignMd } from "./lib/formatters/markdown.js";
19
19
  import { parseSitemap } from "./lib/discovery.js";
20
20
  import { mergeResults } from "./lib/merger.js";
21
- import { writeFileSync, mkdirSync } from "fs";
22
- import { join } from "path";
21
+ import { writeFileSync, mkdirSync, readFileSync } from "fs";
22
+ import { join, dirname } from "path";
23
+ import { fileURLToPath } from "url";
23
24
  import { checkRobotsTxt } from "./lib/robots.js";
24
25
 
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const { version } = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
28
+
25
29
  program
26
30
  .name("dembrandt")
27
31
  .description("Extract design tokens from any website")
28
- .version("0.11.0")
32
+ .version(version)
29
33
  .argument("<url>")
30
34
  .option("--browser <type>", "Browser to use (chromium|firefox); set BROWSER_CDP_ENDPOINT env var to connect to an existing Chromium instance via CDP", "chromium")
31
35
  .option("--json-only", "Output raw JSON")
@@ -39,6 +43,7 @@ program
39
43
  .option("--no-sandbox", "Disable browser sandbox (needed for Docker/CI)")
40
44
  .option("--raw-colors", "Include pre-filter raw colors in JSON output")
41
45
  .option("--screenshot <path>", "Save a screenshot of the page")
46
+ .option("--wcag", "Analyze WCAG contrast ratios between palette colors")
42
47
  .option("--pages <n>", "Analyze up to N total pages including start URL (default: 5)", (v) => {
43
48
  const n = parseInt(v, 10);
44
49
  if (isNaN(n) || n < 1) throw new Error(`--pages must be a positive integer, got: ${v}`);
@@ -116,6 +121,7 @@ program
116
121
  slow: opts.slow,
117
122
  screenshotPath: opts.screenshot,
118
123
  discoverLinks: isMultiPage && !opts.sitemap ? maxPages : null,
124
+ wcag: opts.wcag,
119
125
  });
120
126
 
121
127
  // Multi-page crawl
package/lib/colors.js CHANGED
@@ -228,6 +228,52 @@ export function hexToRgb(hex) {
228
228
  return null;
229
229
  }
230
230
 
231
+ /**
232
+ * Compute WCAG 2.1 relative luminance for a hex color.
233
+ * @param {string} hex
234
+ * @returns {number|null}
235
+ */
236
+ export function relativeLuminance(hex) {
237
+ const rgb = hexToRgb(hex);
238
+ if (!rgb) return null;
239
+ return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
240
+ }
241
+
242
+ /**
243
+ * Compute WCAG contrast ratios for all pairs in a color palette.
244
+ * @param {Array<{color: string, normalized: string, confidence: string}>} palette
245
+ * @returns {Array<{fg: string, bg: string, ratio: number, aa: boolean, aaLarge: boolean, aaa: boolean}>}
246
+ */
247
+ export function computeWcag(palette) {
248
+ const colors = palette
249
+ .filter(c => c.normalized && c.normalized.startsWith('#'))
250
+ .slice(0, 10);
251
+
252
+ const pairs = [];
253
+ for (let i = 0; i < colors.length; i++) {
254
+ for (let j = i + 1; j < colors.length; j++) {
255
+ const l1 = relativeLuminance(colors[i].normalized);
256
+ const l2 = relativeLuminance(colors[j].normalized);
257
+ if (l1 === null || l2 === null) continue;
258
+ const lighter = Math.max(l1, l2);
259
+ const darker = Math.min(l1, l2);
260
+ const ratio = (lighter + 0.05) / (darker + 0.05);
261
+ const fg = l1 >= l2 ? colors[i].normalized : colors[j].normalized;
262
+ const bg = l1 >= l2 ? colors[j].normalized : colors[i].normalized;
263
+ pairs.push({
264
+ fg,
265
+ bg,
266
+ ratio: Math.round(ratio * 100) / 100,
267
+ aa: ratio >= 4.5,
268
+ aaLarge: ratio >= 3,
269
+ aaa: ratio >= 7,
270
+ });
271
+ }
272
+ }
273
+
274
+ return pairs.sort((a, b) => b.ratio - a.ratio);
275
+ }
276
+
231
277
  /**
232
278
  * Convert any supported color format to all formats
233
279
  * @param {string} colorString - Color in hex, rgb(), or rgba() format
@@ -0,0 +1,251 @@
1
+ export async function extractBreakpoints(page) {
2
+ return await page.evaluate(() => {
3
+ const breakpoints = new Set();
4
+
5
+ for (const sheet of document.styleSheets) {
6
+ try {
7
+ for (const rule of sheet.cssRules || []) {
8
+ if (rule.media) {
9
+ const match = rule.media.mediaText.match(/(\d+)px/g);
10
+ if (match) match.forEach((m) => breakpoints.add(parseInt(m)));
11
+ }
12
+ }
13
+ } catch (e) {}
14
+ }
15
+
16
+ return Array.from(breakpoints)
17
+ .sort((a, b) => a - b)
18
+ .map((px) => ({ px: px + "px" }));
19
+ });
20
+ }
21
+
22
+ export async function detectIconSystem(page) {
23
+ return await page.evaluate(() => {
24
+ const systems = [];
25
+
26
+ if (document.querySelector('[class*="fa-"]')) {
27
+ systems.push({ name: "Font Awesome", type: "icon-font" });
28
+ }
29
+ if (document.querySelector('[class*="material-icons"]')) {
30
+ systems.push({ name: "Material Icons", type: "icon-font" });
31
+ }
32
+ if (document.querySelector('svg[class*="heroicon"]') || document.querySelector('svg[data-slot="icon"]')) {
33
+ systems.push({ name: "Heroicons", type: "svg" });
34
+ }
35
+ if (document.querySelector('svg[class*="hugeicons"]') || document.querySelector('[class*="hugeicons-"]')) {
36
+ systems.push({ name: "Hugeicons", type: "svg" });
37
+ }
38
+ if (document.querySelector('ion-icon') || document.querySelector('[class*="ionicons"]')) {
39
+ systems.push({ name: "Ionicons", type: "svg" });
40
+ }
41
+ if (document.querySelector('svg[data-feather]') || document.querySelector('i[data-feather]') || document.querySelector('[class*="feather-"]')) {
42
+ systems.push({ name: "Feather Icons", type: "svg" });
43
+ }
44
+ if (document.querySelector('svg[class*="icon"]')) {
45
+ systems.push({ name: "SVG Icons", type: "svg" });
46
+ }
47
+
48
+ return systems;
49
+ });
50
+ }
51
+
52
+ export async function detectFrameworks(page) {
53
+ return await page.evaluate(() => {
54
+ const frameworks = [];
55
+ const html = document.documentElement.outerHTML;
56
+ const body = document.body;
57
+
58
+ function countMatches(selector) {
59
+ try {
60
+ return document.querySelectorAll(selector).length;
61
+ } catch {
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ function hasResource(pattern) {
67
+ const links = Array.from(document.querySelectorAll('link[href], script[src]'));
68
+ return links.some(el => pattern.test(el.href || el.src));
69
+ }
70
+
71
+ // Tailwind CSS
72
+ const tailwindEvidence = [];
73
+ if (/\b\w+-\[[^\]]+\]/.test(html)) tailwindEvidence.push('arbitrary values (e.g., top-[117px])');
74
+ if (/(sm|md|lg|xl|2xl|dark|hover|focus|group-hover|peer-):[a-z]/.test(html)) tailwindEvidence.push('responsive/state modifiers');
75
+ if (hasResource(/tailwindcss|tailwind\.css|cdn\.tailwindcss/)) tailwindEvidence.push('stylesheet');
76
+ if (tailwindEvidence.length >= 2) {
77
+ frameworks.push({ name: 'Tailwind CSS', confidence: 'high', evidence: tailwindEvidence.join(', ') });
78
+ }
79
+
80
+ // Bootstrap
81
+ const bootstrapEvidence = [];
82
+ const hasContainer = countMatches('.container, .container-fluid') > 0;
83
+ const hasRow = countMatches('.row') > 0;
84
+ const hasCol = countMatches('[class*="col-"]') > 0;
85
+ if (hasContainer && hasRow && hasCol) bootstrapEvidence.push('grid system (container + row + col)');
86
+ if (/\bbtn-primary\b|\bbtn-secondary\b|\bbtn-success\b/.test(html)) bootstrapEvidence.push('button variants');
87
+ if (hasResource(/bootstrap\.min\.css|bootstrap\.css|getbootstrap\.com/)) bootstrapEvidence.push('stylesheet');
88
+ if (bootstrapEvidence.length >= 2) {
89
+ frameworks.push({ name: 'Bootstrap', confidence: 'high', evidence: bootstrapEvidence.join(', ') });
90
+ }
91
+
92
+ // Material UI (MUI)
93
+ const muiCount = countMatches('[class*="MuiBox-"], [class*="MuiButton-"], [class*="Mui"]');
94
+ if (muiCount > 3) frameworks.push({ name: 'Material UI (MUI)', confidence: 'high', evidence: `${muiCount} MUI components` });
95
+
96
+ // Chakra UI
97
+ const chakraCount = countMatches('[class*="chakra-"]');
98
+ if (chakraCount > 3) frameworks.push({ name: 'Chakra UI', confidence: 'high', evidence: `${chakraCount} Chakra components` });
99
+
100
+ // Ant Design
101
+ const antCount = countMatches('[class^="ant-"], [class*=" ant-"]');
102
+ if (antCount > 3) frameworks.push({ name: 'Ant Design', confidence: 'high', evidence: `${antCount} Ant components` });
103
+
104
+ // Vuetify
105
+ const vuetifySpecific = countMatches('[class*="v-btn"], [class*="v-card"], [class*="v-app"], [class*="v-toolbar"], [class*="v-navigation"], [class*="v-list"], [class*="v-sheet"]');
106
+ const hasVuetifyTheme = body.classList.contains('theme--light') || body.classList.contains('theme--dark');
107
+ const hasVuetifyApp = countMatches('[class*="v-application"]') > 0;
108
+ if ((vuetifySpecific > 8 && hasVuetifyApp) || (hasVuetifyTheme && vuetifySpecific > 5)) {
109
+ frameworks.push({ name: 'Vuetify', confidence: 'high', evidence: `${vuetifySpecific} Vuetify components` });
110
+ }
111
+
112
+ // Shopify Polaris
113
+ const polarisCount = countMatches('[class*="Polaris-"]');
114
+ if (polarisCount > 2) frameworks.push({ name: 'Shopify Polaris', confidence: 'high', evidence: `${polarisCount} Polaris components` });
115
+
116
+ // Radix UI
117
+ const radixCount = document.querySelectorAll('[data-radix-], [data-state]').length;
118
+ if (radixCount > 5) frameworks.push({ name: 'Radix UI', confidence: 'high', evidence: `${radixCount} Radix primitives` });
119
+
120
+ // DaisyUI
121
+ if (tailwindEvidence.length >= 2) {
122
+ const daisySpecific = countMatches('.btn-primary.btn, .badge, .drawer, .swap, .mockup-code');
123
+ const hasDaisyTheme = body.hasAttribute('data-theme');
124
+ if (daisySpecific > 3 || hasDaisyTheme) {
125
+ frameworks.push({ name: 'DaisyUI', confidence: 'high', evidence: `Tailwind + ${daisySpecific} DaisyUI components` });
126
+ }
127
+ }
128
+
129
+ // Foundation
130
+ const foundationEvidence = [];
131
+ if (countMatches('.grid-x, .grid-y, .cell') > 0 || countMatches('.button.primary, .button.secondary') > 0) foundationEvidence.push('grid/button system');
132
+ if (hasResource(/foundation\.min\.css|foundation\.css|zurb\.com\/foundation/)) foundationEvidence.push('stylesheet');
133
+ if (foundationEvidence.length >= 1 || countMatches('[data-foundation]') > 0) {
134
+ frameworks.push({ name: 'Foundation', confidence: 'high', evidence: foundationEvidence.join(', ') || 'data attributes' });
135
+ }
136
+
137
+ // Bulma
138
+ const bulmaEvidence = [];
139
+ if (countMatches('.columns, .column') > 0 && countMatches('.column') > 2) bulmaEvidence.push('columns system');
140
+ if (/\bbutton is-primary\b|\bbutton is-link\b/.test(html)) bulmaEvidence.push('button modifiers');
141
+ if (hasResource(/bulma\.min\.css|bulma\.css/)) bulmaEvidence.push('stylesheet');
142
+ if (bulmaEvidence.length >= 2) frameworks.push({ name: 'Bulma', confidence: 'high', evidence: bulmaEvidence.join(', ') });
143
+
144
+ // Semantic UI
145
+ const semanticCount = countMatches('.ui.button, .ui.menu, .ui.card, .ui.grid');
146
+ if (semanticCount > 3 || hasResource(/semantic\.min\.css|semantic-ui/)) {
147
+ frameworks.push({ name: 'Semantic UI', confidence: 'high', evidence: `${semanticCount} .ui components` });
148
+ }
149
+
150
+ // UIkit
151
+ const uikitCount = countMatches('[class*="uk-"], [uk-grid], [uk-navbar]');
152
+ if (uikitCount > 3 || hasResource(/uikit\.min\.css|getuikit\.com/)) {
153
+ frameworks.push({ name: 'UIkit', confidence: 'high', evidence: `${uikitCount} uk- components` });
154
+ }
155
+
156
+ // shadcn/ui
157
+ const shadcnClasses = /\bcn\(|\bslot-\w+|\bdata-\[state=/.test(html);
158
+ const hasShadcnComponents = countMatches('[data-slot], [data-state]') > 5;
159
+ if (tailwindEvidence.length >= 2 && radixCount > 3 && (shadcnClasses || hasShadcnComponents)) {
160
+ frameworks.push({ name: 'shadcn/ui', confidence: 'medium', evidence: 'Tailwind + Radix + component patterns' });
161
+ }
162
+
163
+ // Headless UI
164
+ const headlessCount = document.querySelectorAll('[aria-controls][aria-expanded], [role="dialog"][data-headlessui]').length;
165
+ if (tailwindEvidence.length >= 2 && headlessCount > 2) {
166
+ frameworks.push({ name: 'Headless UI', confidence: 'high', evidence: `${headlessCount} headless components with Tailwind` });
167
+ }
168
+
169
+ // PrimeReact/Vue/NG
170
+ const primeCount = countMatches('[class*="p-"], .p-component, .p-button, .p-datatable');
171
+ if (primeCount > 5) frameworks.push({ name: 'PrimeReact/Vue/NG', confidence: 'high', evidence: `${primeCount} Prime components` });
172
+
173
+ // Mantine
174
+ const mantineCount = countMatches('[class*="mantine-"], [data-mantine]');
175
+ if (mantineCount > 3) frameworks.push({ name: 'Mantine', confidence: 'high', evidence: `${mantineCount} Mantine components` });
176
+
177
+ // Carbon Design System
178
+ const carbonCount = countMatches('[class*="cds--"], [class*="bx--"]');
179
+ if (carbonCount > 3) frameworks.push({ name: 'Carbon Design System', confidence: 'high', evidence: `${carbonCount} Carbon components` });
180
+
181
+ // Fluent UI
182
+ const fluentCount = countMatches('[class*="ms-"], .ms-Button, .ms-TextField');
183
+ if (fluentCount > 5) frameworks.push({ name: 'Fluent UI', confidence: 'high', evidence: `${fluentCount} Fluent components` });
184
+
185
+ // Quasar
186
+ const quasarCount = countMatches('[class*="q-"]');
187
+ if (quasarCount > 5 || body.classList.contains('q-app')) {
188
+ frameworks.push({ name: 'Quasar', confidence: 'high', evidence: `${quasarCount} q- components` });
189
+ }
190
+
191
+ // Element Plus/UI
192
+ const elementCount = countMatches('[class*="el-"]');
193
+ if (elementCount > 5) frameworks.push({ name: 'Element Plus/UI', confidence: 'high', evidence: `${elementCount} el- components` });
194
+
195
+ return frameworks;
196
+ });
197
+ }
198
+
199
+ export async function extractGradients(page) {
200
+ return await page.evaluate(() => {
201
+ const seen = new Map();
202
+
203
+ const els = document.querySelectorAll('*');
204
+ let checked = 0;
205
+ for (const el of els) {
206
+ if (checked++ > 2000) break;
207
+ if (el.style.backgroundImage === 'none') continue;
208
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) continue;
209
+ const s = getComputedStyle(el);
210
+ const bg = s.backgroundImage;
211
+ if (!bg || bg === 'none') continue;
212
+
213
+ const gradients = [];
214
+ let depth = 0, start = 0;
215
+ for (let i = 0; i < bg.length; i++) {
216
+ if (bg[i] === '(') depth++;
217
+ else if (bg[i] === ')') depth--;
218
+ else if (bg[i] === ',' && depth === 0) {
219
+ gradients.push(bg.slice(start, i).trim());
220
+ start = i + 1;
221
+ }
222
+ }
223
+ gradients.push(bg.slice(start).trim());
224
+
225
+ for (const grad of gradients) {
226
+ if (!/^(repeating-)?(linear|radial|conic)-gradient/.test(grad)) continue;
227
+
228
+ const base = grad.replace(/^repeating-/, '');
229
+ const repeating = grad.startsWith('repeating-');
230
+ const type = (base.startsWith('linear') ? 'linear' : base.startsWith('radial') ? 'radial' : 'conic') + (repeating ? '-repeating' : '');
231
+
232
+ const key = grad.replace(/\s+/g, ' ');
233
+ if (seen.has(key)) {
234
+ seen.get(key).count++;
235
+ continue;
236
+ }
237
+
238
+ const stopColors = [];
239
+ const stopRe = /#[0-9a-f]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)/gi;
240
+ let m;
241
+ while ((m = stopRe.exec(grad)) !== null) stopColors.push(m[0]);
242
+
243
+ seen.set(key, { gradient: key, type, stopColors, count: 1 });
244
+ }
245
+ }
246
+
247
+ return Array.from(seen.values())
248
+ .sort((a, b) => b.count - a.count)
249
+ .slice(0, 20);
250
+ });
251
+ }