@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.
- package/README.md +10 -9
- package/dist/apps/design-system-dashboard/mcp-app.html +59 -59
- package/dist/apps/token-browser/mcp-app.html +53 -53
- package/dist/cloudflare/core/accessibility-tools.js +306 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +11 -0
- package/dist/cloudflare/core/design-code-tools.js +160 -2
- package/dist/cloudflare/core/figma-desktop-connector.js +2 -0
- package/dist/cloudflare/core/websocket-connector.js +11 -0
- package/dist/cloudflare/core/write-tools.js +49 -4
- package/dist/cloudflare/index.js +16 -7
- package/dist/core/accessibility-tools.d.ts +21 -0
- package/dist/core/accessibility-tools.d.ts.map +1 -0
- package/dist/core/accessibility-tools.js +307 -0
- package/dist/core/accessibility-tools.js.map +1 -0
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +160 -2
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/figma-connector.d.ts +1 -0
- package/dist/core/figma-connector.d.ts.map +1 -1
- package/dist/core/figma-desktop-connector.d.ts +1 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -1
- package/dist/core/figma-desktop-connector.js +2 -0
- package/dist/core/figma-desktop-connector.js.map +1 -1
- package/dist/core/types/design-code.d.ts +8 -0
- package/dist/core/types/design-code.d.ts.map +1 -1
- package/dist/core/websocket-connector.d.ts +1 -0
- package/dist/core/websocket-connector.d.ts.map +1 -1
- package/dist/core/websocket-connector.js +11 -0
- package/dist/core/websocket-connector.js.map +1 -1
- package/dist/core/write-tools.d.ts.map +1 -1
- package/dist/core/write-tools.js +49 -4
- package/dist/core/write-tools.js.map +1 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +52 -4
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +1134 -1
- package/figma-desktop-bridge/ui-full.html +13 -0
- package/figma-desktop-bridge/ui.html +13 -0
- 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': [
|
|
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
|
// ============================================================================
|