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 +63 -58
- package/index.js +42 -3
- package/lib/colors.js +21 -0
- package/lib/design-md.js +259 -0
- package/lib/extractors.js +3 -3
- package/lib/robots.js +101 -0
- package/mcp-server.js +3 -1
- package/package.json +5 -8
package/README.md
CHANGED
|
@@ -4,37 +4,25 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/dembrandt)
|
|
5
5
|
[](https://github.com/dembrandt/dembrandt/blob/main/LICENSE)
|
|
6
6
|
|
|
7
|
-
Extract
|
|
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
|

|
|
10
10
|
|
|
11
|
-
**CLI output**
|
|
12
|
-
|
|
13
|
-

|
|
14
|
-
|
|
15
|
-
**Brand Guide PDF**
|
|
16
|
-
|
|
17
|
-

|
|
18
|
-
|
|
19
|
-
**Local UI**
|
|
20
|
-
|
|
21
|
-

|
|
22
|
-
|
|
23
11
|
## Install
|
|
24
12
|
|
|
25
13
|
Install globally: `npm install -g dembrandt`
|
|
26
14
|
|
|
27
15
|
```bash
|
|
28
|
-
dembrandt
|
|
16
|
+
dembrandt example.com
|
|
29
17
|
```
|
|
30
18
|
|
|
31
|
-
Or use npx without installing: `npx dembrandt
|
|
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
|
|
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>
|
|
73
|
-
dembrandt
|
|
74
|
-
dembrandt
|
|
75
|
-
dembrandt
|
|
76
|
-
dembrandt
|
|
77
|
-
dembrandt
|
|
78
|
-
dembrandt
|
|
79
|
-
dembrandt
|
|
80
|
-
dembrandt
|
|
81
|
-
dembrandt
|
|
82
|
-
dembrandt
|
|
83
|
-
dembrandt
|
|
84
|
-
dembrandt
|
|
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 —
|
|
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
|
|
84
|
+
dembrandt example.com --pages 5
|
|
96
85
|
|
|
97
86
|
# Use sitemap.xml for page discovery instead of DOM link scraping
|
|
98
|
-
dembrandt
|
|
87
|
+
dembrandt example.com --sitemap
|
|
99
88
|
|
|
100
89
|
# Combine both: up to 10 pages from sitemap
|
|
101
|
-
dembrandt
|
|
90
|
+
dembrandt example.com --pages 10 --sitemap
|
|
102
91
|
```
|
|
103
92
|
|
|
104
93
|
**Page discovery** works two ways:
|
|
105
|
-
- **DOM links** (default):
|
|
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
|
|
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
|
|
105
|
+
dembrandt example.com --browser=firefox
|
|
117
106
|
|
|
118
107
|
# Combine with other flags
|
|
119
|
-
dembrandt
|
|
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
|
|
140
|
-
# Saves to: output/
|
|
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
|
|
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
|
|
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
|
-
-
|
|
181
|
-
-
|
|
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,
|
|
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,
|
|
201
|
-
- Medium —
|
|
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
|
|
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 (
|
|
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
|
|
219
|
+
- Default viewport is 1920x1080 (use `--mobile` for 390x844 mobile viewport)
|
|
213
220
|
|
|
214
|
-
##
|
|
221
|
+
## Intended Use
|
|
215
222
|
|
|
216
|
-
Dembrandt
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
Dembrandt does not host, redistribute, or claim rights to any third-party brand assets.
|
|
221
228
|
|
|
222
229
|
## Contributing
|
|
223
230
|
|
|
224
|
-
Bugs
|
|
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
|
-
|
|
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.
|
|
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
|
|
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);
|
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/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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Extract design tokens and
|
|
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
|
-
"
|
|
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
|
+
}
|