dembrandt 0.12.9 → 0.12.10

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
@@ -67,9 +67,9 @@ dembrandt example.com --mobile # Use mobile viewport (390x844) for respo
67
67
  dembrandt example.com --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
68
68
  dembrandt example.com --brand-guide # Generate a brand guide PDF
69
69
  dembrandt example.com --design-md # Generate a DESIGN.md file for AI agents
70
- dembrandt example.com --pages 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
70
+ dembrandt example.com --crawl 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
71
71
  dembrandt example.com --sitemap # Discover pages from sitemap.xml instead of DOM links
72
- dembrandt example.com --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
72
+ dembrandt example.com --crawl 10 --sitemap # Combine: up to 10 pages discovered via sitemap
73
73
  dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
74
74
  dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
75
75
  dembrandt example.com --wcag # WCAG 2.1 contrast analysis — real DOM pairs, AA/AAA grades
@@ -83,13 +83,13 @@ Analyze multiple pages to get a more complete picture of a site's design system.
83
83
 
84
84
  ```bash
85
85
  # Analyze homepage + 4 auto-discovered pages (default: 5 total)
86
- dembrandt example.com --pages 5
86
+ dembrandt example.com --crawl 5
87
87
 
88
88
  # Use sitemap.xml for page discovery instead of DOM link scraping
89
89
  dembrandt example.com --sitemap
90
90
 
91
91
  # Combine both: up to 10 pages from sitemap
92
- dembrandt example.com --pages 10 --sitemap
92
+ dembrandt example.com --crawl 10 --sitemap
93
93
  ```
94
94
 
95
95
  **Page discovery** works two ways:
@@ -197,7 +197,7 @@ dembrandt braintree.com --save-output
197
197
 
198
198
  **Multi-page audit** — get a fuller picture across the whole site
199
199
  ```bash
200
- dembrandt stripe.com --pages 10 --sitemap --save-output
200
+ dembrandt stripe.com --crawl 10 --sitemap --save-output
201
201
  ```
202
202
 
203
203
  **Spot-check a value** — verify a specific token fast
package/index.js CHANGED
@@ -132,6 +132,7 @@ program
132
132
  screenshotPath: opts.screenshot,
133
133
  discoverLinks: isAutoCrawl ? crawlN - 1 : null,
134
134
  wcag: opts.wcag,
135
+ includeRawColors: opts.rawColors,
135
136
  });
136
137
 
137
138
  // Build list of additional URLs to extract
@@ -168,7 +168,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
168
168
  spinner.stop();
169
169
  console.log(color.info("\n Extracting design tokens...\n"));
170
170
 
