@mp3wizard/figma-console-mcp 1.21.2 → 1.22.2

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.
Files changed (39) hide show
  1. package/README.md +10 -9
  2. package/dist/apps/design-system-dashboard/mcp-app.html +59 -59
  3. package/dist/apps/token-browser/mcp-app.html +53 -53
  4. package/dist/cloudflare/core/accessibility-tools.js +306 -0
  5. package/dist/cloudflare/core/cloud-websocket-connector.js +11 -0
  6. package/dist/cloudflare/core/design-code-tools.js +160 -2
  7. package/dist/cloudflare/core/figma-desktop-connector.js +2 -0
  8. package/dist/cloudflare/core/websocket-connector.js +11 -0
  9. package/dist/cloudflare/core/write-tools.js +49 -4
  10. package/dist/cloudflare/index.js +16 -7
  11. package/dist/core/accessibility-tools.d.ts +21 -0
  12. package/dist/core/accessibility-tools.d.ts.map +1 -0
  13. package/dist/core/accessibility-tools.js +307 -0
  14. package/dist/core/accessibility-tools.js.map +1 -0
  15. package/dist/core/design-code-tools.d.ts.map +1 -1
  16. package/dist/core/design-code-tools.js +160 -2
  17. package/dist/core/design-code-tools.js.map +1 -1
  18. package/dist/core/figma-connector.d.ts +1 -0
  19. package/dist/core/figma-connector.d.ts.map +1 -1
  20. package/dist/core/figma-desktop-connector.d.ts +1 -0
  21. package/dist/core/figma-desktop-connector.d.ts.map +1 -1
  22. package/dist/core/figma-desktop-connector.js +2 -0
  23. package/dist/core/figma-desktop-connector.js.map +1 -1
  24. package/dist/core/types/design-code.d.ts +8 -0
  25. package/dist/core/types/design-code.d.ts.map +1 -1
  26. package/dist/core/websocket-connector.d.ts +1 -0
  27. package/dist/core/websocket-connector.d.ts.map +1 -1
  28. package/dist/core/websocket-connector.js +11 -0
  29. package/dist/core/websocket-connector.js.map +1 -1
  30. package/dist/core/write-tools.d.ts.map +1 -1
  31. package/dist/core/write-tools.js +49 -4
  32. package/dist/core/write-tools.js.map +1 -1
  33. package/dist/local.d.ts.map +1 -1
  34. package/dist/local.js +52 -4
  35. package/dist/local.js.map +1 -1
  36. package/figma-desktop-bridge/code.js +1134 -1
  37. package/figma-desktop-bridge/ui-full.html +13 -0
  38. package/figma-desktop-bridge/ui.html +13 -0
  39. package/package.json +5 -101
