@triedotdev/mcp 1.0.38 → 1.0.39
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 +171 -15
- package/dist/{chunk-HRNBSXN2.js → chunk-B3MBKB2U.js} +915 -64
- package/dist/chunk-B3MBKB2U.js.map +1 -0
- package/dist/{chunk-TGEI55FP.js → chunk-HG5AWUH7.js} +2 -2
- package/dist/cli/yolo-daemon.js +2 -2
- package/dist/index.js +2 -2
- package/dist/workers/agent-worker.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-HRNBSXN2.js.map +0 -1
- /package/dist/{chunk-TGEI55FP.js.map → chunk-HG5AWUH7.js.map} +0 -0
|
@@ -887,10 +887,72 @@ var ComprehensionAgent = class extends BaseAgent {
|
|
|
887
887
|
};
|
|
888
888
|
|
|
889
889
|
// src/agents/accessibility.ts
|
|
890
|
+
var WCAG_CRITERIA = {
|
|
891
|
+
// Perceivable
|
|
892
|
+
"1.1.1": { name: "Non-text Content", level: "A", description: "All non-text content has text alternatives" },
|
|
893
|
+
"1.3.1": { name: "Info and Relationships", level: "A", description: "Information and relationships are programmatically determinable" },
|
|
894
|
+
"1.3.2": { name: "Meaningful Sequence", level: "A", description: "Reading order is correct and meaningful" },
|
|
895
|
+
"1.3.5": { name: "Identify Input Purpose", level: "AA", description: "Input purpose can be programmatically determined" },
|
|
896
|
+
"1.4.1": { name: "Use of Color", level: "A", description: "Color is not the only means of conveying information" },
|
|
897
|
+
"1.4.3": { name: "Contrast (Minimum)", level: "AA", description: "Text has 4.5:1 contrast ratio (3:1 for large text)" },
|
|
898
|
+
"1.4.4": { name: "Resize Text", level: "AA", description: "Text can be resized up to 200% without loss of functionality" },
|
|
899
|
+
"1.4.10": { name: "Reflow", level: "AA", description: "Content can reflow without horizontal scrolling at 320px width" },
|
|
900
|
+
"1.4.11": { name: "Non-text Contrast", level: "AA", description: "UI components have 3:1 contrast ratio" },
|
|
901
|
+
"1.4.12": { name: "Text Spacing", level: "AA", description: "No loss of content when text spacing is adjusted" },
|
|
902
|
+
"1.4.13": { name: "Content on Hover or Focus", level: "AA", description: "Hover/focus content is dismissible, hoverable, persistent" },
|
|
903
|
+
// Operable
|
|
904
|
+
"2.1.1": { name: "Keyboard", level: "A", description: "All functionality is available from keyboard" },
|
|
905
|
+
"2.1.2": { name: "No Keyboard Trap", level: "A", description: "Keyboard focus can be moved away using standard keys" },
|
|
906
|
+
"2.4.1": { name: "Bypass Blocks", level: "A", description: "Skip links or landmarks allow bypassing repeated content" },
|
|
907
|
+
"2.4.3": { name: "Focus Order", level: "A", description: "Focus order preserves meaning and operability" },
|
|
908
|
+
"2.4.4": { name: "Link Purpose (In Context)", level: "A", description: "Link purpose is clear from text or context" },
|
|
909
|
+
"2.4.6": { name: "Headings and Labels", level: "AA", description: "Headings and labels describe topic or purpose" },
|
|
910
|
+
"2.4.7": { name: "Focus Visible", level: "AA", description: "Keyboard focus indicator is visible" },
|
|
911
|
+
"2.5.5": { name: "Target Size", level: "AAA", description: "Touch targets are at least 44\xD744 CSS pixels" },
|
|
912
|
+
"2.5.8": { name: "Target Size (Minimum)", level: "AA", description: "Touch targets are at least 24\xD724 CSS pixels" },
|
|
913
|
+
// Understandable
|
|
914
|
+
"3.2.1": { name: "On Focus", level: "A", description: "Focus does not trigger unexpected context changes" },
|
|
915
|
+
"3.2.2": { name: "On Input", level: "A", description: "Input does not trigger unexpected context changes" },
|
|
916
|
+
"3.3.1": { name: "Error Identification", level: "A", description: "Errors are identified and described in text" },
|
|
917
|
+
"3.3.2": { name: "Labels or Instructions", level: "A", description: "Labels or instructions are provided for user input" },
|
|
918
|
+
// Robust
|
|
919
|
+
"4.1.1": { name: "Parsing", level: "A", description: "HTML is well-formed with unique IDs" },
|
|
920
|
+
"4.1.2": { name: "Name, Role, Value", level: "A", description: "UI components have accessible name, role, and state" },
|
|
921
|
+
"4.1.3": { name: "Status Messages", level: "AA", description: "Status messages are announced to assistive technology" }
|
|
922
|
+
};
|
|
923
|
+
var REQUIRED_ARIA_ATTRIBUTES = {
|
|
924
|
+
"checkbox": ["aria-checked"],
|
|
925
|
+
"combobox": ["aria-expanded", "aria-controls"],
|
|
926
|
+
"grid": ["aria-rowcount", "aria-colcount"],
|
|
927
|
+
"gridcell": [],
|
|
928
|
+
"heading": ["aria-level"],
|
|
929
|
+
"listbox": [],
|
|
930
|
+
"menu": [],
|
|
931
|
+
"menubar": [],
|
|
932
|
+
"menuitem": [],
|
|
933
|
+
"menuitemcheckbox": ["aria-checked"],
|
|
934
|
+
"menuitemradio": ["aria-checked"],
|
|
935
|
+
"option": ["aria-selected"],
|
|
936
|
+
"progressbar": ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
937
|
+
"radio": ["aria-checked"],
|
|
938
|
+
"scrollbar": ["aria-controls", "aria-valuenow", "aria-valuemin", "aria-valuemax", "aria-orientation"],
|
|
939
|
+
"searchbox": [],
|
|
940
|
+
"separator": [],
|
|
941
|
+
"slider": ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
942
|
+
"spinbutton": ["aria-valuenow", "aria-valuemin", "aria-valuemax"],
|
|
943
|
+
"switch": ["aria-checked"],
|
|
944
|
+
"tab": ["aria-selected"],
|
|
945
|
+
"tablist": [],
|
|
946
|
+
"tabpanel": [],
|
|
947
|
+
"textbox": [],
|
|
948
|
+
"tree": [],
|
|
949
|
+
"treegrid": [],
|
|
950
|
+
"treeitem": []
|
|
951
|
+
};
|
|
890
952
|
var AccessibilityAgent = class extends BaseAgent {
|
|
891
953
|
name = "accessibility";
|
|
892
|
-
description = "WCAG 2.1
|
|
893
|
-
version = "
|
|
954
|
+
description = "WCAG 2.1 AA compliance: screen readers, keyboard nav, color contrast, touch targets, semantic HTML, ARIA patterns";
|
|
955
|
+
version = "2.0.0";
|
|
894
956
|
shouldActivate(context) {
|
|
895
957
|
return context.touchesUI;
|
|
896
958
|
}
|
|
@@ -899,154 +961,934 @@ var AccessibilityAgent = class extends BaseAgent {
|
|
|
899
961
|
for (const file of files) {
|
|
900
962
|
try {
|
|
901
963
|
const content = await this.readFile(file);
|
|
902
|
-
|
|
903
|
-
|
|
964
|
+
if (this.isFrontendFile(file)) {
|
|
965
|
+
issues.push(...this.analyzeImages(content, file));
|
|
966
|
+
issues.push(...this.analyzeInteractiveElements(content, file));
|
|
967
|
+
issues.push(...this.analyzeFormAccessibility(content, file));
|
|
968
|
+
issues.push(...this.analyzeKeyboardNavigation(content, file));
|
|
969
|
+
issues.push(...this.analyzeSemanticStructure(content, file));
|
|
970
|
+
issues.push(...this.analyzeARIAUsage(content, file));
|
|
971
|
+
issues.push(...this.analyzeColorAccessibility(content, file));
|
|
972
|
+
issues.push(...this.analyzeTouchTargets(content, file));
|
|
973
|
+
issues.push(...this.analyzeMotionAccessibility(content, file));
|
|
974
|
+
issues.push(...this.analyzeLinks(content, file));
|
|
975
|
+
}
|
|
904
976
|
} catch (error) {
|
|
905
977
|
console.error(`Accessibility Agent: Error reading file ${file}:`, error);
|
|
906
978
|
}
|
|
907
979
|
}
|
|
980
|
+
const criticalCount = issues.filter((i) => i.severity === "critical").length;
|
|
981
|
+
const seriousCount = issues.filter((i) => i.severity === "serious").length;
|
|
982
|
+
if (criticalCount > 0 || seriousCount > 2) {
|
|
983
|
+
const score = this.calculateAccessibilityScore(issues);
|
|
984
|
+
issues.push(this.createIssue(
|
|
985
|
+
this.generateIssueId(),
|
|
986
|
+
criticalCount > 0 ? "critical" : "serious",
|
|
987
|
+
`Accessibility Score: ${score}/100 \u2014 ${criticalCount} critical, ${seriousCount} serious issues`,
|
|
988
|
+
`Review and fix accessibility issues starting with critical problems. Use axe-core or Lighthouse for additional validation.`,
|
|
989
|
+
files[0] || "project",
|
|
990
|
+
void 0,
|
|
991
|
+
0.95,
|
|
992
|
+
void 0,
|
|
993
|
+
false
|
|
994
|
+
));
|
|
995
|
+
}
|
|
908
996
|
return issues;
|
|
909
997
|
}
|
|
910
|
-
|
|
998
|
+
isFrontendFile(file) {
|
|
999
|
+
return /\.(tsx|jsx|vue|svelte|astro|html|htm)$/.test(file);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Calculate accessibility score based on issues
|
|
1003
|
+
*/
|
|
1004
|
+
calculateAccessibilityScore(issues) {
|
|
1005
|
+
const criticalPenalty = issues.filter((i) => i.severity === "critical").length * 20;
|
|
1006
|
+
const seriousPenalty = issues.filter((i) => i.severity === "serious").length * 10;
|
|
1007
|
+
const moderatePenalty = issues.filter((i) => i.severity === "moderate").length * 5;
|
|
1008
|
+
const lowPenalty = issues.filter((i) => i.severity === "low").length * 2;
|
|
1009
|
+
return Math.max(0, 100 - criticalPenalty - seriousPenalty - moderatePenalty - lowPenalty);
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Get WCAG criterion string
|
|
1013
|
+
*/
|
|
1014
|
+
getWCAGRef(criterionId) {
|
|
1015
|
+
const criterion = WCAG_CRITERIA[criterionId];
|
|
1016
|
+
return `WCAG 2.1 ${criterion.level} - ${criterionId} ${criterion.name}`;
|
|
1017
|
+
}
|
|
1018
|
+
// ============================================================================
|
|
1019
|
+
// IMAGE ACCESSIBILITY — WCAG 1.1.1
|
|
1020
|
+
// ============================================================================
|
|
1021
|
+
analyzeImages(content, file) {
|
|
911
1022
|
const issues = [];
|
|
912
1023
|
const lines = content.split("\n");
|
|
913
1024
|
for (let i = 0; i < lines.length; i++) {
|
|
914
1025
|
const line = lines[i];
|
|
915
1026
|
const lineNumber = i + 1;
|
|
916
|
-
if (/<img
|
|
1027
|
+
if (/<img\s/i.test(line)) {
|
|
1028
|
+
if (!line.includes("alt=")) {
|
|
1029
|
+
issues.push(this.createIssue(
|
|
1030
|
+
this.generateIssueId(),
|
|
1031
|
+
"critical",
|
|
1032
|
+
"Image missing alt attribute",
|
|
1033
|
+
'Add alt="description" for informative images or alt="" for decorative images. Screen readers cannot describe this image.',
|
|
1034
|
+
file,
|
|
1035
|
+
lineNumber,
|
|
1036
|
+
0.98,
|
|
1037
|
+
this.getWCAGRef("1.1.1"),
|
|
1038
|
+
true
|
|
1039
|
+
));
|
|
1040
|
+
} else if (/alt=["']\s*["']/i.test(line)) {
|
|
1041
|
+
const context = lines.slice(Math.max(0, i - 3), i + 3).join("\n");
|
|
1042
|
+
if (/hero|logo|banner|product|avatar|icon/i.test(context) && !/decorative|spacer|divider/i.test(context)) {
|
|
1043
|
+
issues.push(this.createIssue(
|
|
1044
|
+
this.generateIssueId(),
|
|
1045
|
+
"moderate",
|
|
1046
|
+
"Empty alt on potentially informative image",
|
|
1047
|
+
"If this image conveys information, add descriptive alt text. Empty alt is only for purely decorative images.",
|
|
1048
|
+
file,
|
|
1049
|
+
lineNumber,
|
|
1050
|
+
0.7,
|
|
1051
|
+
this.getWCAGRef("1.1.1"),
|
|
1052
|
+
true
|
|
1053
|
+
));
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (/background-image:\s*url\(/i.test(line) || /backgroundImage.*url\(/i.test(line)) {
|
|
1058
|
+
const context = lines.slice(Math.max(0, i - 5), i + 5).join("\n");
|
|
1059
|
+
if (/hero|banner|feature|card/i.test(context) && !/aria-label|sr-only|visually-hidden/i.test(context)) {
|
|
1060
|
+
issues.push(this.createIssue(
|
|
1061
|
+
this.generateIssueId(),
|
|
1062
|
+
"moderate",
|
|
1063
|
+
"Background image may need text alternative",
|
|
1064
|
+
"If this background image conveys information, add a text alternative using aria-label or visually hidden text.",
|
|
1065
|
+
file,
|
|
1066
|
+
lineNumber,
|
|
1067
|
+
0.65,
|
|
1068
|
+
this.getWCAGRef("1.1.1"),
|
|
1069
|
+
false
|
|
1070
|
+
));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (/<svg\s/i.test(line)) {
|
|
1074
|
+
const svgContext = this.getMultiLineElement(lines, i, "svg");
|
|
1075
|
+
if (!/aria-label|aria-labelledby|<title>|role=["']img["']/i.test(svgContext) && !/aria-hidden=["']true["']/i.test(svgContext)) {
|
|
1076
|
+
issues.push(this.createIssue(
|
|
1077
|
+
this.generateIssueId(),
|
|
1078
|
+
"serious",
|
|
1079
|
+
"SVG missing accessible name",
|
|
1080
|
+
'Add aria-label, aria-labelledby with <title>, or aria-hidden="true" if decorative.',
|
|
1081
|
+
file,
|
|
1082
|
+
lineNumber,
|
|
1083
|
+
0.85,
|
|
1084
|
+
this.getWCAGRef("1.1.1"),
|
|
1085
|
+
true
|
|
1086
|
+
));
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return issues;
|
|
1091
|
+
}
|
|
1092
|
+
// ============================================================================
|
|
1093
|
+
// INTERACTIVE ELEMENTS — WCAG 4.1.2, 2.1.1
|
|
1094
|
+
// ============================================================================
|
|
1095
|
+
analyzeInteractiveElements(content, file) {
|
|
1096
|
+
const issues = [];
|
|
1097
|
+
const lines = content.split("\n");
|
|
1098
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1099
|
+
const line = lines[i];
|
|
1100
|
+
const lineNumber = i + 1;
|
|
1101
|
+
if (/<button/i.test(line) || /Button\s/i.test(line)) {
|
|
1102
|
+
const buttonContext = this.getMultiLineElement(lines, i, "button") || this.getMultiLineElement(lines, i, "Button");
|
|
1103
|
+
const hasIconOnly = />\s*<(Icon|svg|i\s|img)[^>]*>\s*<\/(button|Button)/i.test(buttonContext) || />\s*<[A-Z][a-zA-Z]*Icon[^>]*\s*\/>\s*<\/(button|Button)/i.test(buttonContext) || /<(Icon|svg)[^>]*\/>\s*<\/(button|Button)/i.test(buttonContext);
|
|
1104
|
+
const hasAccessibleName = /aria-label|aria-labelledby|title=|sr-only|visually-hidden/i.test(buttonContext);
|
|
1105
|
+
if (hasIconOnly && !hasAccessibleName) {
|
|
1106
|
+
issues.push(this.createIssue(
|
|
1107
|
+
this.generateIssueId(),
|
|
1108
|
+
"critical",
|
|
1109
|
+
"Icon-only button missing accessible name",
|
|
1110
|
+
'Add aria-label="Close" or include visually hidden text. Screen reader users cannot identify this button.',
|
|
1111
|
+
file,
|
|
1112
|
+
lineNumber,
|
|
1113
|
+
0.95,
|
|
1114
|
+
this.getWCAGRef("4.1.2"),
|
|
1115
|
+
true
|
|
1116
|
+
));
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (/onClick|@click|v-on:click|\(click\)/i.test(line)) {
|
|
1120
|
+
if (/<(div|span|li|td|tr|p|section|article)\s/i.test(line) && !/role=["'](button|link|menuitem|tab|option)/i.test(line)) {
|
|
1121
|
+
const elementContext = lines.slice(Math.max(0, i - 2), i + 3).join("\n");
|
|
1122
|
+
const hasKeyboardSupport = /onKeyDown|onKeyPress|onKeyUp|@keydown|@keypress|@keyup|\(keydown\)|\(keypress\)/i.test(elementContext);
|
|
1123
|
+
const hasRole = /role=/i.test(elementContext);
|
|
1124
|
+
const hasTabIndex = /tabIndex|tabindex/i.test(elementContext);
|
|
1125
|
+
if (!hasKeyboardSupport) {
|
|
1126
|
+
issues.push(this.createIssue(
|
|
1127
|
+
this.generateIssueId(),
|
|
1128
|
+
"critical",
|
|
1129
|
+
"Non-semantic element with click handler lacks keyboard support",
|
|
1130
|
+
`Add role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && handleClick()}. Or use a <button> element instead.`,
|
|
1131
|
+
file,
|
|
1132
|
+
lineNumber,
|
|
1133
|
+
0.92,
|
|
1134
|
+
this.getWCAGRef("2.1.1"),
|
|
1135
|
+
true
|
|
1136
|
+
));
|
|
1137
|
+
} else if (!hasRole || !hasTabIndex) {
|
|
1138
|
+
issues.push(this.createIssue(
|
|
1139
|
+
this.generateIssueId(),
|
|
1140
|
+
"serious",
|
|
1141
|
+
"Clickable element missing role or tabIndex",
|
|
1142
|
+
'Add role="button" and tabIndex={0} for proper keyboard accessibility.',
|
|
1143
|
+
file,
|
|
1144
|
+
lineNumber,
|
|
1145
|
+
0.85,
|
|
1146
|
+
this.getWCAGRef("4.1.2"),
|
|
1147
|
+
true
|
|
1148
|
+
));
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (/<a\s/i.test(line) || /<Link\s/i.test(line)) {
|
|
1153
|
+
const linkContext = this.getMultiLineElement(lines, i, "a") || this.getMultiLineElement(lines, i, "Link");
|
|
1154
|
+
if (!/href=|to=/i.test(linkContext)) {
|
|
1155
|
+
issues.push(this.createIssue(
|
|
1156
|
+
this.generateIssueId(),
|
|
1157
|
+
"serious",
|
|
1158
|
+
"Link element without href",
|
|
1159
|
+
"Add href attribute or use a <button> if this triggers an action. Links must have a destination.",
|
|
1160
|
+
file,
|
|
1161
|
+
lineNumber,
|
|
1162
|
+
0.9,
|
|
1163
|
+
this.getWCAGRef("2.4.4"),
|
|
1164
|
+
true
|
|
1165
|
+
));
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return issues;
|
|
1170
|
+
}
|
|
1171
|
+
// ============================================================================
|
|
1172
|
+
// FORM ACCESSIBILITY — WCAG 1.3.1, 3.3.2, 1.3.5
|
|
1173
|
+
// ============================================================================
|
|
1174
|
+
analyzeFormAccessibility(content, file) {
|
|
1175
|
+
const issues = [];
|
|
1176
|
+
const lines = content.split("\n");
|
|
1177
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1178
|
+
const line = lines[i];
|
|
1179
|
+
const lineNumber = i + 1;
|
|
1180
|
+
if (/<input\s/i.test(line) || /<Input\s/i.test(line) || /<textarea/i.test(line) || /<select/i.test(line)) {
|
|
1181
|
+
const inputContext = lines.slice(Math.max(0, i - 5), i + 5).join("\n");
|
|
1182
|
+
const hasLabel = /<label/i.test(inputContext) && /htmlFor|for=/i.test(inputContext);
|
|
1183
|
+
const hasAriaLabel = /aria-label=/i.test(line);
|
|
1184
|
+
const hasAriaLabelledBy = /aria-labelledby=/i.test(line);
|
|
1185
|
+
const hasPlaceholder = /placeholder=/i.test(line);
|
|
1186
|
+
const hasTitle = /title=/i.test(line);
|
|
1187
|
+
if (!hasLabel && !hasAriaLabel && !hasAriaLabelledBy) {
|
|
1188
|
+
if (hasPlaceholder && !hasTitle) {
|
|
1189
|
+
issues.push(this.createIssue(
|
|
1190
|
+
this.generateIssueId(),
|
|
1191
|
+
"serious",
|
|
1192
|
+
"Form input uses placeholder as only label",
|
|
1193
|
+
"Placeholder text disappears when typing. Add a visible <label> or aria-label for persistent identification.",
|
|
1194
|
+
file,
|
|
1195
|
+
lineNumber,
|
|
1196
|
+
0.88,
|
|
1197
|
+
this.getWCAGRef("3.3.2"),
|
|
1198
|
+
true
|
|
1199
|
+
));
|
|
1200
|
+
} else if (!hasTitle) {
|
|
1201
|
+
issues.push(this.createIssue(
|
|
1202
|
+
this.generateIssueId(),
|
|
1203
|
+
"serious",
|
|
1204
|
+
"Form input without accessible label",
|
|
1205
|
+
'Add <label htmlFor="id"> associated with input, or use aria-label/aria-labelledby.',
|
|
1206
|
+
file,
|
|
1207
|
+
lineNumber,
|
|
1208
|
+
0.85,
|
|
1209
|
+
this.getWCAGRef("1.3.1"),
|
|
1210
|
+
true
|
|
1211
|
+
));
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (/type=["'](email|tel|password|name|address|cc-|url)/i.test(line) && !/autoComplete|autocomplete/i.test(line)) {
|
|
1215
|
+
issues.push(this.createIssue(
|
|
1216
|
+
this.generateIssueId(),
|
|
1217
|
+
"moderate",
|
|
1218
|
+
"Input missing autocomplete attribute",
|
|
1219
|
+
'Add autocomplete attribute (e.g., autocomplete="email") to help users fill forms.',
|
|
1220
|
+
file,
|
|
1221
|
+
lineNumber,
|
|
1222
|
+
0.7,
|
|
1223
|
+
this.getWCAGRef("1.3.5"),
|
|
1224
|
+
true
|
|
1225
|
+
));
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (/required(?!=\{false\})/i.test(line) && !/aria-required|required.*\*/i.test(line)) {
|
|
1229
|
+
const labelContext = lines.slice(Math.max(0, i - 5), i + 5).join("\n");
|
|
1230
|
+
if (!/\*|required|Required/i.test(labelContext)) {
|
|
1231
|
+
issues.push(this.createIssue(
|
|
1232
|
+
this.generateIssueId(),
|
|
1233
|
+
"moderate",
|
|
1234
|
+
"Required field lacks visual indication",
|
|
1235
|
+
'Add visual indicator (e.g., asterisk *) and aria-required="true" for clarity.',
|
|
1236
|
+
file,
|
|
1237
|
+
lineNumber,
|
|
1238
|
+
0.75,
|
|
1239
|
+
this.getWCAGRef("3.3.2"),
|
|
1240
|
+
true
|
|
1241
|
+
));
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (/disabled(?!=\{false\})/i.test(line)) {
|
|
1245
|
+
const elementContext = lines.slice(Math.max(0, i - 2), i + 2).join("\n");
|
|
1246
|
+
if (!/title=|aria-describedby|tooltip|Tooltip/i.test(elementContext)) {
|
|
1247
|
+
issues.push(this.createIssue(
|
|
1248
|
+
this.generateIssueId(),
|
|
1249
|
+
"low",
|
|
1250
|
+
"Disabled element without explanation",
|
|
1251
|
+
"Add tooltip or aria-describedby explaining why the action is unavailable.",
|
|
1252
|
+
file,
|
|
1253
|
+
lineNumber,
|
|
1254
|
+
0.65,
|
|
1255
|
+
void 0,
|
|
1256
|
+
true
|
|
1257
|
+
));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return issues;
|
|
1262
|
+
}
|
|
1263
|
+
// ============================================================================
|
|
1264
|
+
// KEYBOARD NAVIGATION — WCAG 2.1.1, 2.1.2, 2.4.3, 2.4.7
|
|
1265
|
+
// ============================================================================
|
|
1266
|
+
analyzeKeyboardNavigation(content, file) {
|
|
1267
|
+
const issues = [];
|
|
1268
|
+
const lines = content.split("\n");
|
|
1269
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1270
|
+
const line = lines[i];
|
|
1271
|
+
const lineNumber = i + 1;
|
|
1272
|
+
if (/outline:\s*none|outline:\s*0|outline-width:\s*0/i.test(line)) {
|
|
1273
|
+
const styleContext = lines.slice(Math.max(0, i - 3), i + 3).join("\n");
|
|
1274
|
+
const hasFocusReplacement = /focus-visible|ring-|box-shadow.*focus|border.*focus|outline.*focus-visible/i.test(styleContext);
|
|
1275
|
+
if (!hasFocusReplacement) {
|
|
1276
|
+
issues.push(this.createIssue(
|
|
1277
|
+
this.generateIssueId(),
|
|
1278
|
+
"serious",
|
|
1279
|
+
"Focus outline removed without replacement",
|
|
1280
|
+
"Add focus-visible:ring-2 or custom focus indicator. Keyboard users cannot see where they are.",
|
|
1281
|
+
file,
|
|
1282
|
+
lineNumber,
|
|
1283
|
+
0.92,
|
|
1284
|
+
this.getWCAGRef("2.4.7"),
|
|
1285
|
+
true
|
|
1286
|
+
));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (/focus:outline-none|outline-none/i.test(line)) {
|
|
1290
|
+
const styleContext = lines.slice(Math.max(0, i - 1), i + 2).join("\n");
|
|
1291
|
+
if (!/focus-visible:ring|focus:ring|focus-visible:border|focus:border/i.test(styleContext)) {
|
|
1292
|
+
issues.push(this.createIssue(
|
|
1293
|
+
this.generateIssueId(),
|
|
1294
|
+
"serious",
|
|
1295
|
+
"Tailwind outline-none without focus replacement",
|
|
1296
|
+
"Add focus-visible:ring-2 focus-visible:ring-offset-2 for visible focus indicator.",
|
|
1297
|
+
file,
|
|
1298
|
+
lineNumber,
|
|
1299
|
+
0.9,
|
|
1300
|
+
this.getWCAGRef("2.4.7"),
|
|
1301
|
+
true
|
|
1302
|
+
));
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (/tabIndex=\{?["']?([1-9]\d*)["']?\}?|tabindex=["']([1-9]\d*)["']/i.test(line)) {
|
|
1306
|
+
const match = line.match(/tabIndex=\{?["']?([1-9]\d*)["']?\}?|tabindex=["']([1-9]\d*)["']/i);
|
|
1307
|
+
const value = match?.[1] || match?.[2];
|
|
917
1308
|
issues.push(this.createIssue(
|
|
918
1309
|
this.generateIssueId(),
|
|
919
1310
|
"serious",
|
|
920
|
-
|
|
921
|
-
|
|
1311
|
+
`Positive tabIndex value (${value}) disrupts natural tab order`,
|
|
1312
|
+
"Use tabIndex={0} for focusable elements or tabIndex={-1} for programmatic focus. Positive values create confusing navigation.",
|
|
922
1313
|
file,
|
|
923
1314
|
lineNumber,
|
|
924
|
-
0.
|
|
925
|
-
"
|
|
1315
|
+
0.88,
|
|
1316
|
+
this.getWCAGRef("2.4.3"),
|
|
926
1317
|
true
|
|
927
1318
|
));
|
|
928
1319
|
}
|
|
929
|
-
if (/
|
|
1320
|
+
if (/modal|dialog|drawer|overlay/i.test(line.toLowerCase())) {
|
|
1321
|
+
const componentContext = lines.slice(i, Math.min(lines.length, i + 30)).join("\n");
|
|
1322
|
+
if (/onClose|onDismiss|closeModal/i.test(componentContext)) {
|
|
1323
|
+
if (!/onKeyDown.*Escape|useEscapeKey|handleEscape|key.*===.*Escape/i.test(componentContext)) {
|
|
1324
|
+
issues.push(this.createIssue(
|
|
1325
|
+
this.generateIssueId(),
|
|
1326
|
+
"serious",
|
|
1327
|
+
"Modal/dialog may trap keyboard focus without Escape key support",
|
|
1328
|
+
"Add Escape key handler to close modal and manage focus trap with focus-trap library.",
|
|
1329
|
+
file,
|
|
1330
|
+
lineNumber,
|
|
1331
|
+
0.75,
|
|
1332
|
+
this.getWCAGRef("2.1.2"),
|
|
1333
|
+
false
|
|
1334
|
+
));
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return issues;
|
|
1340
|
+
}
|
|
1341
|
+
// ============================================================================
|
|
1342
|
+
// SEMANTIC STRUCTURE — WCAG 1.3.1, 2.4.6
|
|
1343
|
+
// ============================================================================
|
|
1344
|
+
analyzeSemanticStructure(content, file) {
|
|
1345
|
+
const issues = [];
|
|
1346
|
+
const lines = content.split("\n");
|
|
1347
|
+
const headingLevels = [];
|
|
1348
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1349
|
+
const line = lines[i];
|
|
1350
|
+
const lineNumber = i + 1;
|
|
1351
|
+
const headingMatch = line.match(/<h([1-6])|role=["']heading["'].*aria-level=["'](\d)["']/i);
|
|
1352
|
+
if (headingMatch) {
|
|
1353
|
+
const level = parseInt(headingMatch[1] || headingMatch[2] || "0");
|
|
1354
|
+
if (level > 0) {
|
|
1355
|
+
headingLevels.push({ level, line: lineNumber });
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (/<div[^>]*class=["'][^"']*nav|navigation/i.test(line) && !/<nav/i.test(content)) {
|
|
930
1359
|
issues.push(this.createIssue(
|
|
931
1360
|
this.generateIssueId(),
|
|
932
1361
|
"moderate",
|
|
933
|
-
"
|
|
934
|
-
"
|
|
1362
|
+
"Navigation using div instead of semantic <nav>",
|
|
1363
|
+
"Use <nav> element for navigation sections. Screen readers announce this as navigation.",
|
|
935
1364
|
file,
|
|
936
1365
|
lineNumber,
|
|
937
1366
|
0.8,
|
|
938
|
-
"
|
|
1367
|
+
this.getWCAGRef("1.3.1"),
|
|
939
1368
|
true
|
|
940
1369
|
));
|
|
941
1370
|
}
|
|
942
|
-
if (/<
|
|
1371
|
+
if (/<div[^>]*class=["'][^"']*header/i.test(line) && !/<header/i.test(content)) {
|
|
943
1372
|
issues.push(this.createIssue(
|
|
944
1373
|
this.generateIssueId(),
|
|
945
|
-
"
|
|
946
|
-
"
|
|
947
|
-
"
|
|
1374
|
+
"low",
|
|
1375
|
+
"Header content using div instead of semantic <header>",
|
|
1376
|
+
"Use <header> element for page/section headers for better screen reader navigation.",
|
|
1377
|
+
file,
|
|
1378
|
+
lineNumber,
|
|
1379
|
+
0.7,
|
|
1380
|
+
this.getWCAGRef("1.3.1"),
|
|
1381
|
+
true
|
|
1382
|
+
));
|
|
1383
|
+
}
|
|
1384
|
+
if (/<div[^>]*class=["'][^"']*footer/i.test(line) && !/<footer/i.test(content)) {
|
|
1385
|
+
issues.push(this.createIssue(
|
|
1386
|
+
this.generateIssueId(),
|
|
1387
|
+
"low",
|
|
1388
|
+
"Footer content using div instead of semantic <footer>",
|
|
1389
|
+
"Use <footer> element for page/section footers for better screen reader navigation.",
|
|
948
1390
|
file,
|
|
949
1391
|
lineNumber,
|
|
950
1392
|
0.7,
|
|
951
|
-
"
|
|
1393
|
+
this.getWCAGRef("1.3.1"),
|
|
1394
|
+
true
|
|
1395
|
+
));
|
|
1396
|
+
}
|
|
1397
|
+
if (/<div[^>]*class=["'][^"']*main/i.test(line) && !/<main/i.test(content)) {
|
|
1398
|
+
issues.push(this.createIssue(
|
|
1399
|
+
this.generateIssueId(),
|
|
1400
|
+
"moderate",
|
|
1401
|
+
"Main content using div instead of semantic <main>",
|
|
1402
|
+
'Use <main> element for primary content. Allows "skip to main content" functionality.',
|
|
1403
|
+
file,
|
|
1404
|
+
lineNumber,
|
|
1405
|
+
0.75,
|
|
1406
|
+
this.getWCAGRef("2.4.1"),
|
|
952
1407
|
true
|
|
953
1408
|
));
|
|
954
1409
|
}
|
|
955
|
-
if (
|
|
956
|
-
|
|
1410
|
+
if (/<div[^>]*class=["'][^"']*(list|items|menu)[^"']*["']/i.test(line)) {
|
|
1411
|
+
const context = lines.slice(i, Math.min(lines.length, i + 10)).join("\n");
|
|
1412
|
+
if (!/<ul|<ol|<menu|role=["']list/i.test(context)) {
|
|
957
1413
|
issues.push(this.createIssue(
|
|
958
1414
|
this.generateIssueId(),
|
|
959
1415
|
"low",
|
|
960
|
-
"
|
|
961
|
-
|
|
1416
|
+
"List-like content without list semantics",
|
|
1417
|
+
'Use <ul>/<ol> for lists or add role="list" with role="listitem" children.',
|
|
962
1418
|
file,
|
|
963
1419
|
lineNumber,
|
|
964
|
-
0.
|
|
965
|
-
"
|
|
1420
|
+
0.65,
|
|
1421
|
+
this.getWCAGRef("1.3.1"),
|
|
966
1422
|
false
|
|
967
1423
|
));
|
|
968
1424
|
}
|
|
969
1425
|
}
|
|
970
|
-
|
|
1426
|
+
}
|
|
1427
|
+
for (let i = 1; i < headingLevels.length; i++) {
|
|
1428
|
+
const current = headingLevels[i];
|
|
1429
|
+
const previous = headingLevels[i - 1];
|
|
1430
|
+
if (current.level > previous.level + 1) {
|
|
1431
|
+
issues.push(this.createIssue(
|
|
1432
|
+
this.generateIssueId(),
|
|
1433
|
+
"moderate",
|
|
1434
|
+
`Skipped heading level: h${previous.level} to h${current.level}`,
|
|
1435
|
+
`Heading levels should not skip. Go from h${previous.level} to h${previous.level + 1}. Screen reader users navigate by headings.`,
|
|
1436
|
+
file,
|
|
1437
|
+
current.line,
|
|
1438
|
+
0.85,
|
|
1439
|
+
this.getWCAGRef("2.4.6"),
|
|
1440
|
+
true
|
|
1441
|
+
));
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (headingLevels.length > 0 && headingLevels[0].level !== 1) {
|
|
1445
|
+
issues.push(this.createIssue(
|
|
1446
|
+
this.generateIssueId(),
|
|
1447
|
+
"moderate",
|
|
1448
|
+
`First heading is h${headingLevels[0].level} instead of h1`,
|
|
1449
|
+
"Each page should start with an h1 describing the page content.",
|
|
1450
|
+
file,
|
|
1451
|
+
headingLevels[0].line,
|
|
1452
|
+
0.8,
|
|
1453
|
+
this.getWCAGRef("2.4.6"),
|
|
1454
|
+
true
|
|
1455
|
+
));
|
|
1456
|
+
}
|
|
1457
|
+
return issues;
|
|
1458
|
+
}
|
|
1459
|
+
// ============================================================================
|
|
1460
|
+
// ARIA USAGE — WCAG 4.1.2
|
|
1461
|
+
// ============================================================================
|
|
1462
|
+
analyzeARIAUsage(content, file) {
|
|
1463
|
+
const issues = [];
|
|
1464
|
+
const lines = content.split("\n");
|
|
1465
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1466
|
+
const line = lines[i];
|
|
1467
|
+
const lineNumber = i + 1;
|
|
1468
|
+
const roleMatch = line.match(/role=["']([a-z]+)["']/i);
|
|
1469
|
+
if (roleMatch) {
|
|
1470
|
+
const role = roleMatch[1].toLowerCase();
|
|
1471
|
+
const requiredAttrs = REQUIRED_ARIA_ATTRIBUTES[role];
|
|
1472
|
+
if (requiredAttrs && requiredAttrs.length > 0) {
|
|
1473
|
+
const elementContext = this.getMultiLineElement(lines, i, void 0) || line;
|
|
1474
|
+
const missingAttrs = requiredAttrs.filter((attr) => !new RegExp(attr, "i").test(elementContext));
|
|
1475
|
+
if (missingAttrs.length > 0) {
|
|
1476
|
+
issues.push(this.createIssue(
|
|
1477
|
+
this.generateIssueId(),
|
|
1478
|
+
"serious",
|
|
1479
|
+
`Role "${role}" missing required ARIA attributes: ${missingAttrs.join(", ")}`,
|
|
1480
|
+
`Add ${missingAttrs.map((a) => `${a}="value"`).join(" ")} for proper screen reader support.`,
|
|
1481
|
+
file,
|
|
1482
|
+
lineNumber,
|
|
1483
|
+
0.88,
|
|
1484
|
+
this.getWCAGRef("4.1.2"),
|
|
1485
|
+
true
|
|
1486
|
+
));
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (/aria-hidden=["']true["']/i.test(line)) {
|
|
1491
|
+
const elementContext = lines.slice(Math.max(0, i - 1), i + 5).join("\n");
|
|
1492
|
+
if (/<(button|a|input|select|textarea)|tabIndex=\{?["']?0|tabindex=["']0/i.test(elementContext)) {
|
|
1493
|
+
issues.push(this.createIssue(
|
|
1494
|
+
this.generateIssueId(),
|
|
1495
|
+
"serious",
|
|
1496
|
+
"aria-hidden on element containing focusable content",
|
|
1497
|
+
"Remove aria-hidden or add tabIndex={-1} to focusable children. Hidden content should not be focusable.",
|
|
1498
|
+
file,
|
|
1499
|
+
lineNumber,
|
|
1500
|
+
0.85,
|
|
1501
|
+
this.getWCAGRef("4.1.2"),
|
|
1502
|
+
true
|
|
1503
|
+
));
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
if (/aria-expanded=["'](yes|no)["']/i.test(line)) {
|
|
971
1507
|
issues.push(this.createIssue(
|
|
972
1508
|
this.generateIssueId(),
|
|
973
1509
|
"serious",
|
|
974
|
-
|
|
975
|
-
"
|
|
1510
|
+
'Invalid aria-expanded value (use "true"/"false" not "yes"/"no")',
|
|
1511
|
+
'Use aria-expanded="true" or aria-expanded="false".',
|
|
976
1512
|
file,
|
|
977
1513
|
lineNumber,
|
|
978
|
-
0.
|
|
979
|
-
"
|
|
1514
|
+
0.95,
|
|
1515
|
+
this.getWCAGRef("4.1.2"),
|
|
980
1516
|
true
|
|
981
1517
|
));
|
|
982
1518
|
}
|
|
1519
|
+
if (/aria-label=/i.test(line)) {
|
|
1520
|
+
if (/<(div|span|p|section)\s/i.test(line) && !/role=/i.test(line)) {
|
|
1521
|
+
issues.push(this.createIssue(
|
|
1522
|
+
this.generateIssueId(),
|
|
1523
|
+
"moderate",
|
|
1524
|
+
"aria-label on non-interactive element without role",
|
|
1525
|
+
"Add an appropriate role or use aria-labelledby for non-interactive regions.",
|
|
1526
|
+
file,
|
|
1527
|
+
lineNumber,
|
|
1528
|
+
0.7,
|
|
1529
|
+
this.getWCAGRef("4.1.2"),
|
|
1530
|
+
false
|
|
1531
|
+
));
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (/aria-live=/i.test(line)) {
|
|
1535
|
+
if (!/aria-live=["'](polite|assertive|off)["']/i.test(line)) {
|
|
1536
|
+
issues.push(this.createIssue(
|
|
1537
|
+
this.generateIssueId(),
|
|
1538
|
+
"moderate",
|
|
1539
|
+
"Invalid aria-live value",
|
|
1540
|
+
'Use aria-live="polite" for non-urgent updates or aria-live="assertive" for critical alerts.',
|
|
1541
|
+
file,
|
|
1542
|
+
lineNumber,
|
|
1543
|
+
0.85,
|
|
1544
|
+
this.getWCAGRef("4.1.3"),
|
|
1545
|
+
true
|
|
1546
|
+
));
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (/toast|notification|alert|error.*message|success.*message/i.test(line)) {
|
|
1550
|
+
const context = lines.slice(Math.max(0, i - 3), i + 5).join("\n");
|
|
1551
|
+
if (!/aria-live|role=["']alert|role=["']status/i.test(context)) {
|
|
1552
|
+
issues.push(this.createIssue(
|
|
1553
|
+
this.generateIssueId(),
|
|
1554
|
+
"moderate",
|
|
1555
|
+
"Status message may not be announced to screen readers",
|
|
1556
|
+
'Add role="status" aria-live="polite" for status messages or role="alert" for errors.',
|
|
1557
|
+
file,
|
|
1558
|
+
lineNumber,
|
|
1559
|
+
0.75,
|
|
1560
|
+
this.getWCAGRef("4.1.3"),
|
|
1561
|
+
true
|
|
1562
|
+
));
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
983
1565
|
}
|
|
984
1566
|
return issues;
|
|
985
1567
|
}
|
|
986
|
-
|
|
1568
|
+
// ============================================================================
|
|
1569
|
+
// COLOR ACCESSIBILITY — WCAG 1.4.1, 1.4.3, 1.4.11
|
|
1570
|
+
// ============================================================================
|
|
1571
|
+
analyzeColorAccessibility(content, file) {
|
|
987
1572
|
const issues = [];
|
|
988
1573
|
const lines = content.split("\n");
|
|
989
1574
|
for (let i = 0; i < lines.length; i++) {
|
|
990
1575
|
const line = lines[i];
|
|
991
1576
|
const lineNumber = i + 1;
|
|
992
|
-
if (/
|
|
1577
|
+
if (/color:\s*(red|green|#f00|#0f0|#ff0000|#00ff00)/i.test(line)) {
|
|
1578
|
+
const context = lines.slice(Math.max(0, i - 5), i + 5).join("\n");
|
|
1579
|
+
if (/error|success|warning|valid|invalid/i.test(context)) {
|
|
1580
|
+
if (!/icon|Icon|✓|✗|×|⚠|aria-label|sr-only|visually-hidden/i.test(context)) {
|
|
1581
|
+
issues.push(this.createIssue(
|
|
1582
|
+
this.generateIssueId(),
|
|
1583
|
+
"serious",
|
|
1584
|
+
"Color may be the only indicator of status",
|
|
1585
|
+
"Add icon, text, or other visual indicator besides color. Colorblind users cannot distinguish red/green.",
|
|
1586
|
+
file,
|
|
1587
|
+
lineNumber,
|
|
1588
|
+
0.8,
|
|
1589
|
+
this.getWCAGRef("1.4.1"),
|
|
1590
|
+
true
|
|
1591
|
+
));
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (/color:\s*#([0-9a-f]{3,6})/i.test(line)) {
|
|
1596
|
+
const hexMatch = line.match(/color:\s*#([0-9a-f]{3,6})/i);
|
|
1597
|
+
if (hexMatch) {
|
|
1598
|
+
const hex = hexMatch[1].toLowerCase();
|
|
1599
|
+
const lightness = this.getRelativeLuminance(hex);
|
|
1600
|
+
if (lightness > 0.3 && lightness < 0.5) {
|
|
1601
|
+
issues.push(this.createIssue(
|
|
1602
|
+
this.generateIssueId(),
|
|
1603
|
+
"moderate",
|
|
1604
|
+
"Text color may have insufficient contrast",
|
|
1605
|
+
"Verify 4.5:1 contrast ratio at webaim.org/resources/contrastchecker. Consider darker (#374151) or lighter (#d1d5db) alternatives.",
|
|
1606
|
+
file,
|
|
1607
|
+
lineNumber,
|
|
1608
|
+
0.65,
|
|
1609
|
+
this.getWCAGRef("1.4.3"),
|
|
1610
|
+
false
|
|
1611
|
+
));
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (/focus.*border/i.test(line) && !/ring|outline|shadow/i.test(line)) {
|
|
993
1616
|
issues.push(this.createIssue(
|
|
994
1617
|
this.generateIssueId(),
|
|
995
1618
|
"low",
|
|
996
|
-
"
|
|
997
|
-
"
|
|
1619
|
+
"Focus indicator uses only border - verify 3:1 contrast",
|
|
1620
|
+
"Ensure focus border has 3:1 contrast against adjacent colors. Consider adding focus ring.",
|
|
998
1621
|
file,
|
|
999
1622
|
lineNumber,
|
|
1000
|
-
0.
|
|
1623
|
+
0.6,
|
|
1624
|
+
this.getWCAGRef("1.4.11"),
|
|
1625
|
+
false
|
|
1626
|
+
));
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return issues;
|
|
1630
|
+
}
|
|
1631
|
+
// ============================================================================
|
|
1632
|
+
// TOUCH TARGETS — WCAG 2.5.5, 2.5.8
|
|
1633
|
+
// ============================================================================
|
|
1634
|
+
analyzeTouchTargets(content, file) {
|
|
1635
|
+
const issues = [];
|
|
1636
|
+
const lines = content.split("\n");
|
|
1637
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1638
|
+
const line = lines[i];
|
|
1639
|
+
const lineNumber = i + 1;
|
|
1640
|
+
if (/<(button|a|input|select)|onClick|@click/i.test(line)) {
|
|
1641
|
+
const sizeMatch = line.match(/(?:width|height|size):\s*(\d+)(?:px)?/i) || line.match(/(?:w-|h-)(\d+)/);
|
|
1642
|
+
if (sizeMatch) {
|
|
1643
|
+
const size = parseInt(sizeMatch[1]);
|
|
1644
|
+
const pxSize = size <= 12 ? size * 4 : size;
|
|
1645
|
+
if (pxSize < 24) {
|
|
1646
|
+
issues.push(this.createIssue(
|
|
1647
|
+
this.generateIssueId(),
|
|
1648
|
+
"moderate",
|
|
1649
|
+
`Touch target size ${pxSize}px is below WCAG minimum (24px)`,
|
|
1650
|
+
"Increase touch target to at least 24\xD724px (WCAG AA) or 44\xD744px (WCAG AAA) for mobile usability.",
|
|
1651
|
+
file,
|
|
1652
|
+
lineNumber,
|
|
1653
|
+
0.75,
|
|
1654
|
+
this.getWCAGRef("2.5.8"),
|
|
1655
|
+
true
|
|
1656
|
+
));
|
|
1657
|
+
} else if (pxSize < 44) {
|
|
1658
|
+
issues.push(this.createIssue(
|
|
1659
|
+
this.generateIssueId(),
|
|
1660
|
+
"low",
|
|
1661
|
+
`Touch target size ${pxSize}px is below recommended 44px`,
|
|
1662
|
+
"Consider increasing to 44\xD744px for optimal mobile usability (WCAG AAA).",
|
|
1663
|
+
file,
|
|
1664
|
+
lineNumber,
|
|
1665
|
+
0.6,
|
|
1666
|
+
this.getWCAGRef("2.5.5"),
|
|
1667
|
+
false
|
|
1668
|
+
));
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if (/(Icon|icon).*size=["']?(sm|xs|small|12|14|16)/i.test(line)) {
|
|
1673
|
+
const context = lines.slice(Math.max(0, i - 2), i + 2).join("\n");
|
|
1674
|
+
if (/<button|onClick/i.test(context) && !/p-|padding/i.test(context)) {
|
|
1675
|
+
issues.push(this.createIssue(
|
|
1676
|
+
this.generateIssueId(),
|
|
1677
|
+
"moderate",
|
|
1678
|
+
"Small icon button may have insufficient touch target",
|
|
1679
|
+
"Add padding (e.g., p-2 or p-3) to ensure 44\xD744px touch target around small icons.",
|
|
1680
|
+
file,
|
|
1681
|
+
lineNumber,
|
|
1682
|
+
0.7,
|
|
1683
|
+
this.getWCAGRef("2.5.8"),
|
|
1684
|
+
true
|
|
1685
|
+
));
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return issues;
|
|
1690
|
+
}
|
|
1691
|
+
// ============================================================================
|
|
1692
|
+
// MOTION ACCESSIBILITY — WCAG 2.3.3
|
|
1693
|
+
// ============================================================================
|
|
1694
|
+
analyzeMotionAccessibility(content, file) {
|
|
1695
|
+
const issues = [];
|
|
1696
|
+
if (/animation:|@keyframes|animate-|transition:/i.test(content)) {
|
|
1697
|
+
if (!/prefers-reduced-motion/i.test(content)) {
|
|
1698
|
+
issues.push(this.createIssue(
|
|
1699
|
+
this.generateIssueId(),
|
|
1700
|
+
"moderate",
|
|
1701
|
+
"Animations without prefers-reduced-motion support",
|
|
1702
|
+
"Add @media (prefers-reduced-motion: reduce) { animation: none; transition: none; } for motion-sensitive users.",
|
|
1703
|
+
file,
|
|
1001
1704
|
void 0,
|
|
1705
|
+
0.8,
|
|
1706
|
+
"WCAG 2.1 AA - 2.3.3 Animation from Interactions",
|
|
1707
|
+
false
|
|
1708
|
+
));
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (/autoPlay|autoplay/i.test(content)) {
|
|
1712
|
+
if (!/muted|controls/i.test(content)) {
|
|
1713
|
+
issues.push(this.createIssue(
|
|
1714
|
+
this.generateIssueId(),
|
|
1715
|
+
"serious",
|
|
1716
|
+
"Autoplaying media without mute or controls",
|
|
1717
|
+
"Add muted attribute or provide controls. Autoplay audio can disorient screen reader users.",
|
|
1718
|
+
file,
|
|
1719
|
+
void 0,
|
|
1720
|
+
0.85,
|
|
1721
|
+
"WCAG 2.1 A - 1.4.2 Audio Control",
|
|
1722
|
+
true
|
|
1723
|
+
));
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
return issues;
|
|
1727
|
+
}
|
|
1728
|
+
// ============================================================================
|
|
1729
|
+
// LINK ACCESSIBILITY — WCAG 2.4.4
|
|
1730
|
+
// ============================================================================
|
|
1731
|
+
analyzeLinks(content, file) {
|
|
1732
|
+
const issues = [];
|
|
1733
|
+
const lines = content.split("\n");
|
|
1734
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1735
|
+
const line = lines[i];
|
|
1736
|
+
const lineNumber = i + 1;
|
|
1737
|
+
if (/<a[^>]*>\s*<\/a>/i.test(line)) {
|
|
1738
|
+
issues.push(this.createIssue(
|
|
1739
|
+
this.generateIssueId(),
|
|
1740
|
+
"critical",
|
|
1741
|
+
"Empty link with no text content",
|
|
1742
|
+
'Add meaningful link text or aria-label. Screen readers announce this as "link" with no destination.',
|
|
1743
|
+
file,
|
|
1744
|
+
lineNumber,
|
|
1745
|
+
0.95,
|
|
1746
|
+
this.getWCAGRef("2.4.4"),
|
|
1002
1747
|
true
|
|
1003
1748
|
));
|
|
1004
1749
|
}
|
|
1005
|
-
if (/
|
|
1750
|
+
if (/href=["']#["']\s*>/i.test(line) && !/onClick|@click/i.test(line)) {
|
|
1006
1751
|
issues.push(this.createIssue(
|
|
1007
1752
|
this.generateIssueId(),
|
|
1008
1753
|
"moderate",
|
|
1009
|
-
|
|
1010
|
-
"Add
|
|
1754
|
+
'Link with placeholder href="#"',
|
|
1755
|
+
"Add meaningful destination or use <button> for actions. Placeholder links confuse users.",
|
|
1011
1756
|
file,
|
|
1012
1757
|
lineNumber,
|
|
1013
|
-
0.
|
|
1014
|
-
|
|
1758
|
+
0.8,
|
|
1759
|
+
this.getWCAGRef("2.4.4"),
|
|
1015
1760
|
true
|
|
1016
1761
|
));
|
|
1017
1762
|
}
|
|
1018
|
-
if (
|
|
1763
|
+
if (/>(?:click here|here|read more|learn more|more)<\/a>/i.test(line)) {
|
|
1019
1764
|
issues.push(this.createIssue(
|
|
1020
1765
|
this.generateIssueId(),
|
|
1021
1766
|
"moderate",
|
|
1022
|
-
"
|
|
1023
|
-
"
|
|
1767
|
+
'Generic link text ("click here", "read more")',
|
|
1768
|
+
"Use descriptive link text that makes sense out of context. Screen reader users navigate by link list.",
|
|
1024
1769
|
file,
|
|
1025
1770
|
lineNumber,
|
|
1026
|
-
0.
|
|
1027
|
-
"
|
|
1771
|
+
0.75,
|
|
1772
|
+
this.getWCAGRef("2.4.4"),
|
|
1028
1773
|
true
|
|
1029
1774
|
));
|
|
1030
1775
|
}
|
|
1776
|
+
if (/target=["']_blank["']/i.test(line)) {
|
|
1777
|
+
if (!/aria-label.*new window|aria-label.*new tab|external|External|↗|↪/i.test(line)) {
|
|
1778
|
+
issues.push(this.createIssue(
|
|
1779
|
+
this.generateIssueId(),
|
|
1780
|
+
"low",
|
|
1781
|
+
"Link opens in new window without warning",
|
|
1782
|
+
'Add visual indicator (\u2197) and aria-label mentioning "opens in new window" for user awareness.',
|
|
1783
|
+
file,
|
|
1784
|
+
lineNumber,
|
|
1785
|
+
0.65,
|
|
1786
|
+
this.getWCAGRef("3.2.5"),
|
|
1787
|
+
true
|
|
1788
|
+
));
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
if (/<a[^>]*href=["']([^"']+)["'][^>]*>.*<img/i.test(line)) {
|
|
1792
|
+
issues.push(this.createIssue(
|
|
1793
|
+
this.generateIssueId(),
|
|
1794
|
+
"low",
|
|
1795
|
+
"Image and text link may create duplicate links",
|
|
1796
|
+
'Combine image and text in single link or add aria-hidden="true" to decorative image link.',
|
|
1797
|
+
file,
|
|
1798
|
+
lineNumber,
|
|
1799
|
+
0.6,
|
|
1800
|
+
this.getWCAGRef("2.4.4"),
|
|
1801
|
+
false
|
|
1802
|
+
));
|
|
1803
|
+
}
|
|
1031
1804
|
}
|
|
1032
1805
|
return issues;
|
|
1033
1806
|
}
|
|
1807
|
+
// ============================================================================
|
|
1808
|
+
// UTILITY METHODS
|
|
1809
|
+
// ============================================================================
|
|
1810
|
+
/**
|
|
1811
|
+
* Get multi-line element content starting at line index
|
|
1812
|
+
*/
|
|
1813
|
+
getMultiLineElement(lines, startIndex, tagName) {
|
|
1814
|
+
const startLine = lines[startIndex];
|
|
1815
|
+
if (!startLine) return null;
|
|
1816
|
+
const tag = tagName || startLine.match(/<([a-zA-Z][a-zA-Z0-9]*)/)?.[1];
|
|
1817
|
+
if (!tag) return startLine;
|
|
1818
|
+
if (new RegExp(`</${tag}|/>`, "i").test(startLine)) {
|
|
1819
|
+
return startLine;
|
|
1820
|
+
}
|
|
1821
|
+
let content = startLine;
|
|
1822
|
+
for (let j = startIndex + 1; j < Math.min(lines.length, startIndex + 20); j++) {
|
|
1823
|
+
content += "\n" + lines[j];
|
|
1824
|
+
if (new RegExp(`</${tag}>|/>`, "i").test(lines[j])) {
|
|
1825
|
+
break;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
return content;
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Calculate relative luminance for contrast checking
|
|
1832
|
+
*/
|
|
1833
|
+
getRelativeLuminance(hex) {
|
|
1834
|
+
let normalized = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
|
|
1835
|
+
const r = parseInt(normalized.slice(0, 2), 16) / 255;
|
|
1836
|
+
const g = parseInt(normalized.slice(2, 4), 16) / 255;
|
|
1837
|
+
const b = parseInt(normalized.slice(4, 6), 16) / 255;
|
|
1838
|
+
const [R, G, B] = [r, g, b].map(
|
|
1839
|
+
(c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
|
1840
|
+
);
|
|
1841
|
+
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
|
1842
|
+
}
|
|
1034
1843
|
/**
|
|
1035
1844
|
* AI Enhancement for accessibility review
|
|
1036
1845
|
*/
|
|
1037
1846
|
getAIEnhancementSystemPrompt() {
|
|
1038
|
-
return `You are a WCAG 2.1 accessibility expert
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1847
|
+
return `You are a WCAG 2.1 AA accessibility expert and inclusive design advocate.
|
|
1848
|
+
Review code for comprehensive accessibility compliance.
|
|
1849
|
+
|
|
1850
|
+
## Analysis Categories
|
|
1851
|
+
|
|
1852
|
+
### Critical (Blocks Access)
|
|
1853
|
+
- Images without alt text
|
|
1854
|
+
- Icon-only buttons missing aria-label
|
|
1855
|
+
- Non-semantic click handlers without keyboard support
|
|
1856
|
+
- Empty links
|
|
1857
|
+
- Missing form labels
|
|
1858
|
+
|
|
1859
|
+
### Serious (Significantly Impairs)
|
|
1860
|
+
- Focus outline removed without replacement
|
|
1861
|
+
- Positive tabIndex values disrupting tab order
|
|
1862
|
+
- Role without required ARIA attributes
|
|
1863
|
+
- Color-only status indicators
|
|
1864
|
+
- Links without href
|
|
1865
|
+
|
|
1866
|
+
### Moderate (Creates Barriers)
|
|
1867
|
+
- Skipped heading levels
|
|
1868
|
+
- Missing autocomplete on inputs
|
|
1869
|
+
- Touch targets under 44px
|
|
1870
|
+
- Generic link text
|
|
1871
|
+
- Missing prefers-reduced-motion
|
|
1872
|
+
|
|
1873
|
+
### Low (Best Practices)
|
|
1874
|
+
- Semantic element opportunities
|
|
1875
|
+
- Status messages without aria-live
|
|
1876
|
+
- External links without warning
|
|
1877
|
+
|
|
1878
|
+
## WCAG Success Criteria Reference
|
|
1879
|
+
- 1.1.1: Non-text Content (images, icons)
|
|
1880
|
+
- 1.3.1: Info and Relationships (semantic HTML)
|
|
1881
|
+
- 1.4.1: Use of Color (color-only info)
|
|
1882
|
+
- 1.4.3: Contrast (Minimum)
|
|
1883
|
+
- 2.1.1: Keyboard accessibility
|
|
1884
|
+
- 2.4.3: Focus Order
|
|
1885
|
+
- 2.4.4: Link Purpose
|
|
1886
|
+
- 2.4.6: Headings and Labels
|
|
1887
|
+
- 2.4.7: Focus Visible
|
|
1888
|
+
- 2.5.5: Target Size
|
|
1889
|
+
- 4.1.2: Name, Role, Value
|
|
1890
|
+
|
|
1891
|
+
## Output Format
|
|
1050
1892
|
{
|
|
1051
1893
|
"validated": [{
|
|
1052
1894
|
"original_issue": "...",
|
|
@@ -1054,10 +1896,11 @@ Output STRICT JSON:
|
|
|
1054
1896
|
"confidence": 0-100,
|
|
1055
1897
|
"file": "path",
|
|
1056
1898
|
"line": 123,
|
|
1057
|
-
"severity": "serious",
|
|
1058
|
-
"wcag_criterion": "WCAG 2.1 - X.X.X Name",
|
|
1899
|
+
"severity": "critical" | "serious" | "moderate" | "low",
|
|
1900
|
+
"wcag_criterion": "WCAG 2.1 AA - X.X.X Name",
|
|
1059
1901
|
"impact": "How this affects users with disabilities",
|
|
1060
|
-
"
|
|
1902
|
+
"code_snippet": "The problematic code",
|
|
1903
|
+
"fix": "Accessible code fix with example"
|
|
1061
1904
|
}],
|
|
1062
1905
|
"additional": [{
|
|
1063
1906
|
"issue": "Accessibility issue found",
|
|
@@ -1065,10 +1908,18 @@ Output STRICT JSON:
|
|
|
1065
1908
|
"line": 123,
|
|
1066
1909
|
"severity": "moderate",
|
|
1067
1910
|
"wcag_criterion": "WCAG criterion",
|
|
1068
|
-
"impact": "User impact",
|
|
1069
|
-
"
|
|
1911
|
+
"impact": "User impact description",
|
|
1912
|
+
"code_snippet": "Problematic code",
|
|
1913
|
+
"fix": "Accessible implementation with example"
|
|
1070
1914
|
}],
|
|
1071
|
-
"
|
|
1915
|
+
"score": {
|
|
1916
|
+
"overall": 85,
|
|
1917
|
+
"critical_count": 0,
|
|
1918
|
+
"serious_count": 2,
|
|
1919
|
+
"moderate_count": 5,
|
|
1920
|
+
"low_count": 3
|
|
1921
|
+
},
|
|
1922
|
+
"summary": "Overall accessibility assessment with key recommendations"
|
|
1072
1923
|
}`;
|
|
1073
1924
|
}
|
|
1074
1925
|
};
|
|
@@ -8007,4 +8858,4 @@ export {
|
|
|
8007
8858
|
CustomAgent,
|
|
8008
8859
|
getAgentRegistry
|
|
8009
8860
|
};
|
|
8010
|
-
//# sourceMappingURL=chunk-
|
|
8861
|
+
//# sourceMappingURL=chunk-B3MBKB2U.js.map
|