@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/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, contrastRatio = options.contrastRatio, shadowOutlineEmMax = options.shadowOutlineEmMax, pseudoSizeThreshold = options.pseudoSizeThreshold;
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 ? contrastRatio.normal : contrastRatio.large, expected = _ref134.expected, minThreshold = _ref134.minThreshold, maxThreshold = _ref134.maxThreshold;
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 issues = [];
40010
+ const allIssues = [];
39887
40011
  const detected = await runDetectors(page);
39888
- issues.push(...detected.map(detectedToScanIssue));
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
- issues.push({
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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: