@mp3wizard/figma-console-mcp 1.22.1 → 1.22.4

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.
@@ -3289,6 +3289,7 @@ figma.ui.onmessage = async (msg) => {
3289
3289
  'wcag-non-text-contrast', 'wcag-color-only', 'wcag-focus-indicator',
3290
3290
  'wcag-letter-spacing', 'wcag-paragraph-spacing', 'wcag-image-alt',
3291
3291
  'wcag-heading-hierarchy', 'wcag-reflow', 'wcag-reading-order',
3292
+ 'wcag-disabled-no-context', 'token-misuse',
3292
3293
  'hardcoded-color', 'no-text-style', 'default-name', 'detached-component',
3293
3294
  'no-autolayout', 'empty-container'
3294
3295
  ];
@@ -3299,9 +3300,10 @@ figma.ui.onmessage = async (msg) => {
3299
3300
  'wcag-contrast', 'wcag-text-size', 'wcag-target-size', 'wcag-line-height',
3300
3301
  'wcag-non-text-contrast', 'wcag-color-only', 'wcag-focus-indicator',
3301
3302
  'wcag-letter-spacing', 'wcag-paragraph-spacing', 'wcag-image-alt',
3302
- 'wcag-heading-hierarchy', 'wcag-reflow', 'wcag-reading-order'
3303
+ 'wcag-heading-hierarchy', 'wcag-reflow', 'wcag-reading-order',
3304
+ 'wcag-disabled-no-context'
3303
3305
  ],
3304
- 'design-system': ['hardcoded-color', 'no-text-style', 'default-name', 'detached-component'],
3306
+ 'design-system': ['hardcoded-color', 'no-text-style', 'default-name', 'detached-component', 'token-misuse'],
3305
3307
  'layout': ['no-autolayout', 'empty-container']
3306
3308
  };
3307
3309
 
