diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 99eeb15..fc96082 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "diagram-design", "metadata": { - "description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import" + "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling." }, "owner": { "name": "Cathryn Lavery" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 464222f..e58963c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "diagram-design", - "description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import", + "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "version": "2.3.0", "homepage": "https://github.com/cathrynlavery/diagram-design", "repository": "https://github.com/cathrynlavery/diagram-design", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2f7eb9a..a55a1e5 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "diagram-design", - "description": "Branded diagrams across 27 visual types, with semantic system patterns, optional accessible motion, and draw.io or Mermaid import", + "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "version": "2.3.0", "author": { "name": "Cathryn Lavery", @@ -26,7 +26,7 @@ "interface": { "displayName": "Diagram Design", "shortDescription": "Create editorial technical and product diagrams as standalone HTML.", - "longDescription": "Generate branded technical and product diagrams with semantic system patterns, optional accessible motion, and an opinionated editorial design system.", + "longDescription": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "developerName": "Cathryn Lavery", "category": "Productivity", "capabilities": [ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f6719f..c4c12fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) first. All contributions ar ## What this project is -Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 27 diagram types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR. +Diagram Design is an agent skill (Claude Code, Codex, Pi) that produces editorial-quality diagrams as self-contained HTML files. The repo is documentation-first: `skills/diagram-design/SKILL.md` is the index, each of the 27 visual types has its own reference file, and the extractor scripts in `skills/diagram-design/scripts/` turn draw.io and Mermaid sources into a structured IR. See [README.md](README.md) for the full picture, including the design system and the import/export flows. @@ -32,7 +32,7 @@ Every validation gate below must pass before a PR is ready. They also run automa | Accessible SVG contract (unit tests for the a11y linter) | `python3 scripts/test-lint-a11y.py` | | Semantic-pattern routing | `python3 scripts/verify-semantic-motion.py --markdown-only` | | Animated-example structure and accessibility | `python3 scripts/verify-semantic-motion.py --example-only` | -| Skin conformance of every example (colors, fonts, a11y) | `python3 scripts/lint-skin.py --all --baseline` | +| Skin conformance of every example and template (colors, fonts, a11y, assets, scripts) | `python3 scripts/lint-skin.py --all --baseline` | | A single file, e.g. a new example | `python3 scripts/lint-skin.py skills/diagram-design/assets/example-my-type.html` | | Sequence-doc consistency (ATL fragments, budgets) | `python3 scripts/verify-sequence-oauth.py` | | draw.io import path (real extractor vs fixtures + docs sync) | `python3 scripts/verify-drawio-import.py` | @@ -41,6 +41,8 @@ Every validation gate below must pass before a PR is ready. They also run automa | Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` | | Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files | +The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter. + Run them all at once before pushing: ```bash @@ -57,7 +59,7 @@ python3 scripts/test-lint-a11y.py \ ### If a gate fails -- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). +- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). The linter also requires the SHA-pinned controller from `template-motion.html` verbatim and rejects remote resources, CSS `@import`, non-fragment CSS `url()`, event handlers, `srcdoc`, executable URLs, and extra scripts. - **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure. - **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files. @@ -94,7 +96,7 @@ python3 scripts/lint-skin.py skills/diagram-design/assets/example-my-type.html New examples should be added to the gallery (`assets/index.html`) so they stay browsable. -Motion is opt-in. Start from `skills/diagram-design/assets/template-motion.html`, follow `references/animation.md`, and run `python3 scripts/verify-motion.py ` plus `python3 scripts/test-verify-motion.py`. A motion file must preserve complete no-JavaScript, reduced-motion, print, screenshot, and export states. +Motion is opt-in. Start from `skills/diagram-design/assets/template-motion.html`, follow `references/animation.md`, and run `python3 scripts/verify-motion.py ` plus `python3 scripts/test-verify-motion.py`. A motion file must preserve complete no-JavaScript, reduced-motion, print, screenshot, and export states. Keep the controller byte-for-byte identical to the template; changes require updating the canonical template, example, documentation, and adversarial tests together. ## Adding a new diagram type diff --git a/README.md b/README.md index 3e56763..57550fa 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ No Figma. No generic rounded boxes. No 30-minute color-picking sessions. I write at [littlemight.com](https://littlemight.com?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) (and run [BestSelf.co](https://bestself.co?utm_source=diagram-design&utm_medium=readme&utm_campaign=github&utm_content=intro) on the side). Every time I needed a diagram — an architecture sketch, a flowchart, a pyramid of what matters most — I'd ask Claude and get back a generic rounded-box thing that looked nothing like the rest of the site. I'd either fight with Figma for 30 minutes or just skip the diagram. -So I built a Claude Code skill for it. Twenty-seven types, editorial quality, matches your brand in 60 seconds by reading your website. +So I built a Claude Code skill for it. Twenty-seven visual types, editorial quality, matches your brand in 60 seconds by reading your website. > *The highest-quality move is usually deletion.* Every node earns its place. The accent color is reserved for the 1–2 things the reader should look at first. Target density: 4/10. @@ -28,7 +28,7 @@ So I built a Claude Code skill for it. Twenty-seven types, editorial quality, ma ## What it makes -All 27 diagrams ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency. +All 27 visual types ship in three static variants: minimal light, minimal dark, and full-editorial. Open any of them directly in a browser. There is no build step, JavaScript, or external image dependency. @@ -208,13 +208,14 @@ Your agent will pick the right type, build the HTML, and save it. You can also s ```bash cp skills/diagram-design/assets/template.html my-diagram.html # minimal light cp skills/diagram-design/assets/template-full.html my-diagram.html # editorial with summary cards +cp skills/diagram-design/assets/template-motion.html my-diagram.html # optional accessible motion ``` ### Semantic patterns and optional motion When behavior matters, the skill chooses a semantic pattern first and a visual type second. The seven routed patterns cover fan-in queues and bottlenecks, repeated stage slots, unstructured-input transformation, paired policy traces, secure paved roads, governance catalogs, and compensating security layers. Each pattern defines its triggers, primitives, budget, anti-patterns, static fallback, and nearest visual type in [`semantic-patterns.md`](skills/diagram-design/references/semantic-patterns.md). -Motion is optional and does not create another diagram type. [`animation.md`](skills/diagram-design/references/animation.md) defines `none`, `reveal`, `step`, and `loop` modes with a complete static first frame, deterministic timing, and controls when interaction is available. Reduced-motion output shows the complete static frame and hides/disables playback controls. Motion HTML uses the exact reviewed controller from `template-motion.html`; arbitrary inline scripts are rejected. The default is `none`: ordinary output remains static and script-free. [`example-policy-trace-animated.html`](skills/diagram-design/assets/example-policy-trace-animated.html) is the self-contained interactive example. +Motion is optional and does not create another visual type. [`animation.md`](skills/diagram-design/references/animation.md) defines `none`, `reveal`, `step`, and `loop` modes with a complete static first frame, deterministic timing, and controls when interaction is available. Reduced-motion output shows the complete static frame and hides/disables playback controls. Motion HTML uses the exact reviewed controller from `template-motion.html`; arbitrary or modified inline scripts, remote assets, CSS imports, and executable HTML attributes are rejected. The default is `none`: ordinary output remains static and script-free. [`example-policy-trace-animated.html`](skills/diagram-design/assets/example-policy-trace-animated.html) is the self-contained interactive example. --- @@ -295,6 +296,8 @@ Or just ask in natural language: Both formats are diagram-only — editorial cards and headers from `-full` variants aren't included. For a screenshot of the full editorial layout, use your browser's print-to-PDF or full-page screenshot. See [`skills/diagram-design/references/export.md`](skills/diagram-design/references/export.md) for the full procedure. +For motion-enabled HTML, export the explicit final state: open `?motion=static`, wait for `document.fonts.ready`, and confirm the motion root has `data-frame="static"` before capture. Use `?motion=step&step=N` only when a named intermediate frame was requested. + --- ## Architecture @@ -364,10 +367,10 @@ This keeps the agent's working context tight: routine diagrams load one type ref ### Contributing / skin lint 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. -Semantic routing must pass `python3 scripts/verify-semantic-motion.py --markdown-only`; the animated example has a separate `--example-only` gate. Every shipped motion template/example must also pass `python3 scripts/verify-motion.py --shipped`. +The repository-wide check `python3 scripts/lint-skin.py --all --baseline` covers examples and templates and must stay green. +CI separately verifies semantic routing, animated-example structure, animated skin, every shipped motion asset, and adversarial mutations of the controller contract, reporting later gate outcomes even when an earlier gate fails. Semantic routing must pass `python3 scripts/verify-semantic-motion.py --markdown-only`; the animated example has a separate `--example-only` gate. Every shipped motion template/example must also pass `python3 scripts/verify-motion.py --shipped`. 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. +an empty or misplaced title/description, or unsafe bare `title` / `desc` IDs. It also pins the exact reviewed motion controller and rejects remote assets, CSS `@import`, non-fragment CSS `url()`, and executable attributes such as `onclick` or `srcdoc`. 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 02a3218..0911fa9 100644 --- a/scripts/lint-skin.py +++ b/scripts/lint-skin.py @@ -309,7 +309,7 @@ class AccessibleSvgParser(HTMLParser): element.text.append(data) -def lint_accessible_svgs(text, expected_slug): +def lint_accessible_svgs(text, expected_slug, allow_template_placeholders=False): parser = AccessibleSvgParser(text) parser.feed(text) parser.close() @@ -352,7 +352,11 @@ def lint_accessible_svgs(text, expected_slug): if "[" in element_id or "]" in element_id ) ) - if placeholder_ids: + allowed_placeholders = {"[diagram-slug]-title", "[diagram-slug]-desc"} + if placeholder_ids and ( + not allow_template_placeholders + or not set(placeholder_ids).issubset(allowed_placeholders) + ): add( svg.line, svg.offset, @@ -499,8 +503,12 @@ def named_families(value, allowed_fonts): return families -def lint_text(text, colors, rgb_triplets, expected_slug): - findings = lint_accessible_svgs(text, expected_slug) +def lint_text( + text, colors, rgb_triplets, expected_slug, allow_template_placeholders=False +): + findings = lint_accessible_svgs( + text, expected_slug, allow_template_placeholders + ) allowed_fonts = ALLOWED_FONTS | style_guide_families() resources = ResourceParser() @@ -654,7 +662,7 @@ def parse_args(): parser.add_argument( "--all", action="store_true", - help="lint every skills/diagram-design/assets/example-*.html file", + help="lint every skills/diagram-design/assets/example-*.html and template*.html file", ) parser.add_argument( "--baseline", @@ -677,7 +685,10 @@ def main(): skipped = 0 baseline = set() if args.all: - paths = sorted(ASSET_DIR.glob("example-*.html")) + paths = sorted( + set(ASSET_DIR.glob("example-*.html")) + | set(ASSET_DIR.glob("template*.html")) + ) if args.baseline: baseline = load_baseline() for path in paths: @@ -693,10 +704,19 @@ def main(): text = path.read_text(encoding="utf-8") relative = display_path(path) expected_slug = diagram_slug(path) + allow_template_placeholders = path.name.startswith("template") if args.baseline and (path.name in baseline or relative in baseline): - findings = lint_accessible_svgs(text, expected_slug) + findings = lint_accessible_svgs( + text, expected_slug, allow_template_placeholders + ) else: - findings = lint_text(text, colors, rgb_triplets, expected_slug) + findings = lint_text( + text, + colors, + rgb_triplets, + expected_slug, + allow_template_placeholders, + ) except OSError as error: findings = [(0, 0, "read-error", str(error))] diff --git a/scripts/test-lint-a11y.py b/scripts/test-lint-a11y.py index 7502f1c..e85c523 100644 --- a/scripts/test-lint-a11y.py +++ b/scripts/test-lint-a11y.py @@ -436,6 +436,13 @@ def main() -> int: (project / "skills/diagram-design/assets/example-baseline.html").write_text( '\n', encoding="utf-8" ) + template_svg = VALID_SVG.replace( + "fixture-title", "[diagram-slug]-title" + ).replace("fixture-desc", "[diagram-slug]-desc") + (project / "skills/diagram-design/assets/template.html").write_text( + '\n' + template_svg, + encoding="utf-8", + ) baseline_result = subprocess.run( [ sys.executable, @@ -463,7 +470,18 @@ def main() -> int: "baseline-a11y: legacy visual finding was not exempted\n" f"{baseline_result.stdout}" ) + if "external-asset: external resource in src is not allowed" not in baseline_result.stdout: + raise AssertionError( + "template-scan: --all did not lint template HTML\n" + f"{baseline_result.stdout}" + ) + if "unresolved placeholder" in baseline_result.stdout: + raise AssertionError( + "template-scan: canonical template placeholders were rejected\n" + f"{baseline_result.stdout}" + ) print("OK: baseline file receives a11y checks but keeps visual exemptions") + print("OK: --all scans templates and permits canonical slug placeholders") print("All accessible-SVG lint cases passed.") return 0 diff --git a/scripts/test-verify-motion.py b/scripts/test-verify-motion.py index e16126a..9e50718 100644 --- a/scripts/test-verify-motion.py +++ b/scripts/test-verify-motion.py @@ -104,9 +104,10 @@ def main() -> int: print("OK: shipped animated example accepted") shipped = module.shipped_motion_files() - if shipped != sorted([TEMPLATE, EXAMPLE], key=lambda path: path.as_posix()): - raise AssertionError(f"unexpected shipped motion inventory: {shipped}") - print("OK: shipped motion inventory is parsed and complete") + required_shipped = {TEMPLATE, EXAMPLE} + if not required_shipped.issubset(set(shipped)): + raise AssertionError(f"incomplete shipped motion inventory: {shipped}") + print("OK: required shipped motion inventory is parsed and complete") check( "bad-mode", source.replace('data-motion-mode="step"', 'data-motion-mode="cinematic"', 1), @@ -132,6 +133,19 @@ def main() -> int: source.replace('aria-live="polite"', 'aria-live="assertive"', 1), "motion status needs role=status", ) + status_line = ( + '

' + ) + check( + "status-inside-hidden-controls", + source.replace( + f" \n{status_line}", + f"{status_line}\n ", + 1, + ), + "motion status must be outside data-motion-controls", + ) check( "hidden-fallback", source.replace('data-motion-item data-step="1"', 'data-motion-item data-step="1" style="opacity:0"', 1), @@ -147,6 +161,15 @@ def main() -> int: source.replace("prefers-reduced-motion", "user-reduced-motion"), "missing reduced-motion CSS fallback", ) + check( + "comment-cannot-spoof-reduced-motion", + source.replace( + "@media (prefers-reduced-motion: reduce)", + "@media (user-reduced-motion: reduce)", + 1, + ).replace("", " /* prefers-reduced-motion */\n ", 1), + "missing reduced-motion CSS fallback", + ) check( "reduced-motion-controls-visible", source.replace( @@ -161,6 +184,15 @@ def main() -> int: source.replace("[data-motion-controls][hidden] { display: none !important; }", "", 1), "missing hidden control override", ) + check( + "comment-cannot-spoof-script-contract", + source.replace("'visibilitychange'", "'pagehide'", 1).replace( + " })();", + " // visibilitychange\n })();", + 1, + ), + "missing background-tab pause", + ) check( "missing-static-override", source.replace('data-motion="static"', 'data-motion="still"'), @@ -180,6 +212,61 @@ def main() -> int: source.replace('aria-label="Step 1: Request received"', 'aria-description="accent stage"', 1), "needs a non-color aria-label", ) + check( + "unicode-step-count", + source.replace('data-step-count="5"', 'data-step-count="٥"', 1), + "data-step-count must be an ASCII decimal integer", + ) + check( + "underscored-item-step", + source.replace('data-step="1"', 'data-step="1_0"', 1), + "non-ASCII-decimal data-step", + ) + check( + "double-space-ready-scope", + source.replace( + ".motion-ready [data-motion-item] {", + ".motion-ready [data-motion-item] {", + 1, + ), + None, + ) + check( + "comma-unscoped-opacity", + source.replace( + ".motion-ready [data-motion-item] {\n opacity: .12;", + ".motion-ready [data-motion-item], [data-motion-item] {\n opacity: 0;", + 1, + ), + "unscoped data-motion-item hiding", + ) + check( + "unscoped-visibility", + source.replace( + "", + " [data-motion-root] > [data-motion-item] { visibility: hidden; }\n ", + 1, + ), + "unscoped data-motion-item hiding", + ) + check( + "unscoped-display", + source.replace( + "", + " [data-motion-item] { display: none; }\n ", + 1, + ), + "unscoped data-motion-item hiding", + ) + check( + "unscoped-infinite-iteration-count", + source.replace( + "", + " .motion-ready [data-motion-item] { animation-iteration-count: infinite; }\n ", + 1, + ), + "infinite animation must be scoped to data-motion-mode=loop", + ) check( "null-test-frame", source.replace("requestedStep !== null", "true", 1), @@ -195,6 +282,15 @@ def main() -> int: source.replace("/^\\d+$/.test(requestedStep)", "/^-?\\d+$/.test(requestedStep)", 1), "missing non-negative decimal test frame validation", ) + check( + "modified-replay-shortcut", + source.replace( + "!event.ctrlKey && !event.metaKey && !event.altKey && ", + "", + 1, + ), + "missing modified-shortcut guard", + ) check( "over-budget-test-frame", source.replace(" && parsedStep <= count", "", 1), @@ -209,6 +305,16 @@ def main() -> int: ), "must exactly match the controller in template-motion.html", ) + ready_line = " root.classList.add('motion-ready');\n" + check( + "early-motion-ready", + source.replace(ready_line, "", 1).replace( + " if (staticOverride) {", + ready_line + " if (staticOverride) {", + 1, + ), + "motion-ready must be added only after the initial render succeeds", + ) check( "remote-controller", source.replace( @@ -257,6 +363,57 @@ def main() -> int: raise AssertionError(f"broken Trace B connector was accepted: {trace_errors}") print("OK: Trace B connector cannot continue through terminal rows") + decorative_example = directory / "decorative-trace.html" + decorative_example.write_text( + EXAMPLE.read_text(encoding="utf-8").replace( + " ", + ' \n' + " ", + 1, + ), + encoding="utf-8", + ) + decorative_errors = semantic_module.verify_example(decorative_example) + if decorative_errors: + raise AssertionError( + f"decorative semantic-verifier item was rejected: {decorative_errors}" + ) + print("OK: semantic verifier accepts an accessible decorative motion item") + + original_skill = semantic_module.SKILL + try: + missing_router = directory / "missing-router.md" + missing_router.write_text( + original_skill.read_text(encoding="utf-8").replace( + "semantic-patterns.md", "patterns.md" + ), + encoding="utf-8", + ) + semantic_module.SKILL = missing_router + markdown_errors = semantic_module.verify_markdown() + finally: + semantic_module.SKILL = original_skill + if not any("must link to semantic-patterns.md" in error for error in markdown_errors): + raise AssertionError(f"missing semantic router was accepted: {markdown_errors}") + print("OK: missing semantic-pattern router anchor is rejected") + + try: + missing_guide = directory / "missing-guide.md" + missing_guide.write_text( + original_skill.read_text(encoding="utf-8").replace( + "### Visual-type guide (27)", "### Visual guide" + ), + encoding="utf-8", + ) + semantic_module.SKILL = missing_guide + markdown_errors = semantic_module.verify_markdown() + finally: + semantic_module.SKILL = original_skill + if not any("must contain the 27-row visual-type guide" in error for error in markdown_errors): + raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}") + print("OK: missing visual-type guide anchor is rejected") + cli_result = subprocess.run( [sys.executable, str(VERIFIER), str(TEMPLATE)], capture_output=True, diff --git a/scripts/verify-motion.py b/scripts/verify-motion.py index ce09722..ac5418f 100644 --- a/scripts/verify-motion.py +++ b/scripts/verify-motion.py @@ -15,6 +15,7 @@ ASSET_DIR = ROOT / "skills/diagram-design/assets" MOTION_TEMPLATE = ASSET_DIR / "template-motion.html" MODES = {"none", "reveal", "step", "loop"} ACTIONS = {"play", "pause", "replay", "prev", "next"} +ASCII_DECIMAL_RE = re.compile(r"^[0-9]+$") class MotionParser(HTMLParser): @@ -25,14 +26,18 @@ class MotionParser(HTMLParser): self.actions: set[str] = set() self.controls = 0 self.statuses: list[dict[str, str]] = [] + self.statuses_in_controls = 0 self.scripts: list[dict[str, object]] = [] + self.styles: list[dict[str, object]] = [] self.svgs: list[dict[str, object]] = [] self._svg_depth = 0 self._current_svg: dict[str, object] | None = None self._capture: str | None = None self._current_script: dict[str, object] | None = None + self._current_style: dict[str, object] | None = None self._element_stack: list[str] = [] self._motion_root_depth: int | None = None + self._controls_depth: int | None = None def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: tag = tag.casefold() @@ -48,10 +53,14 @@ class MotionParser(HTMLParser): self.items.append(data) if "data-motion-action" in data: self.actions.add(data["data-motion-action"]) - if "data-motion-status" in data: - self.statuses.append(data) if "data-motion-controls" in data: self.controls += 1 + if self._controls_depth is None: + self._controls_depth = len(self._element_stack) + if "data-motion-status" in data: + self.statuses.append(data) + if self._controls_depth is not None: + self.statuses_in_controls += 1 if tag == "script": self._current_script = { "attrs": data, @@ -60,6 +69,9 @@ class MotionParser(HTMLParser): "closed": False, } self.scripts.append(self._current_script) + if tag == "style": + self._current_style = {"body": [], "closed": False} + self.styles.append(self._current_style) self._element_stack.append(tag) if tag == "svg": self._svg_depth = 1 @@ -80,6 +92,9 @@ class MotionParser(HTMLParser): if tag == "script" and self._current_script is not None: self._current_script["closed"] = True self._current_script = None + if tag == "style" and self._current_style is not None: + self._current_style["closed"] = True + self._current_style = None if self._svg_depth: if tag in {"title", "desc"}: self._capture = None @@ -95,12 +110,21 @@ class MotionParser(HTMLParser): and len(self._element_stack) <= self._motion_root_depth ): self._motion_root_depth = None + if ( + self._controls_depth is not None + and len(self._element_stack) <= self._controls_depth + ): + self._controls_depth = None def handle_data(self, data: str) -> None: if self._current_script is not None: body = self._current_script["body"] assert isinstance(body, list) body.append(data) + if self._current_style is not None: + body = self._current_style["body"] + assert isinstance(body, list) + body.append(data) if self._capture and self._current_svg: node = self._current_svg[self._capture] assert isinstance(node, dict) @@ -118,6 +142,111 @@ def parsed_document(source: str) -> MotionParser: return parser +def without_comments(source: str) -> str: + """Remove JS/CSS comments while preserving quoted strings and regex literals.""" + output: list[str] = [] + quote: str | None = None + index = 0 + while index < len(source): + character = source[index] + following = source[index + 1] if index + 1 < len(source) else "" + if quote is not None: + output.append(character) + if character == "\\" and index + 1 < len(source): + index += 1 + output.append(source[index]) + elif character == quote: + quote = None + elif character in {"'", '"', "`"}: + quote = character + output.append(character) + elif character == "/" and following == "*": + index += 2 + while index < len(source) - 1 and source[index : index + 2] != "*/": + if source[index] in "\r\n": + output.append(source[index]) + index += 1 + index += 1 + elif character == "/" and following == "/": + index += 2 + while index < len(source) and source[index] not in "\r\n": + index += 1 + if index < len(source): + output.append(source[index]) + else: + output.append(character) + index += 1 + return "".join(output) + + +def parsed_bodies(blocks: list[dict[str, object]]) -> str: + bodies = [] + for block in blocks: + body = block["body"] + assert isinstance(body, list) + bodies.append("".join(body)) + return without_comments("\n".join(bodies)) + + +def css_at_rule_blocks(source: str, header_pattern: str) -> list[str]: + """Return balanced bodies for CSS at-rules whose headers match a pattern.""" + blocks: list[str] = [] + for match in re.finditer(header_pattern + r"\s*\{", source, re.IGNORECASE): + depth = 1 + index = match.end() + start = index + while index < len(source) and depth: + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + index += 1 + if depth == 0: + blocks.append(source[start : index - 1]) + return blocks + + +def hidden_unscoped_motion_selectors(source: str) -> list[str]: + """Find selectors that hide motion items before progressive enhancement.""" + hidden_declaration = re.compile( + r"(?:opacity\s*:\s*(?:0+(?:\.0*)?|\.0+)\b|" + r"visibility\s*:\s*hidden\b|display\s*:\s*none\b)", + re.IGNORECASE, + ) + findings: list[str] = [] + for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", source, re.DOTALL): + if not hidden_declaration.search(rule.group(2)): + continue + for selector in rule.group(1).split(","): + item = re.search(r"\[\s*data-motion-item\s*\]", selector, re.IGNORECASE) + if item is None: + continue + if ".motion-ready" not in selector[: item.start()]: + findings.append(" ".join(selector.split())) + return findings + + +def infinite_unscoped_selectors(source: str) -> list[str]: + """Find infinite animation rules that are not limited to loop mode.""" + infinite_declaration = re.compile( + r"(?:animation\s*:[^;}]*\binfinite\b|" + r"animation-iteration-count\s*:\s*infinite\b)", + re.IGNORECASE, + ) + loop_scope = re.compile( + r"\[\s*data-motion-mode\s*=\s*['\"]loop['\"]\s*\]", + re.IGNORECASE, + ) + findings: list[str] = [] + for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", source, re.DOTALL): + if not infinite_declaration.search(rule.group(2)): + continue + for selector in rule.group(1).split(","): + if loop_scope.search(selector) is None: + findings.append(" ".join(selector.split())) + return findings + + def canonical_controller() -> str: parser = parsed_document(MOTION_TEMPLATE.read_text(encoding="utf-8")) if len(parser.scripts) != 1 or not parser.scripts[0]["closed"]: @@ -160,11 +289,12 @@ def verify(path: Path) -> list[str]: mode = root.get("data-motion-mode", "") if mode not in MODES: errors.append(f"data-motion-mode must be one of {sorted(MODES)}; got {mode!r}") - try: - count = int(root.get("data-step-count", "")) - except ValueError: + raw_count = root.get("data-step-count", "") + if not ASCII_DECIMAL_RE.fullmatch(raw_count): count = -1 - errors.append("data-step-count must be an integer") + errors.append("data-step-count must be an ASCII decimal integer") + else: + count = int(raw_count) minimum_count = 0 if mode == "none" else 1 if count < minimum_count or count > 8: errors.append(f"semantic step count must be {minimum_count}..8; got {count}") @@ -174,11 +304,13 @@ def verify(path: Path) -> list[str]: steps: list[int] = [] semantic_steps: list[int] = [] for index, item in enumerate(parser.items, 1): - try: - step = int(item.get("data-step", "")) - except ValueError: - errors.append(f"motion item {index} has a non-integer data-step") + raw_step = item.get("data-step", "") + if not ASCII_DECIMAL_RE.fullmatch(raw_step): + errors.append( + f"motion item {index} has a non-ASCII-decimal data-step" + ) continue + step = int(raw_step) steps.append(step) decorative = "data-motion-decorative" in item if not decorative: @@ -214,6 +346,10 @@ def verify(path: Path) -> list[str]: status = parser.statuses[0] if status.get("role") != "status" or status.get("aria-live") != "polite" or status.get("aria-atomic") != "true": errors.append("motion status needs role=status, aria-live=polite, aria-atomic=true") + if parser.statuses_in_controls: + errors.append( + "motion status must be outside data-motion-controls so hidden controls do not hide live announcements" + ) if not parser.scripts: errors.append("controlled mode needs the scoped control script") @@ -248,20 +384,52 @@ def verify(path: Path) -> list[str]: f"script {number} must exactly match the controller in template-motion.html" ) - required_source = { - "prefers-reduced-motion": "reduced-motion CSS fallback", - "@media print": "print CSS fallback", + script_source = parsed_bodies(parser.scripts) + style_source = parsed_bodies(parser.styles) + reduced_motion_css = "\n".join( + css_at_rule_blocks( + style_source, + r"@media\s*\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)", + ) + ) + print_css = css_at_rule_blocks(style_source, r"@media\s+print\b") + + if not reduced_motion_css: + errors.append("missing reduced-motion CSS fallback (prefers-reduced-motion)") + if not print_css: + errors.append("missing print CSS fallback (@media print)") + + required_css = { + r"\[data-motion-controls\]\s*\{[^{}]*display\s*:\s*none\s*!important": ( + reduced_motion_css, + "reduced-motion hidden controls", + "[data-motion-controls] { display: none !important; }", + ), + r"\[data-motion-controls\]\[hidden\]\s*\{[^{}]*display\s*:\s*none\s*!important": ( + style_source, + "hidden control override", + "[data-motion-controls][hidden] { display: none !important; }", + ), + r"html\[data-motion\s*=\s*['\"]static['\"]\]": ( + style_source, + "deterministic static override", + 'data-motion="static"', + ), } if parser.scripts: - required_source.update( + for pattern, (scope, label, example) in required_css.items(): + if re.search(pattern, scope, re.IGNORECASE | re.DOTALL) is None: + errors.append(f"missing {label} ({example})") + + required_script: dict[str, str] = {} + if parser.scripts: + required_script.update( { - "[data-motion-controls] { display: none !important; }": "reduced-motion hidden controls", - "[data-motion-controls][hidden] { display: none !important; }": "hidden control override", - 'data-motion="static"': "deterministic static override", "motion') === 'step'": "deterministic step override", "document.fonts": "font-stable screenshot hook", "visibilitychange": "background-tab pause", "ArrowRight": "keyboard stepping", + "!event.ctrlKey && !event.metaKey && !event.altKey": "modified-shortcut guard", "motion-ready": "progressive enhancement class", "requestedStep !== null": "non-null test frame validation", "/^\\d+$/.test(requestedStep)": "non-negative decimal test frame validation", @@ -271,14 +439,31 @@ def verify(path: Path) -> list[str]: "playback controls unavailable": "static/reduced-motion status", } ) - for needle, label in required_source.items(): - if needle not in source: + for needle, label in required_script.items(): + if needle not in script_source: errors.append(f"missing {label} ({needle})") + ready_position = script_source.find("root.classList.add('motion-ready');") + initialization_position = script_source.find("if (staticOverride)") + if ( + parser.scripts + and ready_position >= 0 + and initialization_position >= 0 + and ready_position < initialization_position + ): + errors.append("motion-ready must be added only after the initial render succeeds") - if re.search(r"(? 2: errors.append("loop mode may contain at most one semantic item and one decorative token") diff --git a/scripts/verify-semantic-motion.py b/scripts/verify-semantic-motion.py index 961e59a..e0eda3e 100644 --- a/scripts/verify-semantic-motion.py +++ b/scripts/verify-semantic-motion.py @@ -20,7 +20,7 @@ SKILL = ROOT / "skills/diagram-design/SKILL.md" PATTERNS = ROOT / "skills/diagram-design/references/semantic-patterns.md" ANIMATION = ROOT / "skills/diagram-design/references/animation.md" EXAMPLE = ROOT / "skills/diagram-design/assets/example-policy-trace-animated.html" -MAX_SKILL_BYTES = 35_000 +MAX_SKILL_BYTES = 40_000 PATTERN_NAMES = ( "Fan-in queue / bottleneck", @@ -190,7 +190,13 @@ def verify_markdown() -> list[str]: ) if "Selection: semantic pattern, then visual type" not in skill: errors.append("SKILL.md must choose semantic pattern before visual type") - if skill.find("semantic-patterns.md") > skill.find("### Visual-type guide (27)"): + router_position = skill.find("semantic-patterns.md") + guide_position = skill.find("### Visual-type guide (27)") + if router_position < 0: + errors.append("SKILL.md must link to semantic-patterns.md") + if guide_position < 0: + errors.append("SKILL.md must contain the 27-row visual-type guide") + if router_position >= 0 and guide_position >= 0 and router_position > guide_position: errors.append("semantic-pattern router must precede the visual-type guide") visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:") @@ -324,9 +330,17 @@ def verify_example(path: Path = EXAMPLE) -> list[str]: steps: list[int] = [] for item in parser.motion_items: try: - steps.append(int(item.get("data-step", ""))) + step = int(item.get("data-step", "")) except ValueError: errors.append("every motion item needs an integer data-step") + continue + if "data-motion-decorative" in item: + if item.get("aria-hidden") != "true" or item.get("focusable") != "false": + errors.append( + "decorative motion items need aria-hidden=true and focusable=false" + ) + continue + steps.append(step) if not item.get("aria-label", "").strip(): errors.append("every semantic motion item needs an aria-label") if steps != [1, 2, 3, 4, 5]: diff --git a/skills/diagram-design/SKILL.md b/skills/diagram-design/SKILL.md index 0029426..3c911dc 100644 --- a/skills/diagram-design/SKILL.md +++ b/skills/diagram-design/SKILL.md @@ -1,6 +1,6 @@ --- name: diagram-design -description: Create branded technical and product diagrams in 27 visual types, with semantic patterns, optional accessible motion, and .drawio/Mermaid import. +description: Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, or DP security matrix diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling. license: MIT metadata: version: "2.3" @@ -18,6 +18,8 @@ Twenty-seven visual types. Semantic patterns describe behavior independently; ty **Before generating your first diagram in a new project, verify the style guide has been customized.** +Don't silently ship default-skinned diagrams into a branded project. + Open [`references/style-guide.md`](references/style-guide.md) and check the default tokens. If they're still the shipped defaults (paper `#faf7f2`, ink `#1c1917`, accent `#b5523a` rust), **pause and ask the user**: > *"This is your first Schematic in this project. The style guide is still at the default (neutral stone + rust). Do you want to customize it to match your brand first? Options: (a) pull from your website URL, (b) extract from an installed skill, (c) extract from a local folder / design-system directory, (d) paste tokens manually, (e) proceed with the default for now."* @@ -51,7 +53,7 @@ Applied to schematics: ## 2. When to Use -Use for any of the 27 diagram types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list. +Use for any of the 27 visual types (§3) when a reader will learn more from a visual than from prose, a table, or a bulleted list. **Don't use for:** @@ -457,7 +459,7 @@ Run before producing any diagram. - [ ] No vertical `writing-mode` text? - [ ] `viewBox` expanded for the legend strip (~60px)? - [ ] Every font size, coord, width, height, gap divisible by 4? -- [ ] If animated, does the complete static/no-JS frame work, does reduced motion hide/disable playback, is the controller copied verbatim from `template-motion.html`, and did I run `verify-motion.py --shipped` plus the skin linter? +- [ ] If animated, does the complete static/no-JS frame work, does reduced motion hide/disable playback, and is the controller copied verbatim from `template-motion.html`? In this repository, run `python3 scripts/verify-motion.py path/to/generated.html` plus the skin linter; from an installed skill, manually check no-JS, reduced-motion, print, and static-query states because the repository verifiers are not packaged. **Typography:** diff --git a/skills/diagram-design/assets/example-policy-trace-animated.html b/skills/diagram-design/assets/example-policy-trace-animated.html index 8505e9f..b30671d 100644 --- a/skills/diagram-design/assets/example-policy-trace-animated.html +++ b/skills/diagram-design/assets/example-policy-trace-animated.html @@ -23,6 +23,7 @@ --motion-fast: 160ms; --motion-step: 480ms; --motion-hold: 720ms; + --motion-total: 3600ms; --motion-ease: cubic-bezier(.2,.8,.2,1); } body { @@ -103,10 +104,10 @@ font: 500 9px/1.4 var(--mono); } [data-motion-status] { - margin-left: auto; + display: block; min-width: 272px; - padding-left: 16px; - border-left: 1px solid var(--rule); + min-height: 16px; + margin: 8px 0 0; color: var(--muted); font: 500 10px/1.4 var(--mono); text-align: right; @@ -143,6 +144,12 @@ transform: none !important; transition: none !important; } + html[data-motion="step"] [data-motion-controls], + html[data-motion="step"] [data-motion-decorative] { display: none !important; } + html[data-motion="step"] [data-motion-item] { + animation: none !important; + transition: none !important; + } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { @@ -271,15 +278,15 @@ -
+
- ←/→ step · Space play/pause · R replay · Home reset - Static frame · all 5 rules visible + ←/→ step · Space play/pause · R replay · Home/End jump
+ Static frame · all 5 rules visible @@ -293,6 +300,7 @@ document.querySelectorAll('[data-motion-root]').forEach((root) => { const count = Number(root.dataset.stepCount); + const hold = parseFloat(getComputedStyle(root).getPropertyValue('--motion-hold')) || 720; const parsedStep = requestedStep !== null && /^\d+$/.test(requestedStep) ? Number(requestedStep) : null; const exactStep = params.get('motion') === 'step' && Number.isSafeInteger(parsedStep) && parsedStep >= 0 && parsedStep <= count ? parsedStep : null; const items = [...root.querySelectorAll('[data-motion-item]')]; @@ -355,7 +363,7 @@ if (step >= count) { finish(); return; } render(step + 1, false); if (step >= count) { finish(); return; } - timer = window.setTimeout(tick, 720); + timer = window.setTimeout(tick, hold); } function play(fromStart = false, userInitiated = false) { @@ -365,7 +373,7 @@ root.classList.add('is-playing'); buttons('play').setAttribute('aria-pressed', 'true'); buttons('pause').setAttribute('aria-pressed', 'false'); - timer = window.setTimeout(tick, 720); + timer = window.setTimeout(tick, hold); } controls.addEventListener('click', (event) => { @@ -387,7 +395,7 @@ playing ? pause(true) : play(false, true); return; } - if (event.key.toLowerCase() === 'r') { + if (!event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'r') { event.preventDefault(); play(true, true); return; @@ -439,7 +447,6 @@ } }); - root.classList.add('motion-ready'); if (staticOverride) { document.documentElement.dataset.motion = 'static'; pause(false); @@ -475,6 +482,7 @@ render(0, false); status.textContent = `Ready · step 0 of ${count}`; } + root.classList.add('motion-ready'); }); })(); diff --git a/skills/diagram-design/assets/template-motion.html b/skills/diagram-design/assets/template-motion.html index 24da7cd..f21fea1 100644 --- a/skills/diagram-design/assets/template-motion.html +++ b/skills/diagram-design/assets/template-motion.html @@ -20,7 +20,7 @@ --motion-fast: 160ms; --motion-step: 480ms; --motion-hold: 720ms; - --motion-total: 4320ms; + --motion-total: 3600ms; --motion-ease: cubic-bezier(.2,.8,.2,1); } body { @@ -46,7 +46,7 @@ /* Static source is complete. Only an initialized enhancement hides items. */ .motion-ready [data-motion-item] { - opacity: 0; + opacity: .12; transform: translateY(8px); transform-box: fill-box; transform-origin: center; @@ -128,6 +128,11 @@ white-space: nowrap; border: 0; } + noscript p { + margin: 12px 0 0; + color: var(--color-muted); + font: 500 12px/1.4 var(--font-mono); + } html[data-motion="static"] [data-motion-controls], html[data-motion="static"] [data-motion-decorative] { display: none !important; } @@ -216,16 +221,17 @@