From 9e336ad538e05ce2c55213347467a762d06bb0ac Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Tue, 11 Aug 2026 16:42:51 -0700
Subject: [PATCH 1/6] fix: address self-review findings
---
README.md | 6 +
scripts/lint-skin.py | 195 +++++++++++++++++-
scripts/test-lint-a11y.py | 131 ++++++++++++
skills/diagram-design/SKILL.md | 16 +-
.../assets/example-architecture-dark.html | 4 +-
.../assets/example-architecture-full.html | 4 +-
.../assets/example-architecture.html | 4 +-
.../assets/example-bar-dark.html | 4 +-
.../assets/example-bar-full.html | 4 +-
skills/diagram-design/assets/example-bar.html | 4 +-
.../assets/example-data-flow-dark.html | 6 +-
.../assets/example-data-flow-full.html | 6 +-
.../assets/example-data-flow.html | 6 +-
.../assets/example-datalake-dark.html | 24 ++-
.../assets/example-datalake-full.html | 24 ++-
.../assets/example-datalake.html | 24 ++-
.../assets/example-dp-integration-dark.html | 4 +-
.../assets/example-dp-integration-full.html | 4 +-
.../assets/example-dp-integration.html | 4 +-
.../example-dp-security-matrix-dark.html | 4 +-
.../example-dp-security-matrix-full.html | 4 +-
.../assets/example-dp-security-matrix.html | 4 +-
.../assets/example-er-dark.html | 4 +-
.../assets/example-er-full.html | 4 +-
skills/diagram-design/assets/example-er.html | 4 +-
.../assets/example-flowchart-dark.html | 4 +-
.../assets/example-flowchart-full.html | 4 +-
.../assets/example-flowchart.html | 4 +-
.../assets/example-gantt-dark.html | 4 +-
.../assets/example-gantt-full.html | 4 +-
.../diagram-design/assets/example-gantt.html | 4 +-
.../assets/example-high-level-dark.html | 28 +--
.../assets/example-high-level-full.html | 28 +--
.../example-high-level-vertical-dark.html | 30 +--
.../example-high-level-vertical-full.html | 30 +--
.../assets/example-high-level-vertical.html | 30 +--
.../assets/example-high-level.html | 28 +--
.../assets/example-import-drawio.html | 4 +-
.../assets/example-it-state-dark.html | 4 +-
.../assets/example-it-state-full.html | 4 +-
.../assets/example-it-state.html | 6 +-
.../assets/example-layers-dark.html | 4 +-
.../assets/example-layers-full.html | 4 +-
.../diagram-design/assets/example-layers.html | 4 +-
.../assets/example-line-dark.html | 4 +-
.../assets/example-line-full.html | 4 +-
.../diagram-design/assets/example-line.html | 4 +-
.../assets/example-loop-dark.html | 6 +-
.../assets/example-loop-full.html | 6 +-
.../assets/example-loop-terminal.html | 6 +-
.../assets/example-medallion-dark.html | 6 +-
.../assets/example-medallion-full.html | 6 +-
.../assets/example-nested-dark.html | 4 +-
.../assets/example-nested-full.html | 4 +-
.../diagram-design/assets/example-nested.html | 4 +-
.../assets/example-org-chart-dark.html | 6 +-
.../assets/example-org-chart-full.html | 6 +-
.../assets/example-org-chart.html | 6 +-
.../assets/example-process-dark.html | 4 +-
.../assets/example-process-full.html | 4 +-
.../assets/example-process.html | 4 +-
.../assets/example-pyramid-dark.html | 4 +-
.../assets/example-pyramid-full.html | 4 +-
.../assets/example-pyramid.html | 4 +-
.../assets/example-quadrant-consultant.html | 4 +-
.../assets/example-quadrant-dark.html | 4 +-
.../assets/example-quadrant-full.html | 4 +-
.../assets/example-quadrant.html | 4 +-
.../assets/example-radar-dark.html | 4 +-
.../assets/example-radar-full.html | 4 +-
.../diagram-design/assets/example-radar.html | 4 +-
.../assets/example-scatter-dark.html | 4 +-
.../assets/example-scatter-full.html | 4 +-
.../assets/example-scatter.html | 4 +-
.../assets/example-sequence-dark.html | 4 +-
.../assets/example-sequence-full.html | 4 +-
.../assets/example-sequence-oauth-dark.html | 6 +-
.../assets/example-sequence-oauth-full.html | 6 +-
.../assets/example-sequence-oauth.html | 6 +-
.../assets/example-sequence.html | 4 +-
.../assets/example-state-dark.html | 4 +-
.../assets/example-state-full.html | 4 +-
.../diagram-design/assets/example-state.html | 4 +-
.../assets/example-swimlane-dark.html | 4 +-
.../assets/example-swimlane-full.html | 4 +-
.../assets/example-swimlane.html | 4 +-
.../assets/example-timeline-dark.html | 4 +-
.../assets/example-timeline-full.html | 4 +-
.../assets/example-timeline.html | 4 +-
.../assets/example-tree-dark.html | 4 +-
.../assets/example-tree-full.html | 4 +-
.../diagram-design/assets/example-tree.html | 4 +-
.../assets/example-venn-dark.html | 4 +-
.../assets/example-venn-full.html | 4 +-
.../diagram-design/assets/example-venn.html | 4 +-
skills/diagram-design/assets/icons.html | 174 ++++++++--------
.../diagram-design/assets/template-dark.html | 4 +-
.../diagram-design/assets/template-full.html | 4 +-
.../assets/template-terminal.html | 4 +-
skills/diagram-design/assets/template.html | 4 +-
skills/diagram-design/references/export.md | 3 +
.../diagram-design/references/output-spec.md | 1 +
102 files changed, 822 insertions(+), 324 deletions(-)
create mode 100644 scripts/test-lint-a11y.py
diff --git a/README.md b/README.md
index 57009e8..56d3711 100644
--- a/README.md
+++ b/README.md
@@ -166,6 +166,10 @@ Every new diagram now uses your colors. Your website's paper color becomes the d
Before writing tokens, the skill verifies WCAG AA contrast on `ink` over `paper`. If your site has a color that fails contrast at diagram sizes (9–12px), it proposes an adjusted value and explains why.
+### Accessible by default
+
+Every diagram template gives the inline SVG an accessible name and description: `role="img"`, a resolving `aria-labelledby`, and first-child `
` / `` slots. IDs are prefixed per diagram and variant, so multiple SVG exports can be safely inlined on one page without duplicate accessible-name IDs. Decorative specimen icons are hidden from assistive technology instead.
+
### Manual override
Prefer to set tokens by hand? Open [`skills/diagram-design/references/style-guide.md`](skills/diagram-design/references/style-guide.md) and edit the table. Everything downstream reads from there — all 27 diagrams, the annotation primitive, and the gallery all inherit semantic role names (`accent`, not `#eb6c36`).
@@ -336,6 +340,8 @@ This keeps the agent's working context tight (only load what you need) and makes
Before submitting a new example, run `python3 scripts/lint-skin.py `.
The repository-wide check `python3 scripts/lint-skin.py --all --baseline` must stay green.
+The linter's `a11y` category rejects diagram SVGs without a resolving accessible name,
+an empty or misplaced title/description, or unsafe bare `title` / `desc` IDs.
If you touch the draw.io import path, `python3 scripts/verify-drawio-import.py` must also pass —
it drives the real extractor against `scripts/fixtures/sample-architecture.drawio` in all four
container formats and checks the references stay in sync.
diff --git a/scripts/lint-skin.py b/scripts/lint-skin.py
index 1662501..1ca5449 100644
--- a/scripts/lint-skin.py
+++ b/scripts/lint-skin.py
@@ -8,6 +8,8 @@ skin. Use ``--all --baseline`` to skip those documented legacy files.
import argparse
import re
import sys
+from dataclasses import dataclass, field
+from html.parser import HTMLParser
from pathlib import Path
@@ -50,6 +52,197 @@ ALLOWED_FONTS = {
CSS_FONT_KEYWORDS = {"inherit", "initial", "revert", "revert-layer", "unset"}
+@dataclass
+class SvgTextElement:
+ tag: str
+ element_id: str | None
+ line: int
+ offset: int
+ text: list[str] = field(default_factory=list)
+
+
+@dataclass
+class SvgElement:
+ attrs: dict[str, str | None]
+ line: int
+ offset: int
+ content_depth: int
+ first_child: str | None = None
+ ids: set[str] = field(default_factory=set)
+ bare_id_locations: list[tuple[int, int]] = field(default_factory=list)
+ titles: list[SvgTextElement] = field(default_factory=list)
+ descriptions: list[SvgTextElement] = field(default_factory=list)
+
+
+class AccessibleSvgParser(HTMLParser):
+ """Collect accessible-name data without rendering or executing HTML.
+
+ Trust boundary: input files are untrusted source text. ``HTMLParser`` only
+ tokenizes that text; this linter never evaluates scripts, fetches URLs, or
+ opens referenced resources.
+ """
+
+ def __init__(self, text):
+ super().__init__(convert_charrefs=True)
+ self.line_offsets = [0]
+ self.line_offsets.extend(
+ match.end() for match in re.finditer(r"\n", text)
+ )
+ self.element_stack = []
+ self.svg_stack = []
+ self.svgs = []
+ self.open_text = []
+
+ def source_offset(self):
+ line, column = self.getpos()
+ return self.line_offsets[line - 1] + column
+
+ def handle_starttag(self, tag, attrs):
+ tag = tag.casefold()
+ attr_map = {name.casefold(): value for name, value in attrs}
+ offset = self.source_offset()
+ line, _column = self.getpos()
+
+ if self.svg_stack:
+ parent = self.svg_stack[-1]
+ if len(self.element_stack) == parent.content_depth:
+ if parent.first_child is None:
+ parent.first_child = tag
+ element_id = attr_map.get("id")
+ if element_id:
+ for svg in self.svg_stack:
+ svg.ids.add(element_id)
+ if element_id in {"title", "desc"}:
+ svg.bare_id_locations.append((line, offset))
+
+ if tag == "svg":
+ svg = SvgElement(
+ attrs=attr_map,
+ line=line,
+ offset=offset,
+ content_depth=len(self.element_stack) + 1,
+ )
+ self.svgs.append(svg)
+ self.svg_stack.append(svg)
+ elif tag in {"title", "desc"} and self.svg_stack:
+ svg = self.svg_stack[-1]
+ if len(self.element_stack) == svg.content_depth:
+ element = SvgTextElement(
+ tag=tag,
+ element_id=attr_map.get("id"),
+ line=line,
+ offset=offset,
+ )
+ if tag == "title":
+ svg.titles.append(element)
+ else:
+ svg.descriptions.append(element)
+ self.open_text.append(element)
+
+ self.element_stack.append(tag)
+
+ def handle_startendtag(self, tag, attrs):
+ self.handle_starttag(tag, attrs)
+ self.handle_endtag(tag)
+
+ def handle_endtag(self, tag):
+ tag = tag.casefold()
+ for index in range(len(self.open_text) - 1, -1, -1):
+ if self.open_text[index].tag == tag:
+ del self.open_text[index]
+ break
+
+ if tag == "svg" and self.svg_stack:
+ self.svg_stack.pop()
+
+ for index in range(len(self.element_stack) - 1, -1, -1):
+ if self.element_stack[index] == tag:
+ del self.element_stack[index:]
+ break
+
+ def handle_data(self, data):
+ for element in self.open_text:
+ element.text.append(data)
+
+
+def lint_accessible_svgs(text):
+ parser = AccessibleSvgParser(text)
+ parser.feed(text)
+ parser.close()
+ findings = []
+
+ def add(line, offset, message):
+ findings.append((line, offset, "a11y", message))
+
+ for svg in parser.svgs:
+ aria_hidden = (svg.attrs.get("aria-hidden") or "").casefold()
+ if aria_hidden == "true":
+ continue
+
+ if (svg.attrs.get("role") or "").casefold() != "img":
+ add(svg.line, svg.offset, 'diagram