diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..45c4c34
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,46 @@
+name: CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ name: Lint & Verify
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Verify accessible SVG contract
+ run: python3 scripts/test-lint-a11y.py
+
+ - name: Run skin linter
+ run: python3 scripts/lint-skin.py --all --baseline
+
+ - name: Verify sequence OAuth specs
+ run: python3 scripts/verify-sequence-oauth.py
+
+ - name: Verify draw.io import script
+ run: python3 scripts/verify-drawio-import.py
+
+ - name: Verify generated icon assets
+ run: |
+ python3 scripts/build-icons.py
+ git diff --ignore-space-at-eol --exit-code -- \
+ skills/diagram-design/assets/icons.html \
+ skills/diagram-design/references/primitive-icons.md
diff --git a/README.md b/README.md
index 0e660d4..f9b938e 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`).
@@ -348,6 +352,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.
@@ -355,6 +361,8 @@ If you touch the Mermaid import path, `python3 scripts/verify-mermaid-import.py`
it covers all supported grammars, multi-block Markdown, adversarial labels, trust-boundary
behavior, resource caps, named failures, and reference/command wiring.
+All pull requests and pushes are automatically validated via GitHub Actions CI (`.github/workflows/ci.yml`).
+
### What loads when
At startup, the agent sees only the skill name and description. When a request matches, it loads `SKILL.md`; type references are pulled in only when relevant. This keeps the skill fast even with 37 reference files.
diff --git a/scripts/build-icons.py b/scripts/build-icons.py
index 1da01fc..b191b2a 100644
--- a/scripts/build-icons.py
+++ b/scripts/build-icons.py
@@ -218,7 +218,7 @@ def normalize_tabler(raw: str) -> str:
body = re.sub(r'', "", body)
body = re.sub(r"\s+", " ", body).strip()
return (
- ''
)
@@ -232,7 +232,7 @@ def normalize_simple(raw: str) -> str:
body = inner.group(1).strip()
body = re.sub(r"\s+", " ", body).strip()
return (
- ''
)
@@ -284,7 +284,7 @@ def normalize_url(raw: str) -> str:
body = re.sub(r'\bfill="none"', '', body)
body = re.sub(r"\s+", " ", body).strip()
return (
- f''
)
@@ -303,7 +303,7 @@ def normalize_devicon(raw: str) -> str:
body = re.sub(r'\bclass="[^"]*"', 'fill="currentColor"', body)
body = re.sub(r"\s+", " ", body).strip()
return (
- ''
)
@@ -320,7 +320,7 @@ def normalize_logz(raw: str) -> str:
body = re.sub(r'\bfill="#[0-9a-fA-F]{3,8}"', 'fill="currentColor"', body)
body = re.sub(r"\s+", " ", body).strip()
return (
- ''
)
diff --git a/scripts/lint-skin.py b/scripts/lint-skin.py
index 1662501..c2c3154 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,244 @@ 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 = []
+ self.id_locations = {}
+
+ 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()
+ element_id = attr_map.get("id")
+ if element_id:
+ self.id_locations.setdefault(element_id, []).append((line, offset))
+
+ 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
+ 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, expected_slug):
+ parser = AccessibleSvgParser(text)
+ parser.feed(text)
+ parser.close()
+ findings = []
+
+ def add(line, offset, message):
+ findings.append((line, offset, "a11y", message))
+
+ naming_ids = {
+ element.element_id
+ for svg in parser.svgs
+ if (svg.attrs.get("aria-hidden") or "").casefold() != "true"
+ for element in svg.titles + svg.descriptions
+ if element.element_id
+ }
+ for element_id in sorted(naming_ids):
+ locations = parser.id_locations[element_id]
+ if len(locations) > 1:
+ line, offset = locations[1]
+ add(line, offset, f'duplicate accessible-name id="{element_id}" is not allowed')
+
+ 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