From 182b1485ca86124a8dc43b5aa17d57628644ac41 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Fri, 24 Jul 2026 10:28:12 +0000 Subject: [PATCH] =?UTF-8?q?qa:=20flag=20empty=20alt=20(not=20just=20missin?= =?UTF-8?q?g)=20=E2=80=94=20decorative-only=20exemption=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty alt is valid only for purely decorative images; a content image (photo/cover/screenshot) with empty alt is a real a11y gap. QA now surfaces empty and missing alt as distinct findings. --- agents/console/server.mjs | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/agents/console/server.mjs b/agents/console/server.mjs index 8557758..d2792f9 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -638,15 +638,22 @@ const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } }; const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]); const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; }; -function qaImgsNoAlt(html) { - // An image "has alt" if it carries an alt attribute at all — including an - // empty one (alt="" or a bare `alt`), which is the valid, deliberate signal - // for a decorative image. Only a total absence of alt is a finding. - const hasAlt = (tag) => /\salt(\s*=|[\s>\/])/i.test(tag); - return [...String(html).matchAll(/]*>/gi)].map((m) => m[0]) - .filter((tag) => !hasAlt(tag)) - .map((tag) => (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1]) - .filter(Boolean); +// Classify each : alt missing entirely, or present-but-empty (alt="" or a +// bare `alt`). Both are surfaced — an empty alt is only correct for a purely +// decorative image, so a content image (photo, cover, screenshot) with empty +// alt is a real accessibility gap, not a pass. +function qaAltIssues(html) { + const out = []; + for (const m of String(html).matchAll(/]*>/gi)) { + const tag = m[0]; + const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1]; + if (!src) continue; + const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i); + if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); } + else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" }); // bare `alt` + else out.push({ src, kind: "missing" }); + } + return out; } async function runQa() { @@ -693,7 +700,12 @@ async function runQa() { else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path); } for (const src of qaGrabAll(/]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); } - for (const src of qaImgsNoAlt(html)) add("a11y", "warning", path, `Image without alt: ${src}`, `On ${path}, the image "${src}" has no alt text. Add descriptive alt text.`); + for (const a of qaAltIssues(html)) { + if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`, + `On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`); + else add("a11y", "warning", path, `Missing alt: ${a.src}`, + `On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`); + } } // duplicate titles across pages