dembrandt 0.9.0 → 0.11.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
@@ -4,37 +4,25 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/dembrandt.svg)](https://www.npmjs.com/package/dembrandt)
5
5
  [![license](https://img.shields.io/npm/l/dembrandt.svg)](https://github.com/dembrandt/dembrandt/blob/main/LICENSE)
6
6
 
7
- Extract any websites design system into design tokens in a few seconds: logo, colors, typography, borders, and more. One command.
7
+ Extract a website's design system into design tokens in a few seconds: logo, colors, typography, borders, and more. One command.
8
8
 
9
9
  ![Dembrandt — Any website to design tokens](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/banner.png)
10
10
 
11
- **CLI output**
12
-
13
- ![CLI extraction of netflix.com](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/cli-output.png)
14
-
15
- **Brand Guide PDF**
16
-
17
- ![Brand guide PDF extracted from any URL](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/brand-guide.png)
18
-
19
- **Local UI**
20
-
21
- ![Local UI showing extracted brand](https://raw.githubusercontent.com/dembrandt/dembrandt/main/docs/images/local-ui.png)
22
-
23
11
  ## Install
24
12
 
25
13
  Install globally: `npm install -g dembrandt`
26
14
 
27
15
  ```bash
28
- dembrandt bmw.de
16
+ dembrandt example.com
29
17
  ```
30
18
 
31
- Or use npx without installing: `npx dembrandt bmw.de`
19
+ Or use npx without installing: `npx dembrandt example.com`
32
20
 
33
21
  Requires Node.js 18+
34
22
 
35
23
  ## AI Agent Integration (MCP)
36
24
 
37
- Use Dembrandt as a tool in Claude Code, Cursor, Windsurf, or any MCP-compatible client. Ask your agent to "extract the color palette from stripe.com" and it calls Dembrandt automatically.
25
+ Use Dembrandt as a tool in Claude Code, Cursor, Windsurf, or any MCP-compatible client. Ask your agent to "extract the color palette from example.com" and it calls Dembrandt automatically.
38
26
 
39
27
  ```bash
40
28
  claude mcp add --transport stdio dembrandt -- npx -y dembrandt-mcp
@@ -69,43 +57,44 @@ Or add to your project's `.mcp.json`:
69
57
  ## Usage
70
58
 
71
59
  ```bash
72
- dembrandt <url> # Basic extraction (terminal display only)
73
- dembrandt bmw.de --json-only # Output raw JSON to terminal (no formatted display, no file save)
74
- dembrandt bmw.de --save-output # Save JSON to output/bmw.de/YYYY-MM-DDTHH-MM-SS.json
75
- dembrandt bmw.de --dtcg # Export in W3C Design Tokens (DTCG) format (auto-saves as .tokens.json)
76
- dembrandt bmw.de --dark-mode # Extract colors from dark mode variant
77
- dembrandt bmw.de --mobile # Use mobile viewport (390x844, iPhone 12/13/14/15) for responsive analysis
78
- dembrandt bmw.de --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
79
- dembrandt bmw.de --brand-guide # Generate a brand guide PDF
80
- dembrandt bmw.de --pages 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
81
- dembrandt bmw.de --sitemap # Discover pages from sitemap.xml instead of DOM links
82
- dembrandt bmw.de --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
83
- dembrandt bmw.de --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
84
- dembrandt bmw.de --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
60
+ dembrandt <url> # Basic extraction (terminal display only)
61
+ dembrandt example.com --json-only # Output raw JSON to terminal (no formatted display, no file save)
62
+ dembrandt example.com --save-output # Save JSON to output/example.com/YYYY-MM-DDTHH-MM-SS.json
63
+ dembrandt example.com --dtcg # Export in W3C Design Tokens (DTCG) format (auto-saves as .tokens.json)
64
+ dembrandt example.com --dark-mode # Extract colors from dark mode variant
65
+ dembrandt example.com --mobile # Use mobile viewport (390x844) for responsive analysis
66
+ dembrandt example.com --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
67
+ dembrandt example.com --brand-guide # Generate a brand guide PDF
68
+ dembrandt example.com --design-md # Generate a DESIGN.md file for AI agents
69
+ dembrandt example.com --pages 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
70
+ dembrandt example.com --sitemap # Discover pages from sitemap.xml instead of DOM links
71
+ dembrandt example.com --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
72
+ dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
73
+ dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
85
74
  ```
86
75
 
87
76
  Default: formatted terminal display only. Use `--save-output` to persist results as JSON files. Browser automatically retries in visible mode if headless extraction fails.
88
77
 
89
78
  ### Multi-Page Extraction
90
79
 
91
- Analyze multiple pages to get a more complete picture of a site's design system. Results are merged into a single unified output with cross-page confidence boosting — colors appearing on multiple pages get higher confidence scores.
80
+ Analyze multiple pages to get a more complete picture of a site's design system. Results are merged into a single unified output with cross-page confidence boosting — tokens appearing on multiple pages get higher confidence scores.
92
81
 
93
82
  ```bash
94
83
  # Analyze homepage + 4 auto-discovered pages (default: 5 total)
95
- dembrandt stripe.com --pages 5
84
+ dembrandt example.com --pages 5
96
85
 
97
86
  # Use sitemap.xml for page discovery instead of DOM link scraping
98
- dembrandt stripe.com --sitemap
87
+ dembrandt example.com --sitemap
99
88
 
100
89
  # Combine both: up to 10 pages from sitemap
101
- dembrandt stripe.com --pages 10 --sitemap
90
+ dembrandt example.com --pages 10 --sitemap
102
91
  ```
103
92
 
104
93
  **Page discovery** works two ways:
105
- - **DOM links** (default): Scrapes navigation, header, and footer links from the homepage, prioritizing key pages like /pricing, /about, /features
94
+ - **DOM links** (default): Reads navigation, header, and footer links from the homepage, prioritizing key pages like /pricing, /about, /features
106
95
  - **Sitemap** (`--sitemap`): Parses sitemap.xml (checks robots.txt first), follows sitemapindex references, and scores URLs by importance
107
96
 
108
- Pages are crawled sequentially with polite delays. Failed pages are skipped without aborting the run.
97
+ Pages are fetched sequentially with polite delays. Failed pages are skipped without aborting the run.
109
98
 
110
99
  ### Browser Selection
111
100
 
@@ -113,10 +102,10 @@ By default, dembrandt uses Chromium. If you encounter bot detection or timeouts
113
102
 
114
103
  ```bash
115
104
  # Use Firefox instead of Chromium
116
- dembrandt bmw.de --browser=firefox
105
+ dembrandt example.com --browser=firefox
117
106
 
118
107
  # Combine with other flags
119
- dembrandt bmw.de --browser=firefox --save-output --dtcg
108
+ dembrandt example.com --browser=firefox --save-output --dtcg
120
109
  ```
121
110
 
122
111
  **When to use Firefox:**
@@ -136,15 +125,33 @@ npx playwright install firefox
136
125
  Use `--dtcg` to export in the standardized [W3C Design Tokens Community Group](https://www.designtokens.org/) format:
137
126
 
138
127
  ```bash
139
- dembrandt stripe.com --dtcg
140
- # Saves to: output/stripe.com/TIMESTAMP.tokens.json
128
+ dembrandt example.com --dtcg
129
+ # Saves to: output/example.com/TIMESTAMP.tokens.json
141
130
  ```
142
131
 
143
132
  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
133
 
134
+ ### DESIGN.md
135
+
136
+ 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.
137
+
138
+ ```bash
139
+ dembrandt example.com --design-md
140
+ # Saves to: output/example.com/DESIGN.md
141
+ ```
142
+
143
+ ### Brand Guide PDF
144
+
145
+ Use `--brand-guide` to generate a printable PDF summarizing the extracted design system — colors, typography, components, and logo on a single document.
146
+
147
+ ```bash
148
+ dembrandt example.com --brand-guide
149
+ # Saves to: output/example.com/TIMESTAMP.brand-guide.pdf
150
+ ```
151
+
145
152
  ## Local UI
146
153
 
147
- Browse your extracted brands in a visual interface.
154
+ Browse your extractions in a visual interface.
148
155
 
149
156
  ### Setup
150
157
 
@@ -163,7 +170,7 @@ Opens http://localhost:5173 with API on port 3002.
163
170
 
164
171
  ### Features
165
172
 
166
- - Visual grid of all extracted brands
173
+ - Visual grid of all extractions
167
174
  - Color palettes with click-to-copy
168
175
  - Typography specimens
169
176
  - Spacing, shadows, border radius visualization
@@ -175,14 +182,14 @@ Extractions are performed via CLI (`dembrandt <url> --save-output`) and automati
175
182
 
176
183
  ## Use Cases
177
184
 
178
- - Brand audits & competitive analysis
179
185
  - Design system documentation
180
- - Reverse engineering brands
181
- - Multi-site brand consolidation
186
+ - Multi-site design consolidation
187
+ - Internal design audits on your own properties
188
+ - Learning how design tokens map to real CSS
182
189
 
183
190
  ## How It Works
184
191
 
185
- Uses Playwright to render the page, extracts computed styles from the DOM, analyzes color usage and confidence, groups similar typography, detects spacing patterns, and returns actionable design tokens.
192
+ Uses Playwright to render the page, reads computed styles from the DOM, analyzes color usage and confidence, groups similar typography, detects spacing patterns, and returns design tokens.
186
193
 
187
194
  ### Extraction Process
188
195
 
@@ -197,35 +204,33 @@ Uses Playwright to render the page, extracts computed styles from the DOM, analy
197
204
 
198
205
  ### Color Confidence
199
206
 
200
- - High — Logo, brand elements, primary buttons
201
- - Medium — Interactive elements, icons, navigation
207
+ - High — Logo, primary interactive elements
208
+ - Medium — Secondary interactive elements, icons, navigation
202
209
  - Low — Generic UI components (filtered from display)
203
210
  - Only shows high and medium confidence colors in terminal. Full palette in JSON.
204
211
 
205
212
  ## Limitations
206
213
 
207
- - Dark mode requires --dark-mode flag (not automatically detected)
214
+ - Dark mode requires `--dark-mode` flag (not automatically detected)
208
215
  - Hover/focus states extracted from CSS (not fully interactive)
209
- - Canvas/WebGL-rendered sites cannot be analyzed (e.g., Tesla, Apple Vision Pro demos)
216
+ - Canvas/WebGL-rendered sites cannot be analyzed (no DOM to read)
210
217
  - JavaScript-heavy sites require hydration time (8s initial + 4s stabilization)
211
218
  - Some dynamically-loaded content may be missed
212
- - Default viewport is 1920x1080 (use --mobile for 390x844 iPhone viewport)
219
+ - Default viewport is 1920x1080 (use `--mobile` for 390x844 mobile viewport)
213
220
 
214
- ## Ethics & Legality
221
+ ## Intended Use
215
222
 
216
- Dembrandt extracts publicly available design information (colors, fonts, spacing) from website DOMs for analysis purposes. This falls under fair use in most jurisdictions (USA's DMCA § 1201(f), EU Software Directive 2009/24/EC) when used for competitive analysis, documentation, or learning.
223
+ Dembrandt reads publicly available CSS and computed styles from website DOMs for documentation, learning, and analysis of design systems you own or have permission to analyze.
217
224
 
218
- Legal: Analyzing public HTML/CSS is generally legal. Does not bypass protections or violate copyright. Check site ToS before mass extraction.
225
+ Only run Dembrandt against sites whose Terms of Service permit automated access, or against your own properties. Do not use extracted material to reproduce third-party brand identities, logos, or trademarks. Respect robots.txt, rate limits, and copyright.
219
226
 
220
- Ethical: Use for inspiration and analysis, not direct copying. Respect servers (no mass crawling), give credit to sources, be transparent about data origin.
227
+ Dembrandt does not host, redistribute, or claim rights to any third-party brand assets.
221
228
 
222
229
  ## Contributing
223
230
 
224
- Bugs you found? Weird websites that make it cry? Pull requests (even one-liners make me happy)?
225
-
226
- Spam me in [Issues](https://github.com/dembrandt/dembrandt/issues) or PRs. I reply to everything.
231
+ Bugs, weird sites, pull requests all welcome.
227
232
 
228
- Let's keep the light alive together.
233
+ Open an [Issue](https://github.com/dembrandt/dembrandt/issues) or PR.
229
234
 
230
235
  @thevangelist
231
236
 
package/index.js CHANGED
@@ -15,15 +15,17 @@ 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";
21
22
  import { join } from "path";
23
+ import { checkRobotsTxt } from "./lib/robots.js";
22
24
 
23
25
  program
24
26
  .name("dembrandt")
25
27
  .description("Extract design tokens from any website")
26
- .version("0.9.0")
28
+ .version("0.11.0")
27
29
  .argument("<url>")
28
30
  .option("--browser <type>", "Browser to use (chromium|firefox)", "chromium")
29
31
  .option("--json-only", "Output raw JSON")
@@ -33,6 +35,7 @@ program
33
35
  .option("--mobile", "Extract from mobile viewport")
34
36
  .option("--slow", "3x longer timeouts for slow-loading sites")
35
37
  .option("--brand-guide", "Export a brand guide PDF")
38
+ .option("--design-md", "Export a DESIGN.md file")
36
39
  .option("--no-sandbox", "Disable browser sandbox (needed for Docker/CI)")
37
40
  .option("--raw-colors", "Include pre-filter raw colors in JSON output")
38
41
  .option("--screenshot <path>", "Save a screenshot of the page")
@@ -55,6 +58,21 @@ program
55
58
  }
56
59
 
57
60
  const spinner = ora({ text: "Starting extraction...", stream: opts.jsonOnly ? process.stderr : process.stdout }).start();
61
+
62
+ try {
63
+ const robots = await checkRobotsTxt(url);
64
+ if (robots.status === "ok" && robots.allowed === false) {
65
+ spinner.warn(
66
+ chalk.hex("#FFB86C")(
67
+ `robots.txt disallows this path (rule: "${robots.rule}"). Proceeding anyway — respect the site's terms.`
68
+ )
69
+ );
70
+ spinner.start("Starting extraction...");
71
+ }
72
+ } catch {
73
+ // robots check is advisory; never block extraction
74
+ }
75
+
58
76
  let browser = null;
