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