@@ -3286,13 +3286,21 @@ figma.ui.onmessage = async (msg) => {
3286
3286
  // ---- Rule configuration ----
3287
3287
  var allRuleIds = [
3288
3288
  'wcag-contrast', 'wcag-text-size', 'wcag-target-size', 'wcag-line-height',
3289
+ 'wcag-non-text-contrast', 'wcag-color-only', 'wcag-focus-indicator',
3290
+ 'wcag-letter-spacing', 'wcag-paragraph-spacing', 'wcag-image-alt',
3291
+ 'wcag-heading-hierarchy', 'wcag-reflow', 'wcag-reading-order',
3289
3292
  'hardcoded-color', 'no-text-style', 'default-name', 'detached-component',
3290
3293
  'no-autolayout', 'empty-container'
3291
3294
  ];
3292
3295
 
3293
3296
  var ruleGroups = {
3294
3297
  'all': allRuleIds,
3295
- 'wcag': ['wcag-contrast', 'wcag-text-size', 'wcag-target-size', 'wcag-line-height'],
3298
+ 'wcag': [
3299
+ 'wcag-contrast', 'wcag-text-size', 'wcag-target-size', 'wcag-line-height',
3300
+ 'wcag-non-text-contrast', 'wcag-color-only', 'wcag-focus-indicator',
3301
+ 'wcag-letter-spacing', 'wcag-paragraph-spacing', 'wcag-image-alt',
3302
+ 'wcag-heading-hierarchy', 'wcag-reflow', 'wcag-reading-order'
3303
+ ],
3296
3304
  'design-system': ['hardcoded-color', 'no-text-style', 'default-name', 'detached-component'],
3297
3305
  'layout': ['no-autolayout', 'empty-container']
3298
3306
  };
@@ -3300,8 +3308,17 @@ figma.ui.onmessage = async (msg) => {
3300
3308
  var severityMap = {
3301
3309
  'wcag-contrast': 'critical',
3302
3310
  'wcag-target-size': 'critical',
3311
+ 'wcag-non-text-contrast': 'critical',
3312
+ 'wcag-color-only': 'critical',
3303
3313
  'wcag-text-size': 'warning',
3304
3314
  'wcag-line-height': 'warning',
3315
+ 'wcag-focus-indicator': 'warning',
3316
+ 'wcag-letter-spacing': 'warning',
3317
+ 'wcag-paragraph-spacing': 'warning',
3318
+ 'wcag-image-alt': 'warning',
3319
+ 'wcag-heading-hierarchy': 'warning',
3320
+ 'wcag-reflow': 'warning',
3321
+ 'wcag-reading-order': 'warning',
3305
3322
  'hardcoded-color': 'warning',
3306
3323
  'no-text-style': 'warning',
3307
3324
  'default-name': 'warning',
@@ -3315,6 +3332,15 @@ figma.ui.onmessage = async (msg) => {
3315
3332
  'wcag-text-size': 'Text size is below 12px minimum',
3316
3333
  'wcag-target-size': 'Interactive element is smaller than 24x24px minimum target size',
3317
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)',
3318
3344
  'hardcoded-color': 'Fill color is not bound to a variable or style',
3319
3345
  'no-text-style': 'Text node is not using a text style',
3320
3346
  'default-name': 'Node has a default Figma name (e.g., "Frame 1")',
@@ -3326,6 +3352,95 @@ figma.ui.onmessage = async (msg) => {
3326
3352
  var defaultNameRegex = /^(Frame|Rectangle|Ellipse|Line|Text|Group|Component|Instance|Vector|Polygon|Star|Section)(\s+\d+)?$/;
3327
3353
  var interactiveNameRegex = /button|link|input|checkbox|radio|switch|toggle|tab|menu-item/i;
3328
3354
 
3355
+ // ---- Additional helpers for new WCAG rules ----
3356
+
3357
+ // Get the first visible solid fill color from a node (for non-text contrast)
3358
+ function lintGetNodeFillColor(node) {
3359
+ try {
3360
+ var fills = node.fills;
3361
+ if (fills && fills.length > 0) {
3362
+ for (var i = fills.length - 1; i >= 0; i--) {
3363
+ if (fills[i].type === 'SOLID' && fills[i].visible !== false) {
3364
+ return { r: fills[i].color.r, g: fills[i].color.g, b: fills[i].color.b };
3365
+ }
3366
+ }
3367
+ }
3368
+ } catch (e) { /* slot sublayer */ }
3369
+ return null;
3370
+ }
3371
+
3372
+ // Get the first visible solid stroke color from a node
3373
+ function lintGetNodeStrokeColor(node) {
3374
+ try {
3375
+ var strokes = node.strokes;
3376
+ if (strokes && strokes.length > 0) {
3377
+ for (var i = strokes.length - 1; i >= 0; i--) {
3378
+ if (strokes[i].type === 'SOLID' && strokes[i].visible !== false) {
3379
+ return { r: strokes[i].color.r, g: strokes[i].color.g, b: strokes[i].color.b };
3380
+ }
3381
+ }
3382
+ }
3383
+ } catch (e) { /* slot sublayer */ }
3384
+ return null;
3385
+ }
3386
+
3387
+ // Check if a node has any non-color visual differentiation (icon children, text change, border)
3388
+ function lintHasNonColorIndicator(node) {
3389
+ try {
3390
+ if (!node.children) return false;
3391
+ for (var i = 0; i < node.children.length; i++) {
3392
+ var child = node.children[i];
3393
+ try {
3394
+ // Icons are typically vectors, instances, or small frames with vector children
3395
+ if (child.type === 'VECTOR' || child.type === 'BOOLEAN_OPERATION') return true;
3396
+ if (child.type === 'INSTANCE') return true;
3397
+ // Check for visible strokes (borders)
3398
+ if (child.strokes && child.strokes.length > 0) {
3399
+ for (var si = 0; si < child.strokes.length; si++) {
3400
+ if (child.strokes[si].visible !== false) return true;
3401
+ }
3402
+ }
3403
+ } catch (e) { /* skip */ }
3404
+ }
3405
+ } catch (e) { /* no children */ }
3406
+ return false;
3407
+ }
3408
+
3409
+ // Heading level detection from text style name or font size
3410
+ var headingStyleRegex = /\bh(\d)\b|heading[\s-]*(\d)/i;
3411
+ function lintGetHeadingLevel(node) {
3412
+ // Try text style name first
3413
+ try {
3414
+ if (node.textStyleId && typeof node.textStyleId === 'string') {
3415
+ // We can't resolve style name in plugin sandbox directly from ID alone,
3416
+ // but we check the node name and font size as fallback
3417
+ }
3418
+ } catch (e) { /* skip */ }
3419
+ // Check node name for heading patterns
3420
+ try {
3421
+ var match = headingStyleRegex.exec(node.name);
3422
+ if (match) return parseInt(match[1] || match[2], 10);
3423
+ } catch (e) { /* skip */ }
3424
+ // Infer from font size (common convention)
3425
+ try {
3426
+ var fs = node.fontSize;
3427
+ if (typeof fs === 'number') {
3428
+ if (fs >= 40) return 1;
3429
+ if (fs >= 32) return 2;
3430
+ if (fs >= 24) return 3;
3431
+ if (fs >= 20) return 4;
3432
+ if (fs >= 18) return 5;
3433
+ }
3434
+ } catch (e) { /* mixed */ }
3435
+ return 0; // Not a heading
3436
+ }
3437
+
3438
+ // Tracking for heading hierarchy check (reset per root scan)
3439
+ var headingSequence = [];
3440
+
3441
+ // Tracking for reading order check — collect positioned children per parent
3442
+ // (checked after tree walk per-frame)
3443
+
3329
3444
  // ---- Resolve active rules ----
3330
3445
  var requestedRules = msg.rules || ['all'];
3331
3446
  var activeRuleSet = {};
@@ -3531,6 +3646,355 @@ figma.ui.onmessage = async (msg) => {
3531
3646
  } catch (e) { /* slot sublayer or mixed */ }
3532
3647
  }
3533
3648
 
3649
+ // wcag-non-text-contrast: UI components need 3:1 against adjacent color (WCAG 1.4.11)
3650
+ if (activeRuleSet['wcag-non-text-contrast'] && !isPage && !isSection && !truncated) {
3651
+ try {
3652
+ if ((nodeType === 'FRAME' || nodeType === 'COMPONENT' || nodeType === 'INSTANCE' || nodeType === 'COMPONENT_SET') && interactiveNameRegex.test(nodeName)) {
3653
+ var uiFill = lintGetNodeFillColor(node);
3654
+ var uiStroke = lintGetNodeStrokeColor(node);
3655
+ var uiBg = lintGetEffectiveBg(node);
3656
+ // Check fill against background
3657
+ if (uiFill) {
3658
+ var uiRatio = lintContrastRatio(uiFill.r, uiFill.g, uiFill.b, uiBg.r, uiBg.g, uiBg.b);
3659
+ if (uiRatio < 3.0) {
3660
+ if (totalFindings < maxFindings) {
3661
+ findings['wcag-non-text-contrast'].push({
3662
+ id: nodeId,
3663
+ name: nodeName,
3664
+ ratio: uiRatio.toFixed(1) + ':1',
3665
+ required: '3.0:1',
3666
+ component: lintRgbToHex(uiFill.r, uiFill.g, uiFill.b),
3667
+ bg: lintRgbToHex(uiBg.r, uiBg.g, uiBg.b),
3668
+ element: 'fill'
3669
+ });
3670
+ totalFindings++;
3671
+ } else { truncated = true; }
3672
+ }
3673
+ }
3674
+ // Check stroke/border against background
3675
+ if (uiStroke && !truncated) {
3676
+ var strokeRatio = lintContrastRatio(uiStroke.r, uiStroke.g, uiStroke.b, uiBg.r, uiBg.g, uiBg.b);
3677
+ if (strokeRatio < 3.0) {
3678
+ if (totalFindings < maxFindings) {
3679
+ findings['wcag-non-text-contrast'].push({
3680
+ id: nodeId,
3681
+ name: nodeName,
3682
+ ratio: strokeRatio.toFixed(1) + ':1',
3683
+ required: '3.0:1',
3684
+ component: lintRgbToHex(uiStroke.r, uiStroke.g, uiStroke.b),
3685
+ bg: lintRgbToHex(uiBg.r, uiBg.g, uiBg.b),
3686
+ element: 'stroke'
3687
+ });
3688
+ totalFindings++;
3689
+ } else { truncated = true; }
3690
+ }
3691
+ }
3692
+ }
3693
+ } catch (e) { /* slot sublayer */ }
3694
+ }
3695
+
3696
+ // wcag-color-only: Component variants that differ only by color (WCAG 1.4.1)
3697
+ if (activeRuleSet['wcag-color-only'] && nodeType === 'COMPONENT_SET' && !truncated) {
3698
+ try {
3699
+ var variants = node.children;
3700
+ if (variants && variants.length >= 2) {
3701
+ // Compare each variant pair for color-only differentiation
3702
+ for (var vi = 0; vi < variants.length && !truncated; vi++) {
3703
+ var variant = variants[vi];
3704
+ try {
3705
+ var vName = variant.name.toLowerCase();
3706
+ // Only check state-related variants (error, warning, success, disabled)
3707
+ if (/(error|warning|danger|success|invalid|alert)/.test(vName)) {
3708
+ if (!lintHasNonColorIndicator(variant)) {
3709
+ // Check if this variant's fill differs from default
3710
+ var defaultVariant = null;
3711
+ for (var dvi = 0; dvi < variants.length; dvi++) {
3712
+ var dvName = variants[dvi].name.toLowerCase();
3713
+ if (/(default|rest|idle|normal|base)/.test(dvName) || dvi === 0) {
3714
+ defaultVariant = variants[dvi];
3715
+ break;
3716
+ }
3717
+ }
3718
+ if (defaultVariant) {
3719
+ var varFill = lintGetNodeFillColor(variant);
3720
+ var defFill = lintGetNodeFillColor(defaultVariant);
3721
+ if (varFill && defFill && (varFill.r !== defFill.r || varFill.g !== defFill.g || varFill.b !== defFill.b)) {
3722
+ if (totalFindings < maxFindings) {
3723
+ findings['wcag-color-only'].push({
3724
+ id: variant.id,
3725
+ name: nodeName + ' / ' + variant.name,
3726
+ variantColor: lintRgbToHex(varFill.r, varFill.g, varFill.b),
3727
+ defaultColor: lintRgbToHex(defFill.r, defFill.g, defFill.b),
3728
+ suggestion: 'Add an icon, text label, or border to differentiate this state beyond color alone'
3729
+ });
3730
+ totalFindings++;
3731
+ } else { truncated = true; }
3732
+ }
3733
+ }
3734
+ }
3735
+ }
3736
+ } catch (e) { /* skip variant */ }
3737
+ }
3738
+ }
3739
+ } catch (e) { /* slot sublayer */ }
3740
+ }
3741
+
3742
+ // wcag-focus-indicator: Interactive components missing focus variant (WCAG 2.4.7)
3743
+ if (activeRuleSet['wcag-focus-indicator'] && nodeType === 'COMPONENT_SET' && !truncated) {
3744
+ try {
3745
+ if (interactiveNameRegex.test(nodeName)) {
3746
+ var hasFocusVariant = false;
3747
+ var focusVariantNode = null;
3748
+ var csVariants = node.children;
3749
+ if (csVariants) {
3750
+ for (var fvi = 0; fvi < csVariants.length; fvi++) {
3751
+ var fvName = csVariants[fvi].name.toLowerCase();
3752
+ if (/focus|focused/.test(fvName)) {
3753
+ hasFocusVariant = true;
3754
+ focusVariantNode = csVariants[fvi];
3755
+ break;
3756
+ }
3757
+ }
3758
+ }
3759
+ if (!hasFocusVariant) {
3760
+ if (totalFindings < maxFindings) {
3761
+ findings['wcag-focus-indicator'].push({
3762
+ id: nodeId,
3763
+ name: nodeName,
3764
+ issue: 'missing-variant',
3765
+ suggestion: 'Add a focus/focused variant with a visible focus ring or outline'
3766
+ });
3767
+ totalFindings++;
3768
+ } else { truncated = true; }
3769
+ } else if (focusVariantNode) {
3770
+ // Check if focus variant has a visible stroke/border (focus ring)
3771
+ var focusStroke = lintGetNodeStrokeColor(focusVariantNode);
3772
+ var hasFocusEffect = false;
3773
+ try {
3774
+ var effects = focusVariantNode.effects;
3775
+ if (effects) {
3776
+ for (var ei = 0; ei < effects.length; ei++) {
3777
+ if (effects[ei].visible !== false && (effects[ei].type === 'DROP_SHADOW' || effects[ei].type === 'INNER_SHADOW')) {
3778
+ hasFocusEffect = true;
3779
+ break;
3780
+ }
3781
+ }
3782
+ }
3783
+ } catch (e) { /* skip */ }
3784
+ if (!focusStroke && !hasFocusEffect) {
3785
+ if (totalFindings < maxFindings) {
3786
+ findings['wcag-focus-indicator'].push({
3787
+ id: focusVariantNode.id,
3788
+ name: nodeName + ' / ' + focusVariantNode.name,
3789
+ issue: 'no-visible-indicator',
3790
+ suggestion: 'Focus variant exists but has no visible border, outline, or shadow for the focus indicator'
3791
+ });
3792
+ totalFindings++;
3793
+ } else { truncated = true; }
3794
+ }
3795
+ }
3796
+ }
3797
+ } catch (e) { /* slot sublayer */ }
3798
+ }
3799
+
3800
+ // wcag-letter-spacing: Negative letter spacing (WCAG 1.4.12)
3801
+ if (activeRuleSet['wcag-letter-spacing'] && nodeType === 'TEXT' && !truncated) {
3802
+ try {
3803
+ var ls = node.letterSpacing;
3804
+ if (ls && typeof ls === 'object' && typeof ls.value === 'number') {
3805
+ if (ls.unit === 'PIXELS' && ls.value < 0) {
3806
+ if (totalFindings < maxFindings) {
3807
+ findings['wcag-letter-spacing'].push({
3808
+ id: nodeId,
3809
+ name: nodeName,
3810
+ letterSpacing: ls.value + 'px'
3811
+ });
3812
+ totalFindings++;
3813
+ } else { truncated = true; }
3814
+ } else if (ls.unit === 'PERCENT' && ls.value < 0) {
3815
+ if (totalFindings < maxFindings) {
3816
+ findings['wcag-letter-spacing'].push({
3817
+ id: nodeId,
3818
+ name: nodeName,
3819
+ letterSpacing: ls.value + '%'
3820
+ });
3821
+ totalFindings++;
3822
+ } else { truncated = true; }
3823
+ }
3824
+ }
3825
+ } catch (e) { /* slot sublayer or mixed */ }
3826
+ }
3827
+
3828
+ // wcag-paragraph-spacing: Paragraph spacing < 2x font size (WCAG 1.4.12)
3829
+ if (activeRuleSet['wcag-paragraph-spacing'] && nodeType === 'TEXT' && !truncated) {
3830
+ try {
3831
+ var ps = node.paragraphSpacing;
3832
+ var psFs = node.fontSize;
3833
+ if (typeof ps === 'number' && typeof psFs === 'number' && ps > 0) {
3834
+ var requiredPs = 2 * psFs;
3835
+ if (ps < requiredPs) {
3836
+ if (totalFindings < maxFindings) {
3837
+ findings['wcag-paragraph-spacing'].push({
3838
+ id: nodeId,
3839
+ name: nodeName,
3840
+ paragraphSpacing: ps,
3841
+ fontSize: psFs,
3842
+ recommended: requiredPs
3843
+ });
3844
+ totalFindings++;
3845
+ } else { truncated = true; }
3846
+ }
3847
+ }
3848
+ } catch (e) { /* slot sublayer or mixed */ }
3849
+ }
3850
+
3851
+ // wcag-image-alt: Image fills without description (WCAG 1.1.1)
3852
+ if (activeRuleSet['wcag-image-alt'] && !isPage && !isSection && !truncated) {
3853
+ try {
3854
+ var hasImageFill = false;
3855
+ var imgFills = node.fills;
3856
+ if (imgFills && imgFills.length > 0) {
3857
+ for (var ifi = 0; ifi < imgFills.length; ifi++) {
3858
+ if (imgFills[ifi].type === 'IMAGE' && imgFills[ifi].visible !== false) {
3859
+ hasImageFill = true;
3860
+ break;
3861
+ }
3862
+ }
3863
+ }
3864
+ if (hasImageFill) {
3865
+ var hasDescription = false;
3866
+ try {
3867
+ if (node.description && node.description.trim().length > 0) {
3868
+ hasDescription = true;
3869
+ }
3870
+ } catch (e) { /* skip */ }
3871
+ // Also check if node name indicates decorative
3872
+ var isDecorative = false;
3873
+ try {
3874
+ var lowerName = nodeName.toLowerCase();
3875
+ if (lowerName === 'decorative' || lowerName === 'decoration' || lowerName.indexOf('decorative') !== -1) {
3876
+ isDecorative = true;
3877
+ }
3878
+ } catch (e) { /* skip */ }
3879
+ if (!hasDescription && !isDecorative) {
3880
+ if (totalFindings < maxFindings) {
3881
+ findings['wcag-image-alt'].push({
3882
+ id: nodeId,
3883
+ name: nodeName,
3884
+ suggestion: 'Add a description in the node\'s description field, or name it "decorative" if purely presentational'
3885
+ });
3886
+ totalFindings++;
3887
+ } else { truncated = true; }
3888
+ }
3889
+ }
3890
+ } catch (e) { /* slot sublayer */ }
3891
+ }
3892
+
3893
+ // wcag-heading-hierarchy: Track heading levels for hierarchy check (WCAG 1.3.1)
3894
+ if (activeRuleSet['wcag-heading-hierarchy'] && nodeType === 'TEXT' && !truncated) {
3895
+ try {
3896
+ var hlevel = lintGetHeadingLevel(node);
3897
+ if (hlevel > 0) {
3898
+ headingSequence.push({ level: hlevel, id: nodeId, name: nodeName });
3899
+ }
3900
+ } catch (e) { /* skip */ }
3901
+ }
3902
+
3903
+ // wcag-reflow: Fixed-position frames without auto-layout (WCAG 1.4.10)
3904
+ if (activeRuleSet['wcag-reflow'] && !isPage && !isSection && !truncated) {
3905
+ try {
3906
+ if ((nodeType === 'FRAME' || nodeType === 'COMPONENT') && node.children && node.children.length >= 3) {
3907
+ var rlLayoutMode = 'NONE';
3908
+ try { rlLayoutMode = node.layoutMode; } catch (e) { /* skip */ }
3909
+ if (!rlLayoutMode || rlLayoutMode === 'NONE') {
3910
+ // Check if children use absolute positioning (different x/y values)
3911
+ var hasAbsoluteChildren = false;
3912
+ var childXs = [];
3913
+ var childYs = [];
3914
+ for (var rci = 0; rci < Math.min(node.children.length, 10); rci++) {
3915
+ try {
3916
+ childXs.push(node.children[rci].x);
3917
+ childYs.push(node.children[rci].y);
3918
+ } catch (e) { /* skip */ }
3919
+ }
3920
+ if (childXs.length >= 3) {
3921
+ // If children are spread across both axes, it's likely absolute positioning
3922
+ var uniqueXs = [];
3923
+ var uniqueYs = [];
3924
+ for (var uxi = 0; uxi < childXs.length; uxi++) {
3925
+ if (uniqueXs.indexOf(childXs[uxi]) === -1) uniqueXs.push(childXs[uxi]);
3926
+ if (uniqueYs.indexOf(childYs[uxi]) === -1) uniqueYs.push(childYs[uxi]);
3927
+ }
3928
+ hasAbsoluteChildren = uniqueXs.length > 2 && uniqueYs.length > 2;
3929
+ }
3930
+ if (hasAbsoluteChildren) {
3931
+ if (totalFindings < maxFindings) {
3932
+ findings['wcag-reflow'].push({
3933
+ id: nodeId,
3934
+ name: nodeName,
3935
+ childCount: node.children.length,
3936
+ suggestion: 'Convert to auto-layout so content can reflow at different viewport sizes'
3937
+ });
3938
+ totalFindings++;
3939
+ } else { truncated = true; }
3940
+ }
3941
+ }
3942
+ }
3943
+ } catch (e) { /* slot sublayer */ }
3944
+ }
3945
+
3946
+ // wcag-reading-order: Check if visual order matches layer order (WCAG 1.3.2)
3947
+ if (activeRuleSet['wcag-reading-order'] && !isPage && !isSection && !truncated) {
3948
+ try {
3949
+ if ((nodeType === 'FRAME' || nodeType === 'COMPONENT' || nodeType === 'INSTANCE') && node.children && node.children.length >= 2) {
3950
+ var roLayoutMode = 'NONE';
3951
+ try { roLayoutMode = node.layoutMode; } catch (e) { /* skip */ }
3952
+ // Only check non-auto-layout frames (auto-layout enforces order)
3953
+ if (!roLayoutMode || roLayoutMode === 'NONE') {
3954
+ // Collect children y positions (for vertical reading order)
3955
+ var childPositions = [];
3956
+ for (var roi = 0; roi < node.children.length; roi++) {
3957
+ try {
3958
+ childPositions.push({
3959
+ index: roi,
3960
+ y: node.children[roi].y,
3961
+ x: node.children[roi].x,
3962
+ name: node.children[roi].name
3963
+ });
3964
+ } catch (e) { /* skip */ }
3965
+ }
3966
+ if (childPositions.length >= 2) {
3967
+ // Sort by visual position (top-to-bottom, left-to-right)
3968
+ var visualOrder = childPositions.slice().sort(function(a, b) {
3969
+ if (Math.abs(a.y - b.y) > 10) return a.y - b.y; // Different row
3970
+ return a.x - b.x; // Same row, left to right
3971
+ });
3972
+ // Check if layer order matches visual order
3973
+ var orderMismatches = 0;
3974
+ for (var omi = 0; omi < visualOrder.length; omi++) {
3975
+ if (visualOrder[omi].index !== omi) {
3976
+ orderMismatches++;
3977
+ }
3978
+ }
3979
+ // Flag if more than 30% of elements are out of order
3980
+ if (orderMismatches > childPositions.length * 0.3 && orderMismatches >= 2) {
3981
+ if (totalFindings < maxFindings) {
3982
+ findings['wcag-reading-order'].push({
3983
+ id: nodeId,
3984
+ name: nodeName,
3985
+ childCount: childPositions.length,
3986
+ mismatches: orderMismatches,
3987
+ suggestion: 'Reorder layers to match visual top-to-bottom, left-to-right reading order'
3988
+ });
3989
+ totalFindings++;
3990
+ } else { truncated = true; }
3991
+ }
3992
+ }
3993
+ }
3994
+ }
3995
+ } catch (e) { /* slot sublayer */ }
3996
+ }
3997
+
3534
3998
  // ---- Design System checks ----
3535
3999
 
3536
4000
  // hardcoded-color: Solid fills without variable binding or style
@@ -3690,6 +4154,31 @@ figma.ui.onmessage = async (msg) => {
3690
4154
  // ---- Execute walk ----
3691
4155
  walkNode(rootNode, 0);
3692
4156
 
4157
+ // ---- Post-walk: heading hierarchy validation ----
4158
+ if (activeRuleSet['wcag-heading-hierarchy'] && headingSequence.length >= 2 && !truncated) {
4159
+ var prevLevel = 0;
4160
+ for (var hi = 0; hi < headingSequence.length; hi++) {
4161
+ var h = headingSequence[hi];
4162
+ if (prevLevel > 0 && h.level > prevLevel + 1) {
4163
+ // Skipped a level (e.g., H1 → H3)
4164
+ if (totalFindings < maxFindings) {
4165
+ findings['wcag-heading-hierarchy'].push({
4166
+ id: h.id,
4167
+ name: h.name,
4168
+ level: h.level,
4169
+ previousLevel: prevLevel,
4170
+ suggestion: 'Expected H' + (prevLevel + 1) + ' but found H' + h.level + '. Do not skip heading levels.'
4171
+ });
4172
+ totalFindings++;
4173
+ } else {
4174
+ truncated = true;
4175
+ break;
4176
+ }
4177
+ }
4178
+ prevLevel = h.level;
4179
+ }
4180
+ }
4181
+
3693
4182
  // ---- Build response ----
3694
4183
  var categories = [];
3695
4184
  var summaryObj = { critical: 0, warning: 0, info: 0, total: 0 };
@@ -3742,6 +4231,650 @@ figma.ui.onmessage = async (msg) => {
3742
4231
  }
3743
4232
  }
3744
4233
 
4234
+ // ============================================================================
4235
+ // AUDIT_COMPONENT_ACCESSIBILITY — Deep accessibility audit for a component set
4236
+ // ============================================================================
4237
+ else if (msg.type === 'AUDIT_COMPONENT_ACCESSIBILITY') {
4238
+ try {
4239
+ console.log('🌉 [Desktop Bridge] Running component accessibility audit...');
4240
+
4241
+ // ---- Color science helpers (shared with lint) ----
4242
+ function auditLinearize(c) {
4243
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
4244
+ }
4245
+ function auditLuminance(r, g, b) {
4246
+ return 0.2126 * auditLinearize(r) + 0.7152 * auditLinearize(g) + 0.0722 * auditLinearize(b);
4247
+ }
4248
+ function auditContrastRatio(r1, g1, b1, r2, g2, b2) {
4249
+ var l1 = auditLuminance(r1, g1, b1);
4250
+ var l2 = auditLuminance(r2, g2, b2);
4251
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
4252
+ }
4253
+ function auditRgbToHex(r, g, b) {
4254
+ var rr = Math.round(r * 255).toString(16);
4255
+ var gg = Math.round(g * 255).toString(16);
4256
+ var bb = Math.round(b * 255).toString(16);
4257
+ if (rr.length === 1) rr = '0' + rr;
4258
+ if (gg.length === 1) gg = '0' + gg;
4259
+ if (bb.length === 1) bb = '0' + bb;
4260
+ return '#' + rr.toUpperCase() + gg.toUpperCase() + bb.toUpperCase();
4261
+ }
4262
+
4263
+ // ---- Color-blind simulation matrices ----
4264
+ // Brettel/Vienot transformation matrices for dichromatic vision
4265
+ var colorBlindMatrices = {
4266
+ protanopia: [
4267
+ [0.152286, 1.052583, -0.204868],
4268
+ [0.114503, 0.786281, 0.099216],
4269
+ [-0.003882, -0.048116, 1.051998]
4270
+ ],
4271
+ deuteranopia: [
4272
+ [0.367322, 0.860646, -0.227968],
4273
+ [0.280085, 0.672501, 0.047413],
4274
+ [-0.011820, 0.042940, 0.968881]
4275
+ ],
4276
+ tritanopia: [
4277
+ [1.255528, -0.076749, -0.178779],
4278
+ [-0.078411, 0.930809, 0.147602],
4279
+ [0.004733, 0.691367, 0.303900]
4280
+ ]
4281
+ };
4282
+
4283
+ function simulateColorBlind(r, g, b, matrix) {
4284
+ var nr = Math.max(0, Math.min(1, matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b));
4285
+ var ng = Math.max(0, Math.min(1, matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b));
4286
+ var nb = Math.max(0, Math.min(1, matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b));
4287
+ return { r: nr, g: ng, b: nb };
4288
+ }
4289
+
4290
+ // ---- Node inspection helpers ----
4291
+ function auditGetFillColor(node) {
4292
+ try {
4293
+ var fills = node.fills;
4294
+ if (fills && fills.length > 0) {
4295
+ for (var i = fills.length - 1; i >= 0; i--) {
4296
+ if (fills[i].type === 'SOLID' && fills[i].visible !== false) {
4297
+ return { r: fills[i].color.r, g: fills[i].color.g, b: fills[i].color.b };
4298
+ }
4299
+ }
4300
+ }
4301
+ } catch (e) { /* skip */ }
4302
+ return null;
4303
+ }
4304
+
4305
+ function auditGetStrokeColor(node) {
4306
+ try {
4307
+ var strokes = node.strokes;
4308
+ if (strokes && strokes.length > 0) {
4309
+ for (var i = strokes.length - 1; i >= 0; i--) {
4310
+ if (strokes[i].type === 'SOLID' && strokes[i].visible !== false) {
4311
+ return { r: strokes[i].color.r, g: strokes[i].color.g, b: strokes[i].color.b };
4312
+ }
4313
+ }
4314
+ }
4315
+ } catch (e) { /* skip */ }
4316
+ return null;
4317
+ }
4318
+
4319
+ function auditGetEffectiveBg(node) {
4320
+ var current = node.parent;
4321
+ while (current) {
4322
+ try {
4323
+ if (current.fills && current.fills.length > 0) {
4324
+ for (var fi = current.fills.length - 1; fi >= 0; fi--) {
4325
+ var fill = current.fills[fi];
4326
+ if (fill.type === 'SOLID' && fill.visible !== false) {
4327
+ return { r: fill.color.r, g: fill.color.g, b: fill.color.b };
4328
+ }
4329
+ }
4330
+ }
4331
+ } catch (e) { /* skip */ }
4332
+ current = current.parent;
4333
+ }
4334
+ return { r: 1, g: 1, b: 1 };
4335
+ }
4336
+
4337
+ function auditHasChildOfType(node, types) {
4338
+ try {
4339
+ if (!node.children) return false;
4340
+ for (var i = 0; i < node.children.length; i++) {
4341
+ var child = node.children[i];
4342
+ try {
4343
+ for (var t = 0; t < types.length; t++) {
4344
+ if (child.type === types[t]) return true;
4345
+ }
4346
+ // Recurse one level deeper for nested icons
4347
+ if (child.children) {
4348
+ for (var j = 0; j < child.children.length; j++) {
4349
+ try {
4350
+ for (var t2 = 0; t2 < types.length; t2++) {
4351
+ if (child.children[j].type === types[t2]) return true;
4352
+ }
4353
+ } catch (e) { /* skip */ }
4354
+ }
4355
+ }
4356
+ } catch (e) { /* skip */ }
4357
+ }
4358
+ } catch (e) { /* skip */ }
4359
+ return false;
4360
+ }
4361
+
4362
+ // Collect all text fill colors and background colors from a variant tree
4363
+ function auditCollectColorPairs(node, pairs, depth) {
4364
+ if (depth > 5) return;
4365
+ try {
4366
+ if (node.type === 'TEXT') {
4367
+ var fills = node.fills;
4368
+ if (fills && fills.length > 0) {
4369
+ for (var i = 0; i < fills.length; i++) {
4370
+ if (fills[i].type === 'SOLID' && fills[i].visible !== false) {
4371
+ var bg = auditGetEffectiveBg(node);
4372
+ pairs.push({
4373
+ fg: { r: fills[i].color.r, g: fills[i].color.g, b: fills[i].color.b },
4374
+ bg: bg,
4375
+ nodeName: node.name,
4376
+ nodeId: node.id
4377
+ });
4378
+ break;
4379
+ }
4380
+ }
4381
+ }
4382
+ }
4383
+ if (node.children) {
4384
+ for (var ci = 0; ci < node.children.length; ci++) {
4385
+ auditCollectColorPairs(node.children[ci], pairs, depth + 1);
4386
+ }
4387
+ }
4388
+ } catch (e) { /* skip */ }
4389
+ }
4390
+
4391
+ // ---- Resolve target node ----
4392
+ var targetNode;
4393
+ if (msg.nodeId) {
4394
+ targetNode = await figma.getNodeByIdAsync(msg.nodeId);
4395
+ if (!targetNode) {
4396
+ throw new Error('Node not found: ' + msg.nodeId);
4397
+ }
4398
+ } else {
4399
+ // Try current selection
4400
+ var sel = figma.currentPage.selection;
4401
+ if (sel && sel.length === 1) {
4402
+ targetNode = sel[0];
4403
+ } else {
4404
+ throw new Error('No nodeId provided and no single node selected. Select a component set or provide a nodeId.');
4405
+ }
4406
+ }
4407
+
4408
+ // If user selected a COMPONENT or INSTANCE, walk up to the COMPONENT_SET
4409
+ var componentSet = targetNode;
4410
+ if (targetNode.type === 'COMPONENT' && targetNode.parent && targetNode.parent.type === 'COMPONENT_SET') {
4411
+ componentSet = targetNode.parent;
4412
+ } else if (targetNode.type === 'INSTANCE') {
4413
+ try {
4414
+ var mainComponent = await targetNode.getMainComponentAsync();
4415
+ if (mainComponent && mainComponent.parent && mainComponent.parent.type === 'COMPONENT_SET') {
4416
+ componentSet = mainComponent.parent;
4417
+ } else if (mainComponent && mainComponent.type === 'COMPONENT') {
4418
+ componentSet = mainComponent;
4419
+ }
4420
+ } catch (e) { /* use target as-is */ }
4421
+ }
4422
+
4423
+ var isComponentSet = componentSet.type === 'COMPONENT_SET';
4424
+ var isComponent = componentSet.type === 'COMPONENT';
4425
+ if (!isComponentSet && !isComponent) {
4426
+ throw new Error('Node "' + componentSet.name + '" is type ' + componentSet.type + '. Expected COMPONENT_SET or COMPONENT.');
4427
+ }
4428
+
4429
+ // ---- Analyze variants ----
4430
+ var variants = isComponentSet ? componentSet.children : [componentSet];
4431
+ var variantCount = variants.length;
4432
+
4433
+ // ---- Classify component as interactive vs presentational ----
4434
+ var interactiveNames = /^(button|link|input|checkbox|radio|switch|toggle|tab|select|slider|dropdown|menu-item|search|combobox|listbox)/i;
4435
+ 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;
4436
+
4437
+ // Parse variant axes from variant names (e.g., "type=success, style=fill" → {type: [...], style: [...]})
4438
+ var variantAxes = {};
4439
+ var hasStateAxis = false;
4440
+ for (var vai = 0; vai < variants.length; vai++) {
4441
+ var vParts = variants[vai].name.split(',');
4442
+ for (var vpi = 0; vpi < vParts.length; vpi++) {
4443
+ var kv = vParts[vpi].trim().split('=');
4444
+ if (kv.length === 2) {
4445
+ var axisName = kv[0].trim().toLowerCase();
4446
+ var axisValue = kv[1].trim().toLowerCase();
4447
+ if (!variantAxes[axisName]) variantAxes[axisName] = [];
4448
+ if (variantAxes[axisName].indexOf(axisValue) === -1) {
4449
+ variantAxes[axisName].push(axisValue);
4450
+ }
4451
+ // Check if this axis contains interaction state values
4452
+ if (axisName === 'state' && /(hover|focus|pressed|disabled|active)/i.test(axisValue)) {
4453
+ hasStateAxis = true;
4454
+ }
4455
+ }
4456
+ }
4457
+ }
4458
+
4459
+ var componentName = componentSet.name || '';
4460
+ var isInteractive = interactiveNames.test(componentName) || hasStateAxis;
4461
+ var isPresentational = !isInteractive && (presentationalNames.test(componentName) || !hasStateAxis);
4462
+ // If ambiguous, check if any variant mentions interaction states
4463
+ if (!isInteractive && !isPresentational) {
4464
+ for (var ami = 0; ami < variants.length; ami++) {
4465
+ if (/(hover|focus|pressed|disabled)/i.test(variants[ami].name)) {
4466
+ isInteractive = true;
4467
+ break;
4468
+ }
4469
+ }
4470
+ if (!isInteractive) isPresentational = true;
4471
+ }
4472
+
4473
+ // 1. Coverage analysis — adapts to component classification
4474
+ var stateKeywords = {
4475
+ 'default': /(default|rest|idle|normal|base)/i,
4476
+ 'hover': /(hover|hovered)/i,
4477
+ 'focus': /(focus|focused)/i,
4478
+ 'disabled': /(disabled|inactive)/i,
4479
+ 'error': /(error|invalid|danger)/i,
4480
+ 'active': /(active|pressed|selected)/i,
4481
+ 'loading': /(loading|spinner)/i
4482
+ };
4483
+
4484
+ var coveredCount = 0;
4485
+ var totalStates = 0;
4486
+ var missingStates = [];
4487
+ var statesCovered = {};
4488
+ var statesFound = {};
4489
+ var coverageLabel = '';
4490
+ var variantAxisCoverage = null;
4491
+
4492
+ if (isInteractive) {
4493
+ // Interactive components: check for interaction states
4494
+ coverageLabel = 'interactive-states';
4495
+ for (var sk in stateKeywords) {
4496
+ statesCovered[sk] = false;
4497
+ statesFound[sk] = null;
4498
+ }
4499
+ for (var vi = 0; vi < variants.length; vi++) {
4500
+ var vName = variants[vi].name;
4501
+ for (var sk2 in stateKeywords) {
4502
+ if (stateKeywords[sk2].test(vName)) {
4503
+ statesCovered[sk2] = true;
4504
+ if (!statesFound[sk2]) statesFound[sk2] = vName;
4505
+ }
4506
+ }
4507
+ }
4508
+ if (variantCount === 1 && !statesCovered['default']) {
4509
+ statesCovered['default'] = true;
4510
+ statesFound['default'] = variants[0].name;
4511
+ }
4512
+ for (var sk3 in statesCovered) {
4513
+ totalStates++;
4514
+ if (statesCovered[sk3]) {
4515
+ coveredCount++;
4516
+ } else {
4517
+ missingStates.push(sk3);
4518
+ }
4519
+ }
4520
+ } else {
4521
+ // Presentational components: check variant axis completeness
4522
+ coverageLabel = 'variant-axes';
4523
+ // Calculate expected combinations vs actual
4524
+ var axisNames = [];
4525
+ var axisCounts = [];
4526
+ var expectedCombinations = 1;
4527
+ for (var axName in variantAxes) {
4528
+ axisNames.push(axName);
4529
+ axisCounts.push(variantAxes[axName].length);
4530
+ expectedCombinations *= variantAxes[axName].length;
4531
+ }
4532
+ // Score: actual variants / expected combinations (capped at 100%)
4533
+ var axisCoverageRatio = expectedCombinations > 0 ? Math.min(1, variantCount / expectedCombinations) : 1;
4534
+ coveredCount = variantCount;
4535
+ totalStates = expectedCombinations;
4536
+ variantAxisCoverage = {
4537
+ axes: variantAxes,
4538
+ axisCount: axisNames.length,
4539
+ expectedCombinations: expectedCombinations,
4540
+ actualVariants: variantCount,
4541
+ completeness: Math.round(axisCoverageRatio * 100) + '%'
4542
+ };
4543
+ // For presentational, no "missing states" — instead note if combinations are incomplete
4544
+ if (variantCount < expectedCombinations) {
4545
+ missingStates.push(variantCount + '/' + expectedCombinations + ' axis combinations present');
4546
+ }
4547
+ }
4548
+
4549
+ // 2. Focus indicator quality
4550
+ var focusAnalysis = { hasVariant: false, hasVisibleIndicator: false, contrastRatio: null, details: '' };
4551
+ if (statesCovered['focus'] && isComponentSet) {
4552
+ focusAnalysis.hasVariant = true;
4553
+ for (var fvi = 0; fvi < variants.length; fvi++) {
4554
+ if (/(focus|focused)/i.test(variants[fvi].name)) {
4555
+ var focusNode = variants[fvi];
4556
+ // Check for stroke (focus ring)
4557
+ var fStroke = auditGetStrokeColor(focusNode);
4558
+ if (fStroke) {
4559
+ var fBg = auditGetEffectiveBg(focusNode);
4560
+ var fRatio = auditContrastRatio(fStroke.r, fStroke.g, fStroke.b, fBg.r, fBg.g, fBg.b);
4561
+ focusAnalysis.hasVisibleIndicator = true;
4562
+ focusAnalysis.contrastRatio = parseFloat(fRatio.toFixed(1));
4563
+ focusAnalysis.details = 'Focus ring (stroke) with ' + fRatio.toFixed(1) + ':1 contrast';
4564
+ break;
4565
+ }
4566
+ // Check for shadow effect
4567
+ try {
4568
+ var effects = focusNode.effects;
4569
+ if (effects) {
4570
+ for (var ei = 0; ei < effects.length; ei++) {
4571
+ if (effects[ei].visible !== false && (effects[ei].type === 'DROP_SHADOW' || effects[ei].type === 'INNER_SHADOW')) {
4572
+ focusAnalysis.hasVisibleIndicator = true;
4573
+ focusAnalysis.details = 'Focus indicator via ' + effects[ei].type.toLowerCase().replace('_', ' ');
4574
+ break;
4575
+ }
4576
+ }
4577
+ }
4578
+ } catch (e) { /* skip */ }
4579
+ if (!focusAnalysis.hasVisibleIndicator) {
4580
+ focusAnalysis.details = 'Focus variant exists but no visible stroke or shadow detected';
4581
+ }
4582
+ break;
4583
+ }
4584
+ }
4585
+ } else if (!statesCovered['focus']) {
4586
+ focusAnalysis.details = 'No focus/focused variant found';
4587
+ }
4588
+
4589
+ // 3. Non-color differentiation for status states
4590
+ var colorDifferentiation = { issues: [], checked: 0 };
4591
+ if (isComponentSet) {
4592
+ var statusStates = ['error', 'disabled', 'active'];
4593
+ var defaultVariant = null;
4594
+ for (var dvi = 0; dvi < variants.length; dvi++) {
4595
+ if (/(default|rest|idle|normal|base)/i.test(variants[dvi].name) || dvi === 0) {
4596
+ defaultVariant = variants[dvi];
4597
+ break;
4598
+ }
4599
+ }
4600
+ for (var ssi = 0; ssi < statusStates.length; ssi++) {
4601
+ var stateName = statusStates[ssi];
4602
+ if (statesCovered[stateName]) {
4603
+ colorDifferentiation.checked++;
4604
+ for (var svi = 0; svi < variants.length; svi++) {
4605
+ if (stateKeywords[stateName].test(variants[svi].name)) {
4606
+ var stateVariant = variants[svi];
4607
+ var stateFill = auditGetFillColor(stateVariant);
4608
+ var defaultFill = defaultVariant ? auditGetFillColor(defaultVariant) : null;
4609
+ var hasIcon = auditHasChildOfType(stateVariant, ['VECTOR', 'BOOLEAN_OPERATION', 'INSTANCE']);
4610
+ var hasStroke = auditGetStrokeColor(stateVariant) !== null;
4611
+ var colorDiffers = stateFill && defaultFill && (stateFill.r !== defaultFill.r || stateFill.g !== defaultFill.g || stateFill.b !== defaultFill.b);
4612
+ if (colorDiffers && !hasIcon && !hasStroke) {
4613
+ colorDifferentiation.issues.push({
4614
+ variant: stateVariant.name,
4615
+ state: stateName,
4616
+ variantColor: stateFill ? auditRgbToHex(stateFill.r, stateFill.g, stateFill.b) : null,
4617
+ defaultColor: defaultFill ? auditRgbToHex(defaultFill.r, defaultFill.g, defaultFill.b) : null,
4618
+ suggestion: 'Add icon, border, or text indicator beyond color'
4619
+ });
4620
+ }
4621
+ break;
4622
+ }
4623
+ }
4624
+ }
4625
+ }
4626
+ }
4627
+
4628
+ // 4. Target size analysis
4629
+ // WCAG 2.5.8 applies to interactive targets — presentational components
4630
+ // (badges, avatars, progress bars) are not tap targets by definition.
4631
+ // Skip target size checking for presentational components to avoid false positives.
4632
+ var targetSizeAnalysis = { minWidth: Infinity, minHeight: Infinity, variants: [], issues: [] };
4633
+ var minTarget = msg.targetSize || 24; // Default WCAG 2.5.8 minimum
4634
+ for (var tvi = 0; tvi < variants.length; tvi++) {
4635
+ try {
4636
+ var tw = variants[tvi].width;
4637
+ var th = variants[tvi].height;
4638
+ if (typeof tw === 'number' && typeof th === 'number') {
4639
+ targetSizeAnalysis.variants.push({ name: variants[tvi].name, width: tw, height: th });
4640
+ if (tw < targetSizeAnalysis.minWidth) targetSizeAnalysis.minWidth = tw;
4641
+ if (th < targetSizeAnalysis.minHeight) targetSizeAnalysis.minHeight = th;
4642
+ // Only flag target size issues for interactive components
4643
+ if (isInteractive && (tw < minTarget || th < minTarget)) {
4644
+ targetSizeAnalysis.issues.push({
4645
+ variant: variants[tvi].name,
4646
+ width: tw,
4647
+ height: th,
4648
+ required: minTarget + 'x' + minTarget
4649
+ });
4650
+ }
4651
+ }
4652
+ } catch (e) { /* skip */ }
4653
+ }
4654
+ if (targetSizeAnalysis.minWidth === Infinity) targetSizeAnalysis.minWidth = null;
4655
+ if (targetSizeAnalysis.minHeight === Infinity) targetSizeAnalysis.minHeight = null;
4656
+
4657
+ // 5. Annotation completeness
4658
+ var annotations = { hasDescription: false, description: '', hasA11yNotes: false, a11yNotes: '' };
4659
+ try {
4660
+ var desc = componentSet.description || '';
4661
+ if (desc.trim().length > 0) {
4662
+ annotations.hasDescription = true;
4663
+ annotations.description = desc.substring(0, 200);
4664
+ }
4665
+ if (/aria|accessibility|a11y|screen.?reader|keyboard|role|tab.?order/i.test(desc)) {
4666
+ annotations.hasA11yNotes = true;
4667
+ // Extract just the a11y-relevant section
4668
+ var lines = desc.split('\n');
4669
+ var a11yLines = [];
4670
+ for (var ali = 0; ali < lines.length; ali++) {
4671
+ if (/aria|accessibility|a11y|screen.?reader|keyboard|role|tab.?order/i.test(lines[ali])) {
4672
+ a11yLines.push(lines[ali].trim());
4673
+ }
4674
+ }
4675
+ annotations.a11yNotes = a11yLines.join('; ').substring(0, 300);
4676
+ }
4677
+ } catch (e) { /* skip */ }
4678
+
4679
+ // 6. Color-blind simulation
4680
+ var colorBlindAnalysis = { simulations: [], issues: [] };
4681
+ // Collect all text/bg color pairs from the default variant (or first variant)
4682
+ var sampleVariant = defaultVariant || variants[0];
4683
+ var colorPairs = [];
4684
+ auditCollectColorPairs(sampleVariant, colorPairs, 0);
4685
+
4686
+ // Also check component fill against its background
4687
+ var compFill = auditGetFillColor(sampleVariant);
4688
+ var compBg = auditGetEffectiveBg(sampleVariant);
4689
+ if (compFill) {
4690
+ colorPairs.push({ fg: compFill, bg: compBg, nodeName: sampleVariant.name + ' (fill)', nodeId: sampleVariant.id });
4691
+ }
4692
+
4693
+ var cbTypes = ['protanopia', 'deuteranopia', 'tritanopia'];
4694
+ for (var cbi = 0; cbi < cbTypes.length; cbi++) {
4695
+ var cbType = cbTypes[cbi];
4696
+ var matrix = colorBlindMatrices[cbType];
4697
+ var failingPairs = [];
4698
+ for (var cpi = 0; cpi < Math.min(colorPairs.length, 20); cpi++) {
4699
+ var pair = colorPairs[cpi];
4700
+ var simFg = simulateColorBlind(pair.fg.r, pair.fg.g, pair.fg.b, matrix);
4701
+ var simBg = simulateColorBlind(pair.bg.r, pair.bg.g, pair.bg.b, matrix);
4702
+ var originalRatio = auditContrastRatio(pair.fg.r, pair.fg.g, pair.fg.b, pair.bg.r, pair.bg.g, pair.bg.b);
4703
+ var simRatio = auditContrastRatio(simFg.r, simFg.g, simFg.b, simBg.r, simBg.g, simBg.b);
4704
+ // Flag if simulated contrast drops below 4.5:1 (AA) when original was passing
4705
+ if (originalRatio >= 4.5 && simRatio < 4.5) {
4706
+ failingPairs.push({
4707
+ nodeName: pair.nodeName,
4708
+ originalRatio: parseFloat(originalRatio.toFixed(1)),
4709
+ simulatedRatio: parseFloat(simRatio.toFixed(1)),
4710
+ originalFg: auditRgbToHex(pair.fg.r, pair.fg.g, pair.fg.b),
4711
+ simulatedFg: auditRgbToHex(simFg.r, simFg.g, simFg.b)
4712
+ });
4713
+ }
4714
+ // Also flag if contrast drops significantly (>30% reduction) even if still passing
4715
+ if (originalRatio >= 4.5 && simRatio >= 4.5 && (simRatio / originalRatio) < 0.7) {
4716
+ failingPairs.push({
4717
+ nodeName: pair.nodeName,
4718
+ originalRatio: parseFloat(originalRatio.toFixed(1)),
4719
+ simulatedRatio: parseFloat(simRatio.toFixed(1)),
4720
+ originalFg: auditRgbToHex(pair.fg.r, pair.fg.g, pair.fg.b),
4721
+ simulatedFg: auditRgbToHex(simFg.r, simFg.g, simFg.b),
4722
+ note: 'Significant contrast reduction (>30%)'
4723
+ });
4724
+ }
4725
+ }
4726
+ colorBlindAnalysis.simulations.push({
4727
+ type: cbType,
4728
+ pairsChecked: Math.min(colorPairs.length, 20),
4729
+ issues: failingPairs.length,
4730
+ details: failingPairs
4731
+ });
4732
+ if (failingPairs.length > 0) {
4733
+ colorBlindAnalysis.issues.push(cbType + ': ' + failingPairs.length + ' color pair(s) lose sufficient contrast');
4734
+ }
4735
+ }
4736
+
4737
+ // ---- Compute overall score ----
4738
+ var scores = {};
4739
+ // Coverage: percentage of states (interactive) or axis combinations (presentational)
4740
+ scores.variantCoverage = totalStates > 0 ? Math.round((coveredCount / totalStates) * 100) : 100;
4741
+ // Focus indicator: 0 (missing), 50 (exists but no indicator), 100 (good indicator)
4742
+ // For presentational: N/A → score 100 (don't penalize)
4743
+ scores.focusIndicator = isPresentational ? 100 : (!focusAnalysis.hasVariant ? 0 : (!focusAnalysis.hasVisibleIndicator ? 50 : 100));
4744
+ // Color differentiation: 100 if no issues, decremented per issue
4745
+ scores.colorDifferentiation = colorDifferentiation.checked === 0 ? 100 : Math.max(0, Math.round(((colorDifferentiation.checked - colorDifferentiation.issues.length) / colorDifferentiation.checked) * 100));
4746
+ // Target size: N/A for presentational (not tap targets), scored for interactive
4747
+ 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)));
4748
+ // Annotations: 0 (nothing), 50 (description only), 100 (has a11y notes)
4749
+ scores.annotations = annotations.hasA11yNotes ? 100 : (annotations.hasDescription ? 50 : 0);
4750
+ // Color blind: percentage of simulations with no issues
4751
+ var cbPassCount = 0;
4752
+ for (var cbsi = 0; cbsi < colorBlindAnalysis.simulations.length; cbsi++) {
4753
+ if (colorBlindAnalysis.simulations[cbsi].issues === 0) cbPassCount++;
4754
+ }
4755
+ scores.colorBlindSafety = colorBlindAnalysis.simulations.length > 0 ? Math.round((cbPassCount / colorBlindAnalysis.simulations.length) * 100) : 100;
4756
+
4757
+ // Overall weighted score — weights differ by component classification
4758
+ var overall;
4759
+ if (isInteractive) {
4760
+ // Interactive: focus and states matter most
4761
+ overall = Math.round(
4762
+ scores.variantCoverage * 0.20 +
4763
+ scores.focusIndicator * 0.20 +
4764
+ scores.colorDifferentiation * 0.15 +
4765
+ scores.targetSize * 0.15 +
4766
+ scores.annotations * 0.10 +
4767
+ scores.colorBlindSafety * 0.20
4768
+ );
4769
+ } else {
4770
+ // Presentational: variant completeness and color safety matter most, focus is N/A
4771
+ overall = Math.round(
4772
+ scores.variantCoverage * 0.25 +
4773
+ scores.colorDifferentiation * 0.25 +
4774
+ scores.annotations * 0.15 +
4775
+ scores.colorBlindSafety * 0.25 +
4776
+ scores.targetSize * 0.10
4777
+ );
4778
+ }
4779
+
4780
+ // ---- Build response ----
4781
+ var coverageSection;
4782
+ if (isInteractive) {
4783
+ coverageSection = {
4784
+ mode: 'interactive-states',
4785
+ found: statesFound,
4786
+ missing: missingStates,
4787
+ coverage: coveredCount + '/' + totalStates
4788
+ };
4789
+ } else {
4790
+ coverageSection = {
4791
+ mode: 'variant-axes',
4792
+ axes: variantAxisCoverage,
4793
+ coverage: coveredCount + '/' + totalStates
4794
+ };
4795
+ }
4796
+
4797
+ var auditResult = {
4798
+ component: {
4799
+ id: componentSet.id,
4800
+ name: componentSet.name,
4801
+ type: componentSet.type,
4802
+ variantCount: variantCount,
4803
+ classification: isInteractive ? 'interactive' : 'presentational'
4804
+ },
4805
+ overallScore: overall,
4806
+ scores: scores,
4807
+ variantCoverage: coverageSection,
4808
+ focusIndicator: isInteractive ? focusAnalysis : { notApplicable: true, details: 'Focus indicators are not expected for presentational components' },
4809
+ colorDifferentiation: colorDifferentiation,
4810
+ targetSize: isInteractive ? {
4811
+ minimum: minTarget + 'x' + minTarget,
4812
+ smallest: targetSizeAnalysis.minWidth + 'x' + targetSizeAnalysis.minHeight,
4813
+ issues: targetSizeAnalysis.issues
4814
+ } : { notApplicable: true, details: 'Target size checks apply to interactive components (WCAG 2.5.8 is about tap targets)', smallest: targetSizeAnalysis.minWidth + 'x' + targetSizeAnalysis.minHeight },
4815
+ annotations: annotations,
4816
+ colorBlindSimulation: colorBlindAnalysis,
4817
+ recommendations: []
4818
+ };
4819
+
4820
+ // Generate recommendations — classification-aware
4821
+ if (isInteractive) {
4822
+ if (!focusAnalysis.hasVariant) {
4823
+ auditResult.recommendations.push({ priority: 'high', area: 'focus', message: 'Add a focus/focused variant with a visible focus ring (WCAG 2.4.7)' });
4824
+ } else if (!focusAnalysis.hasVisibleIndicator) {
4825
+ auditResult.recommendations.push({ priority: 'medium', area: 'focus', message: 'Focus variant exists but lacks visible indicator — add a border or shadow' });
4826
+ }
4827
+ }
4828
+ if (colorDifferentiation.issues.length > 0) {
4829
+ 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)' });
4830
+ }
4831
+ if (targetSizeAnalysis.issues.length > 0) {
4832
+ auditResult.recommendations.push({ priority: 'high', area: 'target-size', message: targetSizeAnalysis.issues.length + ' variant(s) below ' + minTarget + 'x' + minTarget + 'px minimum target size (WCAG 2.5.8)' });
4833
+ }
4834
+ if (!annotations.hasDescription) {
4835
+ auditResult.recommendations.push({ priority: 'medium', area: 'documentation', message: 'Add a component description with usage guidelines' });
4836
+ }
4837
+ if (!annotations.hasA11yNotes) {
4838
+ var a11yHint = isInteractive
4839
+ ? 'Add accessibility notes (ARIA role, keyboard interactions, screen reader behavior)'
4840
+ : 'Add accessibility notes (ARIA role, live region behavior, semantic usage)';
4841
+ auditResult.recommendations.push({ priority: 'medium', area: 'documentation', message: a11yHint });
4842
+ }
4843
+ if (colorBlindAnalysis.issues.length > 0) {
4844
+ auditResult.recommendations.push({ priority: 'medium', area: 'color-blind', message: colorBlindAnalysis.issues.join('; ') });
4845
+ }
4846
+ if (isInteractive) {
4847
+ for (var msi = 0; msi < missingStates.length; msi++) {
4848
+ var ms = missingStates[msi];
4849
+ if (ms === 'focus' || ms === 'disabled') {
4850
+ auditResult.recommendations.push({ priority: 'medium', area: 'states', message: 'Consider adding a "' + ms + '" variant for complete interactive state coverage' });
4851
+ }
4852
+ }
4853
+ } else if (variantAxisCoverage && variantCount < variantAxisCoverage.expectedCombinations) {
4854
+ auditResult.recommendations.push({ priority: 'low', area: 'coverage', message: variantCount + ' of ' + variantAxisCoverage.expectedCombinations + ' axis combinations present — consider adding missing variants for completeness' });
4855
+ }
4856
+
4857
+ console.log('🌉 [Desktop Bridge] Component audit complete: score ' + overall + '/100 for "' + componentSet.name + '"');
4858
+
4859
+ figma.ui.postMessage({
4860
+ type: 'AUDIT_COMPONENT_ACCESSIBILITY_RESULT',
4861
+ requestId: msg.requestId,
4862
+ success: true,
4863
+ data: auditResult
4864
+ });
4865
+
4866
+ } catch (error) {
4867
+ var auditErrorMsg = error && error.message ? error.message : String(error);
4868
+ console.error('🌉 [Desktop Bridge] Component accessibility audit error:', auditErrorMsg);
4869
+ figma.ui.postMessage({
4870
+ type: 'AUDIT_COMPONENT_ACCESSIBILITY_RESULT',
4871
+ requestId: msg.requestId,
4872
+ success: false,
4873
+ error: auditErrorMsg
4874
+ });
4875
+ }
4876
+ }
4877
+
3745
4878
  // ============================================================================
3746
4879
  // FIGJAM TOOLS — Only functional when editorType === 'figjam'
3747
4880
  // ============================================================================