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