feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
SemanticSvgDocument,
|
||||
SourcePatch,
|
||||
SourceRange,
|
||||
} from "../document/document.types";
|
||||
import { buildReferenceIndex } from "../structure/reference-index";
|
||||
|
||||
export interface AccessibilityFinding {
|
||||
severity: "info" | "warning" | "error";
|
||||
rule: string;
|
||||
nodeKey: string;
|
||||
evidence: string;
|
||||
limitation: string;
|
||||
suggestedFix: string;
|
||||
automaticFix: "add-title" | "add-description" | "mark-decorative" | null;
|
||||
sourceRange?: SourceRange;
|
||||
}
|
||||
|
||||
export function auditAccessibility(
|
||||
semantic: SemanticSvgDocument,
|
||||
): AccessibilityFinding[] {
|
||||
const root = semantic.nodes.get(semantic.rootKey)!;
|
||||
const children = root.childKeys.map((key) => semantic.nodes.get(key)!);
|
||||
const title = children.find((node) => node.localName === "title");
|
||||
const description = children.find((node) => node.localName === "desc");
|
||||
const role = root.attributes.role;
|
||||
const labelledBy = root.attributes["aria-labelledby"];
|
||||
const label = root.attributes["aria-label"];
|
||||
const hidden = root.attributes["aria-hidden"] === "true";
|
||||
const findings: AccessibilityFinding[] = [];
|
||||
if (!hidden && !title && !label && !labelledBy) {
|
||||
findings.push({
|
||||
severity: "warning",
|
||||
rule: "svg-accessible-name",
|
||||
nodeKey: root.key,
|
||||
evidence: "The root has no title, aria-label or aria-labelledby.",
|
||||
limitation:
|
||||
"The embedding page can provide an accessible name outside this file.",
|
||||
suggestedFix:
|
||||
"Add a concise root title or explicitly mark the image decorative.",
|
||||
automaticFix: "add-title",
|
||||
sourceRange: root.sourceRange.openTag,
|
||||
});
|
||||
}
|
||||
if (!hidden && !description) {
|
||||
findings.push({
|
||||
severity: "info",
|
||||
rule: "svg-description",
|
||||
nodeKey: root.key,
|
||||
evidence: "No root description element is present.",
|
||||
limitation:
|
||||
"Not every simple or decorative SVG needs a long description.",
|
||||
suggestedFix:
|
||||
"Add a description when the image communicates non-trivial content.",
|
||||
automaticFix: "add-description",
|
||||
sourceRange: root.sourceRange.openTag,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!hidden &&
|
||||
role &&
|
||||
!["img", "graphics-document", "presentation", "none"].includes(role)
|
||||
) {
|
||||
findings.push({
|
||||
severity: "info",
|
||||
rule: "svg-role-review",
|
||||
nodeKey: root.key,
|
||||
evidence: `Root role is “${role}”.`,
|
||||
limitation:
|
||||
"Role validity depends on the embedding and interaction model.",
|
||||
suggestedFix: "Review the role against the intended embedding context.",
|
||||
automaticFix: null,
|
||||
sourceRange: root.attributeRanges.role?.valueRange,
|
||||
});
|
||||
}
|
||||
const references = buildReferenceIndex(semantic);
|
||||
for (const edge of references.edges.filter(
|
||||
(candidate) =>
|
||||
candidate.attribute.startsWith("aria-") &&
|
||||
candidate.status !== "resolved",
|
||||
)) {
|
||||
const node = semantic.nodes.get(edge.sourceKey)!;
|
||||
findings.push({
|
||||
severity: "error",
|
||||
rule: "aria-reference",
|
||||
nodeKey: edge.sourceKey,
|
||||
evidence: `${edge.attribute} target “${edge.targetId}” is ${edge.status}.`,
|
||||
limitation: "Only local SVG ID references are evaluated.",
|
||||
suggestedFix: "Repair the referenced ID or remove the broken token.",
|
||||
automaticFix: null,
|
||||
sourceRange: node.attributeRanges[edge.attribute]?.valueRange,
|
||||
});
|
||||
}
|
||||
for (const node of semantic.nodes.values()) {
|
||||
if (node.localName === "path" && /text/i.test(node.id ?? "")) {
|
||||
findings.push({
|
||||
severity: "info",
|
||||
rule: "text-as-path-review",
|
||||
nodeKey: node.key,
|
||||
evidence:
|
||||
"A path ID suggests that visible text may have been outlined.",
|
||||
limitation: "The audit cannot infer author intent from path geometry.",
|
||||
suggestedFix:
|
||||
"Prefer a text element when editable, selectable text is required.",
|
||||
automaticFix: null,
|
||||
sourceRange: node.sourceRange.openTag,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function accessibilityFixPatch(
|
||||
semantic: SemanticSvgDocument,
|
||||
fix: "add-title" | "add-description",
|
||||
text: string,
|
||||
): SourcePatch {
|
||||
const root = semantic.nodes.get(semantic.rootKey)!;
|
||||
const escaped = text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
const tag = fix === "add-title" ? "title" : "desc";
|
||||
return {
|
||||
from: root.sourceRange.openTag.to,
|
||||
to: root.sourceRange.openTag.to,
|
||||
insert: `${semantic.preferences.newline}${semantic.preferences.indentation}<${tag}>${escaped}</${tag}>`,
|
||||
label: fix === "add-title" ? "Add accessible title" : "Add description",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user