dembrandt 0.12.9 → 0.13.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
@@ -43,6 +43,23 @@ Or add to your project's `.mcp.json`:
43
43
 
44
44
  7 tools available: `get_design_tokens`, `get_color_palette`, `get_typography`, `get_component_styles`, `get_surfaces`, `get_spacing`, `get_brand_identity`.
45
45
 
46
+ Pair with **[dembrandt-skills](https://github.com/dembrandt/dembrandt-skills)** to give your agent UX intelligence on top of extracted tokens — hierarchy, accessibility, interaction states, and a full 6-stage design pipeline orchestrator.
47
+
48
+ ```bash
49
+ npx skills add dembrandt/dembrandt-skills
50
+ ```
51
+
52
+ ## Dembrandt App (Beta)
53
+
54
+ The **[Dembrandt App](https://www.dembrandt.com/app)** is the new home for your design system audits. It replaces the legacy Local UI with a more powerful, web-based management experience.
55
+
56
+ * **Visual Management:** View typography, color palettes, and spacing scales in a clean, human-readable dashboard.
57
+ * **Privacy-First:** No login required. No analytics. Your data is stored locally in your browser and is never sent to our servers.
58
+ * **CLI Integration:** Simply run your extraction with `--save-output` and open the resulting JSON in the App for deep analysis.
59
+ * **AI-Ready:** Copy tokens directly into your agentic workflows (Cursor, Claude, etc.) with one click.
60
+ * **Public Beta:** More best-in-class features will be added to dembrandt App in upcoming weeks.
61
+
62
+
46
63
  ## What to expect from extraction?
47
64
 
48
65
  - Colors (semantic, palette, CSS variables, gradients)
@@ -67,9 +84,9 @@ dembrandt example.com --mobile # Use mobile viewport (390x844) for respo
67
84
  dembrandt example.com --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
68
85
  dembrandt example.com --brand-guide # Generate a brand guide PDF
69
86
  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
87
+ dembrandt example.com --crawl 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
71
88
  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
89
+ dembrandt example.com --crawl 10 --sitemap # Combine: up to 10 pages discovered via sitemap
73
90
  dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
74
91
  dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
75
92
  dembrandt example.com --wcag # WCAG 2.1 contrast analysis — real DOM pairs, AA/AAA grades
@@ -83,13 +100,13 @@ Analyze multiple pages to get a more complete picture of a site's design system.
83
100
 
84
101
  ```bash
85
102
  # Analyze homepage + 4 auto-discovered pages (default: 5 total)
86
- dembrandt example.com --pages 5
103
+ dembrandt example.com --crawl 5
87
104
 
88
105
  # Use sitemap.xml for page discovery instead of DOM link scraping
89
106
  dembrandt example.com --sitemap
90
107
 
91
108
  # Combine both: up to 10 pages from sitemap
92
- dembrandt example.com --pages 10 --sitemap
109
+ dembrandt example.com --crawl 10 --sitemap
93
110
  ```
94
111
 
95
112
  **Page discovery** works two ways:
@@ -197,7 +214,7 @@ dembrandt braintree.com --save-output
197
214
 
198
215
  **Multi-page audit** — get a fuller picture across the whole site
199
216
  ```bash
200
- dembrandt stripe.com --pages 10 --sitemap --save-output
217
+ dembrandt stripe.com --crawl 10 --sitemap --save-output
201
218
  ```
202
219
 
203
220
  **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,9 +168,9 @@ 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
- { logo, instances: logoInstances, favicons },
173
+ logoResult,
174
174
  colors,
175
175
  typography,
176
176
  spacing,
@@ -184,7 +184,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
184
184
  breakpoints,
185
185
  iconSystem,
186
186
  frameworks,
187
- siteName,
187
+ siteNameRaw,
188
188
  gradients,
189
189
  motion,
190
190
  ] = await Promise.all([
@@ -207,7 +207,71 @@ export async function extractBranding(url, spinner, browser, options = {}) {
207
207
  extractMotion(page),
208
208
  ]);
209
209
 
210
+ const { logo, instances: logoInstances, favicons, manifest } = logoResult;
211
+ let siteName = siteNameRaw;
212
+
210
213
  spinner.stop();
214
+
215
+ // Inject manifest theme_color / background_color as high-confidence palette entries
216
+ if (manifest) {
217
+ const manifestColorEntries = [
218
+ manifest.themeColor && { color: manifest.themeColor, label: 'manifest:theme_color' },
219
+ manifest.backgroundColor && { color: manifest.backgroundColor, label: 'manifest:background_color' },
220
+ ].filter(Boolean);
221
+
222
+ const rawManifestColors = manifestColorEntries.map(e => e.color);
223
+ const manifestNormMap = rawManifestColors.length ? await page.evaluate((cols) => {
224
+ const canvas = document.createElement('canvas');
225
+ canvas.width = canvas.height = 1;
226
+ const ctx = canvas.getContext('2d');
227
+ const out = {};
228
+ for (const c of cols) {
229
+ if (/^#[0-9a-f]{6}$/i.test(c)) { out[c] = c.toLowerCase(); continue; }
230
+ if (/^#[0-9a-f]{3}$/i.test(c)) { out[c] = `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`.toLowerCase(); continue; }
231
+ if (/^#[0-9a-f]{8}$/i.test(c)) { out[c] = c.toLowerCase().slice(0, 7); continue; }
232
+ const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
233
+ if (m) { out[c] = `#${parseInt(m[1]).toString(16).padStart(2,'0')}${parseInt(m[2]).toString(16).padStart(2,'0')}${parseInt(m[3]).toString(16).padStart(2,'0')}`; continue; }
234
+ if (ctx) {
235
+ try {
236
+ ctx.clearRect(0, 0, 1, 1);
237
+ ctx.fillStyle = 'rgba(0,0,0,0)';
238
+ ctx.fillStyle = c;
239
+ ctx.fillRect(0, 0, 1, 1);
240
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
241
+ if (a > 0) { out[c] = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`; continue; }
242
+ } catch {}
243
+ }
244
+ out[c] = c.toLowerCase();
245
+ }
246
+ return out;
247
+ }, rawManifestColors) : {};
248
+
249
+ for (const { color: raw, label } of manifestColorEntries) {
250
+ const normalized = manifestNormMap[raw] ?? raw.toLowerCase();
251
+ if (!colors.palette.some(c => c.normalized === normalized)) {
252
+ colors.palette.push({ color: raw, normalized, count: 10, confidence: 'high', sources: [label] });
253
+ } else {
254
+ const existing = colors.palette.find(c => c.normalized === normalized);
255
+ if (existing) {
256
+ existing.confidence = 'high';
257
+ if (!existing.sources.includes(label)) existing.sources.push(label);
258
+ }
259
+ }
260
+ }
261
+
262
+ if (!siteName && (manifest.name || manifest.shortName)) {
263
+ siteName = manifest.name || manifest.shortName;
264
+ }
265
+ }
266
+
267
+ if (manifest) {
268
+ const parts = [
269
+ manifest.themeColor && `theme: ${manifest.themeColor}`,
270
+ manifest.backgroundColor && `bg: ${manifest.backgroundColor}`,
271
+ manifest.name && `name: "${manifest.name}"`,
272
+ ].filter(Boolean);
273
+ console.log(color.success(` ✓ Manifest: ${parts.join(', ')}`));
274
+ }
211
275
  console.log(colors.palette.length > 0 ? color.success(` ✓ Colors: ${colors.palette.length} found`) : color.warning(` ! Colors: 0 found`));
212
276
  console.log(typography.styles.length > 0 ? color.success(` ✓ Typography: ${typography.styles.length} styles`) : color.warning(` ! Typography: 0 styles`));
213
277
  console.log(spacing.commonValues.length > 0 ? color.success(` ✓ Spacing: ${spacing.commonValues.length} values`) : color.warning(` ! Spacing: 0 values`));
@@ -546,6 +610,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
546
610
  logo,
547
611
  logoInstances,
548
612
  favicons,
613
+ ...(manifest ? { manifest } : {}),
549
614
  colors,
550
615
  typography,
551
616
  spacing,
@@ -44,6 +44,7 @@ export async function extractLogo(page, url) {
44
44
  }, url);
45
45
 
46
46
  let pwaIcons = [];
47
+ let manifestMeta = {};
47
48
  if (manifestIcons.length > 0) {
48
49
  try {
49
50
  const manifestUrl = manifestIcons[0].manifestUrl;
@@ -63,6 +64,13 @@ export async function extractLogo(page, url) {
63
64
  purpose: icon.purpose || 'any',
64
65
  }));
65
66
  }
67
+
68
+ if (response) {
69
+ if (response.theme_color) manifestMeta.themeColor = response.theme_color;
70
+ if (response.background_color) manifestMeta.backgroundColor = response.background_color;
71
+ if (response.name) manifestMeta.name = response.name;
72
+ if (response.short_name) manifestMeta.shortName = response.short_name;
73
+ }
66
74
  } catch {}
67
75
  }
68
76
 
@@ -558,6 +566,7 @@ export async function extractLogo(page, url) {
558
566
 
559
567
  // Merge PWA icons into favicons
560
568
  result.favicons = [...result.favicons, ...pwaIcons];
569
+ result.manifest = Object.keys(manifestMeta).length > 0 ? manifestMeta : null;
561
570
 
562
571
  return result;
563
572
  }
@@ -292,17 +292,18 @@ function displayTypography(typography) {
292
292
  const fontFamilies = new Map();
293
293
 
294
294
  typography.styles.forEach(style => {
295
- // Skip styles without a family name
296
295
  if (!style.family) return;
297
296
 
298
297
  if (!fontFamilies.has(style.family)) {
299
- fontFamilies.set(style.family, { sizes: new Set(), weights: new Set() });
298
+ fontFamilies.set(style.family, { sizeContexts: new Map(), weights: new Set() });
300
299
  }
301
300
 
302
301
  const familyData = fontFamilies.get(style.family);
303
302
  if (style.size) {
304
303
  const px = Math.round(parseFloat(style.size) || 0);
305
- if (px) familyData.sizes.add(px);
304
+ if (px && !familyData.sizeContexts.has(px)) {
305
+ familyData.sizeContexts.set(px, style.context || null);
306
+ }
306
307
  }
307
308
  if (style.weight && style.weight !== 400) {
308
309
  familyData.weights.add(style.weight);
@@ -317,11 +318,11 @@ function displayTypography(typography) {
317
318
  const isFontLast = fontIndex === totalFonts;
318
319
  const fontBranch = isFontLast ? '└─' : '├─';
319
320
 
320
- const sizes = [...data.sizes]
321
- .sort((a, b) => b - a)
322
- .map(px => `${px}px`);
323
- const sizeList = sizes.length
324
- ? ' ' + chalk.dim('[ ') + sizes.join(', ') + chalk.dim(' ]')
321
+ const sizeTokens = [...data.sizeContexts.entries()]
322
+ .sort((a, b) => b[0] - a[0])
323
+ .map(([px, ctx]) => ctx ? `${px}px ${chalk.dim(`(${ctx})`)}` : `${px}px`);
324
+ const sizeList = sizeTokens.length
325
+ ? ' ' + chalk.dim('[ ') + sizeTokens.join(', ') + chalk.dim(' ]')
325
326
  : '';
326
327
 
327
328
  console.log(chalk.dim(`│ ${fontBranch}`) + ' ' + chalk.bold(family) + sizeList);
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.13.0",
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",