chrometools-mcp 2.4.0 → 2.5.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.
@@ -305,73 +305,181 @@ function isSafeSelectorValue(value) {
305
305
  return !/["'\\[\]{}()]/.test(value);
306
306
  }
307
307
 
308
+ /**
309
+ * Check if a class name is a Tailwind CSS utility class
310
+ * Tailwind classes contain special characters that break CSS selectors:
311
+ * - Colons (:) for variants like hover:, focus:, md:, etc.
312
+ * - Slashes (/) for fractions like w-1/2
313
+ * - Brackets ([]) for arbitrary values like bg-[#1da1f2]
314
+ * - Dots (.) in decimal values
315
+ */
316
+ function isTailwindClass(className) {
317
+ if (!className || typeof className !== 'string') return false;
318
+
319
+ // Check for special characters that indicate Tailwind variants/utilities
320
+ if (/[:\/\[\]]/.test(className)) {
321
+ return true;
322
+ }
323
+
324
+ // Common Tailwind utility prefixes
325
+ const tailwindPrefixes = [
326
+ 'bg-', 'text-', 'border-', 'rounded-', 'shadow-', 'font-', 'leading-',
327
+ 'flex-', 'grid-', 'p-', 'px-', 'py-', 'pt-', 'pb-', 'pl-', 'pr-',
328
+ 'm-', 'mx-', 'my-', 'mt-', 'mb-', 'ml-', 'mr-', 'space-',
329
+ 'w-', 'h-', 'min-', 'max-', 'gap-', 'inset-', 'top-', 'right-', 'bottom-', 'left-',
330
+ 'justify-', 'items-', 'content-', 'self-', 'place-',
331
+ 'overflow-', 'opacity-', 'cursor-', 'transition-', 'transform-',
332
+ 'duration-', 'ease-', 'delay-', 'animate-', 'scale-', 'rotate-', 'translate-',
333
+ 'z-', 'order-', 'col-', 'row-', 'auto-', 'tracking-', 'select-',
334
+ 'sr-', 'not-', 'pointer-', 'resize-', 'list-', 'appearance-',
335
+ 'underline', 'line-through', 'no-underline', 'uppercase', 'lowercase', 'capitalize',
336
+ 'italic', 'not-italic', 'ordinal', 'slashed-zero', 'lining-nums', 'oldstyle-nums',
337
+ 'proportional-nums', 'tabular-nums', 'diagonal-fractions', 'stacked-fractions',
338
+ 'hidden', 'block', 'inline', 'flex', 'grid', 'table', 'contents',
339
+ 'absolute', 'relative', 'fixed', 'sticky', 'static'
340
+ ];
341
+
342
+ // Check if className starts with known Tailwind prefix
343
+ return tailwindPrefixes.some(prefix => className.startsWith(prefix));
344
+ }
345
+
308
346
  /**
309
347
  * Generate unique CSS selector for an element
348
+ * Priority: id → data-testid → data-* → aria-label → role → semantic classes → nth-child
349
+ * Filters out Tailwind CSS classes to avoid invalid selectors
310
350
  */
311
351
  function getUniqueSelectorInPage(element) {
312
- // Try ID first
313
- if (element.id) {
314
- return `#${element.id}`;
352
+ const tagName = element.tagName.toLowerCase();
353
+
354
+ // 1. Try ID first (highest priority)
355
+ if (element.id && isSafeSelectorValue(element.id)) {
356
+ try {
357
+ return `#${CSS.escape(element.id)}`;
358
+ } catch (e) {
359
+ // CSS.escape not available in old browsers, use simple escape
360
+ return `#${element.id.replace(/[^\w-]/g, '\\$&')}`;
361
+ }
315
362
  }
316
363
 
317
- // Try unique class combination
318
- if (element.className) {
319
- const classes = element.className.split(' ').filter(c => c.trim());
320
- if (classes.length > 0) {
321
- const selector = `${element.tagName.toLowerCase()}.${classes.join('.')}`;
364
+ // 2. Try data-testid (very common in modern apps)
365
+ if (element.dataset && element.dataset.testid && isSafeSelectorValue(element.dataset.testid)) {
366
+ const selector = `[data-testid="${element.dataset.testid}"]`;
367
+ try {
322
368
  if (document.querySelectorAll(selector).length === 1) {
323
369
  return selector;
324
370
  }
325
- // Try with first class only
326
- const firstClassSelector = `${element.tagName.toLowerCase()}.${classes[0]}`;
327
- if (document.querySelectorAll(firstClassSelector).length === 1) {
328
- return firstClassSelector;
371
+ } catch (e) {
372
+ // Invalid selector, continue
373
+ }
374
+ }
375
+
376
+ // 3. Try other data-* attributes (only if values are safe)
377
+ const dataAttrs = Array.from(element.attributes)
378
+ .filter(attr => attr.name.startsWith('data-') &&
379
+ attr.name !== 'data-testid' &&
380
+ isSafeSelectorValue(attr.value))
381
+ .slice(0, 2);
382
+
383
+ for (const attr of dataAttrs) {
384
+ const selector = `${tagName}[${attr.name}="${attr.value}"]`;
385
+ try {
386
+ if (document.querySelectorAll(selector).length === 1) {
387
+ return selector;
329
388
  }
389
+ } catch (e) {
390
+ continue;
330
391
  }
331
392
  }
332
393
 
333
- // Try name attribute (only if value is safe)
334
- if (element.name && isSafeSelectorValue(element.name)) {
335
- const selector = `${element.tagName.toLowerCase()}[name="${element.name}"]`;
394
+ // 4. Try aria-label
395
+ const ariaLabel = element.getAttribute('aria-label');
396
+ if (ariaLabel && isSafeSelectorValue(ariaLabel)) {
397
+ const selector = `${tagName}[aria-label="${ariaLabel}"]`;
336
398
  try {
337
399
  if (document.querySelectorAll(selector).length === 1) {
338
400
  return selector;
339
401
  }
340
402
  } catch (e) {
341
- // Invalid selector, skip
403
+ // Invalid selector, continue
342
404
  }
343
405
  }
344
406
 
345
- // Try data attributes (only if values are safe)
346
- const dataAttrs = Array.from(element.attributes)
347
- .filter(attr => attr.name.startsWith('data-') && isSafeSelectorValue(attr.value))
348
- .slice(0, 2);
407
+ // 5. Try role attribute
408
+ const role = element.getAttribute('role');
409
+ if (role && isSafeSelectorValue(role)) {
410
+ const selector = `${tagName}[role="${role}"]`;
411
+ try {
412
+ if (document.querySelectorAll(selector).length === 1) {
413
+ return selector;
414
+ }
415
+ } catch (e) {
416
+ // Invalid selector, continue
417
+ }
418
+ }
349
419
 
350
- for (const attr of dataAttrs) {
351
- const selector = `${element.tagName.toLowerCase()}[${attr.name}="${attr.value}"]`;
420
+ // 6. Try name attribute (common for form inputs)
421
+ if (element.name && isSafeSelectorValue(element.name)) {
422
+ const selector = `${tagName}[name="${element.name}"]`;
352
423
  try {
353
424
  if (document.querySelectorAll(selector).length === 1) {
354
425
  return selector;
355
426
  }
356
427
  } catch (e) {
357
- // Invalid selector, skip
358
- continue;
428
+ // Invalid selector, continue
429
+ }
430
+ }
431
+
432
+ // 7. Try semantic classes (filter out Tailwind)
433
+ if (element.className && typeof element.className === 'string') {
434
+ const classes = element.className.split(' ')
435
+ .filter(c => c.trim() && !isTailwindClass(c))
436
+ .slice(0, 3); // Limit to 3 classes max
437
+
438
+ if (classes.length > 0) {
439
+ try {
440
+ // Try with all filtered classes
441
+ const escapedClasses = classes.map(c => {
442
+ try {
443
+ return CSS.escape(c);
444
+ } catch (e) {
445
+ return c.replace(/[^\w-]/g, '\\$&');
446
+ }
447
+ });
448
+ const selector = `${tagName}.${escapedClasses.join('.')}`;
449
+ if (document.querySelectorAll(selector).length === 1) {
450
+ return selector;
451
+ }
452
+
453
+ // Try with first class only
454
+ const firstClassSelector = `${tagName}.${escapedClasses[0]}`;
455
+ if (document.querySelectorAll(firstClassSelector).length === 1) {
456
+ return firstClassSelector;
457
+ }
458
+ } catch (e) {
459
+ // Invalid selector, continue to fallback
460
+ }
359
461
  }
360
462
  }
361
463
 
362
- // Fallback: nth-child
464
+ // 8. Fallback: nth-of-type with path
363
465
  let current = element;
364
466
  const path = [];
365
467
 
366
468
  while (current && current.tagName) {
367
469
  let selector = current.tagName.toLowerCase();
368
470
 
369
- if (current.id) {
370
- selector = `#${current.id}`;
471
+ // Stop at element with ID
472
+ if (current.id && isSafeSelectorValue(current.id)) {
473
+ try {
474
+ selector = `#${CSS.escape(current.id)}`;
475
+ } catch (e) {
476
+ selector = `#${current.id.replace(/[^\w-]/g, '\\$&')}`;
477
+ }
371
478
  path.unshift(selector);
372
479
  break;
373
480
  }
374
481
 
482
+ // Calculate nth-of-type
375
483
  let sibling = current;
376
484
  let nth = 1;
377
485
 
@@ -382,7 +490,9 @@ function getUniqueSelectorInPage(element) {
382
490
  }
383
491
  }
384
492
 
385
- if (nth > 1) {
493
+ // Only add nth-of-type if there are multiple siblings of same type
494
+ if (nth > 1 || (current.parentElement &&
495
+ Array.from(current.parentElement.children).filter(c => c.tagName === current.tagName).length > 1)) {
386
496
  selector += `:nth-of-type(${nth})`;
387
497
  }
388
498
 
package/figma-tools.js CHANGED
@@ -377,3 +377,123 @@ export function collectAllText(node, texts = []) {
377
377
  }
378
378
  return texts;
379
379
  }
380
+
381
+ /**
382
+ * Simplify Figma node structure for code generation
383
+ * Extracts only essential properties: layout, styling, text, and children
384
+ */
385
+ export function simplifyNode(node) {
386
+ if (!node) return null;
387
+
388
+ const simplified = {
389
+ type: node.type,
390
+ name: node.name,
391
+ };
392
+
393
+ // Dimensions
394
+ if (node.absoluteBoundingBox) {
395
+ simplified.size = {
396
+ width: Math.round(node.absoluteBoundingBox.width),
397
+ height: Math.round(node.absoluteBoundingBox.height),
398
+ };
399
+ }
400
+
401
+ // Layout properties (Auto Layout / Flexbox)
402
+ if (node.layoutMode) {
403
+ simplified.layout = {
404
+ mode: node.layoutMode, // HORIZONTAL or VERTICAL
405
+ padding: (node.paddingLeft || node.paddingTop || node.paddingRight || node.paddingBottom) ? {
406
+ top: node.paddingTop || 0,
407
+ right: node.paddingRight || 0,
408
+ bottom: node.paddingBottom || 0,
409
+ left: node.paddingLeft || 0,
410
+ } : undefined,
411
+ gap: node.itemSpacing,
412
+ align: node.primaryAxisAlignItems,
413
+ justify: node.counterAxisAlignItems,
414
+ };
415
+ }
416
+
417
+ // Border radius
418
+ if (node.cornerRadius) {
419
+ simplified.borderRadius = node.cornerRadius;
420
+ } else if (node.rectangleCornerRadii) {
421
+ simplified.borderRadius = node.rectangleCornerRadii;
422
+ }
423
+
424
+ // Fills (backgrounds)
425
+ if (node.fills && node.fills.length > 0) {
426
+ simplified.fills = node.fills
427
+ .filter(fill => fill.visible !== false)
428
+ .map(fill => ({
429
+ type: fill.type,
430
+ color: fill.color ? {
431
+ r: Math.round(fill.color.r * 255),
432
+ g: Math.round(fill.color.g * 255),
433
+ b: Math.round(fill.color.b * 255),
434
+ a: fill.color.a !== undefined ? Math.round(fill.color.a * 100) / 100 : 1,
435
+ } : undefined,
436
+ opacity: fill.opacity,
437
+ }));
438
+ }
439
+
440
+ // Strokes (borders)
441
+ if (node.strokes && node.strokes.length > 0) {
442
+ simplified.strokes = node.strokes
443
+ .filter(stroke => stroke.visible !== false)
444
+ .map(stroke => ({
445
+ type: stroke.type,
446
+ color: stroke.color ? {
447
+ r: Math.round(stroke.color.r * 255),
448
+ g: Math.round(stroke.color.g * 255),
449
+ b: Math.round(stroke.color.b * 255),
450
+ a: stroke.color.a !== undefined ? Math.round(stroke.color.a * 100) / 100 : 1,
451
+ } : undefined,
452
+ }));
453
+
454
+ if (node.strokeWeight) {
455
+ simplified.strokeWeight = node.strokeWeight;
456
+ }
457
+ }
458
+
459
+ // Effects (shadows, blurs)
460
+ if (node.effects && node.effects.length > 0) {
461
+ simplified.effects = node.effects
462
+ .filter(effect => effect.visible !== false)
463
+ .map(effect => ({
464
+ type: effect.type,
465
+ radius: effect.radius,
466
+ offset: effect.offset,
467
+ color: effect.color ? {
468
+ r: Math.round(effect.color.r * 255),
469
+ g: Math.round(effect.color.g * 255),
470
+ b: Math.round(effect.color.b * 255),
471
+ a: Math.round(effect.color.a * 100) / 100,
472
+ } : undefined,
473
+ }));
474
+ }
475
+
476
+ // Text properties
477
+ if (node.type === 'TEXT') {
478
+ simplified.text = node.characters;
479
+ if (node.style) {
480
+ simplified.textStyle = {
481
+ fontFamily: node.style.fontFamily,
482
+ fontWeight: node.style.fontWeight,
483
+ fontSize: node.style.fontSize,
484
+ lineHeight: node.style.lineHeightPx,
485
+ letterSpacing: node.style.letterSpacing,
486
+ textAlign: node.style.textAlignHorizontal,
487
+ };
488
+ }
489
+ }
490
+
491
+ // Recursively simplify children
492
+ if (node.children && node.children.length > 0) {
493
+ simplified.children = node.children
494
+ .map(child => simplifyNode(child))
495
+ .filter(Boolean); // Remove null values
496
+ }
497
+
498
+ return simplified;
499
+ }