@@ -3310,15 +3312,17 @@ figma.ui.onmessage = async (msg) => {
3310
3312
  'wcag-target-size': 'critical',
3311
3313
  'wcag-non-text-contrast': 'critical',
3312
3314
  'wcag-color-only': 'critical',
3315
+ 'wcag-focus-indicator': 'critical',
3313
3316
  'wcag-text-size': 'warning',
3314
- 'wcag-line-height': 'warning',
3315
- 'wcag-focus-indicator': 'warning',
3316
3317
  'wcag-letter-spacing': 'warning',
3317
- 'wcag-paragraph-spacing': 'warning',
3318
3318
  'wcag-image-alt': 'warning',
3319
3319
  'wcag-heading-hierarchy': 'warning',
3320
3320
  'wcag-reflow': 'warning',
3321
3321
  'wcag-reading-order': 'warning',
3322
+ 'wcag-disabled-no-context': 'warning',
3323
+ 'wcag-line-height': 'info',
3324
+ 'wcag-paragraph-spacing': 'info',
3325
+ 'token-misuse': 'warning',
3322
3326
  'hardcoded-color': 'warning',
3323
3327
  'no-text-style': 'warning',
3324
3328
  'default-name': 'warning',
@@ -3327,20 +3331,47 @@ figma.ui.onmessage = async (msg) => {
3327
3331
  'empty-container': 'info'
3328
3332
  };
3329
3333
 
3334
+ // WCAG conformance level per rule — lets teams filter by target level (AA vs AAA)
3335
+ var wcagLevelMap = {
3336
+ 'wcag-contrast': 'aa', // 1.4.3 Contrast (Minimum) — Level AA
3337
+ 'wcag-target-size': 'aa', // 2.5.8 Target Size (Minimum) — Level AA
3338
+ 'wcag-non-text-contrast': 'aa', // 1.4.11 Non-text Contrast — Level AA
3339
+ 'wcag-color-only': 'a', // 1.4.1 Use of Color — Level A
3340
+ 'wcag-focus-indicator': 'aa', // 2.4.7 Focus Visible — Level AA
3341
+ 'wcag-text-size': 'best-practice', // Not actually 1.4.4; 12px minimum is a readability best practice
3342
+ 'wcag-line-height': 'best-practice', // 1.4.12 is about supporting user overrides, not requiring specific values
3343
+ 'wcag-letter-spacing': 'best-practice', // Negative spacing actively harms readability
3344
+ 'wcag-paragraph-spacing': 'best-practice', // 1.4.12 is about supporting user overrides
3345
+ 'wcag-image-alt': 'a', // 1.1.1 Non-text Content — Level A
3346
+ 'wcag-heading-hierarchy': 'a', // 1.3.1 Info and Relationships — Level A
3347
+ 'wcag-reflow': 'aa', // 1.4.10 Reflow — Level AA
3348
+ 'wcag-reading-order': 'a', // 1.3.2 Meaningful Sequence — Level A
3349
+ 'wcag-disabled-no-context': 'aa', // 4.1.2 Name, Role, Value — disabled elements need ARIA context
3350
+ 'token-misuse': 'design-system',
3351
+ 'hardcoded-color': 'design-system',
3352
+ 'no-text-style': 'design-system',
3353
+ 'default-name': 'design-system',
3354
+ 'detached-component': 'design-system',
3355
+ 'no-autolayout': 'design-system',
3356
+ 'empty-container': 'design-system'
3357
+ };
3358
+
3330
3359
  var ruleDescriptions = {
3331
- 'wcag-contrast': 'Text does not meet WCAG AA contrast ratio (4.5:1 normal, 3:1 large)',
3332
- 'wcag-text-size': 'Text size is below 12px minimum',
3333
- 'wcag-target-size': 'Interactive element is smaller than 24x24px minimum target size',
3334
- 'wcag-line-height': 'Line height is less than 1.5x the font size',
3335
- 'wcag-non-text-contrast': 'UI component or graphical object does not meet 3:1 contrast ratio against adjacent color (WCAG 1.4.11)',
3336
- 'wcag-color-only': 'Component variants appear to differ only by color without additional visual indicator (WCAG 1.4.1)',
3337
- 'wcag-focus-indicator': 'Interactive component is missing a focus/focused variant or focus indicator is insufficient (WCAG 2.4.7)',
3338
- 'wcag-letter-spacing': 'Negative letter spacing harms readability (WCAG 1.4.12)',
3339
- 'wcag-paragraph-spacing': 'Paragraph spacing is less than 2x the font size (WCAG 1.4.12)',
3340
- 'wcag-image-alt': 'Image or image fill has no description annotation for alternative text (WCAG 1.1.1)',
3341
- 'wcag-heading-hierarchy': 'Heading levels skip a level (e.g., H1 followed by H3) breaking document structure (WCAG 1.3.1)',
3342
- 'wcag-reflow': 'Frame uses fixed positioning without auto-layout, may not reflow for different viewport sizes (WCAG 1.4.10)',
3343
- 'wcag-reading-order': 'Visual position of elements does not match layer order, which may confuse screen readers (WCAG 1.3.2)',
3360
+ 'wcag-contrast': 'Text does not meet WCAG AA contrast ratio (4.5:1 normal, 3:1 large text ≥24px or ≥18.5px bold). Best practice: always target 4.5:1, especially in dark mode.',
3361
+ 'wcag-text-size': 'Text size is below 12px — readability best practice. Note: WCAG 1.4.4 requires supporting 200% text-only zoom (use rem/em units), not a specific minimum size.',
3362
+ 'wcag-target-size': 'Interactive element is smaller than 24x24px minimum target size (WCAG 2.5.8)',
3363
+ 'wcag-line-height': 'Line height is below 1.5x font size — best practice for readability. Note: WCAG 1.4.12 requires that content does not break when users override spacing to 1.5x, not that designs must use 1.5x by default.',
3364
+ 'wcag-non-text-contrast': 'UI component or graphical object does not meet 3:1 contrast ratio against adjacent color. Also applies to borders and chart elements against adjacent elements (WCAG 1.4.11)',
3365
+ 'wcag-color-only': 'Information is conveyed only through color change (e.g., error state uses red border without an error message or icon). Color can supplement but must not be the sole indicator (WCAG 1.4.1)',
3366
+ 'wcag-focus-indicator': 'Interactive component is missing a focus/focused variant or the focus indicator is insufficient. A visible focus state is critical — without it, keyboard users cannot navigate the interface (WCAG 2.4.7)',
3367
+ 'wcag-letter-spacing': 'Negative letter spacing actively harms readability. WCAG 1.4.12 requires content to support user-overridden spacing without breaking.',
3368
+ 'wcag-paragraph-spacing': 'Paragraph spacing is below 2x font size — best practice. WCAG 1.4.12 requires content to support user-overridden spacing to 2x without loss of content, not that designs must use 2x by default.',
3369
+ 'wcag-image-alt': 'Image or image fill has no description annotation for alternative text. All images need alt text; decorative images should be explicitly marked as decorative. Graphs and charts also need long descriptions (e.g., a data table) (WCAG 1.1.1)',
3370
+ 'wcag-heading-hierarchy': 'Heading levels skip a level (e.g., H1 followed by H3). Use H1 through H6 sequentially without skipping levels (WCAG 1.3.1)',
3371
+ 'wcag-reflow': 'Frame uses fixed positioning without auto-layout. Content must support 400% zoom on 1280px viewport (equivalent to 320px minimum width) without horizontal scrolling or loss of content (WCAG 1.4.10)',
3372
+ 'wcag-reading-order': 'Visual position of elements does not match layer order. Keyboard navigation and screen reader order must follow a logical sequence (WCAG 1.3.2)',
3373
+ 'wcag-disabled-no-context': 'Disabled variant has no tooltip, helper text, or annotation explaining why the element is disabled. Use aria-disabled (not HTML disabled) to keep the element focusable for screen readers, and add a tooltip so all users understand the disabled reason.',
3374
+ 'token-misuse': 'Variable name prefix does not match its usage context (e.g., a bg/* token used as a text fill, or a text/* token used as a background). This may cause contrast issues and indicates a misbound token.',
3344
3375
  'hardcoded-color': 'Fill color is not bound to a variable or style',
3345
3376
  'no-text-style': 'Text node is not using a text style',
3346
3377
  'default-name': 'Node has a default Figma name (e.g., "Frame 1")',
@@ -3995,8 +4026,131 @@ figma.ui.onmessage = async (msg) => {
3995
4026
  } catch (e) { /* slot sublayer */ }
3996
4027
  }
3997
4028
 
4029
+ // wcag-disabled-no-context: Disabled variant without tooltip/helper text (Isabella's pattern)
4030
+ if (activeRuleSet['wcag-disabled-no-context'] && nodeType === 'COMPONENT_SET' && !truncated) {
4031
+ try {
4032
+ var csChildren = node.children;
4033
+ if (csChildren) {
4034
+ for (var dci = 0; dci < csChildren.length && !truncated; dci++) {
4035
+ var dcVariant = csChildren[dci];
4036
+ try {
4037
+ var dcName = dcVariant.name || '';
4038
+ if (/(disabled|inactive)/i.test(dcName)) {
4039
+ // Check if disabled variant has tooltip, helper text, or descriptive child
4040
+ var hasContextChild = false;
4041
+ try {
4042
+ if (dcVariant.children) {
4043
+ for (var dcci = 0; dcci < dcVariant.children.length; dcci++) {
4044
+ var dcChild = dcVariant.children[dcci];
4045
+ try {
4046
+ var dcChildName = (dcChild.name || '').toLowerCase();
4047
+ // Look for tooltip, helper text, hint, description, or error message children
4048
+ if (/tooltip|helper|hint|description|message|caption|note|info|why|reason/i.test(dcChildName)) {
4049
+ hasContextChild = true;
4050
+ break;
4051
+ }
4052
+ // Recurse one level for nested tooltip/helper
4053
+ if (dcChild.children) {
4054
+ for (var dcgci = 0; dcgci < dcChild.children.length; dcgci++) {
4055
+ var dcGrandchild = dcChild.children[dcgci];
4056
+ try {
4057
+ if (/tooltip|helper|hint|description|message/i.test(dcGrandchild.name || '')) {
4058
+ hasContextChild = true;
4059
+ break;
4060
+ }
4061
+ } catch (e) { /* skip */ }
4062
+ }
4063
+ if (hasContextChild) break;
4064
+ }
4065
+ } catch (e) { /* skip */ }
4066
+ }
4067
+ }
4068
+ } catch (e) { /* skip */ }
4069
+ // Also check component description for disabled guidance
4070
+ var hasDisabledAnnotation = false;
4071
+ try {
4072
+ var csDesc = (node.description || '').toLowerCase();
4073
+ if (/disabled.*tooltip|disabled.*helper|disabled.*hint|aria-disabled|why.*disabled|disabled.*reason/i.test(csDesc)) {
4074
+ hasDisabledAnnotation = true;
4075
+ }
4076
+ } catch (e) { /* skip */ }
4077
+ if (!hasContextChild && !hasDisabledAnnotation) {
4078
+ if (totalFindings < maxFindings) {
4079
+ findings['wcag-disabled-no-context'].push({
4080
+ id: dcVariant.id,
4081
+ name: nodeName + ' / ' + dcVariant.name,
4082
+ suggestion: 'Disabled elements should remain focusable (use aria-disabled, not HTML disabled). Add a tooltip or helper text explaining why the element is disabled so screen reader users understand the context.'
4083
+ });
4084
+ totalFindings++;
4085
+ } else { truncated = true; }
4086
+ }
4087
+ }
4088
+ } catch (e) { /* skip variant */ }
4089
+ }
4090
+ }
4091
+ } catch (e) { /* slot sublayer */ }
4092
+ }
4093
+
3998
4094
  // ---- Design System checks ----
3999
4095
 
4096
+ // token-misuse: Variable name prefix doesn't match usage context
4097
+ if (activeRuleSet['token-misuse'] && !isPage && !isSection && !truncated) {
4098
+ try {
4099
+ var tmFills = node.fills;
4100
+ if (tmFills && tmFills.length > 0) {
4101
+ for (var tmi = 0; tmi < tmFills.length; tmi++) {
4102
+ var tmFill = tmFills[tmi];
4103
+ if (tmFill.type === 'SOLID' && tmFill.visible !== false) {
4104
+ try {
4105
+ if (tmFill.boundVariables && tmFill.boundVariables.color) {
4106
+ var tmVarId = tmFill.boundVariables.color.id;
4107
+ // Resolve variable name
4108
+ try {
4109
+ var tmVar = figma.variables.getVariableById(tmVarId);
4110
+ if (tmVar) {
4111
+ var tmVarName = tmVar.name.toLowerCase();
4112
+ var isBgToken = /^(bg|background|surface|fill)[\/-]/.test(tmVarName);
4113
+ var isTextNode = nodeType === 'TEXT';
4114
+ // Flag: bg/surface token used as text fill
4115
+ if (isTextNode && isBgToken) {
4116
+ if (totalFindings < maxFindings) {
4117
+ findings['token-misuse'].push({
4118
+ id: nodeId,
4119
+ name: nodeName,
4120
+ variable: tmVar.name,
4121
+ usage: 'text fill',
4122
+ expectedPrefix: 'text/*, fg/*, foreground/*',
4123
+ suggestion: 'This text node uses a background/surface token ("' + tmVar.name + '") as its fill color. This is likely a misbound token — use a text/foreground token instead.'
4124
+ });
4125
+ totalFindings++;
4126
+ } else { truncated = true; }
4127
+ }
4128
+ // Flag: text/foreground token used as frame/shape background
4129
+ var isTextToken = /^(text|fg|foreground|font)[\/-]/.test(tmVarName);
4130
+ var isContainerNode = nodeType === 'FRAME' || nodeType === 'COMPONENT' || nodeType === 'INSTANCE' || nodeType === 'RECTANGLE';
4131
+ if (isContainerNode && isTextToken && !truncated) {
4132
+ if (totalFindings < maxFindings) {
4133
+ findings['token-misuse'].push({
4134
+ id: nodeId,
4135
+ name: nodeName,
4136
+ variable: tmVar.name,
4137
+ usage: 'background fill',
4138
+ expectedPrefix: 'bg/*, background/*, surface/*',
4139
+ suggestion: 'This container uses a text/foreground token ("' + tmVar.name + '") as its background fill. This is likely a misbound token — use a background/surface token instead.'
4140
+ });
4141
+ totalFindings++;
4142
+ } else { truncated = true; }
4143
+ }
4144
+ }
4145
+ } catch (e) { /* can't resolve variable */ }
4146
+ }
4147
+ } catch (e) { /* no bound vars */ }
4148
+ }
4149
+ }
4150
+ }
4151
+ } catch (e) { /* slot sublayer */ }
4152
+ }
4153
+
4000
4154
  // hardcoded-color: Solid fills without variable binding or style
4001
4155
  if (activeRuleSet['hardcoded-color'] && !isPage && !isSection && !truncated) {
4002
4156
  try {
@@ -4190,6 +4344,7 @@ figma.ui.onmessage = async (msg) => {
4190
4344
  categories.push({
4191
4345
  rule: ruleId,
4192
4346
  severity: sev,
4347
+ wcagLevel: wcagLevelMap[ruleId] || null,
4193
4348
  count: findings[ruleId].length,
4194
4349
  description: ruleDescriptions[ruleId],
4195
4350
  nodes: findings[ruleId]
@@ -4430,7 +4585,47 @@ figma.ui.onmessage = async (msg) => {
4430
4585
  var variants = isComponentSet ? componentSet.children : [componentSet];
4431
4586
  var variantCount = variants.length;
4432
4587
 
4433
- // 1. State coverage analysis
4588
+ // ---- Classify component as interactive vs presentational ----
4589
+ var interactiveNames = /^(button|link|input|checkbox|radio|switch|toggle|tab|select|slider|dropdown|menu-item|search|combobox|listbox)/i;
4590
+ var presentationalNames = /^(alert|badge|card|avatar|divider|skeleton|tooltip|tag|chip|banner|callout|notification|toast|icon|image|separator|progress|spinner|loader|breadcrumb|label|heading|paragraph|caption|stat|meter|indicator)/i;
4591
+
4592
+ // Parse variant axes from variant names (e.g., "type=success, style=fill" → {type: [...], style: [...]})
4593
+ var variantAxes = {};
4594
+ var hasStateAxis = false;
4595
+ for (var vai = 0; vai < variants.length; vai++) {
4596
+ var vParts = variants[vai].name.split(',');
4597
+ for (var vpi = 0; vpi < vParts.length; vpi++) {
4598
+ var kv = vParts[vpi].trim().split('=');
4599
+ if (kv.length === 2) {
4600
+ var axisName = kv[0].trim().toLowerCase();
4601
+ var axisValue = kv[1].trim().toLowerCase();
4602
+ if (!variantAxes[axisName]) variantAxes[axisName] = [];
4603
+ if (variantAxes[axisName].indexOf(axisValue) === -1) {
4604
+ variantAxes[axisName].push(axisValue);
4605
+ }
4606
+ // Check if this axis contains interaction state values
4607
+ if (axisName === 'state' && /(hover|focus|pressed|disabled|active)/i.test(axisValue)) {
4608
+ hasStateAxis = true;
4609
+ }
4610
+ }
4611
+ }
4612
+ }
4613
+
4614
+ var componentName = componentSet.name || '';
4615
+ var isInteractive = interactiveNames.test(componentName) || hasStateAxis;
4616
+ var isPresentational = !isInteractive && (presentationalNames.test(componentName) || !hasStateAxis);
4617
+ // If ambiguous, check if any variant mentions interaction states
4618
+ if (!isInteractive && !isPresentational) {
4619
+ for (var ami = 0; ami < variants.length; ami++) {
4620
+ if (/(hover|focus|pressed|disabled)/i.test(variants[ami].name)) {
4621
+ isInteractive = true;
4622
+ break;
4623
+ }
4624
+ }
4625
+ if (!isInteractive) isPresentational = true;
4626
+ }
4627
+
4628
+ // 1. Coverage analysis — adapts to component classification
4434
4629
  var stateKeywords = {
4435
4630
  'default': /(default|rest|idle|normal|base)/i,
4436
4631
  'hover': /(hover|hovered)/i,
@@ -4440,35 +4635,69 @@ figma.ui.onmessage = async (msg) => {
4440
4635
  'active': /(active|pressed|selected)/i,
4441
4636
  'loading': /(loading|spinner)/i
4442
4637
  };
4638
+
4639
+ var coveredCount = 0;
4640
+ var totalStates = 0;
4641
+ var missingStates = [];
4443
4642
  var statesCovered = {};
4444
4643
  var statesFound = {};
4445
- for (var sk in stateKeywords) {
4446
- statesCovered[sk] = false;
4447
- statesFound[sk] = null;
4448
- }
4449
- for (var vi = 0; vi < variants.length; vi++) {
4450
- var vName = variants[vi].name;
4451
- for (var sk2 in stateKeywords) {
4452
- if (stateKeywords[sk2].test(vName)) {
4453
- statesCovered[sk2] = true;
4454
- if (!statesFound[sk2]) statesFound[sk2] = vName;
4644
+ var coverageLabel = '';
4645
+ var variantAxisCoverage = null;
4646
+
4647
+ if (isInteractive) {
4648
+ // Interactive components: check for interaction states
4649
+ coverageLabel = 'interactive-states';
4650
+ for (var sk in stateKeywords) {
4651
+ statesCovered[sk] = false;
4652
+ statesFound[sk] = null;
4653
+ }
4654
+ for (var vi = 0; vi < variants.length; vi++) {
4655
+ var vName = variants[vi].name;
4656
+ for (var sk2 in stateKeywords) {
4657
+ if (stateKeywords[sk2].test(vName)) {
4658
+ statesCovered[sk2] = true;
4659
+ if (!statesFound[sk2]) statesFound[sk2] = vName;
4660
+ }
4455
4661
  }
4456
4662
  }
4457
- }
4458
- // If only one variant and no state keywords match, assume it's default
4459
- if (variantCount === 1 && !statesCovered['default']) {
4460
- statesCovered['default'] = true;
4461
- statesFound['default'] = variants[0].name;
4462
- }
4463
- var coveredCount = 0;
4464
- var totalStates = 0;
4465
- var missingStates = [];
4466
- for (var sk3 in statesCovered) {
4467
- totalStates++;
4468
- if (statesCovered[sk3]) {
4469
- coveredCount++;
4470
- } else {
4471
- missingStates.push(sk3);
4663
+ if (variantCount === 1 && !statesCovered['default']) {
4664
+ statesCovered['default'] = true;
4665
+ statesFound['default'] = variants[0].name;
4666
+ }
4667
+ for (var sk3 in statesCovered) {
4668
+ totalStates++;
4669
+ if (statesCovered[sk3]) {
4670
+ coveredCount++;
4671
+ } else {
4672
+ missingStates.push(sk3);
4673
+ }
4674
+ }
4675
+ } else {
4676
+ // Presentational components: check variant axis completeness
4677
+ coverageLabel = 'variant-axes';
4678
+ // Calculate expected combinations vs actual
4679
+ var axisNames = [];
4680
+ var axisCounts = [];
4681
+ var expectedCombinations = 1;
4682
+ for (var axName in variantAxes) {
4683
+ axisNames.push(axName);
4684
+ axisCounts.push(variantAxes[axName].length);
4685
+ expectedCombinations *= variantAxes[axName].length;
4686
+ }
4687
+ // Score: actual variants / expected combinations (capped at 100%)
4688
+ var axisCoverageRatio = expectedCombinations > 0 ? Math.min(1, variantCount / expectedCombinations) : 1;
4689
+ coveredCount = variantCount;
4690
+ totalStates = expectedCombinations;
4691
+ variantAxisCoverage = {
4692
+ axes: variantAxes,
4693
+ axisCount: axisNames.length,
4694
+ expectedCombinations: expectedCombinations,
4695
+ actualVariants: variantCount,
4696
+ completeness: Math.round(axisCoverageRatio * 100) + '%'
4697
+ };
4698
+ // For presentational, no "missing states" — instead note if combinations are incomplete
4699
+ if (variantCount < expectedCombinations) {
4700
+ missingStates.push(variantCount + '/' + expectedCombinations + ' axis combinations present');
4472
4701
  }
4473
4702
  }
4474
4703
 
@@ -4552,6 +4781,9 @@ figma.ui.onmessage = async (msg) => {
4552
4781
  }
4553
4782
 
4554
4783
  // 4. Target size analysis
4784
+ // WCAG 2.5.8 applies to interactive targets — presentational components
4785
+ // (badges, avatars, progress bars) are not tap targets by definition.
4786
+ // Skip target size checking for presentational components to avoid false positives.
4555
4787
  var targetSizeAnalysis = { minWidth: Infinity, minHeight: Infinity, variants: [], issues: [] };
4556
4788
  var minTarget = msg.targetSize || 24; // Default WCAG 2.5.8 minimum
4557
4789
  for (var tvi = 0; tvi < variants.length; tvi++) {
@@ -4562,7 +4794,8 @@ figma.ui.onmessage = async (msg) => {
4562
4794
  targetSizeAnalysis.variants.push({ name: variants[tvi].name, width: tw, height: th });
4563
4795
  if (tw < targetSizeAnalysis.minWidth) targetSizeAnalysis.minWidth = tw;
4564
4796
  if (th < targetSizeAnalysis.minHeight) targetSizeAnalysis.minHeight = th;
4565
- if (tw < minTarget || th < minTarget) {
4797
+ // Only flag target size issues for interactive components
4798
+ if (isInteractive && (tw < minTarget || th < minTarget)) {
4566
4799
  targetSizeAnalysis.issues.push({
4567
4800
  variant: variants[tvi].name,
4568
4801
  width: tw,
@@ -4658,14 +4891,15 @@ figma.ui.onmessage = async (msg) => {
4658
4891
 
4659
4892
  // ---- Compute overall score ----
4660
4893
  var scores = {};
4661
- // State coverage: percentage of states found
4662
- scores.stateCoverage = Math.round((coveredCount / totalStates) * 100);
4894
+ // Coverage: percentage of states (interactive) or axis combinations (presentational)
4895
+ scores.variantCoverage = totalStates > 0 ? Math.round((coveredCount / totalStates) * 100) : 100;
4663
4896
  // Focus indicator: 0 (missing), 50 (exists but no indicator), 100 (good indicator)
4664
- scores.focusIndicator = !focusAnalysis.hasVariant ? 0 : (!focusAnalysis.hasVisibleIndicator ? 50 : 100);
4897
+ // For presentational: N/A score 100 (don't penalize)
4898
+ scores.focusIndicator = isPresentational ? 100 : (!focusAnalysis.hasVariant ? 0 : (!focusAnalysis.hasVisibleIndicator ? 50 : 100));
4665
4899
  // Color differentiation: 100 if no issues, decremented per issue
4666
4900
  scores.colorDifferentiation = colorDifferentiation.checked === 0 ? 100 : Math.max(0, Math.round(((colorDifferentiation.checked - colorDifferentiation.issues.length) / colorDifferentiation.checked) * 100));
4667
- // Target size: 100 if all pass, 0 if any fail
4668
- scores.targetSize = targetSizeAnalysis.issues.length === 0 ? 100 : Math.max(0, Math.round(((targetSizeAnalysis.variants.length - targetSizeAnalysis.issues.length) / Math.max(1, targetSizeAnalysis.variants.length)) * 100));
4901
+ // Target size: N/A for presentational (not tap targets), scored for interactive
4902
+ scores.targetSize = isPresentational ? 100 : (targetSizeAnalysis.issues.length === 0 ? 100 : Math.max(0, Math.round(((targetSizeAnalysis.variants.length - targetSizeAnalysis.issues.length) / Math.max(1, targetSizeAnalysis.variants.length)) * 100)));
4669
4903
  // Annotations: 0 (nothing), 50 (description only), 100 (has a11y notes)
4670
4904
  scores.annotations = annotations.hasA11yNotes ? 100 : (annotations.hasDescription ? 50 : 0);
4671
4905
  // Color blind: percentage of simulations with no issues
@@ -4675,48 +4909,76 @@ figma.ui.onmessage = async (msg) => {
4675
4909
  }
4676
4910
  scores.colorBlindSafety = colorBlindAnalysis.simulations.length > 0 ? Math.round((cbPassCount / colorBlindAnalysis.simulations.length) * 100) : 100;
4677
4911
 
4678
- // Overall weighted score
4679
- var overall = Math.round(
4680
- scores.stateCoverage * 0.20 +
4681
- scores.focusIndicator * 0.20 +
4682
- scores.colorDifferentiation * 0.15 +
4683
- scores.targetSize * 0.15 +
4684
- scores.annotations * 0.10 +
4685
- scores.colorBlindSafety * 0.20
4686
- );
4912
+ // Overall weighted score — weights differ by component classification
4913
+ var overall;
4914
+ if (isInteractive) {
4915
+ // Interactive: focus and states matter most
4916
+ overall = Math.round(
4917
+ scores.variantCoverage * 0.20 +
4918
+ scores.focusIndicator * 0.20 +
4919
+ scores.colorDifferentiation * 0.15 +
4920
+ scores.targetSize * 0.15 +
4921
+ scores.annotations * 0.10 +
4922
+ scores.colorBlindSafety * 0.20
4923
+ );
4924
+ } else {
4925
+ // Presentational: variant completeness and color safety matter most, focus is N/A
4926
+ overall = Math.round(
4927
+ scores.variantCoverage * 0.25 +
4928
+ scores.colorDifferentiation * 0.25 +
4929
+ scores.annotations * 0.15 +
4930
+ scores.colorBlindSafety * 0.25 +
4931
+ scores.targetSize * 0.10
4932
+ );
4933
+ }
4687
4934
 
4688
4935
  // ---- Build response ----
4936
+ var coverageSection;
4937
+ if (isInteractive) {
4938
+ coverageSection = {
4939
+ mode: 'interactive-states',
4940
+ found: statesFound,
4941
+ missing: missingStates,
4942
+ coverage: coveredCount + '/' + totalStates
4943
+ };
4944
+ } else {
4945
+ coverageSection = {
4946
+ mode: 'variant-axes',
4947
+ axes: variantAxisCoverage,
4948
+ coverage: coveredCount + '/' + totalStates
4949
+ };
4950
+ }
4951
+
4689
4952
  var auditResult = {
4690
4953
  component: {
4691
4954
  id: componentSet.id,
4692
4955
  name: componentSet.name,
4693
4956
  type: componentSet.type,
4694
- variantCount: variantCount
4957
+ variantCount: variantCount,
4958
+ classification: isInteractive ? 'interactive' : 'presentational'
4695
4959
  },
4696
4960
  overallScore: overall,
4697
4961
  scores: scores,
4698
- stateCoverage: {
4699
- found: statesFound,
4700
- missing: missingStates,
4701
- coverage: coveredCount + '/' + totalStates
4702
- },
4703
- focusIndicator: focusAnalysis,
4962
+ variantCoverage: coverageSection,
4963
+ focusIndicator: isInteractive ? focusAnalysis : { notApplicable: true, details: 'Focus indicators are not expected for presentational components' },
4704
4964
  colorDifferentiation: colorDifferentiation,
4705
- targetSize: {
4965
+ targetSize: isInteractive ? {
4706
4966
  minimum: minTarget + 'x' + minTarget,
4707
4967
  smallest: targetSizeAnalysis.minWidth + 'x' + targetSizeAnalysis.minHeight,
4708
4968
  issues: targetSizeAnalysis.issues
4709
- },
4969
+ } : { notApplicable: true, details: 'Target size checks apply to interactive components (WCAG 2.5.8 is about tap targets)', smallest: targetSizeAnalysis.minWidth + 'x' + targetSizeAnalysis.minHeight },
4710
4970
  annotations: annotations,
4711
4971
  colorBlindSimulation: colorBlindAnalysis,
4712
4972
  recommendations: []
4713
4973
  };
4714
4974
 
4715
- // Generate recommendations
4716
- if (!focusAnalysis.hasVariant) {
4717
- auditResult.recommendations.push({ priority: 'high', area: 'focus', message: 'Add a focus/focused variant with a visible focus ring (WCAG 2.4.7)' });
4718
- } else if (!focusAnalysis.hasVisibleIndicator) {
4719
- auditResult.recommendations.push({ priority: 'medium', area: 'focus', message: 'Focus variant exists but lacks visible indicator — add a border or shadow' });
4975
+ // Generate recommendations — classification-aware
4976
+ if (isInteractive) {
4977
+ if (!focusAnalysis.hasVariant) {
4978
+ auditResult.recommendations.push({ priority: 'high', area: 'focus', message: 'Add a focus/focused variant with a visible focus ring (WCAG 2.4.7)' });
4979
+ } else if (!focusAnalysis.hasVisibleIndicator) {
4980
+ auditResult.recommendations.push({ priority: 'medium', area: 'focus', message: 'Focus variant exists but lacks visible indicator — add a border or shadow' });
4981
+ }
4720
4982
  }
4721
4983
  if (colorDifferentiation.issues.length > 0) {
4722
4984
  auditResult.recommendations.push({ priority: 'high', area: 'color', message: 'Add non-color indicators (icons, borders, text) to ' + colorDifferentiation.issues.length + ' state variant(s) (WCAG 1.4.1)' });
@@ -4728,16 +4990,23 @@ figma.ui.onmessage = async (msg) => {
4728
4990
  auditResult.recommendations.push({ priority: 'medium', area: 'documentation', message: 'Add a component description with usage guidelines' });
4729
4991
  }
4730
4992
  if (!annotations.hasA11yNotes) {
4731
- auditResult.recommendations.push({ priority: 'medium', area: 'documentation', message: 'Add accessibility notes (ARIA role, keyboard interactions, screen reader behavior)' });
4993
+ var a11yHint = isInteractive
4994
+ ? 'Add accessibility notes (ARIA role, keyboard interactions, screen reader behavior)'
4995
+ : 'Add accessibility notes (ARIA role, live region behavior, semantic usage)';
4996
+ auditResult.recommendations.push({ priority: 'medium', area: 'documentation', message: a11yHint });
4732
4997
  }
4733
4998
  if (colorBlindAnalysis.issues.length > 0) {
4734
4999
  auditResult.recommendations.push({ priority: 'medium', area: 'color-blind', message: colorBlindAnalysis.issues.join('; ') });
4735
5000
  }
4736
- for (var msi = 0; msi < missingStates.length; msi++) {
4737
- var ms = missingStates[msi];
4738
- if (ms === 'focus' || ms === 'disabled') {
4739
- auditResult.recommendations.push({ priority: 'medium', area: 'states', message: 'Consider adding a "' + ms + '" variant for complete interactive state coverage' });
5001
+ if (isInteractive) {
5002
+ for (var msi = 0; msi < missingStates.length; msi++) {
5003
+ var ms = missingStates[msi];
5004
+ if (ms === 'focus' || ms === 'disabled') {
5005
+ auditResult.recommendations.push({ priority: 'medium', area: 'states', message: 'Consider adding a "' + ms + '" variant for complete interactive state coverage' });
5006
+ }
4740
5007
  }
5008
+ } else if (variantAxisCoverage && variantCount < variantAxisCoverage.expectedCombinations) {
5009
+ auditResult.recommendations.push({ priority: 'low', area: 'coverage', message: variantCount + ' of ' + variantAxisCoverage.expectedCombinations + ' axis combinations present — consider adding missing variants for completeness' });
4741
5010
  }
4742
5011
 
4743
5012
  console.log('🌉 [Desktop Bridge] Component audit complete: score ' + overall + '/100 for "' + componentSet.name + '"');