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