feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+130
View File
@@ -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("<", "&lt;")
.replaceAll(">", "&gt;");
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",
};
}