/* ============================================================
   WPSiteBeam — CD Scan Custom Checks (Phase 1: Critical Rules)
   ============================================================
   Path: design/scanner/ada-custom-checks.jsx
   Loads: AFTER tab-ada.jsx but BEFORE core.jsx (or imported by tab-ada).

   Purpose: Detection engine for rules NOT covered by axe-core.
   Reads rule definitions from cd-scan-rules.json (versioned catalog).
   Returns violations in axe-compatible format for unified rendering.

   Pattern: window.WPSB.AdaCustomChecks.runChecks(doc, opts)
   - doc: parsed DOMDocument (from DOMParser or jsdom)
   - opts: { phase, severityFilter, siteDomain, language, fqhcOnly }
   - returns: Array of violation objects (axe-compatible shape)

   This module implements Phase 1 (8 critical rules) only. Phase 2 + 3
   will extend this module with additional check functions.

   Status: SCAFFOLD — not yet wired into tab-ada.jsx (Patch 2 step).
            Build verified against spec doc test cases; integration
            requires Jordan input on runtime location (browser vs server).
   ============================================================ */

(function() {
  'use strict';

  if (typeof window === 'undefined') return;
  window.WPSB = window.WPSB || {};

  /* ────────────────────────────────────────────────────────
     CONSTANTS — pulled from SSOT when available, falls back
     to safe defaults so module works standalone.
     ──────────────────────────────────────────────────────── */
  function getWcagDisplay() {
    var C = window.WPSB && window.WPSB.CONSTANTS;
    return (C && C.WCAG && C.WCAG.DISPLAY) || 'WCAG 2.1 AA';
  }

  /* Common Spanish words written without accents — used by lng-002
     to detect missing-accent issues in Spanish content blocks. */
  var SPANISH_UNACCENTED_PATTERN = /\b(Actualizacion|Informacion|Espanol|Atencion|Direccion|Comunicacion|Politica|Medico|Practica|Educacion|Vacunacion|Telefono|Numero|Direccion|Categoria)\b/g;

  /* Spanish word indicators — used by lng-001 to detect Spanish
     content blocks that lack a lang attribute. Heuristic: if a
     block contains 3+ of these markers and is not inside [lang^="es"],
     flag it. */
  var SPANISH_INDICATOR_WORDS = [
    'el', 'la', 'los', 'las', 'que', 'de', 'en', 'para', 'con', 'por',
    'es', 'son', 'esta', 'están', 'cómo', 'cuándo', 'dónde',
    'paciente', 'pacientes', 'salud', 'formulario', 'formularios',
    'español', 'información', 'nuestros', 'nuestra',
    'haga', 'clic', 'aquí', 'aqui',
  ];

  /* Generic link text blocklist (EN + ES) — used by lnk-007 */
  var GENERIC_LINK_TEXT = [
    'click here', 'here', 'read more', 'learn more', 'more',
    'check here', 'more info', 'view more', 'this link', 'this',
    'haga clic aquí', 'haga clic aqui', 'aquí', 'aqui',
    'más', 'mas información', 'mas informacion', 'haga clic',
  ];

  /* PDF / file-icon class names commonly used by themes — used by
     lnk-003 to detect file-type / destination mismatch. */
  var FILE_ICON_CLASS_PATTERNS = {
    pdf: /\b(pdf-icon|icon-pdf|file-pdf|fa-file-pdf|fas?-pdf|pdficon|pdf_icon)\b/i,
    doc: /\b(doc-icon|icon-doc|file-doc|fa-file-word|word-icon)\b/i,
    xls: /\b(xls-icon|icon-xls|file-xls|fa-file-excel|excel-icon)\b/i,
    video: /\b(video-icon|icon-video|fa-video|fa-play-circle)\b/i,
    audio: /\b(audio-icon|icon-audio|fa-volume|fa-music)\b/i,
  };

  /* ────────────────────────────────────────────────────────
     UTILITY: Build axe-compatible violation object
     ──────────────────────────────────────────────────────── */
  function makeViolation(ruleId, opts) {
    return {
      id: ruleId,
      impact: opts.impact || 'serious',
      tags: opts.tags || ['wpsb-custom'],
      description: opts.description || '',
      help: opts.help || '',
      helpUrl: opts.helpUrl || ('https://wpsitebeam.io/docs/ada/' + ruleId),
      nodes: (opts.nodes || []).map(function(n) {
        return {
          html: (n.html || '').substring(0, 200),
          target: n.target || [],
          failureSummary: n.failureSummary || '',
        };
      }),
      nodeCount: (opts.nodes || []).length,
      /* WPSB extensions — visible in our reports, ignored by axe consumers */
      wpsb_category: opts.category || 'custom',
      wpsb_severity: opts.severity || 'warning',
      wpsb_auto_fix: opts.autoFix || null,
      wpsb_fqhc_priority: !!opts.fqhcPriority,
      wpsb_estimated_minutes: opts.estimatedMinutes || 5,
    };
  }

  /* Serialize an element to a CSS selector string for the target field */
  function targetSelector(el) {
    if (!el) return '';
    if (el.id) return '#' + el.id;
    var parts = [el.tagName.toLowerCase()];
    if (el.className && typeof el.className === 'string') {
      var cls = el.className.trim().split(/\s+/).slice(0, 3).join('.');
      if (cls) parts[0] += '.' + cls;
    }
    return parts.join(' ');
  }

  function outerHtmlSnippet(el, max) {
    if (!el || !el.outerHTML) return '';
    return el.outerHTML.substring(0, max || 200);
  }

  /* ────────────────────────────────────────────────────────
     PHASE 1 RULES — 8 critical checks
     ──────────────────────────────────────────────────────── */

  /* lnk-003: File-type icon does not match destination
     Detects links with PDF/DOC/XLS/video/audio icon classes whose
     href doesn't end in the corresponding extension. */
  function checkLnk003(doc) {
    var violations = [];
    var links = doc.querySelectorAll('a[href]');
    var nodes = [];

    links.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      var className = (a.className || '') + ' ' + Array.from(a.querySelectorAll('*')).map(function(c) { return c.className || ''; }).join(' ');

      Object.keys(FILE_ICON_CLASS_PATTERNS).forEach(function(fileType) {
        if (FILE_ICON_CLASS_PATTERNS[fileType].test(className)) {
          /* This link has a file-type icon — check if href matches */
          var hrefLower = href.toLowerCase();
          var expectedExt = '.' + fileType;
          if (!hrefLower.endsWith(expectedExt) && !hrefLower.match(new RegExp('\\.' + fileType + '($|[?#])'))) {
            nodes.push({
              html: outerHtmlSnippet(a),
              target: [targetSelector(a)],
              failureSummary: 'Link has ' + fileType.toUpperCase() + ' icon but href does not end in .' + fileType + '. Either change the icon or link to the actual file.',
            });
          }
        }
      });
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-003', {
        impact: 'critical',
        severity: 'critical',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'wcag324', 'wcag131', 'wcag2a', 'wcag2aa'],
        description: 'File-type icon does not match destination',
        help: 'Links with file-type icons (PDF, DOC, etc.) must link to files of that type. WCAG 3.2.4 Consistent Identification.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 5,
      }));
    }
    return violations;
  }

  /* lnk-004: URL appears to be a file path but missing extension
     Detects /wp-content/uploads/ URLs without a file extension. */
  function checkLnk004(doc) {
    var violations = [];
    var links = doc.querySelectorAll('a[href]');
    var nodes = [];

    /* Match /wp-content/uploads/... where last path segment has no dot */
    var pattern = /\/wp-content\/uploads\/[^?#]*\/[^/?#.]+$/i;

    links.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      /* Strip query and fragment for the check */
      var url = href.split('?')[0].split('#')[0];
      if (pattern.test(url)) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'URL appears to be a WordPress upload but is missing a file extension: ' + href.substring(0, 100),
        });
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-004', {
        impact: 'critical',
        severity: 'critical',
        category: 'link_integrity',
        tags: ['wpsb-custom'],
        description: 'URL appears to be a file path but missing extension',
        help: 'Verify the file exists in the WordPress media library and append the correct extension (typically .pdf, .docx, .jpg).',
        nodes: nodes,
        estimatedMinutes: 5,
      }));
    }
    return violations;
  }

  /* lnk-006: Empty link target href="#"
     Detects <a href="#"> with no JavaScript handler or aria-haspopup.
     These should be <button> elements instead. */
  function checkLnk006(doc) {
    var violations = [];
    var links = doc.querySelectorAll('a[href="#"], a[href=""]');
    var nodes = [];

    links.forEach(function(a) {
      var hasHandler = a.hasAttribute('onclick') ||
                       a.hasAttribute('data-toggle') ||
                       a.hasAttribute('data-target') ||
                       a.hasAttribute('aria-haspopup') ||
                       a.hasAttribute('aria-controls');
      /* Also check for role="button" — sometimes accessibility framework uses this */
      if (a.getAttribute('role') === 'button') hasHandler = true;

      if (!hasHandler) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'Link has href="#" with no handler. Either provide a real destination, convert to <button>, or add aria-haspopup with proper menu handlers.',
        });
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-006', {
        impact: 'critical',
        severity: 'critical',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'wcag244', 'wcag412', 'wcag2a'],
        description: 'Empty link target (href="#")',
        help: 'Links with href="#" and no JavaScript handler are not functional. Use <button> for actions or provide a real href.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 10,
      }));
    }
    return violations;
  }

  /* lng-001: Non-English content missing lang attribute
     Heuristic: count Spanish indicator words per block; if 3+ markers
     in a block (and ancestor doesn't already have lang^="es"), flag it. */
  function checkLng001(doc) {
    var violations = [];
    var nodes = [];

    /* Build a regex that matches whole Spanish indicator words case-insensitively */
    var spanishRegex = new RegExp('\\b(' + SPANISH_INDICATOR_WORDS.map(function(w) {
      return w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }).join('|') + ')\\b', 'gi');

    /* Check content blocks that are commonly used to hold copy */
    var blocks = doc.querySelectorAll('p, div, section, article, aside, h1, h2, h3, h4, h5, h6, li');

    blocks.forEach(function(block) {
      /* Skip if any ancestor already declares Spanish */
      var hasSpanishAncestor = false;
      var ancestor = block;
      while (ancestor && ancestor.tagName) {
        var lang = ancestor.getAttribute && ancestor.getAttribute('lang');
        if (lang && /^es(-|$)/i.test(lang)) { hasSpanishAncestor = true; break; }
        ancestor = ancestor.parentElement;
      }
      if (hasSpanishAncestor) return;

      /* Skip nested blocks — only check leaf-ish blocks */
      var hasBlockChild = false;
      Array.from(block.children).forEach(function(c) {
        if (/^(P|DIV|SECTION|ARTICLE|ASIDE|LI)$/.test(c.tagName)) hasBlockChild = true;
      });
      if (hasBlockChild) return;

      var text = (block.textContent || '').trim();
      if (text.length < 20) return;  /* skip tiny blocks */

      var matches = text.match(spanishRegex);
      if (matches && matches.length >= 3) {
        nodes.push({
          html: outerHtmlSnippet(block),
          target: [targetSelector(block)],
          failureSummary: 'Block contains likely-Spanish content (' + matches.length + ' Spanish indicator words detected) but lacks lang="es" attribute. WCAG 3.1.2.',
        });
      }
    });

    /* Cap at 20 to keep reports readable */
    nodes = nodes.slice(0, 20);

    if (nodes.length > 0) {
      violations.push(makeViolation('lng-001', {
        impact: 'critical',
        severity: 'critical',
        category: 'multilingual_content',
        tags: ['wpsb-custom', 'wcag312', 'wcag2aa'],
        description: 'Non-English content missing lang attribute',
        help: 'Wrap non-English content blocks in an element with the correct lang attribute (e.g., lang="es" for Spanish).',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 3,
      }));
    }
    return violations;
  }

  /* sem-007: Orphan closing tag
     Note: Modern DOMParser and jsdom auto-correct orphan tags before
     the document is queryable, so we can't detect them post-parse.
     This check requires examining the RAW HTML string before parsing.
     For now, this function is a no-op stub; the real implementation
     belongs in a pre-parse step or via html-validate (Patch 4). */
  function checkSem007(doc, rawHtml) {
    var violations = [];
    if (!rawHtml) return violations;  /* Need raw HTML string */

    var nodes = [];
    /* Quick heuristic: look for closing tags that appear before any
       opening tag of the same name. This catches the most obvious cases. */
    var tagsToCheck = ['div', 'span', 'p', 'a', 'li', 'ul', 'ol', 'section', 'article'];

    tagsToCheck.forEach(function(tag) {
      var openCount = (rawHtml.match(new RegExp('<' + tag + '(\\s|>)', 'gi')) || []).length;
      var closeCount = (rawHtml.match(new RegExp('</' + tag + '>', 'gi')) || []).length;
      if (closeCount > openCount) {
        var diff = closeCount - openCount;
        nodes.push({
          html: '<' + tag + '> appears ' + openCount + ' times, </' + tag + '> appears ' + closeCount + ' times',
          target: ['html'],
          failureSummary: 'Found ' + diff + ' orphan </' + tag + '> closing tag(s). Run an HTML validator to locate exact positions.',
        });
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('sem-007', {
        impact: 'critical',
        severity: 'critical',
        category: 'semantic_html',
        tags: ['wpsb-custom'],
        description: 'Orphan closing tag detected',
        help: 'Run an HTML validator (W3C Nu Validator or html-validate) to locate and fix structural HTML errors.',
        nodes: nodes,
        estimatedMinutes: 2,
      }));
    }
    return violations;
  }

  /* sem-001: Duplicate IDs on page
     Note: axe-core already covers this via duplicate-id rule, but we
     duplicate the check here so our custom-checks module is complete
     standalone (used when axe-core isn't available). */
  function checkSem001(doc) {
    var violations = [];
    var elementsWithId = doc.querySelectorAll('[id]');
    var idMap = {};
    elementsWithId.forEach(function(el) {
      var id = el.getAttribute('id');
      if (!id) return;
      if (!idMap[id]) idMap[id] = [];
      idMap[id].push(el);
    });

    var nodes = [];
    Object.keys(idMap).forEach(function(id) {
      if (idMap[id].length > 1) {
        idMap[id].forEach(function(el) {
          nodes.push({
            html: outerHtmlSnippet(el),
            target: [targetSelector(el)],
            failureSummary: 'Duplicate id="' + id + '" — appears ' + idMap[id].length + ' times. WCAG 4.1.1 Parsing.',
          });
        });
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('sem-001', {
        impact: 'critical',
        severity: 'critical',
        category: 'semantic_html',
        tags: ['wpsb-custom', 'wcag411', 'wcag2a'],
        description: 'Duplicate IDs on page',
        help: 'Each id attribute must be unique within a page. Duplicate IDs break ARIA relationships and form associations.',
        nodes: nodes,
        estimatedMinutes: 5,
      }));
    }
    return violations;
  }

  /* ada-005: Image missing alt attribute
     Note: axe-core covers this. Duplicated here for standalone operation. */
  function checkAda005(doc) {
    var violations = [];
    var imgs = doc.querySelectorAll('img:not([alt])');
    var nodes = [];

    imgs.forEach(function(img) {
      nodes.push({
        html: outerHtmlSnippet(img),
        target: [targetSelector(img)],
        failureSummary: 'Image missing alt attribute. Use alt="description" for content images or alt="" for decorative images.',
      });
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('ada-005', {
        impact: 'critical',
        severity: 'critical',
        category: 'ada_specific',
        tags: ['wpsb-custom', 'wcag111', 'wcag2a'],
        description: 'Image missing alt attribute',
        help: 'Every <img> must have an alt attribute. Use descriptive text for content images, empty alt="" for decorative.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 2,
      }));
    }
    return violations;
  }

  /* ada-001: Text contrast under 4.5:1
     Note: Cannot compute contrast from DOM alone — requires CSS resolution
     which axe-core handles. This is a stub that returns empty; production
     should rely on axe-core's color-contrast rule. */
  function checkAda001(doc) {
    /* Intentionally empty — axe-core handles contrast computation.
       Listed here for catalog completeness. */
    return [];
  }

  /* ════════════════════════════════════════════════════════════
     PHASE 1 — v1.1.0 ADDITIONS (Konza audit + CD scanner doc)
     Four new critical checks added 2026-05-13:
       lnk-009 — fake-link spans with role=link but no href
       tel-005 — tel: href digits don't match displayed phone number
       sem-010 — broken/typo HTML tags (<nr>, <l>, etc.)
       ada-008 — aria-label doesn't contain visible link text
                 (WCAG 2.5.3 Label in Name)
     ════════════════════════════════════════════════════════════ */

  /* lnk-009: Fake link — non-anchor element styled / behaving as link
     Detects <span> or <div> with role="link" and/or class="a_link"
     where there's no functional <a href> within. On healthcare sites
     this is most commonly phone numbers that look callable but
     do nothing — a real patient access bug.
     Real example caught: Konza Prairie 5 fake-link phone numbers
     across psychiatry, telehealth, and after-hours pages. */
  function checkLnk009(doc) {
    var violations = [];
    var nodes = [];

    /* Pattern 1: span/div with role="link" or role="button" and no functional anchor child */
    var roleEls = doc.querySelectorAll('span[role="link"], span[role="button"], div[role="link"], div[role="button"]');
    roleEls.forEach(function(el) {
      /* Real link? Skip. */
      if (el.querySelector('a[href]')) return;
      /* Functional via onclick? Treat as borderline — still flag for accessibility. */
      var hasOnclick = el.hasAttribute('onclick') || el.hasAttribute('data-href');
      nodes.push({
        html: outerHtmlSnippet(el),
        target: [targetSelector(el)],
        failureSummary: 'Element has role="' + (el.getAttribute('role') || '') + '" but no <a href> — non-functional link' + (hasOnclick ? ' (onclick present; consider converting to button)' : '. Convert to a real anchor with href.'),
      });
    });

    /* Pattern 2: span with class="a_link" (Konza's link-styling class) and no anchor */
    var styledSpans = doc.querySelectorAll('span.a_link, div.a_link');
    styledSpans.forEach(function(el) {
      if (el.querySelector('a[href]')) return;
      /* Avoid double-flagging Pattern 1 hits */
      if (el.hasAttribute('role') && (el.getAttribute('role') === 'link' || el.getAttribute('role') === 'button')) return;
      nodes.push({
        html: outerHtmlSnippet(el),
        target: [targetSelector(el)],
        failureSummary: 'Element has link-styling class but is not an anchor and contains no link — appears clickable but is not functional.',
      });
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-009', {
        impact: 'critical',
        severity: 'critical',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'wcag412', 'wcag211', 'wcag2a'],
        description: 'Fake link — non-anchor element styled or behaving as link',
        help: 'Elements styled or roled as links must be real <a href> anchors. WCAG 4.1.2 Name, Role, Value (Level A) + 2.1.1 Keyboard (Level A). On healthcare sites this is often a fake phone link — a real patient access bug.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 5,
      }));
    }
    return violations;
  }

  /* tel-005: Phone link digit mismatch
     Compares the digits in the tel: href against the digits in the
     link's visible text. Flags when they differ — caused by editor
     copy-paste errors that route calls to the wrong number.
     Real example caught: Konza after-hours "888-516-6181" linked
     to tel:+17852384711 (JC main line, closed after hours). */
  function checkTel005(doc) {
    var violations = [];
    var telLinks = doc.querySelectorAll('a[href^="tel:"]');
    var nodes = [];

    telLinks.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      var displayText = (a.textContent || '').trim();
      if (!displayText) return;  /* No visible text — different rule (tel-004 aria-label) handles it */

      /* Extract digits from href (stripping +1, comma extensions, formatting) */
      var hrefDigits = href.replace(/^tel:/i, '').replace(/[^0-9]/g, '');
      /* Strip leading 1 (country code) for normalized comparison */
      if (hrefDigits.length === 11 && hrefDigits.charAt(0) === '1') {
        hrefDigits = hrefDigits.substring(1);
      }

      /* Extract digits from display text */
      var displayDigits = displayText.replace(/[^0-9]/g, '');
      /* Strip leading 1 from display too */
      if (displayDigits.length === 11 && displayDigits.charAt(0) === '1') {
        displayDigits = displayDigits.substring(1);
      }

      /* Skip if display has no digits (e.g. "Call us" with phone in aria-label only) */
      if (displayDigits.length < 7) return;

      /* For extensions: only compare the first 10 digits (the phone number,
         not the extension which may or may not be in display). */
      var hrefPhone = hrefDigits.substring(0, 10);
      var displayPhone = displayDigits.substring(0, 10);

      if (hrefPhone.length === 10 && displayPhone.length === 10 && hrefPhone !== displayPhone) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'tel: href dials ' + hrefPhone + ' but visible text shows ' + displayPhone + ' — call routes to wrong number.',
        });
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('tel-005', {
        impact: 'critical',
        severity: 'critical',
        category: 'phone_link_compliance',
        tags: ['wpsb-custom', 'wcag253', 'wcag2aa'],
        description: 'Phone link digit mismatch — tel: href doesn\u2019t match visible number',
        help: 'The digits in the tel: href differ from the visible phone number. Patients tapping the link reach the wrong line. WCAG 2.5.3 Label in Name (Level A).',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 3,
      }));
    }
    return violations;
  }

  /* sem-010: Broken or typo HTML tags
     Detects common HTML typos that browsers silently swallow:
     <nr> (intended <br>), <l> (intended <li>), etc. The content
     attached to these tags often disappears entirely from rendering. */
  function checkSem010(doc, rawHtml) {
    var violations = [];
    if (!rawHtml) return violations;

    /* Common typos found in editor-pasted content */
    var typoPatterns = [
      { tag: 'nr',       intended: 'br',    pattern: /<nr\b[^>]*>/gi },
      { tag: 'l',        intended: 'li',    pattern: /<l\b(?!ink|abel|egend|i\b)[^>]*>/gi },
      { tag: 'al',       intended: 'a',     pattern: /<al\b(?!ign)[^>]*>/gi },
      { tag: 'strg',     intended: 'strong',pattern: /<strg\b[^>]*>/gi },
      { tag: 'brs',      intended: 'br',    pattern: /<brs\b[^>]*>/gi },
      { tag: 'spna',     intended: 'span',  pattern: /<spna\b[^>]*>/gi },
      { tag: 'divv',     intended: 'div',   pattern: /<divv\b[^>]*>/gi },
      { tag: 'paragrap', intended: 'p',     pattern: /<paragrap\b[^>]*>/gi },
    ];

    var nodes = [];
    typoPatterns.forEach(function(p) {
      var matches = rawHtml.match(p.pattern);
      if (matches) {
        matches.slice(0, 5).forEach(function(match) {  /* cap at 5 per type to avoid floods */
          nodes.push({
            html: match.substring(0, 200),
            target: ['html'],
            failureSummary: 'Typo: <' + p.tag + '> appears to be a misspelling of <' + p.intended + '>. Browser silently ignores it.',
          });
        });
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('sem-010', {
        impact: 'critical',
        severity: 'critical',
        category: 'semantic_html',
        tags: ['wpsb-custom', 'wcag411', 'wcag2a'],
        description: 'Broken or typo HTML tags',
        help: 'HTML contains misspelled tags. Browsers silently ignore unknown tags, so content disappears or layout breaks. Run W3C Markup Validator before publishing.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 3,
      }));
    }
    return violations;
  }

  /* ada-008: aria-label doesn't contain visible link text
     WCAG 2.5.3 Label in Name (Level A) — when both visible text and
     aria-label exist on an interactive element, the aria-label must
     start with or contain the visible text. Otherwise voice control
     software (Dragon NaturallySpeaking) can't activate the element
     by speaking the visible label.
     Real example: <a aria-label="Notice of Privacy Practices, Spanish PDF">Espa\u00f1ol (PDF)</a>
     fails — voice user says "Espa\u00f1ol" → command doesn't match aria-label.

     Detection approach: normalize both strings (lowercase, strip
     punctuation, collapse whitespace) and check if aria-label contains
     the visible text as a substring. */
  function checkAda008(doc) {
    var violations = [];
    var elements = doc.querySelectorAll('a[aria-label], button[aria-label], [role="link"][aria-label], [role="button"][aria-label]');
    var nodes = [];

    elements.forEach(function(el) {
      var ariaLabel = (el.getAttribute('aria-label') || '').trim();
      if (!ariaLabel) return;

      /* Get visible text — use textContent but exclude image alt text and other ARIA-hidden content */
      var visibleText = (el.textContent || '').trim();
      if (!visibleText) return;  /* Icon-only link, aria-label is the only name — fine */

      /* Normalize for comparison: lowercase, strip punctuation, collapse whitespace */
      function normalize(s) {
        return s.toLowerCase()
                .replace(/[(){}\[\]<>.,;:!?"'`*\-_/\\]/g, ' ')
                .replace(/\s+/g, ' ')
                .trim();
      }
      var normVisible = normalize(visibleText);
      var normAria    = normalize(ariaLabel);

      if (!normVisible) return;  /* No alphanumeric content after normalization — skip */

      /* Pass condition: aria-label contains the visible text (anywhere within),
         OR they're effectively identical. WCAG 2.5.3 requires the visible text
         be IN the accessible name; the aria-label may add context. */
      var contains = normAria.indexOf(normVisible) !== -1;
      if (contains) return;  /* Pass */

      /* Edge case: if visible text is just a number (phone, address), allow
         slightly more flexibility — check if all digit sequences in visible
         appear in aria. */
      var visibleDigits = visibleText.replace(/[^0-9]/g, '');
      var ariaDigits    = ariaLabel.replace(/[^0-9]/g, '');
      if (visibleDigits.length >= 7 && ariaDigits.indexOf(visibleDigits) !== -1) return;

      /* FAIL */
      nodes.push({
        html: outerHtmlSnippet(el),
        target: [targetSelector(el)],
        failureSummary: 'aria-label "' + ariaLabel.substring(0, 80) + '" does not contain the visible text "' + visibleText.substring(0, 50) + '". Voice control users cannot activate this element by speaking the visible label.',
      });
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('ada-008', {
        impact: 'serious',
        severity: 'critical',
        category: 'ada_specific',
        tags: ['wpsb-custom', 'wcag253', 'wcag2a'],
        description: 'aria-label does not contain visible link text (Label in Name)',
        help: 'WCAG 2.5.3 Label in Name (Level A): the accessible name must contain the visible text. Voice control software requires this to activate elements by spoken label.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 4,
      }));
    }
    return violations;
  }

  /* ada-009: Conflicting ARIA attributes (Name, Role, Value)
     WCAG 4.1.2 Level A. Detects three failure modes:
       A) aria-hidden="true" + tabindex on the same element
       B) Focusable descendant inside an aria-hidden="true" ancestor
       C) role="button" / role="link" without aria-label AND empty visible text
     Common source: Elementor third-party plugins (Element Pack icon-boxes)
     auto-generate aria-hidden + tabindex on link wrappers.
     Real example: Konza header "Location & Hours" / "Patient Portal" /
     "Pay Online" icon-box widgets. */
  function checkAda009(doc) {
    var violations = [];
    var nodes = [];
    var seen = {};  /* dedupe — same element shouldn't be reported by multiple sub-patterns */

    function pushNode(el, summary) {
      var key = targetSelector(el) + '|' + (el.outerHTML || '').substring(0, 50);
      if (seen[key]) return;
      seen[key] = true;
      nodes.push({
        html: outerHtmlSnippet(el),
        target: [targetSelector(el)],
        failureSummary: summary,
      });
    }

    /* Pattern A — direct collision: aria-hidden + tabindex on same element */
    var direct = doc.querySelectorAll('[aria-hidden="true"][tabindex]');
    direct.forEach(function(el) {
      var tabidx = el.getAttribute('tabindex');
      /* Only flag focusable tabindex values (0 or positive). -1 is fine. */
      if (tabidx === '-1') return;
      pushNode(el, 'Element has aria-hidden="true" AND tabindex="' + tabidx + '" — focusable but hidden from screen readers. Keyboard users land here with no announcement.');
    });

    /* Pattern B — focusable descendant inside aria-hidden container.
       Walk every aria-hidden="true" element and check for focusable children. */
    var hidden = doc.querySelectorAll('[aria-hidden="true"]');
    var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
    hidden.forEach(function(container) {
      /* Skip if THIS element was already flagged by Pattern A — Pattern A is more specific */
      if (container.hasAttribute('tabindex') && container.getAttribute('tabindex') !== '-1') return;
      var focusables = container.querySelectorAll(FOCUSABLE_SELECTOR);
      focusables.forEach(function(focusable) {
        pushNode(focusable, 'Focusable element nested inside an aria-hidden="true" container — keyboard users can tab here but screen readers ignore it.');
      });
    });

    /* Pattern C — role="button" / role="link" without aria-label AND no visible text */
    var roleEls = doc.querySelectorAll('[role="button"]:not([aria-label]):not([aria-labelledby]), [role="link"]:not([aria-label]):not([aria-labelledby])');
    roleEls.forEach(function(el) {
      var visibleText = (el.textContent || '').trim();
      if (visibleText.length > 0) return;  /* Has visible text — fine */
      /* Skip if it has an aria-label via Pattern A/B catch */
      var role = el.getAttribute('role') || '';
      pushNode(el, 'Element has role="' + role + '" but no accessible name (no aria-label, no aria-labelledby, no visible text). Screen reader users hear only "' + role + '".');
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('ada-009', {
        impact: 'serious',
        severity: 'critical',
        category: 'ada_specific',
        tags: ['wpsb-custom', 'wcag412', 'wcag131', 'wcag2a'],
        description: 'Conflicting ARIA attributes (Name, Role, Value)',
        help: 'WCAG 4.1.2 Name, Role, Value (Level A): interactive elements must have a programmatically determinable name, role, and value, and must not be hidden from assistive tech while remaining focusable. Common source on Elementor sites: third-party icon-box plugins (Element Pack, etc.).',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 6,
      }));
    }
    return violations;
  }

  /* ada-011: Image link accessible-name stutter
     Detects <a> elements containing BOTH visible text AND an inner
     <img> with non-empty alt that duplicates the visible text. The
     accessible name = textContent + img alt, producing a stutter
     announced to screen reader users.
     Real example: Konza footer "Conceptualized Design" link with
     <img alt="Conceptualized Design — Kansas web design agency">
     announces "Conceptualized Design Conceptualized Design — Kansas
     web design agency". */
  function checkAda011(doc) {
    var violations = [];
    var nodes = [];

    function normalize(s) {
      return (s || '').toLowerCase()
                .replace(/[(){}\[\]<>.,;:!?"'`*\-_/\\—–]/g, ' ')
                .replace(/\s+/g, ' ')
                .trim();
    }

    /* Find all anchors with at least one img child */
    var anchors = doc.querySelectorAll('a img');
    var seenAnchors = {};

    anchors.forEach(function(img) {
      /* Walk up to the nearest <a> ancestor */
      var a = img.parentElement;
      while (a && a.tagName !== 'A') {
        a = a.parentElement;
      }
      if (!a) return;

      /* Dedupe — same anchor with multiple imgs only reported once */
      var anchorKey = targetSelector(a) + '|' + (a.getAttribute('href') || '');
      if (seenAnchors[anchorKey]) return;
      seenAnchors[anchorKey] = true;

      /* Extract visible text — clone the anchor, strip all img alt
         contributions, then get textContent */
      var anchorClone = a.cloneNode(true);
      var clonedImgs = anchorClone.querySelectorAll('img');
      var altTexts = [];
      clonedImgs.forEach(function(ci) {
        var alt = ci.getAttribute('alt');
        if (alt && alt.trim()) altTexts.push(alt.trim());
        ci.remove();  /* strip from clone so textContent excludes it */
      });
      var visibleText = (anchorClone.textContent || '').trim();

      if (!visibleText || altTexts.length === 0) return;  /* No stutter possible */

      var normVisible = normalize(visibleText);
      if (normVisible.length < 3) return;  /* Avoid noise on tiny strings */

      /* Check if any inner img alt duplicates the visible text */
      var stutter = false;
      var offendingAlt = '';
      altTexts.forEach(function(alt) {
        var normAlt = normalize(alt);
        if (normAlt === normVisible || normAlt.indexOf(normVisible) === 0 || normAlt.indexOf(' ' + normVisible + ' ') !== -1 || normAlt.indexOf(normVisible + ' ') === 0 || (normAlt.length > 0 && normVisible.indexOf(normAlt) !== -1)) {
          stutter = true;
          offendingAlt = alt;
        }
      });

      if (stutter) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'Link text "' + visibleText.substring(0, 60) + '" duplicated by inner image alt "' + offendingAlt.substring(0, 60) + '". Screen readers announce both, creating a stutter. Set image alt="" when the link already has visible text.',
        });
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('ada-011', {
        impact: 'moderate',
        severity: 'warning',
        category: 'ada_specific',
        tags: ['wpsb-custom', 'wcag111', 'wcag244', 'wcag2a'],
        description: 'Image link accessible-name stutter',
        help: 'When a link contains both visible text and an image, the image alt should be empty (alt="") so the accessible name comes from the visible text alone. Otherwise screen readers announce both, creating a stutter. WCAG 1.1.1 + 2.4.4.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 3,
      }));
    }
    return violations;
  }

  /* ada-012: <label> nested inside <textarea> (AI-generated bad fix)
     AI accessibility tools sometimes "fix" missing labels by nesting
     the <label> inside the <textarea>. Because HTML parses textarea
     content as text (not markup), the inner label tag becomes literal
     default-value text the user sees in the textarea. axe-core does
     NOT detect this because the inner <label> isn't a DOM element.
     Detection uses rawHtml regex against the source markup.
     Real example: Ally's "Resolve with AI" feature on Konza. */
  function checkAda012(doc, rawHtml) {
    var violations = [];
    if (!rawHtml) return violations;
    var nodes = [];

    /* Match: <textarea ...> followed (with optional text/whitespace)
       by an opening <label tag, before the textarea closes. We don't
       require the </textarea> match because if the syntax is broken
       enough the browser may not have closed it cleanly. */
    var pattern = /<textarea\b[^>]*>([^<]*<label\b[^>]*>[^<]*<\/label>[^<]*)<\/textarea>/gi;
    var simplerPattern = /<textarea\b[^>]*>\s*<label\b[^>]*>/gi;

    /* Primary: full match including the inner label */
    var match;
    var seen = {};
    while ((match = pattern.exec(rawHtml)) !== null) {
      var snippet = match[0].substring(0, 250);
      if (seen[snippet]) continue;
      seen[snippet] = true;
      nodes.push({
        html: snippet,
        target: ['textarea'],
        failureSummary: '<label> element nested inside <textarea>. HTML parses textarea content as text, so the label becomes literal default content rather than an accessible name. Move <label> to a sibling BEFORE the textarea.',
      });
      if (nodes.length >= 10) break;  /* cap */
    }

    /* Secondary: less complete match (textarea + opening label, no closing label).
       Catches partial-AI-fixes where the close tag is missing. */
    if (nodes.length === 0) {
      while ((match = simplerPattern.exec(rawHtml)) !== null) {
        var snippet2 = match[0].substring(0, 250);
        if (seen[snippet2]) continue;
        seen[snippet2] = true;
        nodes.push({
          html: snippet2,
          target: ['textarea'],
          failureSummary: '<textarea> opens followed immediately by a <label> tag. Likely AI-generated bad fix \u2014 the label needs to be a sibling BEFORE the textarea, not nested inside.',
        });
        if (nodes.length >= 10) break;
      }
    }

    if (nodes.length > 0) {
      violations.push(makeViolation('ada-012', {
        impact: 'serious',
        severity: 'critical',
        category: 'ada_specific',
        tags: ['wpsb-custom', 'wcag332', 'wcag131', 'wcag411', 'wcag2a'],
        description: '<label> nested inside <textarea> (AI-generated bad fix)',
        help: 'A <label> element appears inside <textarea> content. HTML parses textarea content as text, so the label tag becomes the textarea\'s default value rather than its accessible name. Move <label> BEFORE the textarea as a sibling. Common source: AI accessibility fix tools (Ally Resolve with AI, similar).',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 2,
      }));
    }
    return violations;
  }

  /* ════════════════════════════════════════════════════════════
     PHASE 2 PROMOTED TO PHASE 1 — v1.2.0 (May 13, 2026)
     Nine rules from the Phase 2 catalog promoted to scanner
     implementation. Detection patterns come from cd-scan-rules.json.

     Link integrity batch:
       lnk-001 — Internal link opens in new tab
       lnk-002 — External link missing rel="noopener noreferrer"
       lnk-005 — Capital letters in URL slug
       lnk-007 — Weak link text ("click here", "read more", etc.)
       lnk-008 — PDF link missing file-type indicator

     Phone link compliance batch:
       tel-001 — tel: link missing E.164 country code (+1)
       tel-002 — tel: link contains formatting characters
       tel-003 — Extension in display text but not in href
       tel-004 — Phone link missing aria-label
     ════════════════════════════════════════════════════════════ */

  /* Helper: derive the page's "internal" hostname.
     Used by lnk-001 and lnk-002 to classify links as internal vs external.
     Priority: <link rel="canonical"> > <meta property="og:url"> > null.
     Returns null when we can't determine, in which case lnk-001 only
     flags relative-path links (no false positives on absolute URLs). */
  function getSiteHostname(doc) {
    try {
      var canonical = doc.querySelector('link[rel="canonical"][href]');
      if (canonical) {
        var u = new URL(canonical.getAttribute('href'));
        return u.hostname.toLowerCase().replace(/^www\./, '');
      }
      var og = doc.querySelector('meta[property="og:url"][content]');
      if (og) {
        var u2 = new URL(og.getAttribute('content'));
        return u2.hostname.toLowerCase().replace(/^www\./, '');
      }
    } catch (e) {}
    return null;
  }

  /* Helper: classify a link's destination relative to the page's host.
     Returns 'internal' | 'external' | 'relative' | 'protocol' | 'unknown'. */
  function classifyHref(href, siteHost) {
    if (!href) return 'unknown';
    var h = href.trim();
    if (/^(mailto|tel|sms|javascript):/i.test(h)) return 'protocol';
    if (h.startsWith('#')) return 'protocol';  /* fragment-only */
    if (/^https?:\/\//i.test(h)) {
      try {
        var u = new URL(h);
        var host = u.hostname.toLowerCase().replace(/^www\./, '');
        if (siteHost && host === siteHost) return 'internal';
        return 'external';
      } catch (e) { return 'unknown'; }
    }
    /* Relative path */
    return 'relative';
  }

  /* lnk-001: Internal link opens in new tab
     Per WCAG 3.2.5 + UX convention, internal links should open in the
     same tab. Opening internal pages in new tabs causes orphaned
     navigation and breaks the back button workflow. PDFs are exempt
     (they should open in new tab even when on the same domain). */
  function checkLnk001(doc) {
    var violations = [];
    var siteHost = getSiteHostname(doc);
    var anchors = doc.querySelectorAll('a[target="_blank"][href]');
    var nodes = [];
    anchors.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      /* Skip PDFs — they legitimately open in new tab */
      if (/\.pdf(\?|#|$)/i.test(href)) return;
      var kind = classifyHref(href, siteHost);
      /* Flag relative paths always; flag absolute same-host only when we have a hostname to compare */
      if (kind === 'relative' || kind === 'internal') {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'Internal link to "' + href.substring(0, 60) + '" opens in a new tab. Remove target="_blank" so navigation stays in the same tab — preserves back-button workflow and avoids orphaned pages.',
        });
      }
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-001', {
        impact: 'moderate',
        severity: 'warning',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'wcag325', 'wcag2aa'],
        description: 'Internal link opens in new tab',
        help: 'Internal links (same-domain, non-PDF) should open in the same tab. WCAG 3.2.5 Change on Request — users should control when new windows open.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* lnk-002: External link missing rel="noopener noreferrer"
     When target="_blank" is used on an external link without rel="noopener",
     the new tab's window.opener references the original tab — a known
     security issue (reverse tabnabbing) and a performance issue. */
  function checkLnk002(doc) {
    var violations = [];
    var siteHost = getSiteHostname(doc);
    var anchors = doc.querySelectorAll('a[target="_blank"][href]');
    var nodes = [];
    anchors.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      var kind = classifyHref(href, siteHost);
      /* Only flag external absolute URLs. Skip PDFs (could be either; we'd over-flag).
         Skip protocol links (mailto/tel — they don't open browser tabs). */
      if (kind !== 'external') return;
      var rel = (a.getAttribute('rel') || '').toLowerCase();
      var hasNoopener  = /\bnoopener\b/.test(rel);
      var hasNoreferrer = /\bnoreferrer\b/.test(rel);
      if (hasNoopener && hasNoreferrer) return;  /* Pass */
      var missing = [];
      if (!hasNoopener) missing.push('noopener');
      if (!hasNoreferrer) missing.push('noreferrer');
      nodes.push({
        html: outerHtmlSnippet(a),
        target: [targetSelector(a)],
        failureSummary: 'External link with target="_blank" missing rel="' + missing.join(' ') + '". Risk: reverse-tabnabbing security issue.',
      });
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-002', {
        impact: 'moderate',
        severity: 'warning',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'best-practice', 'security'],
        description: 'External link missing rel="noopener noreferrer"',
        help: 'Any <a target="_blank"> to an external URL should also have rel="noopener noreferrer". Prevents reverse-tabnabbing security exploit and improves performance (decouples browsing contexts).',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* lnk-005: Capital letters in URL slug
     URL paths should be lowercase. Servers may treat /Contact/ and
     /contact/ as different URLs (case-sensitive on Linux), causing
     404s or inconsistent canonical URLs that hurt SEO. */
  function checkLnk005(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href]');
    var nodes = [];
    var seen = {};
    anchors.forEach(function(a) {
      var href = (a.getAttribute('href') || '').trim();
      if (!href || /^(mailto|tel|sms|javascript|#):/i.test(href) || href.startsWith('#')) return;
      var pathOnly = href;
      try {
        if (/^https?:\/\//i.test(href)) {
          var u = new URL(href);
          pathOnly = u.pathname;  /* strip protocol+host+query+fragment */
        } else {
          /* relative path — strip any query/fragment */
          pathOnly = href.split('?')[0].split('#')[0];
        }
      } catch (e) { return; }
      if (!pathOnly || pathOnly === '/') return;
      /* Check for uppercase letter in path segments (not query/fragment) */
      if (/[A-Z]/.test(pathOnly)) {
        if (seen[pathOnly]) return;  /* dedupe by path */
        seen[pathOnly] = true;
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'Path contains uppercase letters: "' + pathOnly.substring(0, 80) + '". Case-sensitive servers may 404. Use lowercase URLs and set up 301 redirects from uppercase variants.',
        });
      }
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-005', {
        impact: 'minor',
        severity: 'warning',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'best-practice', 'seo'],
        description: 'Capital letters in URL slug',
        help: 'URLs should be lowercase. Case-sensitive servers treat /Contact/ and /contact/ as different resources, causing 404s and split canonical URLs.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 3,
      }));
    }
    return violations;
  }

  /* lnk-007: Weak link text
     "Click here", "here", "read more", "learn more" tell screen reader
     users nothing when pulled out of context. Link text should describe
     the destination or action. WCAG 2.4.4 Link Purpose (In Context). */
  function checkLnk007(doc) {
    var violations = [];
    var WEAK_PATTERNS = [
      /^click here$/i, /^here$/i, /^read more$/i, /^learn more$/i,
      /^more info$/i, /^more$/i, /^view more$/i, /^check here$/i,
      /^read on$/i, /^continue$/i, /^continue reading$/i, /^details$/i,
      /^see more$/i, /^show more$/i, /^link$/i, /^this link$/i, /^this$/i,
      /^haga clic aqu[ií]$/i, /^aqu[ií]$/i, /^m[aá]s informaci[oó]n$/i,
      /^pulse aqu[ií]$/i,
    ];
    var anchors = doc.querySelectorAll('a[href]');
    var nodes = [];
    anchors.forEach(function(a) {
      var text = (a.textContent || '').trim();
      if (!text) return;  /* Empty link — handled by lnk-013 axe-passthrough */
      /* Use aria-label if present and longer than visible text — that overrides */
      var ariaLabel = (a.getAttribute('aria-label') || '').trim();
      if (ariaLabel && ariaLabel.length > text.length) {
        /* aria-label provides better name — check it for weakness instead */
        text = ariaLabel;
      }
      var isWeak = WEAK_PATTERNS.some(function(rx) { return rx.test(text); });
      if (isWeak) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'Link text "' + text.substring(0, 60) + '" is non-descriptive. Screen reader users hear links out of context; the link text alone must describe the destination.',
        });
      }
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-007', {
        impact: 'moderate',
        severity: 'warning',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'wcag244', 'wcag2a'],
        description: 'Weak link text — non-descriptive',
        help: 'Link text like "click here", "read more", "learn more" tells screen reader users nothing about the destination. WCAG 2.4.4 Link Purpose (Level A) — link text must describe its destination in context.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 2,
      }));
    }
    return violations;
  }

  /* lnk-008: PDF link missing file-type indicator
     Links to PDFs should signal that fact in the visible text. Users
     expecting a page get a download instead — disruptive on mobile
     and inaccessible to users without PDF readers. */
  function checkLnk008(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href]');
    var nodes = [];
    anchors.forEach(function(a) {
      var href = (a.getAttribute('href') || '').trim();
      /* Match .pdf at end of path (before query/fragment) */
      if (!/\.pdf(\?|#|$)/i.test(href)) return;
      var visibleText = (a.textContent || '').trim();
      var ariaLabel = (a.getAttribute('aria-label') || '').trim();
      /* Pass if visible text mentions PDF, download, or pdf icon class */
      var hasIndicator =
        /\bpdf\b/i.test(visibleText) ||
        /\bdownload\b/i.test(visibleText) ||
        /\bpdf\b/i.test(ariaLabel) ||
        /\bdownload\b/i.test(ariaLabel) ||
        /pdf|file-pdf|fa-file-pdf|fas-file-pdf/i.test(a.innerHTML);
      if (!hasIndicator) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'Link to PDF "' + href.substring(0, 60) + '" but visible text "' + visibleText.substring(0, 40) + '" gives no indication it is a download. Add "(PDF)" suffix or PDF icon.',
        });
      }
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('lnk-008', {
        impact: 'minor',
        severity: 'warning',
        category: 'link_integrity',
        tags: ['wpsb-custom', 'wcag244', 'best-practice'],
        description: 'PDF link missing file-type indicator',
        help: 'Links to PDFs should signal that in visible text ("(PDF)" suffix) or aria-label. Users on mobile and limited-data plans need to know they\'re triggering a download.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* tel-001: tel: link missing E.164 country code (+1 prefix)
     E.164 format (+1XXXXXXXXXX) ensures international dial compatibility
     and removes ambiguity. Without it, dialers may interpret the number
     differently on different devices/locales. */
  function checkTel001(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href^="tel:"]');
    var nodes = [];
    anchors.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      /* Strip "tel:" prefix and any extension portion (after comma) */
      var phonePart = href.replace(/^tel:/i, '').split(',')[0].split(';')[0].trim();
      if (!phonePart) return;
      /* Skip 3-digit short codes (911, 988, 211, etc.) — they don't need +1 */
      if (/^\d{3}$/.test(phonePart)) return;
      /* Pass if it starts with + (any country code in E.164) */
      if (phonePart.startsWith('+')) return;
      nodes.push({
        html: outerHtmlSnippet(a),
        target: [targetSelector(a)],
        failureSummary: 'tel: link "' + href.substring(0, 60) + '" missing E.164 country code. Should start with "+1" for US numbers (e.g. tel:+17852384711).',
      });
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('tel-001', {
        impact: 'moderate',
        severity: 'warning',
        category: 'phone_link_compliance',
        tags: ['wpsb-custom', 'best-practice'],
        description: 'tel: link missing E.164 country code',
        help: 'Phone number hrefs should use E.164 format with country code (+1 for US). Ensures consistent dial behavior across devices and locales.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* tel-002: tel: link contains formatting characters
     Per RFC 3966, the tel: URI should contain digits, +, and optional
     comma/semicolon for extension. Spaces, hyphens, parens cause some
     mobile dialers to fail. */
  function checkTel002(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href^="tel:"]');
    var nodes = [];
    anchors.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      var phonePart = href.replace(/^tel:/i, '');
      /* Allow only: digits, +, comma (pause for extension), semicolon (extension), period for hex codes? No.
         Strip valid chars; if anything remains, flag. */
      var stripped = phonePart.replace(/[+0-9,;]/g, '');
      if (stripped.length > 0) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'tel: link contains formatting characters: "' + href.substring(0, 60) + '". Strip spaces, hyphens, parens. Use digits + leading "+1" only, comma for extension pause.',
        });
      }
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('tel-002', {
        impact: 'moderate',
        severity: 'warning',
        category: 'phone_link_compliance',
        tags: ['wpsb-custom', 'best-practice'],
        description: 'tel: link contains formatting characters',
        help: 'Per RFC 3966, the tel: URI should contain digits, +, comma (extension pause), and semicolon only. Hyphens, spaces, and parens cause failures on some mobile dialers.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* tel-003: Extension shown in display text but not in href
     If the visible text shows "ext. 4029" but the tel: href doesn't
     have the comma-extension syntax (tel:+1...,4029), the dialer won't
     auto-dial the extension. User has to manually enter it. */
  function checkTel003(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href^="tel:"]');
    var nodes = [];
    /* Match common extension patterns in visible text */
    var EXT_DISPLAY_PATTERNS = [
      /\bext\.?\s*\d{2,5}\b/i,        /* "ext. 4029" or "ext 4029" */
      /\bextension\s+\d{2,5}\b/i,     /* "extension 4029" */
      /\bx\.?\s*\d{2,5}\b/i,           /* "x. 4029" or "x 4029" — discouraged but common */
      /#\s*\d{2,5}\b/,                 /* "#4029" */
    ];
    anchors.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      var visibleText = (a.textContent || '').trim();
      if (!visibleText) return;
      /* Does display text mention an extension? */
      var displayHasExt = EXT_DISPLAY_PATTERNS.some(function(rx) { return rx.test(visibleText); });
      if (!displayHasExt) return;
      /* Does href use the comma extension syntax? */
      var phonePart = href.replace(/^tel:/i, '');
      var hrefHasExt = /[,;]/.test(phonePart);  /* comma or semicolon = extension auto-dial */
      if (!hrefHasExt) {
        nodes.push({
          html: outerHtmlSnippet(a),
          target: [targetSelector(a)],
          failureSummary: 'Display text "' + visibleText.substring(0, 60) + '" includes an extension but href "' + href + '" has no auto-dial comma. Add ",EXTNUM" to the href (e.g. tel:+17852384711,4029).',
        });
      }
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('tel-003', {
        impact: 'moderate',
        severity: 'warning',
        category: 'phone_link_compliance',
        tags: ['wpsb-custom', 'best-practice'],
        description: 'Extension in display text but not in tel: href',
        help: 'When the displayed phone number includes "ext. NNNN", the tel: href should include ",NNNN" so the dialer auto-pauses and enters the extension after connecting.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* tel-004: Phone link missing aria-label
     For screen reader users, "785-238-4711" announces as a number with
     no context. An aria-label like "Call 785-238-4711" tells them it's
     a callable action. Pair this with ada-008 (Label in Name) so the
     aria-label still CONTAINS the visible number. */
  function checkTel004(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href^="tel:"]');
    var nodes = [];
    anchors.forEach(function(a) {
      var ariaLabel = a.getAttribute('aria-label');
      var ariaLabelledby = a.getAttribute('aria-labelledby');
      if (ariaLabel && ariaLabel.trim()) return;
      if (ariaLabelledby && ariaLabelledby.trim()) return;
      var visibleText = (a.textContent || '').trim();
      var href = a.getAttribute('href') || '';
      nodes.push({
        html: outerHtmlSnippet(a),
        target: [targetSelector(a)],
        failureSummary: 'tel: link "' + (visibleText || href).substring(0, 40) + '" missing aria-label. Screen reader users hear only the number, not that it\'s a callable action. Add aria-label="Call ' + (visibleText || 'phone number') + '".',
      });
    });
    if (nodes.length > 0) {
      violations.push(makeViolation('tel-004', {
        impact: 'moderate',
        severity: 'warning',
        category: 'phone_link_compliance',
        tags: ['wpsb-custom', 'wcag244', 'best-practice'],
        description: 'Phone link missing aria-label',
        help: 'tel: links should have an aria-label describing the action ("Call 785-238-4711"). The aria-label should CONTAIN the visible number to also satisfy WCAG 2.5.3 Label in Name (see ada-008).',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* ════════════════════════════════════════════════════════════
     v1.3.0 (May 13, 2026) — Semantic HTML + Content Authoring batch
     7 new custom rules promoted from Phase 2 catalog:
       sem-002 — Multiple H1 elements on page
       sem-004 — Duplicate heading text at same level
       sem-009 — Multiple nav landmarks without aria-label
       sem-011 — Excessive consecutive <br> tags
       sem-012 — Markdown leakage in HTML body
       sem-013 — ALL CAPS body text used for emphasis
       inl-002 — Inline style attribute on link/p/span/div

     Note: sem-003 (skipped heading levels) is handled by axe-core's
     heading-order rule — registered as axe-passthrough in catalog.
     ════════════════════════════════════════════════════════════ */

  /* sem-002: Multiple H1 elements on page
     Each page should have exactly one h1 representing the page title.
     Multiple h1s confuse screen reader users and break document
     outline algorithms. Note: this is distinct from axe's
     page-has-heading-one (which requires AT LEAST one h1) — sem-002
     enforces AT MOST one. */
  function checkSem002(doc) {
    var violations = [];
    var h1s = doc.querySelectorAll('h1');
    if (h1s.length <= 1) return violations;
    var nodes = [];
    /* Flag h1s beyond the first */
    for (var i = 1; i < h1s.length; i++) {
      nodes.push({
        html: outerHtmlSnippet(h1s[i]),
        target: [targetSelector(h1s[i])],
        failureSummary: 'Page contains ' + h1s.length + ' <h1> elements. Each page should have exactly one h1 representing the page title. Convert this and other extra h1s to h2 or h3 as appropriate to the document structure.',
      });
    }
    violations.push(makeViolation('sem-002', {
      impact: 'moderate',
      severity: 'warning',
      category: 'semantic_html',
      tags: ['wpsb-custom', 'wcag131', 'wcag2a', 'best-practice'],
      description: 'Multiple H1 elements on page',
      help: 'A page should have exactly one <h1> representing its title. Multiple h1s break document outline algorithms and disorient screen reader users navigating by heading level.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 3,
    }));
    return violations;
  }

  /* sem-004: Duplicate heading text at same level
     When multiple headings at the same level share identical text,
     screen reader users navigating by heading can't tell them apart.
     Common on FAQ pages, accordion-heavy templates, or auto-generated
     archive pages. */
  function checkSem004(doc) {
    var violations = [];
    var headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
    var seen = {};  /* { level: { text: firstElement } } */
    var nodes = [];
    var flaggedKeys = {};

    headings.forEach(function(h) {
      var level = parseInt(h.tagName.slice(1), 10);
      var text = (h.textContent || '').trim().toLowerCase().replace(/\s+/g, ' ');
      if (!text || text.length < 2) return;
      if (!seen[level]) seen[level] = {};
      if (seen[level][text]) {
        var key = level + ':' + text;
        if (flaggedKeys[key]) return;  /* don't refire on triplicates */
        flaggedKeys[key] = true;
        nodes.push({
          html: outerHtmlSnippet(h),
          target: [targetSelector(h)],
          failureSummary: 'Duplicate <h' + level + '> "' + text.substring(0, 60) + '" appears earlier on the page. Screen reader users navigating by heading can\u2019t distinguish them. Make heading text unique within each level.',
        });
      } else {
        seen[level][text] = h;
      }
    });

    if (nodes.length > 0) {
      violations.push(makeViolation('sem-004', {
        impact: 'minor',
        severity: 'warning',
        category: 'semantic_html',
        tags: ['wpsb-custom', 'wcag246', 'best-practice'],
        description: 'Duplicate heading text at same level',
        help: 'Multiple headings at the same level should have distinct text. WCAG 2.4.6 Headings and Labels (AA) \u2014 headings must describe their topic.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 3,
      }));
    }
    return violations;
  }

  /* sem-009: Multiple nav landmarks without aria-label
     When a page has multiple <nav> elements, each must have a unique
     accessible name so screen reader users can distinguish them in
     landmark navigation. Otherwise they hear "navigation" repeatedly. */
  function checkSem009(doc) {
    var violations = [];
    var navs = doc.querySelectorAll('nav, [role="navigation"]');
    if (navs.length <= 1) return violations;  /* Single nav is fine */
    var nodes = [];
    navs.forEach(function(nav) {
      var ariaLabel = nav.getAttribute('aria-label');
      var ariaLabelledby = nav.getAttribute('aria-labelledby');
      if (ariaLabel && ariaLabel.trim()) return;
      if (ariaLabelledby && ariaLabelledby.trim()) return;
      nodes.push({
        html: outerHtmlSnippet(nav),
        target: [targetSelector(nav)],
        failureSummary: 'Page has ' + navs.length + ' <nav> landmarks but this one has no aria-label or aria-labelledby. Screen reader users navigating by landmark hear "navigation" with no way to distinguish between them. Add aria-label="Main", "Footer", "Breadcrumb", etc.',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('sem-009', {
      impact: 'moderate',
      severity: 'warning',
      category: 'semantic_html',
      tags: ['wpsb-custom', 'wcag131', 'wcag2a'],
      description: 'Multiple nav landmarks without aria-label',
      help: 'When a page has more than one <nav> element, each needs a unique aria-label or aria-labelledby for landmark navigation. Common labels: "Main", "Footer", "Breadcrumb", "Side", "Skip Links".',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 3,
    }));
    return violations;
  }

  /* sem-011: Excessive consecutive <br> tags
     Multiple <br> tags used to create paragraph spacing instead of
     separate <p> elements. Breaks screen reader paragraph navigation
     and is fragile against CSS changes. Distinguishes from legitimate
     <br> use in addresses (where <br>s have content between them). */
  function checkSem011(doc) {
    var violations = [];
    var allBrs = Array.from(doc.querySelectorAll('br'));
    var flagged = {};  /* element id-via-targetSelector → already flagged */
    var nodes = [];

    for (var i = 0; i < allBrs.length; i++) {
      var br = allBrs[i];
      var key = targetSelector(br);
      if (flagged[key]) continue;

      /* Walk forward through siblings to find consecutive br elements */
      var group = [br];
      var next = br.nextSibling;
      while (next) {
        if (next.nodeType === 3 && !next.nodeValue.trim()) {
          /* whitespace text node — skip, keep walking */
          next = next.nextSibling;
          continue;
        }
        if (next.nodeType === 1 && next.tagName === 'BR') {
          group.push(next);
          flagged[targetSelector(next)] = true;
          next = next.nextSibling;
        } else break;
      }

      if (group.length >= 2) {
        flagged[key] = true;
        var parent = br.parentElement;
        nodes.push({
          html: outerHtmlSnippet(parent || br),
          target: [targetSelector(parent || br)],
          failureSummary: 'Found ' + group.length + ' consecutive <br> tags used for paragraph spacing. Replace with separate <p> elements \u2014 better for screen reader paragraph navigation and resilient to CSS changes.',
        });
      }
    }

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('sem-011', {
      impact: 'minor',
      severity: 'suggestion',
      category: 'semantic_html',
      tags: ['wpsb-custom', 'wcag131', 'best-practice'],
      description: 'Excessive consecutive <br> tags for paragraph spacing',
      help: 'Consecutive <br> tags create fake paragraph spacing. Use separate <p> elements instead. Reserve <br> for line breaks within content (addresses, phone listings).',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 2,
    }));
    return violations;
  }

  /* sem-012: Markdown leakage in HTML body
     Markdown syntax like [text](url) or **bold** appears unconverted
     in visible content. Editor pasted from Notion / Obsidian / Slack
     without converting to HTML.
     Real example: Konza SAMHSA links contained literal
     [www.SAMHSA.gov](https://www.SAMHSA.gov) markdown. */
  function checkSem012(doc, rawHtml) {
    var violations = [];
    if (!rawHtml) return violations;

    /* Strip code/pre/script/style/comments to avoid false positives in
       those contexts where markdown-like patterns are legitimate. */
    var clean = rawHtml
      .replace(/<!--[\s\S]*?-->/g, '')
      .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
      .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
      .replace(/<code\b[^>]*>[\s\S]*?<\/code>/gi, '')
      .replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, '')
      .replace(/<textarea\b[^>]*>[\s\S]*?<\/textarea>/gi, '');

    var linkRe = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
    var boldRe = /\*\*([^*\n]{2,})\*\*/g;
    var nodes = [];
    var seen = {};
    var m;

    while ((m = linkRe.exec(clean)) !== null) {
      if (nodes.length >= 8) break;
      var snippet = m[0].substring(0, 200);
      if (seen[snippet]) continue;
      seen[snippet] = true;
      nodes.push({
        html: snippet,
        target: ['body'],
        failureSummary: 'Markdown link syntax visible in HTML: "' + snippet.substring(0, 100) + '". Convert to <a href="' + m[2] + '">' + m[1] + '</a>.',
      });
    }

    while ((m = boldRe.exec(clean)) !== null) {
      if (nodes.length >= 12) break;
      var snippet2 = m[0].substring(0, 200);
      if (seen[snippet2]) continue;
      seen[snippet2] = true;
      nodes.push({
        html: snippet2,
        target: ['body'],
        failureSummary: 'Markdown bold syntax visible in HTML: "' + snippet2.substring(0, 100) + '". Convert to <strong>' + m[1].substring(0, 50) + '</strong>.',
      });
    }

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('sem-012', {
      impact: 'moderate',
      severity: 'warning',
      category: 'semantic_html',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Markdown leakage in HTML body',
      help: 'Markdown syntax like [text](url) or **bold** appears unconverted in visible content. Convert to proper HTML. Common when editors paste from Notion, Obsidian, or Slack.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 3,
    }));
    return violations;
  }

  /* sem-013: ALL CAPS body text used for emphasis
     Long runs of all-uppercase text. Some screen readers pronounce
     capitalized words letter-by-letter as initialisms. Use sentence
     case in markup + CSS text-transform: uppercase for visual styling.
     Note: when uppercase comes from CSS text-transform, the HTML is
     sentence case and this rule won't fire — only flags HTML literally
     containing uppercase. */
  function checkSem013(doc) {
    var violations = [];
    var SKIP_TAGS = { CODE: 1, PRE: 1, ABBR: 1, ACRONYM: 1, SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, KBD: 1, SAMP: 1, 'VAR': 1 };
    var nodes = [];
    var seen = {};
    var pattern = /\b[A-Z]{2,}(?:\s+[A-Z]{2,}){3,}\b/g;

    function walk(node) {
      if (nodes.length >= 10) return;
      if (node.nodeType === 3) {  /* text node */
        var text = node.nodeValue || '';
        if (!/[A-Z]{2,}/.test(text)) return;  /* fast reject */
        var m;
        pattern.lastIndex = 0;
        while ((m = pattern.exec(text)) !== null) {
          if (nodes.length >= 10) break;
          var matched = m[0];
          if (seen[matched]) continue;
          seen[matched] = true;
          var parent = node.parentElement;
          nodes.push({
            html: outerHtmlSnippet(parent || node),
            target: [parent ? targetSelector(parent) : 'body'],
            failureSummary: 'ALL CAPS text run: "' + matched.substring(0, 80) + (matched.length > 80 ? '...' : '') + '". Some screen readers pronounce all-caps as letter-by-letter initialisms. Use sentence case in HTML; apply visual uppercase via CSS text-transform: uppercase.',
          });
        }
      } else if (node.nodeType === 1) {  /* element */
        if (SKIP_TAGS[node.tagName]) return;
        /* Also skip if element has text-transform: uppercase in inline style */
        var inlineStyle = node.getAttribute && node.getAttribute('style');
        if (inlineStyle && /text-transform\s*:\s*uppercase/i.test(inlineStyle)) return;
        for (var c = node.firstChild; c; c = c.nextSibling) walk(c);
      }
    }

    walk(doc.body || doc.documentElement);

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('sem-013', {
      impact: 'minor',
      severity: 'suggestion',
      category: 'semantic_html',
      tags: ['wpsb-custom', 'wcag148', 'best-practice'],
      description: 'ALL CAPS body text used for emphasis',
      help: 'Long runs of all-uppercase text in HTML can be read as initialisms by some screen readers. Use sentence case in HTML and apply visual uppercase via CSS text-transform: uppercase. Cases where uppercase is semantic (acronyms) should use <abbr>.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 2,
    }));
    return violations;
  }

  /* inl-002: Inline style attribute on link, paragraph, span, or div
     Inline styles bypass theme styling, are hard to override, and
     create maintenance burden. Common source: pasted from Word /
     Pages / external sources. */
  function checkInl002(doc) {
    var violations = [];
    var styled = doc.querySelectorAll('a[style], p[style], span[style], div[style]');
    var nodes = [];

    styled.forEach(function(el) {
      var style = el.getAttribute('style') || '';
      style = style.trim();
      if (!style) return;
      /* Skip when style is only a data: URL (e.g. background-image: url(data:...)) */
      if (/^[^:]*:\s*url\(data:[^)]+\)\s*;?$/.test(style)) return;
      /* Skip when style is purely a CSS variable assignment */
      if (/^--[\w-]+\s*:\s*[^;]+;?$/.test(style)) return;
      /* Cap to avoid floods on pages with many inline styles */
      if (nodes.length >= 20) return;
      nodes.push({
        html: outerHtmlSnippet(el),
        target: [targetSelector(el)],
        failureSummary: '<' + el.tagName.toLowerCase() + '> has inline style="' + style.substring(0, 80) + (style.length > 80 ? '...' : '') + '". Move to theme CSS classes for maintainability \u2014 inline styles bypass cascade and theme controls.',
      });
    });

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('inl-002', {
      impact: 'minor',
      severity: 'warning',
      category: 'inline_style_cruft',
      tags: ['wpsb-custom', 'best-practice', 'maintainability'],
      description: 'Inline style attribute on link, paragraph, span, or div',
      help: 'Inline style attributes bypass theme styling and create maintenance burden. Move styling to CSS classes. Common source: Word / Pages copy-paste, or editors over-styling individual elements.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 3,
    }));
    return violations;
  }

  /* ════════════════════════════════════════════════════════════
     v1.4.0 (May 13, 2026) — Inline style + content hygiene batch
     12 new custom rules promoted from Phase 2 catalog:
       inl-001 — Inline color span wrapping a link
       inl-003 — Repeated inline style on consecutive siblings
       inl-005 — Redundant color on wrapper + child link
       inl-006 — Word/Pages/Apple class artifacts (p1, mso-*)
       inl-007 — <b> used instead of <strong>
       tel-006 — Plain-text phone number (no tel: link)
       tel-007 — Phone display uses periods instead of hyphens
       lnk-010 — Year-dated URL for evergreen content
       lnk-011 — URL contains tracking parameters
       lnk-012 — Leading space in href attribute
       mail-001 — Plain-text email address (no mailto: link)
       mail-002 — Outdated "user at domain dot com" aria-label on mailto
     ════════════════════════════════════════════════════════════ */

  /* inl-001: Inline color span wrapping a link
     <span style="color:..."> around an <a> often overrides the
     theme's link color, creating link-vs-text contrast issues and
     making the link harder to identify visually. */
  function checkInl001(doc) {
    var violations = [];
    var spans = doc.querySelectorAll('span[style*="color"], div[style*="color"]');
    var nodes = [];
    spans.forEach(function(span) {
      if (!span.querySelector('a[href]')) return;
      var style = span.getAttribute('style') || '';
      /* Only flag if style explicitly sets color */
      if (!/(^|;)\s*color\s*:/i.test(style)) return;
      nodes.push({
        html: outerHtmlSnippet(span),
        target: [targetSelector(span)],
        failureSummary: 'Span with inline color="' + style.substring(0, 50) + '" wraps an <a> tag, overriding theme link styling. Remove the inline color or move into theme CSS.',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('inl-001', {
      impact: 'minor',
      severity: 'warning',
      category: 'inline_style_cruft',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Inline color span wrapping a link',
      help: 'A span with inline color: wrapping a link overrides theme link styling and creates link/text contrast issues. Remove inline color and rely on theme CSS for link presentation.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 2,
    }));
    return violations;
  }

  /* inl-003: Repeated inline style on consecutive siblings
     When several siblings share the same inline style, that's a
     signal the styling should be a CSS class on the parent or a
     named class on the children. */
  function checkInl003(doc) {
    var violations = [];
    var elements = Array.from(doc.querySelectorAll('[style]'));
    var nodes = [];
    /* Group by parent + style signature */
    var groups = {};
    elements.forEach(function(el) {
      var parent = el.parentElement;
      if (!parent) return;
      var style = (el.getAttribute('style') || '').replace(/\s+/g, ' ').trim();
      if (!style || style.length < 8) return;  /* skip trivial styles */
      var key = (parent.tagName || '') + '|' + targetSelector(parent) + '|' + style;
      if (!groups[key]) groups[key] = [];
      groups[key].push(el);
    });
    Object.keys(groups).forEach(function(key) {
      var group = groups[key];
      if (group.length < 2) return;
      var firstEl = group[0];
      var style = firstEl.getAttribute('style') || '';
      nodes.push({
        html: outerHtmlSnippet(firstEl),
        target: [targetSelector(firstEl)],
        failureSummary: group.length + ' sibling elements share the same inline style "' + style.substring(0, 60) + '". Move to a CSS class on the parent or a named class on the children.',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('inl-003', {
      impact: 'minor',
      severity: 'warning',
      category: 'inline_style_cruft',
      tags: ['wpsb-custom', 'best-practice', 'maintainability'],
      description: 'Repeated inline style on consecutive siblings',
      help: 'Multiple siblings sharing identical inline styles should be styled via a CSS class instead. Common source: Word / Pages export where each list item gets its own redundant style attribute.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 5,
    }));
    return violations;
  }

  /* inl-005: Redundant color declared on both wrapper and child link
     <p style="color:red"><a style="color:red" href=...>...</a></p>
     The child's color overrides the parent's anyway — declaring on
     both is dead weight and a sign of editor-induced cruft. */
  function checkInl005(doc) {
    var violations = [];
    /* Find anchors with inline color */
    var anchors = doc.querySelectorAll('a[style*="color"]');
    var nodes = [];
    anchors.forEach(function(a) {
      var aStyle = a.getAttribute('style') || '';
      var aColorMatch = aStyle.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
      if (!aColorMatch) return;
      /* Check parent's style */
      var parent = a.parentElement;
      if (!parent) return;
      var pStyle = parent.getAttribute('style') || '';
      var pColorMatch = pStyle.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
      if (!pColorMatch) return;
      /* Both have color — flag (regardless of value match; both are cruft) */
      nodes.push({
        html: outerHtmlSnippet(parent),
        target: [targetSelector(a)],
        failureSummary: 'Both <' + parent.tagName.toLowerCase() + '> and child <a> have inline color (parent: "' + pColorMatch[1].trim() + '"; child: "' + aColorMatch[1].trim() + '"). Remove the redundant declaration \u2014 the child wins anyway.',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('inl-005', {
      impact: 'minor',
      severity: 'warning',
      category: 'inline_style_cruft',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Redundant color on wrapper and child link',
      help: 'When both a wrapper and its child <a> have inline color: declarations, the child wins cascade-wise anyway. Remove the redundant declaration.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 2,
    }));
    return violations;
  }

  /* inl-006: Word, Apple Pages, or Google Docs class artifacts
     Class names like p1, li1, ol1, s1 (Apple Pages export) or
     mso-prefixed classes (Microsoft Word) appear in markup from
     copy-paste. Conflict with theme stylesheets and add noise. */
  function checkInl006(doc) {
    var violations = [];
    var ARTIFACT_PATTERN = /\b([ps][0-9]{1,2}|li[0-9]{1,2}|ol[0-9]{1,2}|s[0-9]{1,2}|mso-[\w-]+)\b/;
    var withClass = doc.querySelectorAll('[class]');
    var nodes = [];
    var seenClasses = {};
    withClass.forEach(function(el) {
      var cls = el.getAttribute('class') || '';
      var match = cls.match(ARTIFACT_PATTERN);
      if (!match) return;
      var artifact = match[1];
      if (seenClasses[artifact]) {
        seenClasses[artifact]++;
        return;  /* dedupe — report each artifact class once */
      }
      seenClasses[artifact] = 1;
      nodes.push({
        html: outerHtmlSnippet(el),
        target: [targetSelector(el)],
        failureSummary: 'Element has class "' + artifact + '" \u2014 a Word, Apple Pages, or Google Docs export artifact. Remove it; future prevention: editors should paste-as-plain-text (Cmd+Shift+V / Ctrl+Shift+V).',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('inl-006', {
      impact: 'minor',
      severity: 'suggestion',
      category: 'inline_style_cruft',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Word, Apple Pages, or Google Docs class artifacts',
      help: 'Class names like p1, li1, mso-* indicate the content was pasted from Word / Pages / Docs without being stripped. Remove these classes and train editors to paste-as-plain-text.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 2,
    }));
    return violations;
  }

  /* inl-007: <b> used instead of <strong>
     HTML5 still allows <b> for visual presentation, but <strong>
     conveys semantic importance preferred for accessibility and SEO.
     Often a Word export artifact. */
  function checkInl007(doc) {
    var violations = [];
    var bs = doc.querySelectorAll('b');
    var nodes = [];
    bs.forEach(function(b) {
      /* Skip if it has explicit role indicating semantic intent */
      if (b.getAttribute('role')) return;
      /* Skip empty <b> tags (caught by other rules / less interesting) */
      if (!(b.textContent || '').trim()) return;
      /* Cap to avoid floods */
      if (nodes.length >= 15) return;
      nodes.push({
        html: outerHtmlSnippet(b),
        target: [targetSelector(b)],
        failureSummary: '<b>' + (b.textContent || '').trim().substring(0, 40) + '</b> used instead of <strong>. Both render bold, but <strong> conveys semantic importance (preferred for screen readers and SEO).',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('inl-007', {
      impact: 'minor',
      severity: 'suggestion',
      category: 'inline_style_cruft',
      tags: ['wpsb-custom', 'wcag131', 'best-practice'],
      description: '<b> used instead of <strong>',
      help: 'Replace <b> with <strong> for semantic emphasis. Both render bold; only <strong> conveys importance to screen readers.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 1,
    }));
    return violations;
  }

  /* tel-006: Plain-text phone number (no tel: link wrapper)
     Phone-pattern text in body content not inside an <a href="tel:...">.
     Mobile users can't tap-to-call. */
  function checkTel006(doc) {
    var violations = [];
    var PHONE_RE = /\b(?:\d{3}[-.\s]\d{3}[-.\s]\d{4}|\(\d{3}\)\s?\d{3}[-.\s]\d{4}|1[-.\s]?\d{3}[-.\s]\d{3}[-.\s]\d{4})\b/g;
    var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, CODE: 1, PRE: 1, KBD: 1 };
    var nodes = [];
    var seen = {};

    function isInsideTelLink(node) {
      var el = node.nodeType === 3 ? node.parentElement : node;
      while (el) {
        if (el.tagName === 'A' && (el.getAttribute('href') || '').toLowerCase().indexOf('tel:') === 0) return true;
        if (el.tagName === 'A' && (el.getAttribute('href') || '').toLowerCase().indexOf('sms:') === 0) return true;
        el = el.parentElement;
      }
      return false;
    }

    function walk(node) {
      if (nodes.length >= 12) return;
      if (node.nodeType === 3) {
        var text = node.nodeValue || '';
        if (!/\d/.test(text)) return;
        if (isInsideTelLink(node)) return;
        var m;
        PHONE_RE.lastIndex = 0;
        while ((m = PHONE_RE.exec(text)) !== null) {
          if (nodes.length >= 12) break;
          var matched = m[0];
          if (seen[matched]) continue;
          seen[matched] = true;
          var parent = node.parentElement;
          nodes.push({
            html: outerHtmlSnippet(parent || node),
            target: [parent ? targetSelector(parent) : 'body'],
            failureSummary: 'Plain-text phone number "' + matched + '" not wrapped in <a href="tel:...">. Mobile users cannot tap-to-call.',
          });
        }
      } else if (node.nodeType === 1) {
        if (SKIP_TAGS[node.tagName]) return;
        for (var c = node.firstChild; c; c = c.nextSibling) walk(c);
      }
    }
    walk(doc.body || doc.documentElement);

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('tel-006', {
      impact: 'moderate',
      severity: 'warning',
      category: 'phone_link_compliance',
      tags: ['wpsb-custom', 'wcag255', 'best-practice'],
      description: 'Plain-text phone number not wrapped in tel: link',
      help: 'Phone numbers in body content should be wrapped in <a href="tel:+1XXXXXXXXXX"> so mobile users can tap to call.',
      nodes: nodes,
      fqhcPriority: true,
      estimatedMinutes: 3,
    }));
    return violations;
  }

  /* tel-007: Phone display uses periods instead of hyphens */
  function checkTel007(doc) {
    var violations = [];
    var DOT_PHONE_RE = /\b\d{3}\.\d{3}\.\d{4}\b/g;
    var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, CODE: 1, PRE: 1 };
    var nodes = [];
    var seen = {};

    function walk(node) {
      if (nodes.length >= 10) return;
      if (node.nodeType === 3) {
        var text = node.nodeValue || '';
        if (text.indexOf('.') === -1) return;
        var m;
        DOT_PHONE_RE.lastIndex = 0;
        while ((m = DOT_PHONE_RE.exec(text)) !== null) {
          if (nodes.length >= 10) break;
          if (seen[m[0]]) continue;
          seen[m[0]] = true;
          var parent = node.parentElement;
          nodes.push({
            html: outerHtmlSnippet(parent || node),
            target: [parent ? targetSelector(parent) : 'body'],
            failureSummary: 'Phone number "' + m[0] + '" uses periods. Standard format is hyphens: "' + m[0].replace(/\./g, '-') + '".',
          });
        }
      } else if (node.nodeType === 1) {
        if (SKIP_TAGS[node.tagName]) return;
        for (var c = node.firstChild; c; c = c.nextSibling) walk(c);
      }
    }
    walk(doc.body || doc.documentElement);

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('tel-007', {
      impact: 'minor',
      severity: 'suggestion',
      category: 'phone_link_compliance',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Phone display uses periods instead of hyphens',
      help: 'Standardize phone number display as XXX-XXX-XXXX (hyphens). Periods are non-standard and create inconsistency across the site.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 1,
    }));
    return violations;
  }

  /* lnk-010: Year-dated URL for evergreen content */
  function checkLnk010(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href]');
    var nodes = [];
    var seen = {};
    anchors.forEach(function(a) {
      var href = (a.getAttribute('href') || '').trim();
      if (!href || /^(https?:\/\/[^/]*)?\/?$/.test(href)) return;
      /* Only flag internal-looking URLs (relative or same-host).
         External year-dated URLs are out of our control. */
      if (/^https?:\/\//.test(href)) return;  /* skip absolute for now */
      if (!/\/(?:19|20)\d{2}[-/]/.test(href)) return;
      if (seen[href]) return;
      seen[href] = true;
      nodes.push({
        html: outerHtmlSnippet(a),
        target: [targetSelector(a)],
        failureSummary: 'URL "' + href.substring(0, 80) + '" contains a year stamp. Annual maintenance burden \u2014 every internal link needs updating each year; inbound links break. Migrate to permanent URL like "/sliding-fee-scale/" instead of "/2026-sliding-fee-scale/", with 301 redirects.',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('lnk-010', {
      impact: 'minor',
      severity: 'warning',
      category: 'link_integrity',
      tags: ['wpsb-custom', 'best-practice', 'seo'],
      description: 'Year-dated URL for evergreen content',
      help: 'URLs containing year stamps create annual maintenance burden, break inbound links each January, and reset SEO authority. Use permanent URLs and 301-redirect old year-dated variants.',
      nodes: nodes,
      fqhcPriority: true,
      estimatedMinutes: 15,
    }));
    return violations;
  }

  /* lnk-011: URL contains tracking parameters */
  function checkLnk011(doc) {
    var violations = [];
    var TRACKER_RE = /[?&](utm_\w+|gclid|fbclid|CDC_AAref_Val|bidId|mc_cid|mc_eid|_ga|_gl)=/;
    var anchors = doc.querySelectorAll('a[href]');
    var nodes = [];
    var seen = {};
    anchors.forEach(function(a) {
      var href = a.getAttribute('href') || '';
      if (!TRACKER_RE.test(href)) return;
      if (seen[href]) return;
      seen[href] = true;
      nodes.push({
        html: outerHtmlSnippet(a),
        target: [targetSelector(a)],
        failureSummary: 'URL contains tracking parameters: "' + href.substring(0, 100) + '". Strip ?utm_*, ?gclid, ?fbclid, etc. unless intentional.',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('lnk-011', {
      impact: 'minor',
      severity: 'suggestion',
      category: 'link_integrity',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'URL contains tracking parameters',
      help: 'Links with ?utm_*, ?gclid, ?fbclid, etc. leak referrer data and create messy URLs. Strip these unless intentional analytics tracking is required.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 2,
    }));
    return violations;
  }

  /* lnk-012: Leading space in href attribute
     DOM normalizes leading whitespace; must use rawHtml for detection. */
  function checkLnk012(doc, rawHtml) {
    var violations = [];
    if (!rawHtml) return violations;
    var LEADING_SPACE_RE = /href\s*=\s*(["'])\s+([^"']+)\1/g;
    var nodes = [];
    var seen = {};
    var m;
    while ((m = LEADING_SPACE_RE.exec(rawHtml)) !== null) {
      if (nodes.length >= 10) break;
      var snippet = m[0].substring(0, 200);
      if (seen[snippet]) continue;
      seen[snippet] = true;
      nodes.push({
        html: snippet,
        target: ['html'],
        failureSummary: 'href value starts with whitespace: ' + snippet.substring(0, 100) + '. Causes inconsistent browser handling. Strip leading whitespace from href attributes.',
      });
    }
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('lnk-012', {
      impact: 'moderate',
      severity: 'warning',
      category: 'link_integrity',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Leading space in href attribute',
      help: 'href values should not start with whitespace. Some browsers strip it (link works), others treat as broken (link fails). Caused by editor paste artifacts.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 1,
    }));
    return violations;
  }

  /* mail-001: Plain-text email address not wrapped in mailto: */
  function checkMail001(doc) {
    var violations = [];
    var EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
    var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, CODE: 1, PRE: 1, KBD: 1, SAMP: 1 };
    var nodes = [];
    var seen = {};

    function isInsideMailtoLink(node) {
      var el = node.nodeType === 3 ? node.parentElement : node;
      while (el) {
        if (el.tagName === 'A' && (el.getAttribute('href') || '').toLowerCase().indexOf('mailto:') === 0) return true;
        el = el.parentElement;
      }
      return false;
    }

    function walk(node) {
      if (nodes.length >= 10) return;
      if (node.nodeType === 3) {
        var text = node.nodeValue || '';
        if (text.indexOf('@') === -1) return;
        if (isInsideMailtoLink(node)) return;
        var m;
        EMAIL_RE.lastIndex = 0;
        while ((m = EMAIL_RE.exec(text)) !== null) {
          if (nodes.length >= 10) break;
          if (seen[m[0]]) continue;
          seen[m[0]] = true;
          var parent = node.parentElement;
          nodes.push({
            html: outerHtmlSnippet(parent || node),
            target: [parent ? targetSelector(parent) : 'body'],
            failureSummary: 'Plain-text email "' + m[0] + '" not wrapped in <a href="mailto:...">. Users on mobile cannot tap to compose.',
          });
        }
      } else if (node.nodeType === 1) {
        if (SKIP_TAGS[node.tagName]) return;
        for (var c = node.firstChild; c; c = c.nextSibling) walk(c);
      }
    }
    walk(doc.body || doc.documentElement);

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('mail-001', {
      impact: 'moderate',
      severity: 'warning',
      category: 'email_integrity',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Plain-text email address not wrapped in mailto: link',
      help: 'Email addresses in body content should be wrapped in <a href="mailto:..."> so mobile users can tap to compose. For high-volume addresses, consider replacing with a contact form to reduce spam-harvesting exposure.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 2,
    }));
    return violations;
  }

  /* mail-002: Outdated "user at domain dot com" aria-label on mailto link
     Deprecated screen-reader workaround that now creates WCAG 2.5.3
     (Label in Name) violations because visible "@" doesn't match
     aria-label "at" and visible "." doesn't match "dot". */
  function checkMail002(doc) {
    var violations = [];
    var anchors = doc.querySelectorAll('a[href^="mailto:"][aria-label], a[href^="mailto:"][aria-labelledby]');
    var nodes = [];
    /* Pattern: aria-label contains " at " and " dot " in order (case-insensitive),
       OR " at " followed by "dot" somewhere (more relaxed). */
    var DEPRECATED_RE = /\bat\b[^a-z]+[a-z0-9._%+-]+\b[^a-z]*\bdot\b/i;
    anchors.forEach(function(a) {
      var aria = a.getAttribute('aria-label');
      if (!aria) {
        /* aria-labelledby — get referenced element's text */
        var refId = a.getAttribute('aria-labelledby');
        if (refId) {
          var ref = doc.getElementById(refId);
          if (ref) aria = ref.textContent;
        }
      }
      if (!aria) return;
      if (!DEPRECATED_RE.test(aria)) return;
      nodes.push({
        html: outerHtmlSnippet(a),
        target: [targetSelector(a)],
        failureSummary: 'mailto: link uses deprecated "user at domain dot com" aria-label: "' + aria.substring(0, 80) + '". Modern screen readers handle "@" and "." correctly. The workaround now creates WCAG 2.5.3 Label in Name violations \u2014 voice control users speaking the visible email cannot activate the link. Remove the aria-label.',
      });
    });
    if (nodes.length === 0) return violations;
    violations.push(makeViolation('mail-002', {
      impact: 'serious',
      severity: 'warning',
      category: 'email_integrity',
      tags: ['wpsb-custom', 'wcag253', 'wcag2a'],
      description: 'Outdated "user at domain dot com" aria-label on mailto link',
      help: 'Modern screen readers (NVDA, JAWS, VoiceOver, TalkBack) handle @ and . in email addresses correctly. The "at...dot..." spelling workaround now creates Label in Name violations. Remove the aria-label entirely.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 1,
    }));
    return violations;
  }

  /* ════════════════════════════════════════════════════════════
     v1.5.0 (May 13, 2026) — Final Phase 2 catalog promotion batch
     5 new custom rules with deliberately narrow detection to avoid
     false positives:
       ada-002 — Color used as sole indicator (heuristic)
       ada-003 — Focus indicator removed (partial — inline + style blocks)
       ada-007 — Social icon link accessible name leaks class
       lng-002 — Missing Spanish accents (conservative — "-ción" only)
       lng-003 — Untranslated English in Spanish content (small wordlist)

     Deferred: ada-004 (touch target size) — needs rendered dimensions
     via Puppeteer/Playwright on Railway.
     ════════════════════════════════════════════════════════════ */

  /* ada-002: Color used as sole indicator of meaning
     Heuristic detection of text that instructs users to identify
     content by color alone (e.g., "items marked in red are required").
     WCAG 1.4.1 — information conveyed by color must also be available
     through another visual means (icon, label, pattern).
     This is a narrow heuristic that won't catch all cases — full
     detection requires understanding visual context (icons present?
     borders? labels?) which static DOM scanning can't do reliably. */
  function checkAda002(doc) {
    var violations = [];
    /* Pattern: "marked|highlighted|colored|shown|indicated|labeled|flagged in [color]" */
    var SOLE_INDICATOR_RE = /\b(marked|highlighted|colou?red|shown|indicated|labeled|flagged|displayed|shaded)\s+(in\s+)?(red|green|blue|yellow|orange|purple|gray|grey|pink|black|white)\b/gi;
    /* Also catch "[color] indicates" / "[color] means" / "[color] = ..." */
    var COLOR_SEMANTIC_RE = /\b(red|green|blue|yellow|orange|purple)\s+(indicates?|means?|signifies|equals?|=)\s+/gi;
    var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, CODE: 1, PRE: 1 };
    var nodes = [];
    var seen = {};

    function walk(node) {
      if (nodes.length >= 8) return;
      if (node.nodeType === 3) {
        var text = node.nodeValue || '';
        if (text.length < 8) return;
        var combinedText = text;
        SOLE_INDICATOR_RE.lastIndex = 0;
        COLOR_SEMANTIC_RE.lastIndex = 0;
        var m;
        while ((m = SOLE_INDICATOR_RE.exec(combinedText)) !== null) {
          if (nodes.length >= 8) break;
          if (seen[m[0]]) continue;
          seen[m[0]] = true;
          var parent = node.parentElement;
          nodes.push({
            html: outerHtmlSnippet(parent || node),
            target: [parent ? targetSelector(parent) : 'body'],
            failureSummary: 'Text instructs users to identify content by color alone: "' + m[0] + '". WCAG 1.4.1 \u2014 information conveyed by color must also have another visual indicator (icon, label, pattern, or border).',
          });
        }
        while ((m = COLOR_SEMANTIC_RE.exec(combinedText)) !== null) {
          if (nodes.length >= 8) break;
          if (seen[m[0]]) continue;
          seen[m[0]] = true;
          var parent2 = node.parentElement;
          nodes.push({
            html: outerHtmlSnippet(parent2 || node),
            target: [parent2 ? targetSelector(parent2) : 'body'],
            failureSummary: 'Text assigns semantic meaning to a color: "' + m[0] + '". Color-blind users miss this. Add an additional indicator (icon, text label, pattern).',
          });
        }
      } else if (node.nodeType === 1) {
        if (SKIP_TAGS[node.tagName]) return;
        for (var c = node.firstChild; c; c = c.nextSibling) walk(c);
      }
    }
    walk(doc.body || doc.documentElement);

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('ada-002', {
      impact: 'serious',
      severity: 'warning',
      category: 'ada_specific',
      tags: ['wpsb-custom', 'wcag141', 'wcag2a'],
      description: 'Color used as sole indicator of meaning',
      help: 'WCAG 1.4.1 Use of Color (Level A): information conveyed by color must also be available through another visual means \u2014 icon, text label, border pattern, etc. Color-blind users (~8% of men) miss color-only cues. Note: this heuristic catches text patterns that reference color as meaning; full detection of color-only cues requires manual review of visual design.',
      nodes: nodes,
      fqhcPriority: true,
      estimatedMinutes: 8,
    }));
    return violations;
  }

  /* ada-003: Focus indicator removed without replacement
     WCAG 2.4.7 Focus Visible (Level AA) — every focusable element
     must have a visible focus indicator. Common violations:
     inline `outline: 0` or CSS rules like `*:focus { outline: none }`.
     Partial detection — catches inline styles + style blocks. Full
     detection requires computed style access (which requires
     rendered browser, not static DOM). */
  function checkAda003(doc, rawHtml) {
    var violations = [];
    var nodes = [];
    var seen = {};

    /* Part A: inline style outline removal on focusable elements */
    var FOCUSABLE_INLINE = doc.querySelectorAll(
      'a[style*="outline"], button[style*="outline"], input[style*="outline"], textarea[style*="outline"], select[style*="outline"], [tabindex][style*="outline"]'
    );
    var OUTLINE_REMOVED_RE = /outline\s*:\s*(0|none|0px)\b/i;
    FOCUSABLE_INLINE.forEach(function(el) {
      var style = el.getAttribute('style') || '';
      if (!OUTLINE_REMOVED_RE.test(style)) return;
      /* Check if there's also a focus-style replacement (border, box-shadow on focus would be in a stylesheet — can't check from inline) */
      var key = targetSelector(el);
      if (seen[key]) return;
      seen[key] = true;
      nodes.push({
        html: outerHtmlSnippet(el),
        target: [key],
        failureSummary: 'Focusable element has inline style removing outline: "' + style.substring(0, 80) + '". Keyboard users lose the visible focus indicator. WCAG 2.4.7 (AA).',
      });
    });

    /* Part B: style blocks with :focus { outline: none } patterns */
    if (rawHtml) {
      var styleBlockRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
      var FOCUS_REMOVE_RE = /([^{}]*:focus[^{}]*)\{\s*([^}]*outline\s*:\s*(?:0|none|0px)[^}]*)\}/gi;
      var styleMatch;
      while ((styleMatch = styleBlockRe.exec(rawHtml)) !== null) {
        if (nodes.length >= 12) break;
        var blockContent = styleMatch[1];
        var ruleMatch;
        FOCUS_REMOVE_RE.lastIndex = 0;
        while ((ruleMatch = FOCUS_REMOVE_RE.exec(blockContent)) !== null) {
          if (nodes.length >= 12) break;
          var selector = ruleMatch[1].trim();
          var declarations = ruleMatch[2].trim();
          /* Skip if there's a focus replacement (box-shadow, border) — likely intentional restyle */
          if (/(box-shadow|border|outline-offset|background)\s*:/i.test(declarations)) continue;
          var snippet = selector + ' { ' + declarations + ' }';
          if (seen[snippet]) continue;
          seen[snippet] = true;
          nodes.push({
            html: snippet.substring(0, 200),
            target: ['style'],
            failureSummary: 'CSS rule removes focus indicator: "' + snippet.substring(0, 120) + '". No visible focus-state replacement found. WCAG 2.4.7 (AA) requires every focusable element to have a visible focus indicator.',
          });
        }
      }
    }

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('ada-003', {
      impact: 'serious',
      severity: 'warning',
      category: 'ada_specific',
      tags: ['wpsb-custom', 'wcag247', 'wcag2aa'],
      description: 'Focus indicator removed without replacement',
      help: 'WCAG 2.4.7 Focus Visible (Level AA): every focusable element must have a visible focus indicator. If you remove the default outline, replace it with another visible style (box-shadow, border, background change). Note: this detection covers inline styles and stylesheet rules visible in the page source; external stylesheets and computed styles require runtime browser checks.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 10,
    }));
    return violations;
  }

  /* ada-007: Social icon link accessible name leaks framework class
     Detects when an <a>'s accessible name is just a CSS class name
     (FontAwesome, theme icon class) instead of a descriptive label
     like "Visit our Facebook page". */
  function checkAda007(doc) {
    var violations = [];
    /* Framework class patterns that indicate name leak */
    var CLASS_PATTERNS = [
      /^fa[brs]?-/i,         /* FontAwesome: fa-, fab-, fas-, far- */
      /^icon[-_]/i,           /* "icon-facebook", "icon_twitter" */
      /^social[-_]/i,         /* "social-facebook" */
      /^elementor-/i,         /* "elementor-icon-..." */
      /^ti[-_]/i,             /* Themify icons */
      /^dashicons/i,          /* WordPress dashicons */
    ];
    /* Looks like a CSS class? Lowercase + hyphens, no spaces, contains a hyphen */
    var LOOKS_LIKE_CLASS_RE = /^[a-z][a-z0-9_]*(-[a-z0-9_]+)+$/i;
    var SOCIAL_PLATFORMS = ['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'tiktok', 'pinterest', 'snapchat', 'reddit', 'github', 'mastodon', 'threads'];

    function isClassLikeName(name) {
      if (!name) return false;
      var trimmed = name.trim();
      if (!trimmed) return false;
      /* Match framework prefixes */
      for (var i = 0; i < CLASS_PATTERNS.length; i++) {
        if (CLASS_PATTERNS[i].test(trimmed)) return true;
      }
      /* Class-name shape AND contains a social platform name embedded */
      if (LOOKS_LIKE_CLASS_RE.test(trimmed)) {
        var lower = trimmed.toLowerCase();
        for (var j = 0; j < SOCIAL_PLATFORMS.length; j++) {
          if (lower.indexOf(SOCIAL_PLATFORMS[j]) !== -1) return true;
        }
      }
      return false;
    }

    var anchors = doc.querySelectorAll('a[href]');
    var nodes = [];
    anchors.forEach(function(a) {
      var ariaLabel = (a.getAttribute('aria-label') || '').trim();
      var visibleText = (a.textContent || '').trim();
      var accessibleName = ariaLabel || visibleText;
      if (!accessibleName) return;  /* No name at all — caught by lnk-013 */
      if (!isClassLikeName(accessibleName)) return;
      nodes.push({
        html: outerHtmlSnippet(a),
        target: [targetSelector(a)],
        failureSummary: 'Link\u2019s accessible name "' + accessibleName.substring(0, 60) + '" looks like a CSS class, not a descriptive label. Screen readers announce the class name literally. Replace with something like "Visit our Facebook page".',
      });
    });

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('ada-007', {
      impact: 'serious',
      severity: 'warning',
      category: 'ada_specific',
      tags: ['wpsb-custom', 'wcag244', 'wcag412', 'wcag2a'],
      description: 'Social icon link accessible name leaks framework class',
      help: 'A link\u2019s accessible name should describe what it does ("Visit our Facebook page"), not match a CSS class name ("fa-facebook"). Common when theme builders auto-populate aria-label from icon class.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 3,
    }));
    return violations;
  }

  /* lng-002: Missing Spanish accents (conservative — "-ción" patterns)
     Only flags high-confidence cases inside lang="es" content where
     a word ending in "-cion" should be "-ción". Avoids ambiguous
     words like "ano" (anus) vs "año" (year) or "mas" (but) vs "más"
     (more). */
  function checkLng002(doc) {
    var violations = [];
    /* Find lang="es" containers */
    var spanishContainers = doc.querySelectorAll('[lang="es"], [lang^="es-"]');
    if (spanishContainers.length === 0) return violations;

    /* High-confidence Spanish word list (accented forms).
       Strategy: detect the unaccented form INSIDE lang="es" content.
       Only include words where the unaccented form has no alternate
       valid Spanish meaning. */
    var ACCENTED_TO_UNACCENTED = {
      'información': 'informacion',
      'atención':    'atencion',
      'sección':     'seccion',
      'ubicación':   'ubicacion',
      'dirección':   'direccion',
      'situación':   'situacion',
      'tradición':   'tradicion',
      'comunicación':'comunicacion',
      'declaración': 'declaracion',
      'organización':'organizacion',
      'inscripción': 'inscripcion',
      'descripción': 'descripcion',
      'aplicación':  'aplicacion',
      'institución': 'institucion',
      'condición':   'condicion',
      'producción':  'produccion',
      'reducción':   'reduccion',
      'acción':      'accion',
      'reunión':     'reunion',
      'opción':      'opcion',
      'fundación':   'fundacion',
      'también':     'tambien',
      'después':     'despues',
      'según':       'segun',
      'español':     'espanol',
      'cómo':        'como_in_question',  /* needs context — skipped */
    };

    /* Build reverse map of unaccented → accented (for words we'll flag) */
    var FLAG_MAP = {};
    Object.keys(ACCENTED_TO_UNACCENTED).forEach(function(accented) {
      var unaccented = ACCENTED_TO_UNACCENTED[accented];
      if (unaccented.indexOf('_in_question') !== -1) return;  /* skip ambiguous */
      FLAG_MAP[unaccented.toLowerCase()] = accented;
    });

    var nodes = [];
    var seen = {};

    function walkText(node) {
      if (nodes.length >= 15) return;
      if (node.nodeType === 3) {
        var text = node.nodeValue || '';
        if (text.length < 4) return;
        /* Find word boundaries, check each word against FLAG_MAP */
        var wordRe = /\b([a-záéíóúñü]+)\b/gi;
        var m;
        while ((m = wordRe.exec(text)) !== null) {
          if (nodes.length >= 15) break;
          var word = m[1].toLowerCase();
          if (FLAG_MAP[word]) {
            var key = word;
            if (seen[key]) continue;
            seen[key] = true;
            var parent = node.parentElement;
            nodes.push({
              html: outerHtmlSnippet(parent || node),
              target: [parent ? targetSelector(parent) : 'body'],
              failureSummary: 'Spanish word "' + m[1] + '" missing accent. Should be "' + FLAG_MAP[word] + '".',
            });
          }
        }
      } else if (node.nodeType === 1) {
        for (var c = node.firstChild; c; c = c.nextSibling) walkText(c);
      }
    }
    spanishContainers.forEach(walkText);

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('lng-002', {
      impact: 'minor',
      severity: 'warning',
      category: 'multilingual_content',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Missing Spanish accents in common words',
      help: 'Words inside lang="es" content are missing their required diacritical marks (acentos). Screen reader pronunciation engines for Spanish rely on accents. Detection is conservative \u2014 only flags high-confidence patterns like "-ción" without the accent.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 4,
    }));
    return violations;
  }

  /* lng-003: Untranslated English inside Spanish content
     Conservative detection — small wordlist of common English UI
     words appearing inside lang="es" content. Avoids ambiguous
     words that legitimately appear in Spanish (e.g., "menu" can be
     intentional). */
  function checkLng003(doc) {
    var violations = [];
    var spanishContainers = doc.querySelectorAll('[lang="es"], [lang^="es-"]');
    if (spanishContainers.length === 0) return violations;

    /* Conservative wordlist: English words common in UI/navigation
       that should be translated. Limited to phrases/words rarely
       used as-is in Spanish content. */
    var ENGLISH_PHRASES = [
      /\bclick here\b/i,
      /\bread more\b/i,
      /\blearn more\b/i,
      /\bsubscribe\b/i,
      /\bcontact us\b/i,
      /\bsearch\.\.\.\b/i,
      /\bloading\.\.\./i,
      /\bplease wait\b/i,
      /\bsubmit\b/i,
      /\bget started\b/i,
      /\bwelcome\b/i,
      /\bsign in\b/i,
      /\bsign up\b/i,
      /\bsign out\b/i,
      /\blog in\b/i,
      /\blog out\b/i,
      /\babout us\b/i,
      /\bour services\b/i,
      /\bdownload now\b/i,
      /\bview all\b/i,
    ];

    var nodes = [];
    var seen = {};

    function walkText(node) {
      if (nodes.length >= 10) return;
      if (node.nodeType === 3) {
        var text = node.nodeValue || '';
        if (text.length < 4) return;
        for (var i = 0; i < ENGLISH_PHRASES.length; i++) {
          var m = text.match(ENGLISH_PHRASES[i]);
          if (m) {
            if (seen[m[0].toLowerCase()]) continue;
            seen[m[0].toLowerCase()] = true;
            var parent = node.parentElement;
            nodes.push({
              html: outerHtmlSnippet(parent || node),
              target: [parent ? targetSelector(parent) : 'body'],
              failureSummary: 'English phrase "' + m[0] + '" appears inside lang="es" content. Translate to Spanish equivalent.',
            });
            if (nodes.length >= 10) return;
          }
        }
      } else if (node.nodeType === 1) {
        for (var c = node.firstChild; c; c = c.nextSibling) walkText(c);
      }
    }
    spanishContainers.forEach(walkText);

    if (nodes.length === 0) return violations;
    violations.push(makeViolation('lng-003', {
      impact: 'minor',
      severity: 'warning',
      category: 'multilingual_content',
      tags: ['wpsb-custom', 'best-practice'],
      description: 'Untranslated English inside Spanish content',
      help: 'Common English UI phrases ("click here", "subscribe", "welcome") appear inside lang="es" content. Translate to Spanish equivalents for native screen reader pronunciation and consistent UX.',
      nodes: nodes,
      fqhcPriority: false,
      estimatedMinutes: 3,
    }));
    return violations;
  }


  /* ────────────────────────────────────────────────────────
     KPCHC ENGAGEMENT RULES (v1.6.0) — Alt Text Quality + Placeholder
     Source: kpchc.org live engagement May 2026
     ──────────────────────────────────────────────────────── */

  /* alt-001: Empty alt on non-decorative image
     img[alt=""] where image appears to convey meaning.
     Distinct from ada-005 (missing alt attribute entirely). */
  function checkAlt001(doc) {
    var violations = [];
    var nodes = [];
    var imgs = doc.querySelectorAll('img[alt=""]');
    imgs.forEach(function(img) {
      /* Skip known-decorative patterns */
      var role = img.getAttribute('role') || '';
      if (role === 'presentation' || role === 'none') return;
      /* Skip tiny images (icon-size) */
      var w = parseInt(img.getAttribute('width') || '0', 10);
      var h = parseInt(img.getAttribute('height') || '0', 10);
      if (w > 0 && w < 16 && h > 0 && h < 16) return;
      /* Skip images with decorative class names */
      var cls = (img.className || '').toLowerCase();
      if (/(decorative|icon-bg|background-img|spacer)/.test(cls)) return;
      nodes.push({
        html: outerHtmlSnippet(img),
        target: [targetSelector(img)],
        failureSummary: 'Image has empty alt but appears to convey meaning. Add descriptive alt text, or add role="presentation" to confirm it is decorative.',
      });
    });
    if (nodes.length) {
      violations.push(makeViolation('alt-001', {
        impact: 'serious',
        severity: 'critical',
        category: 'alt_text_quality',
        tags: ['wpsb-custom', 'wcag111', 'wcag2a'],
        description: 'Empty alt on non-decorative image',
        help: 'img[alt=""] on a meaningful content image is a WCAG 1.1.1 failure. Add descriptive alt text or add role="presentation" if truly decorative.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 3,
      }));
    }
    return violations;
  }

  /* alt-002: Redundant "image of" / "photo of" prefix in alt text */
  function checkAlt002(doc) {
    var violations = [];
    var nodes = [];
    var pattern = /^(an?\s+)?(image|picture|photo|photograph|graphic|pic)\s+(of|showing)/i;
    var imgs = doc.querySelectorAll('img[alt]');
    imgs.forEach(function(img) {
      var alt = img.getAttribute('alt') || '';
      if (!alt.trim()) return;
      if (pattern.test(alt)) {
        nodes.push({
          html: outerHtmlSnippet(img),
          target: [targetSelector(img)],
          failureSummary: 'Alt text starts with redundant prefix "' + alt.substring(0, 30) + '". Screen readers already announce images.',
        });
      }
    });
    if (nodes.length) {
      violations.push(makeViolation('alt-002', {
        impact: 'minor',
        severity: 'low',
        category: 'alt_text_quality',
        tags: ['wpsb-custom', 'wcag111'],
        description: 'Redundant "image of" / "photo of" prefix in alt text',
        help: 'Remove redundant prefix. "An image of Dr. Smith" → "Dr. Smith". Screen readers already announce the image role.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* alt-003: Action instruction in alt text ("click on it", "tap to view") */
  function checkAlt003(doc) {
    var violations = [];
    var nodes = [];
    var pattern = /\b(click|tap|press|select\s+this|see\s+more|view\s+more)\b.*\b(here|it|this|more)\b/i;
    var imgs = doc.querySelectorAll('img[alt]');
    imgs.forEach(function(img) {
      var alt = img.getAttribute('alt') || '';
      if (!alt.trim()) return;
      if (pattern.test(alt)) {
        nodes.push({
          html: outerHtmlSnippet(img),
          target: [targetSelector(img)],
          failureSummary: 'Alt text contains action instruction: "' + alt.substring(0, 50) + '".',
        });
      }
    });
    if (nodes.length) {
      violations.push(makeViolation('alt-003', {
        impact: 'moderate',
        severity: 'warning',
        category: 'alt_text_quality',
        tags: ['wpsb-custom', 'wcag111', 'wcag244'],
        description: 'Action instruction in alt text',
        help: 'Alt text describes the image, not what to do. Move "click to read bio" to link text or aria-label.',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 2,
      }));
    }
    return violations;
  }

  /* alt-004: Non-descriptive linked logo — alt is just "Logo" or "[Name] Logo"
     while the anchor links to the homepage. */
  function checkAlt004(doc) {
    var violations = [];
    var nodes = [];
    var logoAltPattern = /^([\w\s]+\s+)?logo$/i;
    var links = doc.querySelectorAll('a[href] img[alt]');
    links.forEach(function(img) {
      var alt = (img.getAttribute('alt') || '').trim();
      if (!alt) return;
      if (!logoAltPattern.test(alt)) return;
      var anchor = img.closest('a');
      if (!anchor) return;
      var href = (anchor.getAttribute('href') || '').replace(/\/$/, '');
      /* Flag if links to root or very short path (homepage patterns) */
      if (href === '' || href === '/' || href === '#' || /^https?:\/\/[^\/]+\/?$/.test(href)) {
        nodes.push({
          html: outerHtmlSnippet(img),
          target: [targetSelector(img)],
          failureSummary: 'Logo image links to homepage but alt="' + alt + '" does not describe the destination. Use "[Org Name] — Home".',
        });
      }
    });
    if (nodes.length) {
      violations.push(makeViolation('alt-004', {
        impact: 'moderate',
        severity: 'warning',
        category: 'alt_text_quality',
        tags: ['wpsb-custom', 'wcag111', 'wcag244'],
        description: 'Non-descriptive linked logo alt text',
        help: 'Logo links to homepage. Alt text should describe link destination: "[Organization Name] — Home".',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 2,
      }));
    }
    return violations;
  }

  /* alt-005: Standalone word "Image" in alt text (redundant) */
  function checkAlt005(doc) {
    var violations = [];
    var nodes = [];
    /* Match standalone word "Image" but exclude contextual uses like "MRI image" */
    var pattern = /\bimage\b/i;
    var exclude = /\b(MRI|CT|X-ray|ultrasound|radiograph|stock|satellite|aerial|thermal)\s+image\b/i;
    var imgs = doc.querySelectorAll('img[alt]');
    imgs.forEach(function(img) {
      var alt = (img.getAttribute('alt') || '').trim();
      if (!alt) return;
      if (pattern.test(alt) && !exclude.test(alt)) {
        nodes.push({
          html: outerHtmlSnippet(img),
          target: [targetSelector(img)],
          failureSummary: 'Alt text contains redundant word "image": "' + alt.substring(0, 50) + '".',
        });
      }
    });
    if (nodes.length) {
      violations.push(makeViolation('alt-005', {
        impact: 'minor',
        severity: 'low',
        category: 'alt_text_quality',
        tags: ['wpsb-custom', 'wcag111'],
        description: 'Standalone word "Image" in alt text is redundant',
        help: 'Screen readers announce images as "image" automatically. Remove the word: "Gold Level Award Image" → "Gold Level Award".',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 1,
      }));
    }
    return violations;
  }

  /* plh-001: Known placeholder image asset on live page
     Checks for Elementor placeholder.png and common builder placeholder paths. */
  function checkPlh001(doc) {
    var violations = [];
    var nodes = [];
    var placeholderPattern = /(elementor\/assets\/images\/placeholder\.png|[\/](placeholder|photo-filler|coming-soon-placeholder|person-placeholder|silhouette-placeholder|dummy-image)\.(png|jpg|svg|gif)|[\/]img\/placeholder|filler[-_]image)/i;
    var imgs = doc.querySelectorAll('img[src], img[data-src]');
    imgs.forEach(function(img) {
      var src = img.getAttribute('src') || img.getAttribute('data-src') || '';
      if (!placeholderPattern.test(src)) return;
      /* Elevate severity if alt text contains a real-sounding name */
      var alt = (img.getAttribute('alt') || '').trim();
      var hasName = alt.length > 3 && /^[A-Z][a-z]+ [A-Z][a-z]+/.test(alt);
      nodes.push({
        html: outerHtmlSnippet(img),
        target: [targetSelector(img)],
        failureSummary: 'Placeholder image on live page: ' + src.split('/').pop() + (hasName ? ' (alt suggests real content: "' + alt + '")' : ''),
      });
    });
    if (nodes.length) {
      violations.push(makeViolation('plh-001', {
        impact: nodes.some(function(n) { return n.failureSummary.indexOf('alt suggests') !== -1; }) ? 'serious' : 'moderate',
        severity: 'warning',
        category: 'content_placeholder',
        tags: ['wpsb-custom', 'content-quality'],
        description: 'Known placeholder image asset on live page',
        help: 'Replace placeholder with real content, or confirm the placeholder is intentional and add alt="".',
        nodes: nodes,
        fqhcPriority: true,
        estimatedMinutes: 10,
      }));
    }
    return violations;
  }

  /* plh-002: Page builder field default left unpopulated
     Catches Elementor/Divi/WPBakery default text published on live pages. */
  function checkPlh002(doc) {
    var violations = [];
    var nodes = [];
    var builderDefaults = [
      'Add Your Heading Text Here',
      'I Am Slide Title',
      'Click to Edit',
      'Write Your Caption Here',
      'Add a strong one liner',
      'Your Subtitle Here',
      'Click here to edit',
      'Write Your Text Here',
      'Add Your Title Text Here',
    ];
    var pattern = new RegExp('(' + builderDefaults.map(function(s) {
      return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }).join('|') + ')', 'i');

    /* Also check for standalone "Title" as visible text in heading/span
       that isn't a label — common Elementor unpopulated field */
    var headings = doc.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,div');
    headings.forEach(function(el) {
      if (el.children.length > 0) return; /* skip wrappers */
      var text = (el.textContent || '').trim();
      if (!text) return;
      if (pattern.test(text)) {
        nodes.push({
          html: outerHtmlSnippet(el),
          target: [targetSelector(el)],
          failureSummary: 'Page builder default text on live page: "' + text.substring(0, 60) + '".',
        });
      }
    });
    if (nodes.length) {
      violations.push(makeViolation('plh-002', {
        impact: 'moderate',
        severity: 'warning',
        category: 'content_placeholder',
        tags: ['wpsb-custom', 'content-quality'],
        description: 'Page builder field default left unpopulated',
        help: 'Fill in the actual content for this page builder field before publishing.',
        nodes: nodes,
        fqhcPriority: false,
        estimatedMinutes: 5,
      }));
    }
    return violations;
  }

  /* ────────────────────────────────────────────────────────
     RULE REGISTRY — maps rule_id to check function
     Phase 2/3 rules will register here as they're implemented.
     ──────────────────────────────────────────────────────── */
  var PHASE_1_CHECKS = {
    'lnk-001': checkLnk001,   /* v1.2.0 — internal target=_blank */
    'lnk-002': checkLnk002,   /* v1.2.0 — external missing rel=noopener */
    'lnk-003': checkLnk003,
    'lnk-004': checkLnk004,
    'lnk-005': checkLnk005,   /* v1.2.0 — uppercase URL slug */
    'lnk-006': checkLnk006,
    'lnk-007': checkLnk007,   /* v1.2.0 — weak link text */
    'lnk-008': checkLnk008,   /* v1.2.0 — PDF link missing indicator */
    'lnk-009': checkLnk009,   /* v1.1.0 — fake link */
    'tel-001': checkTel001,   /* v1.2.0 — tel: missing +1 */
    'tel-002': checkTel002,   /* v1.2.0 — tel: contains formatting chars */
    'tel-003': checkTel003,   /* v1.2.0 — extension in display but not href */
    'tel-004': checkTel004,   /* v1.2.0 — phone link missing aria-label */
    'tel-005': checkTel005,   /* v1.1.0 — phone digit mismatch */
    'lng-001': checkLng001,
    'lng-002': checkLng002,   /* v1.5.0 — missing Spanish accents */
    'lng-003': checkLng003,   /* v1.5.0 — untranslated English in Spanish */
    'sem-001': checkSem001,
    'sem-002': checkSem002,   /* v1.3.0 — multiple H1 elements */
    'sem-004': checkSem004,   /* v1.3.0 — duplicate heading text at same level */
    'sem-007': checkSem007,
    'sem-009': checkSem009,   /* v1.3.0 — multiple nav without aria-label */
    'sem-010': checkSem010,   /* v1.1.0 — broken HTML tags */
    'sem-011': checkSem011,   /* v1.3.0 — excessive consecutive <br> tags */
    'sem-012': checkSem012,   /* v1.3.0 — markdown leakage in HTML body */
    'sem-013': checkSem013,   /* v1.3.0 — ALL CAPS body text */
    'inl-001': checkInl001,   /* v1.4.0 — inline color span wrapping link */
    'inl-002': checkInl002,   /* v1.3.0 — inline style on link/p/span/div */
    'inl-003': checkInl003,   /* v1.4.0 — repeated inline style on siblings */
    'inl-005': checkInl005,   /* v1.4.0 — redundant color on wrapper+child */
    'inl-006': checkInl006,   /* v1.4.0 — Word/Pages class artifacts */
    'inl-007': checkInl007,   /* v1.4.0 — <b> vs <strong> */
    'lnk-010': checkLnk010,   /* v1.4.0 — year-dated URL */
    'lnk-011': checkLnk011,   /* v1.4.0 — URL tracking parameters */
    'lnk-012': checkLnk012,   /* v1.4.0 — leading space in href */
    'tel-006': checkTel006,   /* v1.4.0 — plain-text phone (no tel: wrapper) */
    'tel-007': checkTel007,   /* v1.4.0 — phone display uses periods */
    'mail-001': checkMail001, /* v1.4.0 — plain-text email (no mailto: wrapper) */
    'mail-002': checkMail002, /* v1.4.0 — outdated "user at domain dot com" aria-label */
    'ada-001': checkAda001,
    'ada-002': checkAda002,   /* v1.5.0 — color as sole indicator (heuristic) */
    'ada-003': checkAda003,   /* v1.5.0 — focus indicator removed (partial) */
    'ada-005': checkAda005,
    'ada-007': checkAda007,   /* v1.5.0 — social icon class leak */
    'ada-008': checkAda008,   /* v1.1.0 — aria-label Label in Name */
    'ada-009': checkAda009,   /* v1.1.1 — conflicting ARIA (WCAG 4.1.2) */
    'ada-011': checkAda011,   /* v1.1.3 — image link stutter (WCAG 1.1.1/2.4.4) */
    'ada-012': checkAda012,   /* v1.1.4 — label inside textarea (AI anti-pattern) */
    /* v1.6.0 — kpchc.org engagement rules */
    'alt-001': checkAlt001,   /* v1.6.0 — empty alt on non-decorative image */
    'alt-002': checkAlt002,   /* v1.6.0 — "image of" prefix in alt */
    'alt-003': checkAlt003,   /* v1.6.0 — action instruction in alt */
    'alt-004': checkAlt004,   /* v1.6.0 — non-descriptive linked logo alt */
    'alt-005': checkAlt005,   /* v1.6.0 — standalone "Image" in alt */
    'plh-001': checkPlh001,   /* v1.6.0 — known placeholder image on live page */
    'plh-002': checkPlh002,   /* v1.6.0 — page builder default text unpopulated */
  };

  /* ────────────────────────────────────────────────────────
     PUBLIC API
     ──────────────────────────────────────────────────────── */
  window.WPSB.AdaCustomChecks = {

    /* VERSION = scanner code version (Phase 1 custom checks in this file).
       CATALOG_VERSION = cd-scan-rules.json catalog version (catalog can
       advance separately when server-side rules like ada-004 are added). */
    VERSION: '1.6.0',
    CATALOG_VERSION: '1.8.0',
    PHASES_IMPLEMENTED: [1],
    RULES_ADDED_V160: ['alt-001','alt-002','alt-003','alt-004','alt-005','plh-001','plh-002'],

    /* Run all available checks against a DOM document.
       opts:
         - phase: number (1, 2, 3) — restrict to specific phase
         - severityFilter: 'critical' | 'warning' | 'suggestion' | null
         - rawHtml: string (required for sem-007 orphan-tag check)
         - fqhcOnly: boolean — return only FQHC-priority violations
       Returns: Array of violation objects (axe-compatible) */
    runChecks: function(doc, opts) {
      opts = opts || {};
      var allViolations = [];

      Object.keys(PHASE_1_CHECKS).forEach(function(ruleId) {
        try {
          var fn = PHASE_1_CHECKS[ruleId];
          var result = fn(doc, opts.rawHtml);
          if (result && result.length) {
            result.forEach(function(v) { allViolations.push(v); });
          }
        } catch (err) {
          console.warn('[ada-custom-checks] Rule ' + ruleId + ' failed:', err.message);
        }
      });

      /* Apply filters */
      if (opts.severityFilter) {
        allViolations = allViolations.filter(function(v) {
          return v.wpsb_severity === opts.severityFilter;
        });
      }
      if (opts.fqhcOnly) {
        allViolations = allViolations.filter(function(v) {
          return v.wpsb_fqhc_priority === true;
        });
      }

      return allViolations;
    },

    /* Returns metadata for a given rule_id, useful for UI rendering */
    getRuleMetadata: function(ruleId) {
      return {
        rule_id: ruleId,
        implemented: !!PHASE_1_CHECKS[ruleId],
        phase: 1,  /* placeholder — pull from catalog when wired */
      };
    },

    /* List of currently-implemented rule IDs */
    implementedRules: function() {
      return Object.keys(PHASE_1_CHECKS);
    },
  };

  /* Debug helper — log to console on load */
  console.log('[WPSB] AdaCustomChecks v' + window.WPSB.AdaCustomChecks.VERSION + ' loaded (' + Object.keys(PHASE_1_CHECKS).length + ' Phase 1 rules)');

})();
