@webability/core 1.0.0 → 1.1.0
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 +6 -0
- package/dist/index.cjs +566 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -1
- package/dist/index.d.ts +75 -1
- package/dist/index.js +561 -5
- package/dist/index.js.map +1 -1
- package/package.json +9 -6
package/README.md
CHANGED
|
@@ -56,6 +56,12 @@ const html = toHtmlReport(result)
|
|
|
56
56
|
fs.writeFileSync('report.html', html)
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
## Links
|
|
60
|
+
|
|
61
|
+
- [WebAbility](https://webability.io) — AI-powered web accessibility platform
|
|
62
|
+
- [Abilyo](https://abilyo.com) — Developer tools for accessibility testing
|
|
63
|
+
- [Documentation](https://abilyo.com/docs)
|
|
64
|
+
|
|
59
65
|
## License
|
|
60
66
|
|
|
61
67
|
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -27086,7 +27086,7 @@ var require_axe = __commonJS({
|
|
|
27086
27086
|
}
|
|
27087
27087
|
var link_in_text_block_evaluate_default = linkInTextBlockEvaluate;
|
|
27088
27088
|
function colorContrastEvaluate(node, options, virtualNode) {
|
|
27089
|
-
var ignoreUnicode = options.ignoreUnicode, ignoreLength = options.ignoreLength, ignorePseudo = options.ignorePseudo, boldValue = options.boldValue, boldTextPt = options.boldTextPt, largeTextPt = options.largeTextPt,
|
|
27089
|
+
var ignoreUnicode = options.ignoreUnicode, ignoreLength = options.ignoreLength, ignorePseudo = options.ignorePseudo, boldValue = options.boldValue, boldTextPt = options.boldTextPt, largeTextPt = options.largeTextPt, contrastRatio2 = options.contrastRatio, shadowOutlineEmMax = options.shadowOutlineEmMax, pseudoSizeThreshold = options.pseudoSizeThreshold;
|
|
27090
27090
|
if (!_isVisibleOnScreen(node)) {
|
|
27091
27091
|
this.data({
|
|
27092
27092
|
messageKey: "hidden"
|
|
@@ -27106,7 +27106,7 @@ var require_axe = __commonJS({
|
|
|
27106
27106
|
var bold = parseFloat(fontWeight) >= boldValue || fontWeight === "bold";
|
|
27107
27107
|
var ptSize = Math.ceil(fontSize * 72) / 96;
|
|
27108
27108
|
var isSmallFont = bold && ptSize < boldTextPt || !bold && ptSize < largeTextPt;
|
|
27109
|
-
var _ref134 = isSmallFont ?
|
|
27109
|
+
var _ref134 = isSmallFont ? contrastRatio2.normal : contrastRatio2.large, expected = _ref134.expected, minThreshold = _ref134.minThreshold, maxThreshold = _ref134.maxThreshold;
|
|
27110
27110
|
var pseudoElm = findPseudoElement(virtualNode, {
|
|
27111
27111
|
ignorePseudo,
|
|
27112
27112
|
pseudoSizeThreshold
|
|
@@ -34065,16 +34065,145 @@ var init_modal_dismissal = __esm({
|
|
|
34065
34065
|
}
|
|
34066
34066
|
});
|
|
34067
34067
|
|
|
34068
|
+
// src/engines/htmlcs.ts
|
|
34069
|
+
var htmlcs_exports = {};
|
|
34070
|
+
__export(htmlcs_exports, {
|
|
34071
|
+
runHtmlcs: () => runHtmlcs
|
|
34072
|
+
});
|
|
34073
|
+
async function runHtmlcs(page, standard = "WCAG2AA") {
|
|
34074
|
+
try {
|
|
34075
|
+
await page.addScriptTag({ content: `
|
|
34076
|
+
window.__htmlcsLoaded = false;
|
|
34077
|
+
window.__htmlcsResults = [];
|
|
34078
|
+
` });
|
|
34079
|
+
const p = page;
|
|
34080
|
+
if (p.addScriptTag) {
|
|
34081
|
+
await p.addScriptTag({ url: HTMLCS_CDN }).catch(() => {
|
|
34082
|
+
});
|
|
34083
|
+
}
|
|
34084
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
34085
|
+
const results = await page.evaluate((std) => {
|
|
34086
|
+
return new Promise((resolve) => {
|
|
34087
|
+
const w = window;
|
|
34088
|
+
if (!w.HTMLCS) {
|
|
34089
|
+
resolve([]);
|
|
34090
|
+
return;
|
|
34091
|
+
}
|
|
34092
|
+
w.HTMLCS.process(std, document, () => {
|
|
34093
|
+
const messages = w.HTMLCS.getMessages();
|
|
34094
|
+
const issues = messages.filter((m) => m.type === 1).slice(0, 50).map((m) => {
|
|
34095
|
+
let selector = "";
|
|
34096
|
+
try {
|
|
34097
|
+
if (m.element && m.element.tagName) {
|
|
34098
|
+
const el = m.element;
|
|
34099
|
+
selector = el.id ? `#${el.id}` : el.className ? `${el.tagName.toLowerCase()}.${el.className.split(" ")[0]}` : el.tagName.toLowerCase();
|
|
34100
|
+
}
|
|
34101
|
+
} catch {
|
|
34102
|
+
}
|
|
34103
|
+
return {
|
|
34104
|
+
type: m.type,
|
|
34105
|
+
code: m.code || "",
|
|
34106
|
+
msg: m.msg || "",
|
|
34107
|
+
element: m.element?.outerHTML?.slice(0, 150) || "",
|
|
34108
|
+
selector
|
|
34109
|
+
};
|
|
34110
|
+
});
|
|
34111
|
+
resolve(issues);
|
|
34112
|
+
});
|
|
34113
|
+
});
|
|
34114
|
+
}, standard);
|
|
34115
|
+
return results.map((r, i) => {
|
|
34116
|
+
const wcag = extractWcagFromCode(r.code);
|
|
34117
|
+
return {
|
|
34118
|
+
id: `htmlcs-${i}-${hashStr(r.selector)}`,
|
|
34119
|
+
source: "webability",
|
|
34120
|
+
// don't expose engine name
|
|
34121
|
+
type: r.code.split(".").pop() || r.code,
|
|
34122
|
+
wcag,
|
|
34123
|
+
level: "AA",
|
|
34124
|
+
impact: "moderate",
|
|
34125
|
+
message: r.msg.slice(0, 200),
|
|
34126
|
+
selector: r.selector,
|
|
34127
|
+
html: r.element
|
|
34128
|
+
};
|
|
34129
|
+
});
|
|
34130
|
+
} catch {
|
|
34131
|
+
return [];
|
|
34132
|
+
}
|
|
34133
|
+
}
|
|
34134
|
+
function extractWcagFromCode(code) {
|
|
34135
|
+
const match = code.match(/Guideline(\d+)_(\d+)\.(\d+)_(\d+)_(\d+)/);
|
|
34136
|
+
if (match) return `${match[3]}.${match[4]}.${match[5]}`;
|
|
34137
|
+
const simple = code.match(/(\d+)_(\d+)_(\d+)/);
|
|
34138
|
+
if (simple) return `${simple[1]}.${simple[2]}.${simple[3]}`;
|
|
34139
|
+
return "";
|
|
34140
|
+
}
|
|
34141
|
+
function hashStr(s) {
|
|
34142
|
+
let h = 0;
|
|
34143
|
+
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i) | 0;
|
|
34144
|
+
return Math.abs(h).toString(36);
|
|
34145
|
+
}
|
|
34146
|
+
var HTMLCS_CDN;
|
|
34147
|
+
var init_htmlcs = __esm({
|
|
34148
|
+
"src/engines/htmlcs.ts"() {
|
|
34149
|
+
"use strict";
|
|
34150
|
+
HTMLCS_CDN = "https://squizlabs.github.io/HTML_CodeSniffer/build/HTMLCS.js";
|
|
34151
|
+
}
|
|
34152
|
+
});
|
|
34153
|
+
|
|
34154
|
+
// src/engines/dedup.ts
|
|
34155
|
+
var dedup_exports = {};
|
|
34156
|
+
__export(dedup_exports, {
|
|
34157
|
+
deduplicateIssues: () => deduplicateIssues2
|
|
34158
|
+
});
|
|
34159
|
+
function deduplicateIssues2(allIssues) {
|
|
34160
|
+
const seen = /* @__PURE__ */ new Map();
|
|
34161
|
+
const sorted = [...allIssues].sort((a, b) => {
|
|
34162
|
+
const sourceOrder = { webability: 0, "axe-core": 1, "deep-analysis": 2 };
|
|
34163
|
+
const aOrder = sourceOrder[a.source] ?? 3;
|
|
34164
|
+
const bOrder = sourceOrder[b.source] ?? 3;
|
|
34165
|
+
return aOrder - bOrder;
|
|
34166
|
+
});
|
|
34167
|
+
for (const issue of sorted) {
|
|
34168
|
+
const key = normalizeKey(issue);
|
|
34169
|
+
if (!seen.has(key)) {
|
|
34170
|
+
seen.set(key, issue);
|
|
34171
|
+
} else {
|
|
34172
|
+
const existing = seen.get(key);
|
|
34173
|
+
if (!existing.fix?.suggestedValue && issue.fix?.suggestedValue) {
|
|
34174
|
+
seen.set(key, issue);
|
|
34175
|
+
}
|
|
34176
|
+
}
|
|
34177
|
+
}
|
|
34178
|
+
return Array.from(seen.values());
|
|
34179
|
+
}
|
|
34180
|
+
function normalizeKey(issue) {
|
|
34181
|
+
const sel = issue.selector.replace(/:focus-visible$/, "").replace(/:focus$/, "").replace(/\[data-[^\]]+\]/g, "").trim();
|
|
34182
|
+
const wcag = issue.wcag || "";
|
|
34183
|
+
if (sel && wcag) return `${sel}::${wcag}`;
|
|
34184
|
+
return `${issue.type}::${sel || issue.id}`;
|
|
34185
|
+
}
|
|
34186
|
+
var init_dedup = __esm({
|
|
34187
|
+
"src/engines/dedup.ts"() {
|
|
34188
|
+
"use strict";
|
|
34189
|
+
}
|
|
34190
|
+
});
|
|
34191
|
+
|
|
34068
34192
|
// src/index.ts
|
|
34069
34193
|
var index_exports = {};
|
|
34070
34194
|
__export(index_exports, {
|
|
34071
34195
|
VIEWPORT_SIZES: () => VIEWPORT_SIZES,
|
|
34072
34196
|
WCAG_BY_ISSUE: () => WCAG_BY_ISSUE,
|
|
34197
|
+
detectFramework: () => detectFramework,
|
|
34073
34198
|
dismissModals: () => dismissModals,
|
|
34074
34199
|
extractSiteTheme: () => extractSiteTheme,
|
|
34200
|
+
generateDevSuggestion: () => generateDevSuggestion,
|
|
34201
|
+
getContrastRatio: () => getContrastRatio,
|
|
34075
34202
|
runAxe: () => runAxe,
|
|
34076
34203
|
runDetectors: () => runDetectors,
|
|
34077
34204
|
scan: () => scan,
|
|
34205
|
+
solveContrast: () => solveContrast,
|
|
34206
|
+
suggestTailwindFix: () => suggestTailwindFix,
|
|
34078
34207
|
toHtmlReport: () => toHtmlReport
|
|
34079
34208
|
});
|
|
34080
34209
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -39899,15 +40028,15 @@ async function scan(target, options) {
|
|
|
39899
40028
|
url = page.url();
|
|
39900
40029
|
}
|
|
39901
40030
|
try {
|
|
39902
|
-
const
|
|
40031
|
+
const allIssues = [];
|
|
39903
40032
|
const detected = await runDetectors(page);
|
|
39904
|
-
|
|
40033
|
+
allIssues.push(...detected.map(detectedToScanIssue));
|
|
39905
40034
|
if (opts.includeAxe) {
|
|
39906
40035
|
try {
|
|
39907
40036
|
const axeResults = await runAxe(page, opts.wcagTags ?? DEFAULT_WCAG_TAGS);
|
|
39908
40037
|
for (const v of axeResults.violations) {
|
|
39909
40038
|
for (const node of v.nodes) {
|
|
39910
|
-
|
|
40039
|
+
allIssues.push({
|
|
39911
40040
|
id: `axe-${v.id}-${hashSelector(node.target?.[0] ?? "")}`,
|
|
39912
40041
|
source: "axe-core",
|
|
39913
40042
|
type: v.id,
|
|
@@ -39923,6 +40052,14 @@ async function scan(target, options) {
|
|
|
39923
40052
|
} catch {
|
|
39924
40053
|
}
|
|
39925
40054
|
}
|
|
40055
|
+
try {
|
|
40056
|
+
const { runHtmlcs: runHtmlcs2 } = await Promise.resolve().then(() => (init_htmlcs(), htmlcs_exports));
|
|
40057
|
+
const htmlcsIssues = await runHtmlcs2(page);
|
|
40058
|
+
allIssues.push(...htmlcsIssues);
|
|
40059
|
+
} catch {
|
|
40060
|
+
}
|
|
40061
|
+
const { deduplicateIssues: dedup } = await Promise.resolve().then(() => (init_dedup(), dedup_exports));
|
|
40062
|
+
const issues = dedup(allIssues);
|
|
39926
40063
|
if (opts.deep && opts.deepApiKey) {
|
|
39927
40064
|
try {
|
|
39928
40065
|
const deepIssues = await runDeepViaApi(url, issues, opts);
|
|
@@ -40086,15 +40223,439 @@ function toHtmlReport(result) {
|
|
|
40086
40223
|
function esc(s) {
|
|
40087
40224
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
40088
40225
|
}
|
|
40226
|
+
|
|
40227
|
+
// src/suggestions/frameworkDetector.ts
|
|
40228
|
+
async function detectFramework(page) {
|
|
40229
|
+
return page.evaluate(() => {
|
|
40230
|
+
const html = document.documentElement.outerHTML;
|
|
40231
|
+
const classes = Array.from(document.querySelectorAll("[class]")).slice(0, 200).map((el) => el.className).join(" ");
|
|
40232
|
+
const twPatterns = /\b(text-(?:gray|slate|zinc|neutral|stone|red|orange|amber|yellow|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}|bg-(?:gray|slate|blue|red|green)-\d{2,3}|flex|grid|p-\d|m-\d|rounded|shadow|border-\[|text-\[|w-\d|h-\d)\b/g;
|
|
40233
|
+
const twMatches = classes.match(twPatterns) || [];
|
|
40234
|
+
const twScore = Math.min(twMatches.length / 10, 1);
|
|
40235
|
+
const bsPatterns = /\b(btn-(?:primary|secondary|danger|warning|success)|col-(?:sm|md|lg|xl)-\d|container-fluid|navbar-(?:expand|collapse)|form-control|card-body|modal-dialog)\b/g;
|
|
40236
|
+
const bsMatches = classes.match(bsPatterns) || [];
|
|
40237
|
+
const bsScore = Math.min(bsMatches.length / 5, 1);
|
|
40238
|
+
const muiPatterns = /\b(Mui[A-Z]|MuiButton|MuiTypography|MuiPaper|css-[a-z0-9]{5,}|makeStyles)\b/g;
|
|
40239
|
+
const muiMatches = (classes + " " + html.slice(0, 5e3)).match(muiPatterns) || [];
|
|
40240
|
+
const muiScore = Math.min(muiMatches.length / 5, 1);
|
|
40241
|
+
const hasTwConfig = !!document.querySelector("style[data-tw]") || html.includes("tailwindcss") || classes.includes("antialiased");
|
|
40242
|
+
if (twScore > 0.3 || hasTwConfig) {
|
|
40243
|
+
const styles = document.documentElement.style.cssText || "";
|
|
40244
|
+
const isV4 = styles.includes("oklch") || !!document.querySelector('[class*="text-[oklch"]');
|
|
40245
|
+
return { framework: "tailwind", version: isV4 ? "4" : "3", confidence: Math.max(twScore, hasTwConfig ? 0.8 : 0) };
|
|
40246
|
+
}
|
|
40247
|
+
if (bsScore > 0.3) return { framework: "bootstrap", confidence: bsScore };
|
|
40248
|
+
if (muiScore > 0.3) return { framework: "mui", confidence: muiScore };
|
|
40249
|
+
return { framework: "plain-css", confidence: 0.5 };
|
|
40250
|
+
});
|
|
40251
|
+
}
|
|
40252
|
+
|
|
40253
|
+
// src/suggestions/contrastSolver.ts
|
|
40254
|
+
function hexToRgb(hex) {
|
|
40255
|
+
hex = hex.replace("#", "");
|
|
40256
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
40257
|
+
const n = parseInt(hex, 16);
|
|
40258
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
40259
|
+
}
|
|
40260
|
+
function rgbToHex(r, g, b) {
|
|
40261
|
+
return "#" + [r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("");
|
|
40262
|
+
}
|
|
40263
|
+
function srgbToLinear(c) {
|
|
40264
|
+
const s = c / 255;
|
|
40265
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
40266
|
+
}
|
|
40267
|
+
function relativeLuminance(r, g, b) {
|
|
40268
|
+
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
|
40269
|
+
}
|
|
40270
|
+
function contrastRatio(l1, l2) {
|
|
40271
|
+
const lighter = Math.max(l1, l2);
|
|
40272
|
+
const darker = Math.min(l1, l2);
|
|
40273
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
40274
|
+
}
|
|
40275
|
+
function parseColor(color) {
|
|
40276
|
+
if (color.startsWith("#")) return hexToRgb(color);
|
|
40277
|
+
const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
40278
|
+
if (rgbMatch) return [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])];
|
|
40279
|
+
return null;
|
|
40280
|
+
}
|
|
40281
|
+
function getContrastRatio(fg, bg) {
|
|
40282
|
+
const fgRgb = parseColor(fg);
|
|
40283
|
+
const bgRgb = parseColor(bg);
|
|
40284
|
+
if (!fgRgb || !bgRgb) return 0;
|
|
40285
|
+
return contrastRatio(relativeLuminance(...fgRgb), relativeLuminance(...bgRgb));
|
|
40286
|
+
}
|
|
40287
|
+
function solveContrast(fgHex, bgHex, requiredRatio = 4.5) {
|
|
40288
|
+
const fg = parseColor(fgHex);
|
|
40289
|
+
const bg = parseColor(bgHex);
|
|
40290
|
+
if (!fg || !bg) return null;
|
|
40291
|
+
const bgLum = relativeLuminance(...bg);
|
|
40292
|
+
const currentRatio = contrastRatio(relativeLuminance(...fg), bgLum);
|
|
40293
|
+
if (currentRatio >= requiredRatio) return null;
|
|
40294
|
+
const fgLum = relativeLuminance(...fg);
|
|
40295
|
+
const needDarker = fgLum > bgLum;
|
|
40296
|
+
let lo = 0, hi = 1;
|
|
40297
|
+
let bestColor = fg;
|
|
40298
|
+
for (let i = 0; i < 20; i++) {
|
|
40299
|
+
const mid = (lo + hi) / 2;
|
|
40300
|
+
const shifted = needDarker ? [fg[0] * (1 - mid), fg[1] * (1 - mid), fg[2] * (1 - mid)] : [fg[0] + (255 - fg[0]) * mid, fg[1] + (255 - fg[1]) * mid, fg[2] + (255 - fg[2]) * mid];
|
|
40301
|
+
const shiftedLum = relativeLuminance(shifted[0], shifted[1], shifted[2]);
|
|
40302
|
+
const ratio = contrastRatio(shiftedLum, bgLum);
|
|
40303
|
+
if (ratio >= requiredRatio) {
|
|
40304
|
+
bestColor = shifted;
|
|
40305
|
+
hi = mid;
|
|
40306
|
+
} else {
|
|
40307
|
+
lo = mid;
|
|
40308
|
+
}
|
|
40309
|
+
}
|
|
40310
|
+
const suggestedHex = rgbToHex(bestColor[0], bestColor[1], bestColor[2]);
|
|
40311
|
+
const suggestedRatio = getContrastRatio(suggestedHex, bgHex);
|
|
40312
|
+
return {
|
|
40313
|
+
currentColor: fgHex,
|
|
40314
|
+
suggestedColor: suggestedHex,
|
|
40315
|
+
currentRatio: Math.round(currentRatio * 100) / 100,
|
|
40316
|
+
suggestedRatio: Math.round(suggestedRatio * 100) / 100,
|
|
40317
|
+
requiredRatio,
|
|
40318
|
+
bgColor: bgHex
|
|
40319
|
+
};
|
|
40320
|
+
}
|
|
40321
|
+
|
|
40322
|
+
// src/suggestions/tailwindMapper.ts
|
|
40323
|
+
var TW_COLORS = {
|
|
40324
|
+
slate: { "50": "#f8fafc", "100": "#f1f5f9", "200": "#e2e8f0", "300": "#cbd5e1", "400": "#94a3b8", "500": "#64748b", "600": "#475569", "700": "#334155", "800": "#1e293b", "900": "#0f172a", "950": "#020617" },
|
|
40325
|
+
gray: { "50": "#f9fafb", "100": "#f3f4f6", "200": "#e5e7eb", "300": "#d1d5db", "400": "#9ca3af", "500": "#6b7280", "600": "#4b5563", "700": "#374151", "800": "#1f2937", "900": "#111827", "950": "#030712" },
|
|
40326
|
+
zinc: { "50": "#fafafa", "100": "#f4f4f5", "200": "#e4e4e7", "300": "#d4d4d8", "400": "#a1a1aa", "500": "#71717a", "600": "#52525b", "700": "#3f3f46", "800": "#27272a", "900": "#18181b", "950": "#09090b" },
|
|
40327
|
+
neutral: { "50": "#fafafa", "100": "#f5f5f5", "200": "#e5e5e5", "300": "#d4d4d4", "400": "#a3a3a3", "500": "#737373", "600": "#525252", "700": "#404040", "800": "#262626", "900": "#171717", "950": "#0a0a0a" },
|
|
40328
|
+
red: { "50": "#fef2f2", "100": "#fee2e2", "200": "#fecaca", "300": "#fca5a5", "400": "#f87171", "500": "#ef4444", "600": "#dc2626", "700": "#b91c1c", "800": "#991b1b", "900": "#7f1d1d", "950": "#450a0a" },
|
|
40329
|
+
blue: { "50": "#eff6ff", "100": "#dbeafe", "200": "#bfdbfe", "300": "#93c5fd", "400": "#60a5fa", "500": "#3b82f6", "600": "#2563eb", "700": "#1d4ed8", "800": "#1e40af", "900": "#1e3a8a", "950": "#172554" },
|
|
40330
|
+
green: { "50": "#f0fdf4", "100": "#dcfce7", "200": "#bbf7d0", "300": "#86efac", "400": "#4ade80", "500": "#22c55e", "600": "#16a34a", "700": "#15803d", "800": "#166534", "900": "#14532d", "950": "#052e16" },
|
|
40331
|
+
indigo: { "50": "#eef2ff", "100": "#e0e7ff", "200": "#c7d2fe", "300": "#a5b4fc", "400": "#818cf8", "500": "#6366f1", "600": "#4f46e5", "700": "#4338ca", "800": "#3730a3", "900": "#312e81", "950": "#1e1b4e" },
|
|
40332
|
+
purple: { "50": "#faf5ff", "100": "#f3e8ff", "200": "#e9d5ff", "300": "#d8b4fe", "400": "#c084fc", "500": "#a855f7", "600": "#9333ea", "700": "#7e22ce", "800": "#6b21a8", "900": "#581c87", "950": "#3b0764" },
|
|
40333
|
+
amber: { "50": "#fffbeb", "100": "#fef3c7", "200": "#fde68a", "300": "#fcd34d", "400": "#fbbf24", "500": "#f59e0b", "600": "#d97706", "700": "#b45309", "800": "#92400e", "900": "#78350f", "950": "#451a03" }
|
|
40334
|
+
};
|
|
40335
|
+
function hexDistance(a, b) {
|
|
40336
|
+
const ar = parseColor(a);
|
|
40337
|
+
const br = parseColor(b);
|
|
40338
|
+
if (!ar || !br) return Infinity;
|
|
40339
|
+
return Math.sqrt((ar[0] - br[0]) ** 2 + (ar[1] - br[1]) ** 2 + (ar[2] - br[2]) ** 2);
|
|
40340
|
+
}
|
|
40341
|
+
function hexToTailwindClass(hex, prefix = "text") {
|
|
40342
|
+
let bestMatch = null;
|
|
40343
|
+
let bestDist = Infinity;
|
|
40344
|
+
for (const [colorName, shades] of Object.entries(TW_COLORS)) {
|
|
40345
|
+
for (const [shade, shadeHex] of Object.entries(shades)) {
|
|
40346
|
+
const dist = hexDistance(hex.toLowerCase(), shadeHex);
|
|
40347
|
+
if (dist < bestDist) {
|
|
40348
|
+
bestDist = dist;
|
|
40349
|
+
bestMatch = `${prefix}-${colorName}-${shade}`;
|
|
40350
|
+
}
|
|
40351
|
+
}
|
|
40352
|
+
}
|
|
40353
|
+
return bestDist < 30 ? bestMatch : null;
|
|
40354
|
+
}
|
|
40355
|
+
function suggestTailwindFix(fgHex, bgHex, requiredRatio = 4.5, prefix = "text") {
|
|
40356
|
+
const currentClass = hexToTailwindClass(fgHex, prefix);
|
|
40357
|
+
const currentRatio = getContrastRatio(fgHex, bgHex);
|
|
40358
|
+
if (currentRatio >= requiredRatio) return null;
|
|
40359
|
+
let bestFamily = "gray";
|
|
40360
|
+
let bestShade = "500";
|
|
40361
|
+
let bestDist = Infinity;
|
|
40362
|
+
for (const [colorName, shades2] of Object.entries(TW_COLORS)) {
|
|
40363
|
+
for (const [shade, shadeHex] of Object.entries(shades2)) {
|
|
40364
|
+
const dist = hexDistance(fgHex.toLowerCase(), shadeHex);
|
|
40365
|
+
if (dist < bestDist) {
|
|
40366
|
+
bestDist = dist;
|
|
40367
|
+
bestFamily = colorName;
|
|
40368
|
+
bestShade = shade;
|
|
40369
|
+
}
|
|
40370
|
+
}
|
|
40371
|
+
}
|
|
40372
|
+
const shades = Object.entries(TW_COLORS[bestFamily] || TW_COLORS.gray);
|
|
40373
|
+
const bgRgb = parseColor(bgHex);
|
|
40374
|
+
if (!bgRgb) return null;
|
|
40375
|
+
const bgIsLight = (bgRgb[0] + bgRgb[1] + bgRgb[2]) / 3 > 128;
|
|
40376
|
+
const orderedShades = bgIsLight ? shades.sort((a, b) => parseInt(b[0]) - parseInt(a[0])) : shades.sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
|
40377
|
+
for (const [shade, shadeHex] of orderedShades) {
|
|
40378
|
+
const ratio = getContrastRatio(shadeHex, bgHex);
|
|
40379
|
+
if (ratio >= requiredRatio) {
|
|
40380
|
+
return {
|
|
40381
|
+
currentClass,
|
|
40382
|
+
suggestedClass: `${prefix}-${bestFamily}-${shade}`,
|
|
40383
|
+
currentHex: fgHex,
|
|
40384
|
+
suggestedHex: shadeHex,
|
|
40385
|
+
currentRatio: Math.round(currentRatio * 100) / 100,
|
|
40386
|
+
suggestedRatio: Math.round(ratio * 100) / 100,
|
|
40387
|
+
requiredRatio
|
|
40388
|
+
};
|
|
40389
|
+
}
|
|
40390
|
+
}
|
|
40391
|
+
const solved = solveContrast(fgHex, bgHex, requiredRatio);
|
|
40392
|
+
if (solved) {
|
|
40393
|
+
return {
|
|
40394
|
+
currentClass,
|
|
40395
|
+
suggestedClass: hexToTailwindClass(solved.suggestedColor, prefix) || `${prefix}-[${solved.suggestedColor}]`,
|
|
40396
|
+
currentHex: fgHex,
|
|
40397
|
+
suggestedHex: solved.suggestedColor,
|
|
40398
|
+
currentRatio: solved.currentRatio,
|
|
40399
|
+
suggestedRatio: solved.suggestedRatio,
|
|
40400
|
+
requiredRatio
|
|
40401
|
+
};
|
|
40402
|
+
}
|
|
40403
|
+
return null;
|
|
40404
|
+
}
|
|
40405
|
+
|
|
40406
|
+
// src/suggestions/devSuggestions.ts
|
|
40407
|
+
function generateDevSuggestion(issue, framework, elementClasses, fgColor, bgColor) {
|
|
40408
|
+
const wcagLink = `https://www.w3.org/WAI/WCAG21/Understanding/${issue.wcag.replace(/\./g, "")}`;
|
|
40409
|
+
if (issue.type === "contrast_insufficient" || issue.type === "color-contrast") {
|
|
40410
|
+
return generateContrastSuggestion(issue, framework, elementClasses, fgColor, bgColor, wcagLink);
|
|
40411
|
+
}
|
|
40412
|
+
if (issue.type === "non_text_contrast_insufficient") {
|
|
40413
|
+
return generateNonTextContrastSuggestion(issue, framework, elementClasses, wcagLink);
|
|
40414
|
+
}
|
|
40415
|
+
if (issue.type === "focus_not_visible") {
|
|
40416
|
+
return generateFocusSuggestion(issue, framework, wcagLink);
|
|
40417
|
+
}
|
|
40418
|
+
if (issue.type === "missing_alt") {
|
|
40419
|
+
return {
|
|
40420
|
+
issue,
|
|
40421
|
+
problem: "Image has no alt text \u2014 screen readers skip it entirely.",
|
|
40422
|
+
fix: framework === "tailwind" ? 'Add alt="description" to the <img>. For decorative images, use alt="" (empty).' : 'Add alt="description" to the <img>. For decorative images, use alt="" and role="presentation".',
|
|
40423
|
+
wcagLink
|
|
40424
|
+
};
|
|
40425
|
+
}
|
|
40426
|
+
if (issue.type === "target_too_small") {
|
|
40427
|
+
const size = issue.fix?.currentValue || "unknown";
|
|
40428
|
+
return {
|
|
40429
|
+
issue,
|
|
40430
|
+
problem: `Touch target is ${size} \u2014 minimum 24x24px required (WCAG 2.5.8).`,
|
|
40431
|
+
fix: framework === "tailwind" ? "Add min-h-[44px] min-w-[44px] p-2 to the element for comfortable tap targets." : "Add min-height: 44px; min-width: 44px; padding: 8px; to the element.",
|
|
40432
|
+
before: framework === "tailwind" && elementClasses ? elementClasses : void 0,
|
|
40433
|
+
after: framework === "tailwind" && elementClasses ? elementClasses + " min-h-[44px] min-w-[44px]" : void 0,
|
|
40434
|
+
wcagLink
|
|
40435
|
+
};
|
|
40436
|
+
}
|
|
40437
|
+
if (issue.type === "decorative_icon") {
|
|
40438
|
+
return {
|
|
40439
|
+
issue,
|
|
40440
|
+
problem: "SVG/icon is announced by screen readers but has no meaningful content.",
|
|
40441
|
+
fix: `Add aria-hidden="true" to the SVG element \u2014 it's decorative.`,
|
|
40442
|
+
before: "<svg ...",
|
|
40443
|
+
after: '<svg aria-hidden="true" ...',
|
|
40444
|
+
wcagLink
|
|
40445
|
+
};
|
|
40446
|
+
}
|
|
40447
|
+
if (issue.type === "heading_skip") {
|
|
40448
|
+
return {
|
|
40449
|
+
issue,
|
|
40450
|
+
problem: `Heading hierarchy skips a level \u2014 confuses screen reader navigation.`,
|
|
40451
|
+
fix: "Use sequential heading levels (h1 \u2192 h2 \u2192 h3). Don't skip from h2 to h4.",
|
|
40452
|
+
wcagLink
|
|
40453
|
+
};
|
|
40454
|
+
}
|
|
40455
|
+
if (issue.type === "missing_label") {
|
|
40456
|
+
return {
|
|
40457
|
+
issue,
|
|
40458
|
+
problem: "Form input has no label \u2014 screen readers can't identify what to enter.",
|
|
40459
|
+
fix: framework === "tailwind" ? 'Add a <label htmlFor="id"> or aria-label="description" to the input.' : 'Add a <label for="id"> element or aria-label attribute.',
|
|
40460
|
+
wcagLink
|
|
40461
|
+
};
|
|
40462
|
+
}
|
|
40463
|
+
if (issue.type === "skip_link") {
|
|
40464
|
+
return {
|
|
40465
|
+
issue,
|
|
40466
|
+
problem: "Page has no skip navigation link \u2014 keyboard users must tab through the entire header on every page.",
|
|
40467
|
+
fix: 'Add a visually-hidden skip link as the first focusable element: <a href="#main-content" class="sr-only focus:not-sr-only">Skip to content</a>',
|
|
40468
|
+
wcagLink
|
|
40469
|
+
};
|
|
40470
|
+
}
|
|
40471
|
+
if (issue.type === "motion_without_reduced_motion") {
|
|
40472
|
+
return {
|
|
40473
|
+
issue,
|
|
40474
|
+
problem: 'Page has animations but no @media (prefers-reduced-motion) query. Users who set "Reduce motion" in OS settings still see all animations.',
|
|
40475
|
+
fix: framework === "tailwind" ? "Use Tailwind's motion-safe: and motion-reduce: variants, or add @media (prefers-reduced-motion: reduce) to globals.css." : "Add @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }",
|
|
40476
|
+
wcagLink
|
|
40477
|
+
};
|
|
40478
|
+
}
|
|
40479
|
+
if (issue.type === "weak_link_name" || issue.type === "weak_button_name") {
|
|
40480
|
+
const name = issue.fix?.currentValue || "";
|
|
40481
|
+
return {
|
|
40482
|
+
issue,
|
|
40483
|
+
problem: `"${name}" is too generic \u2014 screen reader users can't tell what this links to.`,
|
|
40484
|
+
fix: `Add descriptive text or aria-label. E.g., "${name} about accessibility testing" instead of just "${name}".`,
|
|
40485
|
+
wcagLink
|
|
40486
|
+
};
|
|
40487
|
+
}
|
|
40488
|
+
if (issue.type === "svg_missing_name") {
|
|
40489
|
+
return {
|
|
40490
|
+
issue,
|
|
40491
|
+
problem: 'SVG has no accessible name \u2014 screen readers announce it as "image" with no description.',
|
|
40492
|
+
fix: 'If decorative: add aria-hidden="true". If meaningful: add aria-label="description" or <title> inside the SVG.',
|
|
40493
|
+
before: "<svg ...",
|
|
40494
|
+
after: '<svg aria-hidden="true" ... (decorative)\n <svg aria-label="Search icon" ... (meaningful)',
|
|
40495
|
+
wcagLink
|
|
40496
|
+
};
|
|
40497
|
+
}
|
|
40498
|
+
if (issue.type === "new_window_link") {
|
|
40499
|
+
return {
|
|
40500
|
+
issue,
|
|
40501
|
+
problem: "Link opens in a new tab without warning \u2014 disorienting for screen reader users.",
|
|
40502
|
+
fix: 'Add "(opens in new tab)" to the link text or aria-label.',
|
|
40503
|
+
wcagLink
|
|
40504
|
+
};
|
|
40505
|
+
}
|
|
40506
|
+
if (issue.type === "missing_required_indicator") {
|
|
40507
|
+
return {
|
|
40508
|
+
issue,
|
|
40509
|
+
problem: "Required form field has no visual indicator \u2014 users don't know it's mandatory.",
|
|
40510
|
+
fix: 'Add aria-required="true" and a visual asterisk (*) to the label.',
|
|
40511
|
+
wcagLink
|
|
40512
|
+
};
|
|
40513
|
+
}
|
|
40514
|
+
if (issue.type === "redundant_role") {
|
|
40515
|
+
return {
|
|
40516
|
+
issue,
|
|
40517
|
+
problem: "Element has a redundant ARIA role matching its implicit HTML role.",
|
|
40518
|
+
fix: 'Remove the role attribute \u2014 <nav> already implies role="navigation".',
|
|
40519
|
+
wcagLink
|
|
40520
|
+
};
|
|
40521
|
+
}
|
|
40522
|
+
if (issue.type === "duplicate_id") {
|
|
40523
|
+
return {
|
|
40524
|
+
issue,
|
|
40525
|
+
problem: "Multiple elements share the same ID \u2014 breaks label associations and ARIA references.",
|
|
40526
|
+
fix: "Make each id unique across the page.",
|
|
40527
|
+
wcagLink
|
|
40528
|
+
};
|
|
40529
|
+
}
|
|
40530
|
+
if (issue.type === "insufficient_navigation") {
|
|
40531
|
+
return {
|
|
40532
|
+
issue,
|
|
40533
|
+
problem: "Page has only one way to navigate \u2014 WCAG requires multiple (e.g., nav + search, or nav + sitemap).",
|
|
40534
|
+
fix: "Add a search input, sitemap link, or breadcrumbs alongside the main navigation.",
|
|
40535
|
+
wcagLink
|
|
40536
|
+
};
|
|
40537
|
+
}
|
|
40538
|
+
if (issue.type === "image_of_text") {
|
|
40539
|
+
return {
|
|
40540
|
+
issue,
|
|
40541
|
+
problem: "Image appears to contain text \u2014 text in images can't be resized, translated, or read by screen readers.",
|
|
40542
|
+
fix: "Replace the image with real text styled with CSS. Exception: logos are exempt.",
|
|
40543
|
+
wcagLink
|
|
40544
|
+
};
|
|
40545
|
+
}
|
|
40546
|
+
if (issue.type === "focus_obscured_by_sticky") {
|
|
40547
|
+
return {
|
|
40548
|
+
issue,
|
|
40549
|
+
problem: "Sticky header may obscure focused elements when tabbing \u2014 users can't see where focus is.",
|
|
40550
|
+
fix: framework === "tailwind" ? "Add scroll-pt-20 to the html element, or scroll-padding-top: 80px to account for the sticky header height." : "Add scroll-padding-top: 80px to html { } in your CSS.",
|
|
40551
|
+
wcagLink
|
|
40552
|
+
};
|
|
40553
|
+
}
|
|
40554
|
+
if (issue.type === "missing_media_controls") {
|
|
40555
|
+
return {
|
|
40556
|
+
issue,
|
|
40557
|
+
problem: "Video/audio element has no visible controls \u2014 users can't pause, stop, or adjust volume.",
|
|
40558
|
+
fix: "Add the controls attribute to <video> or <audio>, or implement custom accessible controls with proper ARIA.",
|
|
40559
|
+
wcagLink
|
|
40560
|
+
};
|
|
40561
|
+
}
|
|
40562
|
+
if (issue.type === "multiple_h1") {
|
|
40563
|
+
return {
|
|
40564
|
+
issue,
|
|
40565
|
+
problem: "Page has multiple <h1> elements \u2014 should have exactly one.",
|
|
40566
|
+
fix: "Keep one <h1> for the main page title. Change others to <h2>.",
|
|
40567
|
+
wcagLink
|
|
40568
|
+
};
|
|
40569
|
+
}
|
|
40570
|
+
return {
|
|
40571
|
+
issue,
|
|
40572
|
+
problem: issue.message.replace(/: set .*/, "").replace(/: missing .*/, " is missing"),
|
|
40573
|
+
fix: issue.fix?.suggestedValue && !issue.fix.suggestedValue.includes("!important") ? `Set ${issue.fix.attribute}="${issue.fix.suggestedValue.slice(0, 60)}"` : `Review this element for WCAG ${issue.wcag} compliance.`,
|
|
40574
|
+
wcagLink
|
|
40575
|
+
};
|
|
40576
|
+
}
|
|
40577
|
+
function generateContrastSuggestion(issue, framework, elementClasses, fgColor, bgColor, wcagLink = "") {
|
|
40578
|
+
const fg = fgColor || issue.fix?.currentValue || "";
|
|
40579
|
+
const bg = bgColor || "#ffffff";
|
|
40580
|
+
const currentRatio = fg && bg ? getContrastRatio(fg, bg) : 0;
|
|
40581
|
+
const requiredRatio = 4.5;
|
|
40582
|
+
if (framework === "tailwind" && fg && bg) {
|
|
40583
|
+
const twSuggestion = suggestTailwindFix(fg, bg, requiredRatio);
|
|
40584
|
+
if (twSuggestion) {
|
|
40585
|
+
return {
|
|
40586
|
+
issue,
|
|
40587
|
+
problem: `Contrast ratio ${twSuggestion.currentRatio}:1 \u2014 needs ${requiredRatio}:1 for WCAG AA.`,
|
|
40588
|
+
fix: twSuggestion.currentClass ? `Change ${twSuggestion.currentClass} \u2192 ${twSuggestion.suggestedClass}` : `Use ${twSuggestion.suggestedClass} (${twSuggestion.suggestedHex})`,
|
|
40589
|
+
before: twSuggestion.currentClass || void 0,
|
|
40590
|
+
after: twSuggestion.suggestedClass,
|
|
40591
|
+
contrast: {
|
|
40592
|
+
current: twSuggestion.currentRatio,
|
|
40593
|
+
required: requiredRatio,
|
|
40594
|
+
suggested: twSuggestion.suggestedRatio,
|
|
40595
|
+
fgColor: fg,
|
|
40596
|
+
bgColor: bg,
|
|
40597
|
+
suggestedColor: twSuggestion.suggestedHex
|
|
40598
|
+
},
|
|
40599
|
+
wcagLink
|
|
40600
|
+
};
|
|
40601
|
+
}
|
|
40602
|
+
}
|
|
40603
|
+
const solved = fg && bg ? solveContrast(fg, bg, requiredRatio) : null;
|
|
40604
|
+
if (solved) {
|
|
40605
|
+
return {
|
|
40606
|
+
issue,
|
|
40607
|
+
problem: `Contrast ratio ${solved.currentRatio}:1 \u2014 needs ${requiredRatio}:1 for WCAG AA.`,
|
|
40608
|
+
fix: `Change color from ${solved.currentColor} to ${solved.suggestedColor}`,
|
|
40609
|
+
contrast: {
|
|
40610
|
+
current: solved.currentRatio,
|
|
40611
|
+
required: requiredRatio,
|
|
40612
|
+
suggested: solved.suggestedRatio,
|
|
40613
|
+
fgColor: fg,
|
|
40614
|
+
bgColor: bg,
|
|
40615
|
+
suggestedColor: solved.suggestedColor
|
|
40616
|
+
},
|
|
40617
|
+
wcagLink
|
|
40618
|
+
};
|
|
40619
|
+
}
|
|
40620
|
+
return {
|
|
40621
|
+
issue,
|
|
40622
|
+
problem: "Text contrast is below WCAG AA minimum.",
|
|
40623
|
+
fix: "Darken the text color or lighten the background to achieve at least 4.5:1 contrast ratio.",
|
|
40624
|
+
wcagLink
|
|
40625
|
+
};
|
|
40626
|
+
}
|
|
40627
|
+
function generateNonTextContrastSuggestion(issue, framework, elementClasses, wcagLink = "") {
|
|
40628
|
+
return {
|
|
40629
|
+
issue,
|
|
40630
|
+
problem: "Non-text element (border, icon, UI component) has contrast below 3:1 against its background.",
|
|
40631
|
+
fix: framework === "tailwind" ? "Use a darker border color \u2014 e.g., border-gray-400 instead of border-gray-200. Non-text elements need 3:1 contrast." : "Darken the border/outline color to achieve at least 3:1 contrast against the background.",
|
|
40632
|
+
wcagLink
|
|
40633
|
+
};
|
|
40634
|
+
}
|
|
40635
|
+
function generateFocusSuggestion(issue, framework, wcagLink = "") {
|
|
40636
|
+
return {
|
|
40637
|
+
issue,
|
|
40638
|
+
problem: "Element has no visible focus indicator \u2014 keyboard users can't see where they are.",
|
|
40639
|
+
fix: framework === "tailwind" ? "Add focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 to the element." : "Add :focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; } to the element's CSS.",
|
|
40640
|
+
before: framework === "tailwind" ? 'className="..."' : void 0,
|
|
40641
|
+
after: framework === "tailwind" ? 'className="... focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"' : void 0,
|
|
40642
|
+
wcagLink
|
|
40643
|
+
};
|
|
40644
|
+
}
|
|
40089
40645
|
// Annotate the CommonJS export names for ESM import in node:
|
|
40090
40646
|
0 && (module.exports = {
|
|
40091
40647
|
VIEWPORT_SIZES,
|
|
40092
40648
|
WCAG_BY_ISSUE,
|
|
40649
|
+
detectFramework,
|
|
40093
40650
|
dismissModals,
|
|
40094
40651
|
extractSiteTheme,
|
|
40652
|
+
generateDevSuggestion,
|
|
40653
|
+
getContrastRatio,
|
|
40095
40654
|
runAxe,
|
|
40096
40655
|
runDetectors,
|
|
40097
40656
|
scan,
|
|
40657
|
+
solveContrast,
|
|
40658
|
+
suggestTailwindFix,
|
|
40098
40659
|
toHtmlReport
|
|
40099
40660
|
});
|
|
40100
40661
|
/*! Bundled license information:
|