/** * run-design-audit — read-only a11y audit over a design's rendered HTML/DOM. * * Flags low-opacity text color classes as a contrast hint (real contrast * ratios require a DOM/CSS cascade resolver that isn't available server-side, * so this is not a computed ratio check), plus tap-target sizes, missing * alt/labels, focus visibility, and reduced-motion concerns, all by static * analysis of the stored HTML. Does NOT perform writes. Results are returned * as `A11yFinding[]` and may be persisted by the caller via * `create-design-review-snapshot`. * * See DESIGN-STUDIO-PLAN.md §6.5 + §7 (Review surface). */ import { defineAction } from "@agent-native/core"; import { getText, hasCollabState } from "@agent-native/core/collab"; import { accessFilter } from "@agent-native/core/sharing"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import "../server/db/index.js"; // ensure registerShareableResource runs import type { A11yFinding, A11yFindingCategory, A11ySeverity, } from "../shared/design-review.js"; // --------------------------------------------------------------------------- // HTML helpers (static analysis — no DOM runtime available server-side) // --------------------------------------------------------------------------- /** Extract all attribute values matching a simple regex over raw HTML. */ function extractAttrs( html: string, tagPattern: RegExp, attrName: string, ): string[] { const attrRegex = new RegExp( `${attrName}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`, "gi", ); const results: string[] = []; let match: RegExpExecArray | null; const tagMatches = [...html.matchAll(tagPattern)]; for (const tm of tagMatches) { attrRegex.lastIndex = 0; while ((match = attrRegex.exec(tm[0])) !== null) { results.push(match[1] ?? match[2] ?? ""); } } return results; } /** Pull a node id from a raw tag string (data-agent-native-node-id attr). */ function extractNodeId(tagHtml: string): string | undefined { const m = tagHtml.match( /data-agent-native-node-id\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i, ); return m ? (m[1] ?? m[2] ?? undefined) : undefined; } /** Pull a CSS selector hint from a raw tag string (id or class). */ function extractSelector(tagHtml: string, tagName: string): string | undefined { const idMatch = tagHtml.match(/\bid\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i); if (idMatch) return `#${idMatch[1] ?? idMatch[2]}`; const classMatch = tagHtml.match(/\bclass\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i); if (classMatch) { const classNames = (classMatch[1] ?? classMatch[2] ?? "") .trim() .split(/\s+/) .filter(Boolean); if (classNames.length > 0) { // Include every class, not just the first: Tailwind screens commonly // have several sibling elements sharing one common utility class (e.g. // two buttons both carrying "h-4" but differing in every other class). // A first-class-only selector is ambiguous and apply-a11y-fix's // deterministic edit engine (which treats every dot segment after the // tag as a required class, see shared/code-layer.ts) would match the // wrong element among several sharing only that one class. return `${tagName.toLowerCase()}.${classNames.join(".")}`; } } return undefined; } // --------------------------------------------------------------------------- // Individual audit checks // --------------------------------------------------------------------------- /** Check tags without a meaningful alt attribute. */ function checkMissingAlt(html: string): A11yFinding[] { const findings: A11yFinding[] = []; const imgPattern = /]*>/gi; let idx = 0; for (const m of html.matchAll(imgPattern)) { const tag = m[0]; // Missing alt entirely, or empty alt on a non-decorative image (heuristic) const altMatch = tag.match(/\balt\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i); if (!altMatch) { findings.push({ id: `missing-alt:img-${idx}`, severity: "error" as A11ySeverity, category: "missing-alt" as A11yFindingCategory, message: " is missing an alt attribute.", detail: 'Add alt="" for decorative images or a descriptive alt for informative images.', nodeId: extractNodeId(tag), selector: extractSelector(tag, "img"), wcag: "1.1.1", fixAvailable: false, }); } idx++; } return findings; } /** Escape regex metacharacters so untrusted HTML attribute values (e.g. an * author-supplied `id`) can be safely interpolated into a `RegExp` source * string instead of crashing the audit or matching unintended text. */ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Check form inputs without an associated