/**
* 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