what-devtools-mcp 0.6.0 → 0.8.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.
@@ -1,30 +1,151 @@
1
1
  /**
2
2
  * Extended command handlers for the browser client.
3
- * Handles: eval, dom-inspect, get-route, navigate
3
+ * Handles: eval, dom-inspect, get-route, navigate, get-app-info, visual-inspect, page-map, get-signal-writers, component-screenshot
4
4
  *
5
5
  * Usage in client.js:
6
6
  * import { handleExtendedCommand } from './client-commands.js';
7
7
  *
8
8
  * // Inside handleCommand(), before the default case:
9
- * const extResult = handleExtendedCommand(command, args, devtools);
9
+ * const extResult = await handleExtendedCommand(command, args, devtools);
10
10
  * if (extResult !== null) { result = extResult; break; }
11
11
  */
12
12
 
13
+ // ---------------------------------------------------------------------------
14
+ // Helper: resolve the actual DOM Element for a component registry entry.
15
+ //
16
+ // The devtools stores `ctx._wrapper` which is a comment node (boundary marker,
17
+ // nodeType 8). Comment nodes don't have getBoundingClientRect, innerHTML,
18
+ // children, or any Element-level API. This helper walks from the stored node
19
+ // to find the nearest real Element.
20
+ // ---------------------------------------------------------------------------
21
+ function getComponentElement(entry) {
22
+ let el = entry.element;
23
+ if (!el) return null;
24
+
25
+ // Already a real Element — use it directly
26
+ if (el.nodeType === 1 && typeof el.getBoundingClientRect === 'function') return el;
27
+
28
+ // Comment node (component boundary marker) — find the next sibling element
29
+ if (el.nodeType === 8) {
30
+ let sibling = el.nextSibling;
31
+ while (sibling) {
32
+ if (sibling.nodeType === 1) return sibling;
33
+ sibling = sibling.nextSibling;
34
+ }
35
+ // No sibling element found — try parent
36
+ if (el.parentElement) return el.parentElement;
37
+ }
38
+
39
+ // Text node — use parent
40
+ if (el.nodeType === 3 && el.parentElement) return el.parentElement;
41
+
42
+ return null;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Module-level ring buffer for correlating signal writes with effect runs.
47
+ // Auto-initialized when initEventTracking() is called (early, not lazy).
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const MAX_WRITE_LOG = 200;
51
+ let _signalWriteLog = []; // { signalId, signalName, previousValue, newValue, timestamp, writerEffect }
52
+ let _lastRunningEffect = null; // { id, name, timestamp } — most recent effect:run event
53
+ let _trackingInitialized = false;
54
+ let _unsubTracker = null;
55
+
56
+ /**
57
+ * Initialize event tracking early so signal writes are always captured.
58
+ * Called from client.js on connection, not lazily on first tool call.
59
+ */
60
+ export function initEventTracking(devtools) {
61
+ if (_trackingInitialized || !devtools?.subscribe) return;
62
+ _trackingInitialized = true;
63
+
64
+ _unsubTracker = devtools.subscribe((event, data) => {
65
+ if (event === 'effect:run') {
66
+ _lastRunningEffect = {
67
+ id: data?.id,
68
+ name: data?.name,
69
+ timestamp: Date.now(),
70
+ };
71
+ }
72
+
73
+ if (event === 'signal:updated' && data?.id != null) {
74
+ // Try to get the previous value from the registry snapshot.
75
+ // The event fires after the value has already changed, so we
76
+ // cannot recover the true previous value retroactively.
77
+ // However, the emit payload from devtools includes `value` (new).
78
+ // We store what we can — the previous value will be the last
79
+ // known `newValue` for this signal in the log, or undefined.
80
+ let previousValue;
81
+ const priorEntry = findLastWrite(data.id);
82
+ if (priorEntry) {
83
+ previousValue = priorEntry.newValue;
84
+ }
85
+
86
+ const entry = {
87
+ signalId: data.id,
88
+ signalName: data.name || `signal_${data.id}`,
89
+ previousValue,
90
+ newValue: data.value,
91
+ timestamp: Date.now(),
92
+ writerEffect: _lastRunningEffect ? { ..._lastRunningEffect } : null,
93
+ };
94
+
95
+ _signalWriteLog.push(entry);
96
+ if (_signalWriteLog.length > MAX_WRITE_LOG) {
97
+ _signalWriteLog = _signalWriteLog.slice(-MAX_WRITE_LOG);
98
+ }
99
+ }
100
+ });
101
+ }
102
+
103
+ function findLastWrite(signalId) {
104
+ for (let i = _signalWriteLog.length - 1; i >= 0; i--) {
105
+ if (_signalWriteLog[i].signalId === signalId) return _signalWriteLog[i];
106
+ }
107
+ return null;
108
+ }
109
+
13
110
  /**
14
111
  * Handle extended commands sent from the MCP server via the bridge.
15
112
  *
16
113
  * @param {string} command - The command name
17
114
  * @param {object} args - Command arguments
18
115
  * @param {object|null} devtools - window.__WHAT_DEVTOOLS__ reference
19
- * @returns {object|null} Result object, or null if command not handled
116
+ * @returns {Promise<object|null>} Result object, or null if command not handled
20
117
  */
21
- export function handleExtendedCommand(command, args, devtools) {
118
+ export async function handleExtendedCommand(command, args, devtools) {
22
119
  switch (command) {
23
120
 
24
121
  // -------------------------------------------------------------------------
25
122
  // eval — Execute arbitrary JS in the browser context
123
+ // WARNING: This executes arbitrary code. The MCP server guards this behind
124
+ // the --unsafe-eval flag. The browser side also checks a global flag so
125
+ // that even if the command somehow reaches the client, it is rejected
126
+ // unless explicitly enabled.
26
127
  // -------------------------------------------------------------------------
27
128
  case 'eval': {
129
+ // Guard: only execute if explicitly enabled on the client side
130
+ const evalEnabled = typeof window !== 'undefined' &&
131
+ (window.__WHAT_UNSAFE_EVAL__ === true ||
132
+ devtools?._unsafeEvalEnabled === true);
133
+
134
+ // Allow safe read-only expressions without the unsafe flag
135
+ const code = (args.code || '').trim();
136
+ const isSafeRead = /^[\w.[\]'"]+$/.test(code) || // property access: document.title, window.innerWidth
137
+ /^typeof\s+\w+/.test(code) || // typeof checks
138
+ /^document\.(title|URL|readyState|visibilityState|characterSet|contentType)$/.test(code) ||
139
+ /^window\.(innerWidth|innerHeight|devicePixelRatio|screen\.\w+)$/.test(code) ||
140
+ /^navigator\.\w+$/.test(code) ||
141
+ /^location\.\w+$/.test(code);
142
+
143
+ if (!evalEnabled && !isSafeRead) {
144
+ return {
145
+ error: 'Eval is disabled for arbitrary code. Safe read-only expressions (document.title, window.innerWidth, etc.) work without the flag. For full eval, set window.__WHAT_UNSAFE_EVAL__ = true or enable --unsafe-eval on the MCP server.',
146
+ };
147
+ }
148
+
28
149
  const start = performance.now();
29
150
  try {
30
151
  // Use Function constructor to execute in global scope
@@ -61,7 +182,7 @@ export function handleExtendedCommand(command, args, devtools) {
61
182
  return { error: `Component ${componentId} not found` };
62
183
  }
63
184
 
64
- const el = entry.element;
185
+ const el = getComponentElement(entry);
65
186
  if (!el) {
66
187
  return { error: `Component "${entry.name}" (id: ${componentId}) has no DOM element` };
67
188
  }
@@ -197,6 +318,484 @@ export function handleExtendedCommand(command, args, devtools) {
197
318
  }
198
319
  }
199
320
 
321
+ // -------------------------------------------------------------------------
322
+ // validate-code — Compile or statically analyse a code snippet
323
+ // -------------------------------------------------------------------------
324
+ case 'validate-code': {
325
+ const { code, format } = args || {};
326
+ if (!code) return { valid: false, errors: [{ message: 'No code provided' }], warnings: [] };
327
+
328
+ const errors = [];
329
+ const warnings = [];
330
+
331
+ // 1. Try the Babel/What compiler if available on window
332
+ if (typeof window !== 'undefined' && window.__WHAT_COMPILER__) {
333
+ try {
334
+ const result = window.__WHAT_COMPILER__.compile(code, { format: format || 'jsx' });
335
+ return {
336
+ valid: !result.errors || result.errors.length === 0,
337
+ output: result.output || result.code || null,
338
+ errors: result.errors || [],
339
+ warnings: result.warnings || [],
340
+ };
341
+ } catch (e) {
342
+ // Compiler threw — fall through to static analysis
343
+ errors.push({ message: `Compiler error: ${e.message}` });
344
+ }
345
+ }
346
+
347
+ // 2. Static analysis fallback
348
+
349
+ // --- Bracket/brace matching ---
350
+ const brackets = { '(': ')', '[': ']', '{': '}' };
351
+ const closers = new Set([')', ']', '}']);
352
+ const stack = [];
353
+ // Strip string literals and comments to avoid false positives
354
+ const stripped = code
355
+ .replace(/\/\/[^\n]*/g, '')
356
+ .replace(/\/\*[\s\S]*?\*\//g, '')
357
+ .replace(/(["'`])(?:\\[\s\S]|(?!\1)[^\\])*\1/g, '');
358
+ for (let i = 0; i < stripped.length; i++) {
359
+ const ch = stripped[i];
360
+ if (brackets[ch]) {
361
+ stack.push({ char: ch, pos: i });
362
+ } else if (closers.has(ch)) {
363
+ const last = stack.pop();
364
+ if (!last) {
365
+ errors.push({ message: `Unexpected '${ch}' at position ${i}`, pos: i });
366
+ } else if (brackets[last.char] !== ch) {
367
+ errors.push({ message: `Mismatched '${last.char}' at position ${last.pos} and '${ch}' at position ${i}`, pos: i });
368
+ }
369
+ }
370
+ }
371
+ if (stack.length > 0) {
372
+ for (const item of stack) {
373
+ errors.push({ message: `Unclosed '${item.char}' at position ${item.pos}`, pos: item.pos });
374
+ }
375
+ }
376
+
377
+ // --- Common import errors ---
378
+ const importFromWhat = code.match(/import\s*\{([^}]+)\}\s*from\s*['"]what['"]/g);
379
+ if (importFromWhat) {
380
+ warnings.push({ message: "Import from 'what' detected. Use 'what-framework' instead.", rule: 'import-path' });
381
+ }
382
+
383
+ // --- Lint patterns ---
384
+
385
+ // 1. Signal read without () — look for JSX expressions like {count} where count is likely a signal
386
+ const signalWithoutCall = stripped.match(/\{(\s*[a-z][a-zA-Z0-9_]*\s*)\}/g);
387
+ if (signalWithoutCall) {
388
+ for (const match of signalWithoutCall) {
389
+ const name = match.replace(/[{}\s]/g, '');
390
+ // Heuristic: if the same name appears with () elsewhere, it's a signal used without ()
391
+ if (stripped.includes(`${name}(`) && !match.includes('(')) {
392
+ warnings.push({ message: `Possible signal '${name}' used without () — renders as [Function]. Use {${name}()}.`, rule: 'signal-read-without-call' });
393
+ }
394
+ }
395
+ }
396
+
397
+ // 2. innerHTML without __html marker
398
+ if (/innerHTML\s*=/.test(stripped) && !/__html/.test(stripped)) {
399
+ warnings.push({ message: 'innerHTML set without __html safety marker. XSS risk. Use { __html: content }.', rule: 'unsafe-innerhtml' });
400
+ }
401
+
402
+ // 3. Effect cycle — effect that reads and writes the same signal
403
+ const effectBodies = code.matchAll(/effect\s*\(\s*\(\s*\)\s*=>\s*\{([^}]+)\}/g);
404
+ for (const m of effectBodies) {
405
+ const body = m[1];
406
+ // Find signal names that appear as both read sig() and write sig(value)
407
+ const reads = [...body.matchAll(/(\w+)\(\)/g)].map(r => r[1]);
408
+ const writes = [...body.matchAll(/(\w+)\([^)]+\)/g)].map(r => r[1]);
409
+ for (const name of reads) {
410
+ if (writes.includes(name) && name !== 'untrack') {
411
+ warnings.push({ message: `Potential effect cycle: '${name}' is both read and written inside effect. Use untrack() for the read.`, rule: 'effect-cycle' });
412
+ }
413
+ }
414
+ }
415
+
416
+ // 4. Missing cleanup — effect with addEventListener but no removeEventListener
417
+ if (/effect\s*\(/.test(code) && /addEventListener/.test(code) && !/removeEventListener/.test(code)) {
418
+ warnings.push({ message: 'Effect adds event listener but no cleanup detected (missing removeEventListener). Return a cleanup function.', rule: 'missing-cleanup' });
419
+ }
420
+
421
+ return {
422
+ valid: errors.length === 0,
423
+ output: null,
424
+ errors,
425
+ warnings,
426
+ };
427
+ }
428
+
429
+ // -------------------------------------------------------------------------
430
+ // get-app-info — Return app metadata for bootstrap
431
+ // -------------------------------------------------------------------------
432
+ case 'get-app-info': {
433
+ return {
434
+ url: window.location.href,
435
+ title: document.title,
436
+ viewport: { width: window.innerWidth, height: window.innerHeight },
437
+ // Try to detect framework version
438
+ version: window.__WHAT_CORE__?.version || window.__WHAT_DEVTOOLS__?.version || 'unknown',
439
+ // Get the entry point from Vite's module graph if available
440
+ entryPoint: document.querySelector('script[type="module"][src]')?.getAttribute('src') ||
441
+ document.querySelector('script[type="module"]')?.textContent?.match(/from ['"]([^'"]+)['"]/)?.[1] || 'unknown',
442
+ };
443
+ }
444
+
445
+ // -------------------------------------------------------------------------
446
+ // visual-inspect — Computed visual info about a component (no image)
447
+ // -------------------------------------------------------------------------
448
+ case 'visual-inspect': {
449
+ const { componentId } = args || {};
450
+ const registries = devtools?._registries;
451
+ if (!registries?.components) return { error: 'DevTools registries not available' };
452
+
453
+ const entry = registries.components.get(componentId);
454
+ if (!entry) return { error: `Component ${componentId} not found` };
455
+
456
+ const el = getComponentElement(entry);
457
+ if (!el) return { error: `Component "${entry.name}" has no DOM element` };
458
+
459
+ const rect = el.getBoundingClientRect();
460
+ const cs = window.getComputedStyle(el);
461
+
462
+ // Key computed styles
463
+ const styles = {
464
+ display: cs.display,
465
+ position: cs.position,
466
+ flexDirection: cs.flexDirection !== 'row' ? cs.flexDirection : undefined,
467
+ flexWrap: cs.flexWrap !== 'nowrap' ? cs.flexWrap : undefined,
468
+ gridTemplateColumns: cs.gridTemplateColumns !== 'none' ? cs.gridTemplateColumns : undefined,
469
+ gridTemplateRows: cs.gridTemplateRows !== 'none' ? cs.gridTemplateRows : undefined,
470
+ backgroundColor: cs.backgroundColor,
471
+ color: cs.color,
472
+ fontSize: cs.fontSize,
473
+ fontFamily: cs.fontFamily?.split(',')[0]?.trim(),
474
+ padding: cs.padding,
475
+ margin: cs.margin,
476
+ border: cs.border !== 'none' && cs.borderWidth !== '0px' ? cs.border : undefined,
477
+ borderRadius: cs.borderRadius !== '0px' ? cs.borderRadius : undefined,
478
+ zIndex: cs.zIndex !== 'auto' ? cs.zIndex : undefined,
479
+ opacity: cs.opacity !== '1' ? cs.opacity : undefined,
480
+ overflow: cs.overflow !== 'visible' ? cs.overflow : undefined,
481
+ visibility: cs.visibility !== 'visible' ? cs.visibility : undefined,
482
+ width: cs.width,
483
+ height: cs.height,
484
+ maxWidth: cs.maxWidth !== 'none' ? cs.maxWidth : undefined,
485
+ };
486
+ // Remove undefined values
487
+ Object.keys(styles).forEach(k => styles[k] === undefined && delete styles[k]);
488
+
489
+ // Text content preview
490
+ const textContent = (el.textContent || '').trim().substring(0, 200);
491
+
492
+ // Child element types
493
+ const childTypes = {};
494
+ const selectors = ['button', 'a', 'input', 'select', 'textarea', 'img', 'form', 'table', 'ul', 'ol', 'video', 'canvas', 'svg'];
495
+ for (const sel of selectors) {
496
+ const count = el.querySelectorAll(sel).length;
497
+ if (count > 0) childTypes[sel] = count;
498
+ }
499
+
500
+ // Accessibility info
501
+ const a11y = {};
502
+ const role = el.getAttribute('role');
503
+ const ariaLabel = el.getAttribute('aria-label');
504
+ const tabIndex = el.getAttribute('tabindex');
505
+ if (role) a11y.role = role;
506
+ if (ariaLabel) a11y.ariaLabel = ariaLabel;
507
+ if (tabIndex) a11y.tabIndex = tabIndex;
508
+
509
+ // Layout classification
510
+ let layout = styles.display || 'block';
511
+ const childCount = el.children.length;
512
+ if (cs.display === 'flex') {
513
+ layout = `flex ${cs.flexDirection === 'column' ? 'column' : 'row'} with ${childCount} children`;
514
+ } else if (cs.display === 'grid') {
515
+ const cols = cs.gridTemplateColumns.split(' ').length;
516
+ const rows = cs.gridTemplateRows.split(' ').length;
517
+ layout = `grid ${cols}×${rows} with ${childCount} children`;
518
+ } else if (cs.display === 'block' || cs.display === 'flow-root') {
519
+ layout = `block with ${childCount} children`;
520
+ } else if (cs.display === 'inline-flex' || cs.display === 'inline-block') {
521
+ layout = `${cs.display} with ${childCount} children`;
522
+ }
523
+
524
+ return {
525
+ componentName: entry.name,
526
+ componentId,
527
+ boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
528
+ styles,
529
+ textContent: textContent || '(empty)',
530
+ childElements: childTypes,
531
+ totalChildren: childCount,
532
+ accessibility: Object.keys(a11y).length > 0 ? a11y : undefined,
533
+ layout,
534
+ viewport: { width: window.innerWidth, height: window.innerHeight },
535
+ };
536
+ }
537
+
538
+ // -------------------------------------------------------------------------
539
+ // page-map — Structured map of the entire visible page
540
+ // -------------------------------------------------------------------------
541
+ case 'page-map': {
542
+ const maxElements = args?.maxElements || 200;
543
+ let count = 0;
544
+
545
+ // Landmarks
546
+ const landmarks = [];
547
+ const landmarkEls = document.querySelectorAll('[role], header, footer, nav, main, aside, section, article');
548
+ for (const el of landmarkEls) {
549
+ if (count >= maxElements) break;
550
+ const rect = el.getBoundingClientRect();
551
+ if (rect.width === 0 && rect.height === 0) continue;
552
+ landmarks.push({
553
+ tag: el.tagName.toLowerCase(),
554
+ role: el.getAttribute('role') || undefined,
555
+ id: el.id || undefined,
556
+ text: (el.textContent || '').trim().substring(0, 50),
557
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
558
+ });
559
+ count++;
560
+ }
561
+
562
+ // Interactive elements
563
+ const interactives = [];
564
+ const interactiveEls = document.querySelectorAll('button, a[href], input, select, textarea, [role=button], [role=link], [contenteditable]');
565
+ for (const el of interactiveEls) {
566
+ if (count >= maxElements) break;
567
+ const rect = el.getBoundingClientRect();
568
+ if (rect.width === 0 && rect.height === 0) continue;
569
+ const label = el.getAttribute('aria-label') || el.textContent?.trim().substring(0, 40) || el.getAttribute('placeholder') || el.getAttribute('name') || '';
570
+ interactives.push({
571
+ tag: el.tagName.toLowerCase(),
572
+ type: el.getAttribute('type') || undefined,
573
+ label: label || '(unlabeled)',
574
+ disabled: el.disabled || undefined,
575
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
576
+ });
577
+ count++;
578
+ }
579
+
580
+ // Headings
581
+ const headings = [];
582
+ const headingEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
583
+ for (const el of headingEls) {
584
+ if (count >= maxElements) break;
585
+ headings.push({
586
+ level: parseInt(el.tagName[1]),
587
+ text: (el.textContent || '').trim().substring(0, 80),
588
+ });
589
+ count++;
590
+ }
591
+
592
+ // WhatFW component boundaries
593
+ const components = [];
594
+ const registries = devtools?._registries;
595
+ if (registries?.components) {
596
+ for (const [id, entry] of registries.components) {
597
+ if (count >= maxElements) break;
598
+ const compEl = getComponentElement(entry);
599
+ if (!compEl) continue;
600
+ const rect = compEl.getBoundingClientRect();
601
+ if (rect.width === 0 && rect.height === 0) continue;
602
+ components.push({
603
+ id,
604
+ name: entry.name,
605
+ rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) },
606
+ });
607
+ count++;
608
+ }
609
+ }
610
+
611
+ return {
612
+ viewport: { width: window.innerWidth, height: window.innerHeight },
613
+ landmarks,
614
+ interactives,
615
+ headings,
616
+ components,
617
+ totalElements: count,
618
+ };
619
+ }
620
+
621
+ // -------------------------------------------------------------------------
622
+ // get-signal-writers — Correlate signal writes with effect runs
623
+ // Uses the module-level ring buffer populated by initEventTracking().
624
+ // -------------------------------------------------------------------------
625
+ case 'get-signal-writers': {
626
+ const { signalId } = args || {};
627
+ const registries = devtools?._registries;
628
+
629
+ if (!registries?.signals) {
630
+ return { error: 'DevTools registries not available' };
631
+ }
632
+
633
+ const sigEntry = registries.signals.get(signalId);
634
+ if (!sigEntry) {
635
+ return { error: `Signal ${signalId} not found` };
636
+ }
637
+
638
+ // Lazily initialize event tracking on first call
639
+ initEventTracking(devtools);
640
+
641
+ // Filter the write log for this signal
642
+ const writes = _signalWriteLog
643
+ .filter(w => w.signalId === signalId)
644
+ .slice(-20); // Last 20 writes
645
+
646
+ const totalWrites = _signalWriteLog.filter(w => w.signalId === signalId).length;
647
+
648
+ return {
649
+ signalId,
650
+ signalName: sigEntry.name,
651
+ currentValue: devtools.safeSerialize
652
+ ? devtools.safeSerialize(sigEntry.ref.peek())
653
+ : sigEntry.ref.peek(),
654
+ recentWrites: writes,
655
+ totalWrites,
656
+ };
657
+ }
658
+
659
+ // -------------------------------------------------------------------------
660
+ // component-screenshot — Render component to base64 image via foreignObject
661
+ // -------------------------------------------------------------------------
662
+ case 'component-screenshot': {
663
+ const { componentId, maxWidth = 400, quality = 0.7, format = 'jpeg' } = args || {};
664
+ const registries = devtools?._registries;
665
+
666
+ if (!registries?.components) return { error: 'DevTools registries not available' };
667
+
668
+ const entry = registries.components.get(componentId);
669
+ if (!entry) return { error: `Component ${componentId} not found` };
670
+
671
+ const el = getComponentElement(entry);
672
+ if (!el) return { error: `Component "${entry.name}" has no DOM element` };
673
+
674
+ try {
675
+ const rect = el.getBoundingClientRect();
676
+ if (rect.width === 0 || rect.height === 0) {
677
+ return { error: `Component "${entry.name}" has zero dimensions (${rect.width}x${rect.height}). It may be hidden.` };
678
+ }
679
+
680
+ // Clone and inline styles so foreignObject renders correctly
681
+ const clone = el.cloneNode(true);
682
+
683
+ function inlineStyles(source, target) {
684
+ const cs = window.getComputedStyle(source);
685
+ const importantProps = [
686
+ 'display', 'position', 'top', 'left', 'right', 'bottom',
687
+ 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
688
+ 'margin', 'padding', 'border', 'border-radius',
689
+ 'background', 'background-color', 'background-image',
690
+ 'color', 'font-family', 'font-size', 'font-weight', 'line-height', 'text-align', 'text-decoration',
691
+ 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap',
692
+ 'grid-template-columns', 'grid-template-rows',
693
+ 'overflow', 'opacity', 'visibility', 'z-index',
694
+ 'box-shadow', 'text-shadow', 'transform',
695
+ 'white-space', 'word-break', 'letter-spacing',
696
+ ];
697
+ for (const prop of importantProps) {
698
+ const val = cs.getPropertyValue(prop);
699
+ if (val && val !== '' && val !== 'none' && val !== 'normal' && val !== 'auto' && val !== '0px') {
700
+ target.style.setProperty(prop, val);
701
+ }
702
+ }
703
+ const sourceChildren = source.children;
704
+ const targetChildren = target.children;
705
+ const maxChildren = Math.min(sourceChildren.length, targetChildren.length, 100);
706
+ for (let i = 0; i < maxChildren; i++) {
707
+ inlineStyles(sourceChildren[i], targetChildren[i]);
708
+ }
709
+ }
710
+
711
+ inlineStyles(el, clone);
712
+
713
+ // Reset position so it renders at 0,0 inside the SVG
714
+ clone.style.position = 'static';
715
+ clone.style.margin = '0';
716
+
717
+ // Serialize to SVG foreignObject
718
+ const serialized = new XMLSerializer().serializeToString(clone);
719
+ const svgWidth = Math.ceil(rect.width);
720
+ const svgHeight = Math.ceil(rect.height);
721
+
722
+ const svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}">
723
+ <foreignObject width="100%" height="100%">
724
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:${svgWidth}px;height:${svgHeight}px;overflow:hidden;">
725
+ ${serialized}
726
+ </div>
727
+ </foreignObject>
728
+ </svg>`;
729
+
730
+ // Render SVG to canvas
731
+ const dpr = window.devicePixelRatio || 1;
732
+ const scale = Math.min(1, maxWidth / svgWidth);
733
+ const canvasWidth = Math.ceil(svgWidth * scale * dpr);
734
+ const canvasHeight = Math.ceil(svgHeight * scale * dpr);
735
+
736
+ const canvas = document.createElement('canvas');
737
+ canvas.width = canvasWidth;
738
+ canvas.height = canvasHeight;
739
+ const ctx = canvas.getContext('2d');
740
+ ctx.scale(scale * dpr, scale * dpr);
741
+
742
+ // Load SVG blob as image
743
+ const img = new Image();
744
+ const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
745
+ const url = URL.createObjectURL(blob);
746
+
747
+ await new Promise((resolve, reject) => {
748
+ img.onload = resolve;
749
+ img.onerror = () => reject(new Error('SVG rendering failed — component may contain cross-origin resources'));
750
+ img.src = url;
751
+ });
752
+
753
+ ctx.drawImage(img, 0, 0);
754
+ URL.revokeObjectURL(url);
755
+
756
+ // Export to base64
757
+ const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
758
+ let dataUrl = canvas.toDataURL(mimeType, format === 'png' ? undefined : quality);
759
+ let base64 = dataUrl.split(',')[1];
760
+ let sizeBytes = Math.ceil(base64.length * 3 / 4);
761
+
762
+ // Size safety: if over 100KB, reduce quality then dimensions
763
+ if (sizeBytes > 102400 && format !== 'png') {
764
+ dataUrl = canvas.toDataURL('image/jpeg', 0.3);
765
+ base64 = dataUrl.split(',')[1];
766
+ sizeBytes = Math.ceil(base64.length * 3 / 4);
767
+ }
768
+ if (sizeBytes > 102400) {
769
+ const smallCanvas = document.createElement('canvas');
770
+ smallCanvas.width = Math.ceil(canvasWidth / 2);
771
+ smallCanvas.height = Math.ceil(canvasHeight / 2);
772
+ const smallCtx = smallCanvas.getContext('2d');
773
+ smallCtx.drawImage(canvas, 0, 0, smallCanvas.width, smallCanvas.height);
774
+ dataUrl = smallCanvas.toDataURL('image/jpeg', 0.3);
775
+ base64 = dataUrl.split(',')[1];
776
+ sizeBytes = Math.ceil(base64.length * 3 / 4);
777
+ }
778
+ if (sizeBytes > 102400) {
779
+ return { error: 'Screenshot exceeds 100KB even after reduction. Use what_look for text-based visual info instead.' };
780
+ }
781
+
782
+ return {
783
+ base64,
784
+ format: format === 'png' ? 'png' : 'jpeg',
785
+ mimeType,
786
+ width: Math.round(svgWidth * scale),
787
+ height: Math.round(svgHeight * scale),
788
+ sizeBytes,
789
+ componentName: entry.name,
790
+ };
791
+ } catch (e) {
792
+ return {
793
+ error: `Screenshot failed: ${e.message}`,
794
+ fallback: 'Use what_look for text-based visual inspection without an image.',
795
+ };
796
+ }
797
+ }
798
+
200
799
  // -------------------------------------------------------------------------
201
800
  // Not handled — return null so caller falls through
202
801
  // -------------------------------------------------------------------------