59
77
 
60
78
  try {
@@ -99,8 +117,7 @@ program
99
117
  let additionalUrls;
100
118
  if (opts.sitemap) {
101
119
  // Try post-redirect URL first, fall back to user-provided URL
102
- // (sites like spotify.com redirect browser to open.spotify.com
103
- // but sitemap lives at www.spotify.com)
120
+ // (some sites redirect to a subdomain while the sitemap stays on www)
104
121
  additionalUrls = await parseSitemap(result.url, maxPages);
105
122
  if (additionalUrls.length === 0 && result.url !== url) {
106
123
  additionalUrls = await parseSitemap(url, maxPages);
@@ -242,6 +259,28 @@ program
242
259
  }
243
260
  }
244
261
 
262
+ // Generate DESIGN.md
263
+ if (opts.designMd) {
264
+ try {
265
+ const mdDomain = new URL(url).hostname.replace("www.", "");
266
+ const mdDir = join(process.cwd(), "output", mdDomain);
267
+ mkdirSync(mdDir, { recursive: true });
268
+ const mdPath = join(mdDir, "DESIGN.md");
269
+ writeFileSync(mdPath, generateDesignMd(result));
270
+ console.log(
271
+ chalk.dim(
272
+ `DESIGN.md saved to: ${chalk.hex('#8BE9FD')(
273
+ `output/${mdDomain}/DESIGN.md`
274
+ )}`
275
+ )
276
+ );
277
+ } catch (err) {
278
+ console.log(
279
+ chalk.hex('#FFB86C')(`Could not generate DESIGN.md: ${err.message}`)
280
+ );
281
+ }
282
+ }
283
+
245
284
  // Output to terminal
246
285
  if (opts.jsonOnly) {
247
286
  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/lib/extractors.js CHANGED
@@ -131,7 +131,7 @@ export async function extractBranding(
131
131
  timeouts.push('Body content rendering');
132
132
  }
133
133
 
134
- // Give SPAs time to hydrate (Linear, Figma, Notion, etc.)
134
+ // Give SPAs time to hydrate
135
135
  spinner.start("Waiting for SPA hydration...");
136
136
  const hydrationTime = 8000 * timeoutMultiplier;
137
137
  await page.waitForTimeout(hydrationTime);
@@ -627,7 +627,7 @@ export async function extractBranding(
627
627
  frameworks,
628
628
  };
629
629
 
630
- // Detect canvas-only / WebGL sites (Tesla, Apple Vision Pro, etc.)
630
+ // Detect canvas-only / WebGL sites
631
631
  const isCanvasOnly = await page.evaluate(() => {
632
632
  const canvases = document.querySelectorAll("canvas");
633
633
  const hasRealContent = document.body.textContent.trim().length > 200;
@@ -641,7 +641,7 @@ export async function extractBranding(
641
641
 
642
642
  if (isCanvasOnly) {
643
643
  result.note =
644
- "This website uses canvas/WebGL rendering (e.g. Tesla, Apple Vision Pro). Design system cannot be extracted from DOM.";
644
+ "This website uses canvas/WebGL rendering. Design system cannot be extracted from DOM.";
645
645
  result.isCanvasOnly = true;
646
646
  }
647
647
 
package/lib/robots.js ADDED
@@ -0,0 +1,101 @@
1
+ const UA = "Dembrandt";
2
+
3
+ export async function checkRobotsTxt(targetUrl, { timeoutMs = 5000 } = {}) {
4
+ const u = new URL(targetUrl);
5
+ const robotsUrl = `${u.protocol}//${u.host}/robots.txt`;
6
+ const path = u.pathname || "/";
7
+
8
+ const controller = new AbortController();
9
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
10
+
11
+ let body;
12
+ try {
13
+ const res = await fetch(robotsUrl, {
14
+ signal: controller.signal,
15
+ headers: { "User-Agent": UA },
16
+ });
17
+ if (!res.ok) return { status: "unavailable", robotsUrl };
18
+ body = await res.text();
19
+ } catch {
20
+ return { status: "unavailable", robotsUrl };
21
+ } finally {
22
+ clearTimeout(timer);
23
+ }
24
+
25
+ const groups = parseRobots(body);
26
+ const rules = matchGroup(groups, UA) || matchGroup(groups, "*") || [];
27
+ const decision = evaluate(rules, path);
28
+
29
+ return { status: "ok", robotsUrl, ...decision };
30
+ }
31
+
32
+ function parseRobots(text) {
33
+ const groups = [];
34
+ let current = null;
35
+ let lastWasAgent = false;
36
+
37
+ for (const raw of text.split(/\r?\n/)) {
38
+ const line = raw.replace(/#.*$/, "").trim();
39
+ if (!line) continue;
40
+ const idx = line.indexOf(":");
41
+ if (idx === -1) continue;
42
+ const field = line.slice(0, idx).trim().toLowerCase();
43
+ const value = line.slice(idx + 1).trim();
44
+
45
+ if (field === "user-agent") {
46
+ if (!current || !lastWasAgent) {
47
+ current = { agents: [], rules: [] };
48
+ groups.push(current);
49
+ }
50
+ current.agents.push(value.toLowerCase());
51
+ lastWasAgent = true;
52
+ } else if (field === "allow" || field === "disallow") {
53
+ if (!current) {
54
+ current = { agents: ["*"], rules: [] };
55
+ groups.push(current);
56
+ }
57
+ current.rules.push({ type: field, value });
58
+ lastWasAgent = false;
59
+ }
60
+ }
61
+ return groups;
62
+ }
63
+
64
+ function matchGroup(groups, agent) {
65
+ const wanted = agent.toLowerCase();
66
+ for (const g of groups) {
67
+ if (g.agents.includes(wanted)) return g.rules;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function evaluate(rules, path) {
73
+ let best = { type: null, length: -1, value: "" };
74
+ for (const r of rules) {
75
+ if (!r.value) continue;
76
+ if (!pathMatches(path, r.value)) continue;
77
+ if (r.value.length > best.length) best = { ...r, length: r.value.length };
78
+ }
79
+ if (best.type === "disallow") return { allowed: false, rule: best.value };
80
+ return { allowed: true, rule: best.value || null };
81
+ }
82
+
83
+ function pathMatches(path, pattern) {
84
+ const anchored = pattern.endsWith("$");
85
+ const p = anchored ? pattern.slice(0, -1) : pattern;
86
+ const parts = p.split("*");
87
+ let i = 0;
88
+ for (let k = 0; k < parts.length; k++) {
89
+ const seg = parts[k];
90
+ if (k === 0) {
91
+ if (!path.startsWith(seg)) return false;
92
+ i = seg.length;
93
+ } else {
94
+ const found = path.indexOf(seg, i);
95
+ if (found === -1) return false;
96
+ i = found + seg.length;
97
+ }
98
+ }
99
+ if (anchored && i !== path.length) return false;
100
+ return true;
101
+ }
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
  /**
@@ -112,7 +114,7 @@ function toolHandler(pick, extraOptions = {}) {
112
114
 
113
115
  // ── Shared params ──────────────────────────────────────────────────────
114
116
 
115
- const url = z.string().describe("Website URL (e.g. stripe.com)");
117
+ const url = z.string().describe("Website URL (e.g. example.com)");
116
118
  const slow = z.boolean().optional().default(false).describe("3x timeouts for heavy SPAs");
117
119
 
118
120
  // ── Tools ──────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.9.0",
4
- "description": "Extract design tokens and brand assets from any website",
3
+ "version": "0.11.0",
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",
7
7
  "type": "module",
@@ -16,8 +16,6 @@
16
16
  ],
17
17
  "scripts": {
18
18
  "start": "node index.js",
19
- "brand-challenge": "node run-no-login-challenge.mjs",
20
- "brand-challenge:report": "node run-no-login-challenge.mjs || true",
21
19
  "install-browser": "npx playwright install chromium firefox || echo 'Playwright browser installation failed. You may need to install system dependencies manually.'",
22
20
  "local-ui": "cd local-ui && npm start",
23
21
  "qa:baseline": "node test/qa.mjs --baseline",
@@ -28,10 +26,9 @@
28
26
  "design-tokens",
29
27
  "design-system",
30
28
  "branding",
31
- "web-scraping",
29
+ "css-analysis",
32
30
  "cli",
33
- "playwright",
34
- "extraction"
31
+ "playwright"
35
32
  ],
36
33
  "repository": {
37
34
  "type": "git",
@@ -56,4 +53,4 @@
56
53
  "engines": {
57
54
  "node": ">=18.0.0"
58
55
  }
59
- }
56
+ }