171
- spinner.start("Analyzing design system (16 parallel tasks)...");
171
+ spinner.start("Analyzing design system (17 parallel tasks)...");
172
172
  const [
173
173
  { logo, instances: logoInstances, favicons },
174
174
  colors,
package/lib/merger.js CHANGED
@@ -275,6 +275,56 @@ function mergeByName(results, getter) {
275
275
  return out;
276
276
  }
277
277
 
278
+ function mergeGradients(results) {
279
+ const map = new Map();
280
+ results.forEach(r => {
281
+ (r.gradients || []).forEach(g => {
282
+ const key = g.gradient;
283
+ if (!map.has(key)) {
284
+ map.set(key, { ...g });
285
+ } else {
286
+ map.get(key).count += (g.count || 1);
287
+ }
288
+ });
289
+ });
290
+ return [...map.values()].sort((a, b) => b.count - a.count);
291
+ }
292
+
293
+ function mergeMotion(results) {
294
+ const base = results[0].motion || { durations: [], easings: [], animations: [], contexts: {}, interactiveDeltas: [] };
295
+
296
+ const durationMap = new Map();
297
+ results.forEach(r => {
298
+ (r.motion?.durations || []).forEach(d => {
299
+ if (!durationMap.has(d.value)) durationMap.set(d.value, { ...d });
300
+ else durationMap.get(d.value).count += (d.count || 1);
301
+ });
302
+ });
303
+
304
+ const easingMap = new Map();
305
+ results.forEach(r => {
306
+ (r.motion?.easings || []).forEach(e => {
307
+ if (!easingMap.has(e.value)) easingMap.set(e.value, { ...e });
308
+ else easingMap.get(e.value).count += (e.count || 1);
309
+ });
310
+ });
311
+
312
+ const animMap = new Map();
313
+ results.forEach(r => {
314
+ (r.motion?.animations || []).forEach(a => {
315
+ if (!animMap.has(a.name || a.value)) animMap.set(a.name || a.value, { ...a });
316
+ else animMap.get(a.name || a.value).count += (a.count || 1);
317
+ });
318
+ });
319
+
320
+ return {
321
+ ...base,
322
+ durations: [...durationMap.values()].sort((a, b) => a.ms - b.ms),
323
+ easings: [...easingMap.values()].sort((a, b) => b.count - a.count).slice(0, 8),
324
+ animations: [...animMap.values()].sort((a, b) => b.count - a.count).slice(0, 8),
325
+ };
326
+ }
327
+
278
328
  /**
279
329
  * Merge an array of per-page result objects into a single unified result.
280
330
  * @param {Object[]} results - Array of extractBranding() result objects
@@ -298,6 +348,8 @@ export function mergeResults(results) {
298
348
  borderRadius: mergeBorderRadius(results),
299
349
  borders: mergeBorders(results),
300
350
  shadows: mergeShadows(results),
351
+ gradients: mergeGradients(results),
352
+ motion: mergeMotion(results),
301
353
  components: mergeComponents(results),
302
354
  breakpoints: [
303
355
  ...new Map(
package/mcp-server.js CHANGED
@@ -99,16 +99,119 @@ function errorResult(message) {
99
99
  return { content: [{ type: "text", text: message }], isError: true };
100
100
  }
101
101
 
102
+ // ── Job Queue ──────────────────────────────────────────────────────────
103
+
104
+ class JobQueue {
105
+ #jobs = new Map();
106
+ #queue = [];
107
+ #running = new Set();
108
+ #maxConcurrent = 2;
109
+
110
+ enqueue(url, opts, pick) {
111
+ const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
112
+ this.#jobs.set(id, {
113
+ status: "queued",
114
+ url,
115
+ opts,
116
+ pick,
117
+ createdAt: Date.now(),
118
+ startedAt: undefined,
119
+ completedAt: undefined,
120
+ result: undefined,
121
+ error: undefined,
122
+ });
123
+ this.#queue.push(id);
124
+ void this.#drain();
125
+ return id;
126
+ }
127
+
128
+ get(id) {
129
+ return this.#jobs.get(id) ?? null;
130
+ }
131
+
132
+ cancel(id) {
133
+ const job = this.#jobs.get(id);
134
+ if (!job || job.status !== "queued") return false;
135
+ job.status = "cancelled";
136
+ job.completedAt = Date.now();
137
+ const idx = this.#queue.indexOf(id);
138
+ if (idx !== -1) this.#queue.splice(idx, 1);
139
+ return true;
140
+ }
141
+
142
+ async #drain() {
143
+ while (this.#queue.length > 0 && this.#running.size < this.#maxConcurrent) {
144
+ const id = this.#queue.shift();
145
+ const job = this.#jobs.get(id);
146
+ if (!job || job.status === "cancelled") continue;
147
+
148
+ job.status = "running";
149
+ job.startedAt = Date.now();
150
+ this.#running.add(id);
151
+
152
+ runExtraction(job.url, job.opts)
153
+ .then((result) => {
154
+ if (job.status === "cancelled") return;
155
+ if (result.ok) {
156
+ job.status = "completed";
157
+ job.result = job.pick(result.data);
158
+ } else {
159
+ job.status = "failed";
160
+ job.error = result.error;
161
+ }
162
+ })
163
+ .catch((err) => {
164
+ if (job.status !== "cancelled") {
165
+ job.status = "failed";
166
+ job.error = err.message || String(err);
167
+ }
168
+ })
169
+ .finally(() => {
170
+ job.completedAt = Date.now();
171
+ this.#running.delete(id);
172
+ void this.#drain();
173
+ });
174
+ }
175
+ }
176
+
177
+ // Remove completed/failed/cancelled jobs older than 1 hour
178
+ cleanup() {
179
+ const cutoff = Date.now() - 3_600_000;
180
+ for (const [id, job] of this.#jobs) {
181
+ if (
182
+ ["completed", "failed", "cancelled"].includes(job.status) &&
183
+ job.completedAt !== undefined &&
184
+ job.completedAt < cutoff
185
+ ) {
186
+ this.#jobs.delete(id);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ const jobQueue = new JobQueue();
193
+ setInterval(() => jobQueue.cleanup(), 600_000);
194
+
195
+ // ── Helpers ────────────────────────────────────────────────────────────
196
+
102
197
  /**
103
- * Wrapper that handles extraction + error formatting for all tools.
104
- * `pick` receives the full result and returns the filtered subset.
198
+ * Wrapper for extraction tools.
199
+ * Async by default: enqueues and returns a job_id immediately.
200
+ * Pass sync: true to block and return the result directly.
105
201
  */
106
202
  function toolHandler(pick, extraOptions = {}) {
107
203
  return async (params) => {
108
- const { url, slow, darkMode } = params;
109
- const result = await runExtraction(url, { slow, darkMode, ...extraOptions });
110
- if (!result.ok) return errorResult(result.error);
111
- return jsonResult(pick(result.data));
204
+ const { url, slow, darkMode, sync } = params;
205
+ const opts = { slow, darkMode, ...extraOptions };
206
+
207
+ if (sync) {
208
+ const result = await runExtraction(url, opts);
209
+ if (!result.ok) return errorResult(result.error);
210
+ return jsonResult(pick(result.data));
211
+ }
212
+
213
+ const jobId = jobQueue.enqueue(url, opts, pick);
214
+ return jsonResult({ job_id: jobId, status: "queued" });
112
215
  };
113
216
  }
114
217
 
@@ -116,21 +219,22 @@ function toolHandler(pick, extraOptions = {}) {
116
219
 
117
220
  const url = z.string().describe("Website URL (e.g. example.com)");
118
221
  const slow = z.boolean().optional().default(false).describe("3x timeouts for heavy SPAs");
222
+ const sync = z.boolean().optional().default(false).describe("Wait for result directly instead of returning a job_id (blocks 15-40s)");
119
223
 
120
- // ── Tools ──────────────────────────────────────────────────────────────
224
+ // ── Extraction tools ───────────────────────────────────────────────────
121
225
 
122
226
  server.tool(
123
227
  "get_design_tokens",
124
- "Extract the full design system from a live website. Launches a real browser, navigates to the site, and returns production-ready design tokens: color palette (hex, RGB, LCH, OKLCH) with semantic roles and CSS custom properties, typography scale (families, fallbacks, sizes, weights, line heights, letter spacing by context), spacing system with grid detection, border radii, border patterns, box shadows for elevation, component styles (buttons with hover/focus states, inputs, links, badges), responsive breakpoints, logo and favicons, site name, detected CSS frameworks, and icon systems. Takes 15-40 seconds depending on site complexity.",
125
- { url, slow },
228
+ "Extract the full design system from a live website. Launches a real browser, navigates to the site, and returns production-ready design tokens: color palette (hex, RGB, LCH, OKLCH) with semantic roles and CSS custom properties, typography scale (families, fallbacks, sizes, weights, line heights, letter spacing by context), spacing system with grid detection, border radii, border patterns, box shadows for elevation, component styles (buttons with hover/focus states, inputs, links, badges), responsive breakpoints, logo and favicons, site name, detected CSS frameworks, and icon systems. Returns a job_id by default use get_job_status to poll for the result.",
229
+ { url, slow, sync },
126
230
  toolHandler((d) => d),
127
231
  );
128
232
 
129
233
  server.tool(
130
234
  "get_color_palette",
131
- "Extract brand colors from a live website. Returns semantic colors (primary, secondary, accent), full palette ranked by usage frequency and confidence (high/medium/low), CSS custom properties with their design-system names, and hover/focus state colors discovered by simulating real user interactions. Each color in hex, RGB, LCH, and OKLCH.",
235
+ "Extract brand colors from a live website. Returns semantic colors (primary, secondary, accent), full palette ranked by usage frequency and confidence (high/medium/low), CSS custom properties with their design-system names, and hover/focus state colors discovered by simulating real user interactions. Each color in hex, RGB, LCH, and OKLCH. Returns a job_id by default — use get_job_status to poll for the result.",
132
236
  {
133
- url, slow,
237
+ url, slow, sync,
134
238
  darkMode: z.boolean().optional().default(false).describe("Also extract dark mode palette"),
135
239
  },
136
240
  toolHandler((d) => ({ url: d.url, colors: d.colors })),
@@ -138,22 +242,22 @@ server.tool(
138
242
 
139
243
  server.tool(
140
244
  "get_typography",
141
- "Extract typography from a live website. Returns every font family with its fallback stack, the complete type scale grouped by context (heading, body, button, link, caption) with pixel and rem sizes, weights, line heights, letter spacing, and text transforms. Also reports font sources: Google Fonts URLs, Adobe Fonts usage, and variable font detection.",
142
- { url, slow },
245
+ "Extract typography from a live website. Returns every font family with its fallback stack, the complete type scale grouped by context (heading, body, button, link, caption) with pixel and rem sizes, weights, line heights, letter spacing, and text transforms. Also reports font sources: Google Fonts URLs, Adobe Fonts usage, and variable font detection. Returns a job_id by default — use get_job_status to poll for the result.",
246
+ { url, slow, sync },
143
247
  toolHandler((d) => ({ url: d.url, typography: d.typography })),
144
248
  );
145
249
 
146
250
  server.tool(
147
251
  "get_component_styles",
148
- "Extract UI component styles from a live website. Returns button variants with default, hover, active, and focus states (background, text color, padding, border radius, border, shadow, outline, opacity), input field styles (border, focus ring, padding, placeholder), link styles (color, text decoration, hover changes), and badge/tag styles.",
149
- { url, slow },
252
+ "Extract UI component styles from a live website. Returns button variants with default, hover, active, and focus states (background, text color, padding, border radius, border, shadow, outline, opacity), input field styles (border, focus ring, padding, placeholder), link styles (color, text decoration, hover changes), and badge/tag styles. Returns a job_id by default — use get_job_status to poll for the result.",
253
+ { url, slow, sync },
150
254
  toolHandler((d) => ({ url: d.url, components: d.components })),
151
255
  );
152
256
 
153
257
  server.tool(
154
258
  "get_surfaces",
155
- "Extract surface treatment tokens from a live website: border radii with element context (which radii are used on buttons vs cards vs inputs vs modals), border patterns (width + style + color combinations), and box shadow elevation levels.",
156
- { url, slow },
259
+ "Extract surface treatment tokens from a live website: border radii with element context (which radii are used on buttons vs cards vs inputs vs modals), border patterns (width + style + color combinations), and box shadow elevation levels. Returns a job_id by default — use get_job_status to poll for the result.",
260
+ { url, slow, sync },
157
261
  toolHandler((d) => ({
158
262
  url: d.url,
159
263
  borderRadius: d.borderRadius,
@@ -164,15 +268,15 @@ server.tool(
164
268
 
165
269
  server.tool(
166
270
  "get_spacing",
167
- "Extract the spacing system from a live website: common margin and padding values sorted by frequency, pixel and rem values, and grid system detection (4px, 8px, or custom scale).",
168
- { url, slow },
271
+ "Extract the spacing system from a live website: common margin and padding values sorted by frequency, pixel and rem values, and grid system detection (4px, 8px, or custom scale). Returns a job_id by default — use get_job_status to poll for the result.",
272
+ { url, slow, sync },
169
273
  toolHandler((d) => ({ url: d.url, spacing: d.spacing })),
170
274
  );
171
275
 
172
276
  server.tool(
173
277
  "get_brand_identity",
174
- "Extract brand identity from a live website: site name, logo (source, dimensions, safe zone), all favicon variants (icon, apple-touch-icon, og:image, twitter:image with sizes and URLs), detected CSS frameworks (Tailwind, Bootstrap, MUI, etc.), icon systems (Font Awesome, Material Icons, SVG), and responsive breakpoints.",
175
- { url, slow },
278
+ "Extract brand identity from a live website: site name, logo (source, dimensions, safe zone), all favicon variants (icon, apple-touch-icon, og:image, twitter:image with sizes and URLs), detected CSS frameworks (Tailwind, Bootstrap, MUI, etc.), icon systems (Font Awesome, Material Icons, SVG), and responsive breakpoints. Returns a job_id by default — use get_job_status to poll for the result.",
279
+ { url, slow, sync },
176
280
  toolHandler((d) => ({
177
281
  url: d.url,
178
282
  siteName: d.siteName,
@@ -184,6 +288,31 @@ server.tool(
184
288
  })),
185
289
  );
186
290
 
291
+ // ── Job management tools ───────────────────────────────────────────────
292
+
293
+ server.tool(
294
+ "get_job_status",
295
+ "Poll for the result of an async extraction job. Returns status (queued/running/completed/failed/cancelled) and the full result once completed. Call this after any extraction tool that returned a job_id.",
296
+ { job_id: z.string().describe("The job_id returned by an extraction tool") },
297
+ ({ job_id }) => {
298
+ const job = jobQueue.get(job_id);
299
+ if (!job) return errorResult(`No job found with id: ${job_id}`);
300
+ if (job.status === "completed") return jsonResult({ job_id, status: "completed", result: job.result });
301
+ if (job.status === "failed") return errorResult(`Job failed: ${job.error}`);
302
+ return jsonResult({ job_id, status: job.status });
303
+ },
304
+ );
305
+
306
+ server.tool(
307
+ "cancel_job",
308
+ "Cancel a queued extraction job. Has no effect on jobs that are already running.",
309
+ { job_id: z.string().describe("The job_id to cancel") },
310
+ ({ job_id }) => {
311
+ const cancelled = jobQueue.cancel(job_id);
312
+ return jsonResult({ job_id, cancelled });
313
+ },
314
+ );
315
+
187
316
  // ── Start ──────────────────────────────────────────────────────────────
188
317
 
189
318
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.12.9",
3
+ "version": "0.12.10",
4
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",