diff --git a/.claude/settings.json b/.claude/settings.json
index 6d7fbf4..48a5211 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -51,6 +51,7 @@
"Skill(sentry-skills:doc-coauthoring)",
"Skill(sentry-skills:document-api-endpoint)",
"Skill(sentry-skills:find-bugs)",
+ "Skill(sentry-skills:flowchart-maker)",
"Skill(sentry-skills:gh-review-requests)",
"Skill(sentry-skills:gha-security-review)",
"Skill(sentry-skills:iterate-pr)",
diff --git a/README.md b/README.md
index 0b17434..59364da 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ Works with Claude Code, Cursor, Cline, GitHub Copilot, and other compatible agen
| [doc-coauthoring](skills/doc-coauthoring/SKILL.md) | Guide users through a structured workflow for co-authoring documentation. |
| [document-api-endpoint](skills/document-api-endpoint/SKILL.md) | Document and type a Sentry API endpoint with drf-spectacular OpenAPI schema — write/fix `@extend_schema`, specify response TypedDicts, type parameters, fix type drift, and promote to PUBLIC. |
| [find-bugs](skills/find-bugs/SKILL.md) | Find bugs, security vulnerabilities, and code quality issues in local branch changes. |
+| [flowchart-maker](skills/flowchart-maker/SKILL.md) | Draw flat-editorial flowchart illustrations for engineering blog posts from a Mermaid spec, with a bundled renderer that outputs HTML and PNG. |
| [gh-review-requests](skills/gh-review-requests/SKILL.md) | Fetch unread GitHub notifications for open PRs where review is requested from a specified team or opened by a team member. |
| [gha-security-review](skills/gha-security-review/SKILL.md) | GitHub Actions security review for workflow exploitation vulnerabilities. |
| [iterate-pr](skills/iterate-pr/SKILL.md) | Iterate on a PR until CI passes and actionable review feedback is addressed. |
diff --git a/skills/claude-settings-audit/SKILL.md b/skills/claude-settings-audit/SKILL.md
index 7c5644f..141eed2 100644
--- a/skills/claude-settings-audit/SKILL.md
+++ b/skills/claude-settings-audit/SKILL.md
@@ -155,6 +155,7 @@ If this is a Sentry project (or sentry-skills plugin is installed), include:
"Skill(sentry-skills:doc-coauthoring)",
"Skill(sentry-skills:document-api-endpoint)",
"Skill(sentry-skills:find-bugs)",
+ "Skill(sentry-skills:flowchart-maker)",
"Skill(sentry-skills:gh-review-requests)",
"Skill(sentry-skills:gha-security-review)",
"Skill(sentry-skills:iterate-pr)",
diff --git a/skills/flowchart-maker/SKILL.md b/skills/flowchart-maker/SKILL.md
new file mode 100644
index 0000000..a884902
--- /dev/null
+++ b/skills/flowchart-maker/SKILL.md
@@ -0,0 +1,178 @@
+---
+name: flowchart-maker
+description: Use this skill to draw flow-chart illustrations for engineering blog posts — system diagrams, request paths, retry loops, swimlanes. Contains the full token set, node/edge markup primitives, and composition rules for a flat-editorial, line-drawn figure style. Output is plain HTML/CSS (no libraries), exportable to PNG or SVG. Includes `flowfig.py`, which renders a Mermaid flowchart spec into this style.
+user-invocable: true
+---
+
+# Flow figures
+
+A line-drawn, flat-editorial illustration system for explaining how a system works to other engineers. Figures are built from a fixed vocabulary — five node types, four edge types, three marks — laid out on CSS grid. Nothing is hand-drawn; nothing is bespoke.
+
+## Generator: `flowfig.py`
+
+Prefer the generator over hand-written markup. Write a Mermaid flowchart to a `.mmd` file, then run the bundled script from the repository root:
+
+```bash
+uv run ${CLAUDE_SKILL_ROOT}/scripts/flowfig.py figure.mmd # writes figure.html
+uv run ${CLAUDE_SKILL_ROOT}/scripts/flowfig.py figure.mmd --png figure.png --scale 2
+```
+
+- Requires the `uv` CLI (https://docs.astral.sh/uv/getting-started/installation/). The script has no dependencies beyond the Python standard library, so `python3` works too.
+- `--png` needs Google Chrome or Chromium on the machine and takes a few seconds per figure. Without a browser, deliver the HTML and tell the user to screenshot the plate at 2x.
+- The script prints the output path on success and rule violations on stderr. A parse error names the offending statement; fix the spec and rerun.
+- Rubik is embedded from `assets/fonts/rubik-latin.woff2` (SIL Open Font License, see `assets/fonts/OFL.txt`), so the output needs no network.
+- Example specs with rendered PNGs live in `references/examples/`. Open one when unsure how a pattern should look.
+
+Supported Mermaid subset:
+
+```
+%% note: An annotation rendered inside the plate.
+%% steps: queue, send, ok numbered badges in reading order
+%% width: 1200 max plate width in px
+graph LR or: flowchart TD
+ queue[(Queue)] --> send[Send webhook POST · 10s timeout]:::focus
+ send --> ok{2xx?}
+ ok -- yes --> done((Delivered))
+ ok -- no --> retry[Backoff attempt < 5]:::warn
+ retry -.->|requeue| queue
+ retry --x dead([Dead letter]):::fail
+ subgraph ingest [Ingest tier] dashed group boundary, may nest
+ a --> b
+ end
+```
+
+- Shapes: `[process]`, `([terminal])`, `((filled end state))`, `[(store)]`, `{decision}`.
+- Text after ` ` becomes the mono sub-label.
+- Edges: `-->` solid, `-.->` dashed, `--x` failure (pink). An edge into a `fail` node is pink too.
+- Labels: `-->|label|` or `-- label -->`. A bracketed label `-->|[40 ms]|` renders as a yellow measurement chip.
+- Tones: `:::focus`, `:::warn`, `:::fail`, or `class a,b focus`.
+- Layout hints: `:::below` forces a node under its parent, `:::beside` forces it to the next column. By default a non-primary successor with no outgoing edges drops below its parent; everything else goes right.
+- Figures carry no title, number, legend, or caption. The host page supplies those. Put what the reader must know into node labels and the optional `%% note:`.
+- The tool warns on stderr when a figure breaks a rule: more than nine nodes, more than three accented nodes, an upward failure edge, or overlapping edges.
+
+Edit the generated HTML only for one-off tweaks. Change the spec for anything structural.
+
+## Tokens
+
+```css
+:root {
+ /* ink & paper */
+ --ink: #2B2233; /* strokes, node titles */
+ --muted: #80708F; /* mono labels, lane names */
+ --body: #4D4158; /* captions, annot */
+ --rule: #E0DCE5; /* hairlines, grid overlay */
+ --wash: #F5F3F7; /* swimlane bands */
+ --paper: #FAF9FB; /* page */
+ --plate: #FFFFFF; /* figure background, node fill */
+
+ /* signal — use sparingly */
+ --accent: #6C5FC7; /* the node the paragraph is about */
+ --accent-ink: #4D3FA3; /* mono label on accent fill */
+ --accent-fill:#EFEBFA;
+ --accent-line:#C6BEEB; /* dashed boundaries */
+ --warn: #FFC227; /* attention — the slow or costly step */
+ --warn-ink: #7A5200;
+ --warn-fill: #FFF4D4;
+ --warn-line: #F5DFA3;
+ --fail: #FF45A8; /* failure paths only */
+ --fail-ink: #B01B70;
+ --fail-fill: #FFEBF5;
+
+ /* geometry */
+ --stroke: 2.5px; /* node + edge strokes */
+ --stroke-plate: 3px;
+ --r-node: 10px;
+ --r-plate: 18px;
+ --shadow-node: 4px 4px 0; /* hard offset, no blur — colour = ink, or the node's own accent */
+ --shadow-plate: 8px 8px 0;
+ --unit: 8px; /* all spacing is a multiple */
+}
+```
+
+Fonts: **Rubik** (500/600/700) for titles, captions, node labels. **Monaco** (`Monaco, Menlo, 'Ubuntu Mono', monospace` — no webfont) for every small label — always uppercase, `letter-spacing: .08em–.12em`, 11px.
+
+Type scale: node title 17/700 · secondary node title 16/700 · caption 16/500 · annotation 15/500 · mono label 12/500 caps (700 for eyebrows).
+
+## Primitives
+
+Copy these verbatim. All values are literal — do not round them.
+
+**Process node** (the default)
+```html
+
+
Relay
+
auth · rate limit
+
+```
+
+**Focus node** — same, but `border:2.5px solid #2B2233; background:#EFEBFA; box-shadow:4px 4px 0 #6C5FC7;` and the sub-label in `#4D3FA3`. Max two per figure.
+
+**Attention node** — same, but `border:2.5px solid #2B2233; background:#FFF4D4; box-shadow:4px 4px 0 #FFC227;` and the sub-label in `#7A5200`. Marks the slow, costly, or noisy step — never an error.
+
+**Failure node** — same, but `border:2.5px solid #2B2233; background:#FFEBF5; box-shadow:4px 4px 0 #FF45A8;` and the sub-label in `#B01B70`.
+
+**Terminal** — `padding:13px 24px; border:2.5px solid #2B2233; border-radius:999px; box-shadow:4px 4px 0 #2B2233;`. Filled (`background:#2B2233; color:#FFFFFF;`) for the end state.
+
+**Decision** — a rotated square with counter-rotated text:
+```html
+
+
2xx?
+
+```
+Wrap it in a fixed-height flex cell (~154px) so the rotation doesn't disturb the grid.
+
+**Store** — `padding:19px 21px; border:2.5px solid #2B2233; border-radius:50%/18px;` (the cylinder).
+
+**Edge, horizontal** — a flex row: line + CSS-triangle head.
+```html
+
+
+
+
+```
+
+**Edge, vertical** — flex column: `
` + head `border-top:12px solid #2B2233; border-left:8px solid transparent; border-right:8px solid transparent;`. Flip the head's border side to point up.
+
+**Edge variants** — dashed for async/deferred/conditional (`border-top:3px dashed` horizontally, `border-left:3px dashed` vertically); `#FF45A8` for failure; labelled = stack an 11px mono caps label above the line in the same cell.
+
+**Marks** — step badge: `width:30px;height:30px;border-radius:999px;background:#6C5FC7;color:#FFFFFF;border:2.5px solid #2B2233;font:700 13px/25px Monaco,Menlo,'Ubuntu Mono',monospace;text-align:center;`. Annotation: a 3px accent vertical rule + 15px body sentence. Measurement chip: `padding:3px 7px;border-radius:3px;background:#FFC227;font:500 10px/1.3 Monaco,monospace;letter-spacing:.05em;color:#2B2233;` — stack it above an edge to label latency or volume. Group boundary: `border:2.5px dashed #C6BEEB;border-radius:14px;`.
+
+**Figure plate** — `background:#FFFFFF;border:3px solid #2B2233;border-radius:18px;box-shadow:8px 8px 0 #2B2233;padding:46px 38px;`. No caption, figure number, or legend below the plate. The page that embeds the figure supplies those.
+
+## Layout method
+
+Never absolutely position edges. Use one CSS grid per figure with **alternating node and edge columns**, named `grid-template-areas`, and `align-items:center`:
+
+```css
+grid-template-columns: minmax(146px,1fr) 76px minmax(146px,1fr) 76px minmax(146px,1fr);
+grid-template-rows: auto 60px auto;
+grid-template-areas:
+ 'sdk e1 relay e2 queue'
+ '. . v1 . .'
+ '. . reject . .';
+```
+
+Edge cells are 68–76px wide / 58–62px tall (the 12px arrowheads need the room). L-shaped return edges are two orthogonal segments in adjacent cells (a half-width horizontal stub in the node's own column, then a vertical segment in the row above). Swimlane bands are full-row divs placed with explicit `grid-column:1/-1; grid-row:N;` **before** the node cells in source order, with `margin:-14px 0` to bleed past the nodes.
+
+## Three patterns
+
+1. **Linear path with branches** — one left-to-right spine, exceptions dropped below. The default; use for "what happens to a request".
+2. **Decision with a loop back** — one diamond, one dashed orthogonal return edge. Use for retries, polling, reconciliation.
+3. **Swimlanes with numbered steps** — lanes for tiers, badges for reading order. Use when the point is which tier owns which step. (Not yet supported by the generator; hand-write the lane bands.)
+
+## Rules
+
+- One idea per figure. Two sentences to describe it means two figures.
+- No titles, numbers, legends, or captions inside the figure. Labels on nodes and edges must stand on their own.
+- Nine nodes, hard cap. Past nine, split it or collapse a group into one node with a dashed boundary.
+- Accent is scarce — blurple marks only the node(s) the surrounding paragraph is about; yellow marks the one that costs time; pink is exceptions only. Never more than three accented nodes in one figure.
+- Orthogonal edges only. No diagonals, no curves, no crossings.
+- Edge labels are mono, caps, two or three words. Sentences go in the caption or annotation.
+- Flow reads left, then down. Never route a failure upward.
+- Every node and plate carries a hard offset shadow, never a blurred one. Node shadows are 4px ink — or the node's own accent when it is a focus/attention/exception node. Plates are 8px ink.
+- Light mode only — this system has no dark variant. Don't invent one; ask.
+- Never hand-draw SVG illustration. Every shape here is a bordered div, a CSS triangle, or a rotated square.
+
+## Export
+
+Figures are plain HTML/CSS, so screenshot the plate element at 2–3× for PNG, leaving room for the 8px offset shadow (transparent-safe: the plate is opaque white). `flowfig.py --png` does this through headless Chrome. For SVG, redraw the same geometry as ``/``/`` at the same literal values — the 2.5px stroke, 10px radius and hard offset shadow carry the style. Target 1200–1600px wide for full-width in-post figures.
diff --git a/skills/flowchart-maker/SPEC.md b/skills/flowchart-maker/SPEC.md
new file mode 100644
index 0000000..a94e721
--- /dev/null
+++ b/skills/flowchart-maker/SPEC.md
@@ -0,0 +1,80 @@
+# Flowchart Maker Specification
+
+## Intent
+
+Produce flow-chart illustrations for engineering blog posts in one fixed visual style: flat-editorial, line-drawn, built from five node types, four edge types and three marks on a CSS grid. The style is encoded twice so it cannot drift: as literal markup primitives in `SKILL.md` for hand-written figures, and as `scripts/flowfig.py`, which renders a Mermaid flowchart subset into the same markup and exports PNG through headless Chrome.
+
+## Scope
+
+In scope:
+- System diagrams, request paths, retry loops, grouped tiers, and top-down decision flows with nine nodes or fewer.
+- The design tokens, node and edge primitives, layout method and composition rules of the style.
+- A deterministic renderer for a Mermaid flowchart subset, with HTML and PNG output.
+
+Out of scope:
+- Sequence, class, state, ER or Gantt diagrams. Use `mermaid-layout` for those.
+- Charts and data visualizations.
+- A dark variant. The style is light mode only.
+- Swimlane bands. The generator does not draw them; hand-write the lane markup from `SKILL.md`.
+
+## Users And Trigger Context
+
+- Primary users: engineers and agents illustrating how a system works in a blog post, design doc or internal explainer.
+- Common user requests: "draw a figure of the request path", "make an illustration of the retry loop", "render this flowchart in the blog style", "turn this Mermaid graph into a figure", "export the diagram as PNG".
+- Should not trigger for: plain Mermaid source meant for a markdown renderer, sequence or class diagrams, charts, slides, UI mockups.
+
+## Runtime Contract
+
+- Required first actions: write the figure as a Mermaid flowchart spec, then run `scripts/flowfig.py` on it. Fall back to hand-written primitives only for what the generator cannot draw.
+- Required outputs: an HTML file with the plate, and a PNG when the user wants an image. Report the output paths.
+- Non-negotiable constraints:
+ - Nine nodes at most, three accented nodes at most, orthogonal edges, hard offset shadows, light mode only.
+ - No figure title, number, legend or caption inside the figure. The host page supplies those.
+ - Plain-language labels when the audience is outside the team. Internal names go in the prose, not the figure.
+ - Never hand-draw SVG shapes.
+- Expected bundled files loaded at runtime: `SKILL.md`. The script runs from `scripts/`. `references/examples/` is opened only when a pattern needs a visual reference.
+
+## Source And Evidence Model
+
+Authoritative sources:
+- `SKILL.md` token set and primitives, originally produced with Claude Design as a reference implementation.
+- The Mermaid flowchart syntax (https://mermaid.js.org/syntax/flowchart.html) for the accepted spec subset.
+
+Useful improvement sources:
+- positive examples: figures accepted into published posts without layout edits.
+- negative examples: overlapping edges, clipped labels, plates wider than the host column, figures that needed hand edits after rendering.
+- commit logs/changelogs: layout and router changes in `scripts/flowfig.py`.
+- issue or PR feedback: requests for unsupported Mermaid syntax or new patterns.
+- eval results: the three example specs re-rendered after every script change.
+
+Data that must not be stored:
+- Customer or organization identifiers in example specs. Use generic service names.
+- Secrets or internal hostnames in labels.
+
+## Reference Architecture
+
+- `SKILL.md` contains: generator usage, the supported Mermaid subset, tokens, primitives, layout method, the three patterns, rules and export notes.
+- `references/examples/` contains: three example specs (`.mmd`) with their rendered PNGs, one per pattern.
+- `scripts/flowfig.py` contains: the parser, layered layout, orthogonal edge router, HTML renderer and Chrome PNG export. Standard library only.
+- `assets/fonts/` contains: the Rubik latin subset embedded into every output, with its SIL Open Font License text.
+
+## Evaluation
+
+- Lightweight validation: render the three example specs and inspect the PNGs. Edges must meet node borders, labels must not overlap nodes, and the plate must not clip.
+- Deeper evaluation: render a spec with a back edge, a merge from a lower row and nested groups. The script must print no overlap warning.
+- Holdout examples: none stored yet.
+- Acceptance gates: the script exits zero on all example specs, `uv run scripts/quick_validate.py` from `skill-writer` passes, and the rendered examples match the style rules in `SKILL.md`.
+
+## Known Limitations
+
+- Swimlanes and step badges inside lanes are not generated.
+- Edge labels wider than an edge cell (about nine mono characters) crowd the next node. Prefer two short words.
+- The router picks the first conflict-free orthogonal path and warns when none exists. It does not optimize crossings globally.
+- PNG export depends on a local Chrome or Chromium binary and kills the browser once the file is complete, because headless Chrome does not exit reliably.
+
+## Maintenance Notes
+
+- When to update `SKILL.md`: a token, primitive or rule of the style changes, or the script accepts new syntax.
+- When to update `SOURCES.md`: not kept for this skill. Record source changes in the PR description.
+- When to update `EVAL.md`: not kept for this skill. The example specs are the evaluation set.
+- When to update `references/evidence/`: when a figure needed hand edits after rendering, store the spec and the fix as a negative example.
diff --git a/skills/flowchart-maker/assets/fonts/OFL.txt b/skills/flowchart-maker/assets/fonts/OFL.txt
new file mode 100644
index 0000000..03af63b
--- /dev/null
+++ b/skills/flowchart-maker/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/skills/flowchart-maker/assets/fonts/rubik-latin.woff2 b/skills/flowchart-maker/assets/fonts/rubik-latin.woff2
new file mode 100644
index 0000000..4ba94d1
Binary files /dev/null and b/skills/flowchart-maker/assets/fonts/rubik-latin.woff2 differ
diff --git a/skills/flowchart-maker/references/examples/01-request-path.mmd b/skills/flowchart-maker/references/examples/01-request-path.mmd
new file mode 100644
index 0000000..0145edb
--- /dev/null
+++ b/skills/flowchart-maker/references/examples/01-request-path.mmd
@@ -0,0 +1,7 @@
+%% steps: sdk, relay, queue
+graph LR
+ sdk[SDK browser · mobile] --> relay[Relay auth · rate limit]:::focus
+ relay -->|accepted| queue[(Kafka)]
+ relay -.->|429| reject([Rejected]):::fail
+ queue --> proc[Processing symbolicate · group]:::warn
+ proc --> store[(Snuba)]
diff --git a/skills/flowchart-maker/references/examples/01-request-path.png b/skills/flowchart-maker/references/examples/01-request-path.png
new file mode 100644
index 0000000..c856a02
Binary files /dev/null and b/skills/flowchart-maker/references/examples/01-request-path.png differ
diff --git a/skills/flowchart-maker/references/examples/02-retry-loop.mmd b/skills/flowchart-maker/references/examples/02-retry-loop.mmd
new file mode 100644
index 0000000..9168f3e
--- /dev/null
+++ b/skills/flowchart-maker/references/examples/02-retry-loop.mmd
@@ -0,0 +1,7 @@
+graph LR
+ queue[(Queue)] --> send[Send webhook POST · 10s timeout]:::focus
+ send --> ok{2xx?}
+ ok -- yes --> done((Delivered))
+ ok -- no --> retry[Backoff attempt < 5]:::warn
+ retry -.->|requeue| queue
+ retry --x dead([Dead letter]):::fail
diff --git a/skills/flowchart-maker/references/examples/02-retry-loop.png b/skills/flowchart-maker/references/examples/02-retry-loop.png
new file mode 100644
index 0000000..0560666
Binary files /dev/null and b/skills/flowchart-maker/references/examples/02-retry-loop.png differ
diff --git a/skills/flowchart-maker/references/examples/03-grouped.mmd b/skills/flowchart-maker/references/examples/03-grouped.mmd
new file mode 100644
index 0000000..e62c6bf
--- /dev/null
+++ b/skills/flowchart-maker/references/examples/03-grouped.mmd
@@ -0,0 +1,8 @@
+%% note: Only Relay is shown in detail. The other ingest services are collapsed into the group.
+graph LR
+ subgraph ingest [Ingest tier]
+ sdk[SDK] --> relay[Relay auth · rate limit] --> queue[(Kafka)]
+ end
+ queue --> consumer[Consumer batch · 500ms]:::focus
+ consumer --> db[(Postgres)]
+ consumer -.->|[~40 ms]| cache[(Redis)]
diff --git a/skills/flowchart-maker/references/examples/03-grouped.png b/skills/flowchart-maker/references/examples/03-grouped.png
new file mode 100644
index 0000000..0e3b5d9
Binary files /dev/null and b/skills/flowchart-maker/references/examples/03-grouped.png differ
diff --git a/skills/flowchart-maker/scripts/flowfig.py b/skills/flowchart-maker/scripts/flowfig.py
new file mode 100644
index 0000000..5613084
--- /dev/null
+++ b/skills/flowchart-maker/scripts/flowfig.py
@@ -0,0 +1,953 @@
+#!/usr/bin/env python3
+# /// script
+# requires-python = ">=3.10"
+# ///
+"""Render a Mermaid flowchart subset as a flat-editorial flowchart HTML plate.
+
+ uv run scripts/flowfig.py SPEC.mmd [-o OUT.html] [--png OUT.png] [--scale 2]
+
+Supported Mermaid syntax
+ graph LR / flowchart TD
+ id[Title sub label] process node
+ id([Title]) terminal (outlined)
+ id((Title)) terminal (filled, end state)
+ id[(Title)] store
+ id{Question?} decision
+ a --> b a -.-> b a --x b arrows: solid, dashed, failure
+ a -->|label| b a -- label --> b a -.->|label| b
+ a -->|[40 ms]| b a bracketed label renders as a measurement chip
+ id:::focus id:::warn id:::fail tone classes (also: class a,b focus)
+ id:::below id:::beside layout hints: force a node under, or right of, its parent
+ subgraph name [Title] ... end dashed group boundary; groups may nest
+
+Directives (comment lines)
+ %% note: An annotation rendered inside the plate.
+ %% steps: sdk, relay, queue numbered badges in this order
+ %% width: 1200
+"""
+from __future__ import annotations
+
+import argparse
+import base64
+import html
+import heapq
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from collections.abc import Callable
+from dataclasses import dataclass, field
+
+INK = "#2B2233"
+MUTED = "#80708F"
+BODY = "#4D4158"
+PAPER = "#FAF9FB"
+PLATE = "#FFFFFF"
+ACCENT = "#6C5FC7"
+ACCENT_INK = "#4D3FA3"
+ACCENT_FILL = "#EFEBFA"
+ACCENT_LINE = "#C6BEEB"
+WARN = "#FFC227"
+WARN_INK = "#7A5200"
+WARN_FILL = "#FFF4D4"
+FAIL = "#FF45A8"
+FAIL_INK = "#B01B70"
+FAIL_FILL = "#FFEBF5"
+
+SANS = "'Rubik',-apple-system,system-ui,sans-serif"
+MONO = "Monaco,Menlo,'Ubuntu Mono',monospace"
+
+TONES = {
+ "plain": (PLATE, INK, MUTED),
+ "focus": (ACCENT_FILL, ACCENT, ACCENT_INK),
+ "warn": (WARN_FILL, WARN, WARN_INK),
+ "fail": (FAIL_FILL, FAIL, FAIL_INK),
+}
+TONE_ALIASES = {"attention": "warn", "failure": "fail", "error": "fail", "accent": "focus"}
+LAYOUT_CLASSES = {"below", "beside"}
+
+NODE_COL = "minmax(146px,1fr)"
+EDGE_COL = "76px"
+NODE_ROW = "auto"
+EDGE_ROW = "60px"
+
+
+def warn(msg: str) -> None:
+ print(f"flowfig: {msg}", file=sys.stderr)
+
+
+# ---------------------------------------------------------------- model
+
+
+@dataclass
+class Node:
+ id: str
+ title: str
+ sub: str = ""
+ shape: str = "process"
+ tone: str = "plain"
+ layout: str | None = None
+ group: str | None = None
+
+
+@dataclass
+class Edge:
+ src: str
+ dst: str
+ label: str = ""
+ dashed: bool = False
+ fail: bool = False
+
+
+@dataclass
+class Group:
+ id: str
+ title: str
+ parent: str | None = None
+
+
+@dataclass
+class Spec:
+ direction: str = "LR"
+ nodes: dict[str, Node] = field(default_factory=dict)
+ edges: list[Edge] = field(default_factory=list)
+ groups: dict[str, Group] = field(default_factory=dict)
+ meta: dict[str, str] = field(default_factory=dict)
+
+
+# ---------------------------------------------------------------- parser
+
+SHAPES = [
+ ("([", "])", "terminal"),
+ ("[(", ")]", "store"),
+ ("((", "))", "end"),
+ ("[[", "]]", "process"),
+ ("[", "]", "process"),
+ ("(", ")", "process"),
+ ("{", "}", "decision"),
+]
+
+ID_RE = re.compile(r"\s*([A-Za-z0-9_]+)")
+CLASS_RE = re.compile(r":::([\w,]+)")
+ARROW_RE = re.compile(
+ r"""\s*(?:
+ --\s+(?P.+?)\s+-->
+ | -\.\s+(?P.+?)\s+\.->
+ | ==\s+(?P.+?)\s+==>
+ | (?P(?:-{2,}|-\.+-|={2,})(?P[>xo]))
+ )\s*(?:\|(?P[^|]*)\|)?\s*""",
+ re.X,
+)
+
+
+def split_text(text: str) -> tuple[str, str]:
+ text = text.strip()
+ if len(text) >= 2 and text[0] == text[-1] == '"':
+ text = text[1:-1]
+ parts = re.split(r" |\\n", text)
+ parts = [p.strip() for p in parts if p.strip()]
+ if not parts:
+ return "", ""
+ return parts[0], " · ".join(parts[1:])
+
+
+class Parser:
+ def __init__(self) -> None:
+ self.spec = Spec()
+ self.group_stack: list[str] = []
+
+ def parse(self, text: str) -> Spec:
+ lines = text.splitlines()
+ if lines and lines[0].strip() == "---":
+ end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
+ if end is not None:
+ for fm in lines[1:end]:
+ if ":" in fm:
+ k, v = fm.split(":", 1)
+ self.spec.meta[k.strip().lower()] = v.strip()
+ lines = lines[end + 1 :]
+ for raw in lines:
+ line = raw.strip()
+ if not line:
+ continue
+ m = re.match(r"%%\s*([A-Za-z_]+)\s*:\s*(.*)$", line)
+ if m:
+ self.spec.meta[m.group(1).lower()] = m.group(2).strip()
+ continue
+ if line.startswith("%%"):
+ continue
+ line = re.sub(r"\s%%.*$", "", line).strip()
+ for stmt in line.split(";"):
+ stmt = stmt.strip()
+ if stmt:
+ self.statement(stmt)
+ return self.spec
+
+ def statement(self, s: str) -> None:
+ m = re.match(r"^(graph|flowchart)\b\s*(\w+)?", s)
+ if m:
+ d = (m.group(2) or "LR").upper()
+ if d in ("TD", "TB", "BT"):
+ self.spec.direction = "TD"
+ else:
+ self.spec.direction = "LR"
+ if d in ("RL", "BT"):
+ warn(f"direction {d} is not supported; using {self.spec.direction}")
+ return
+ m = re.match(r"^subgraph\s+(.+)$", s)
+ if m:
+ body = m.group(1).strip()
+ mm = re.match(r'^([^\[\s]+)\s*(?:\[\s*"?(.*?)"?\s*\])?$', body)
+ if mm:
+ gid, title = mm.group(1), mm.group(2) or mm.group(1)
+ else:
+ gid, title = body, body
+ parent = self.group_stack[-1] if self.group_stack else None
+ self.spec.groups[gid] = Group(gid, title, parent)
+ self.group_stack.append(gid)
+ return
+ if s == "end":
+ if self.group_stack:
+ self.group_stack.pop()
+ return
+ m = re.match(r"^class\s+([\w,\s]+?)\s+(\w+)$", s)
+ if m:
+ for nid in re.split(r"[,\s]+", m.group(1)):
+ if nid:
+ self.apply_class(self.node(nid), m.group(2))
+ return
+ if re.match(r"^(classDef|style|linkStyle|click|direction)\b", s):
+ return
+ self.chain(s)
+
+ def node(self, nid: str) -> Node:
+ n = self.spec.nodes.get(nid)
+ if n is None:
+ n = Node(nid, nid)
+ self.spec.nodes[nid] = n
+ if self.group_stack and n.group is None:
+ n.group = self.group_stack[-1]
+ return n
+
+ def apply_class(self, n: Node, cls: str) -> None:
+ cls = TONE_ALIASES.get(cls, cls)
+ if cls in TONES:
+ n.tone = cls
+ elif cls in LAYOUT_CLASSES:
+ n.layout = cls
+ else:
+ warn(f"unknown class '{cls}' on node '{n.id}' (ignored)")
+
+ def node_token(self, s: str, pos: int) -> tuple[Node, int]:
+ m = ID_RE.match(s, pos)
+ if not m:
+ raise ValueError(f"expected a node id at: {s[pos:]!r}")
+ n = self.node(m.group(1))
+ pos = m.end()
+ for opener, closer, shape in SHAPES:
+ if s.startswith(opener, pos):
+ end = s.find(closer, pos + len(opener))
+ if end < 0:
+ raise ValueError(f"unclosed node text in: {s!r}")
+ n.title, n.sub = split_text(s[pos + len(opener) : end])
+ n.shape = shape
+ pos = end + len(closer)
+ break
+ m = CLASS_RE.match(s, pos)
+ if m:
+ for cls in m.group(1).split(","):
+ self.apply_class(n, cls)
+ pos = m.end()
+ return n, pos
+
+ def chain(self, s: str) -> None:
+ src, pos = self.node_token(s, 0)
+ while pos < len(s):
+ m = ARROW_RE.match(s, pos)
+ if not m or m.end() == pos:
+ raise ValueError(f"cannot parse {s[pos:]!r} in: {s!r}")
+ label = m.group("l1") or m.group("l2") or m.group("l3") or m.group("lp") or ""
+ arrow_text = m.group(0)
+ dashed = "." in arrow_text.split("|")[0] and not m.group("l1") and not m.group("l3")
+ fail = m.group("head") == "x"
+ dst, pos = self.node_token(s, m.end())
+ self.spec.edges.append(Edge(src.id, dst.id, label.strip(), dashed, fail))
+ src = dst
+
+
+# ---------------------------------------------------------------- layout
+
+
+@dataclass
+class Placement:
+ layer: int
+ row: int
+
+
+def assign_positions(spec: Spec) -> tuple[dict[str, Placement], set[int]]:
+ """Return (id -> Placement, indices of back edges)."""
+ ids = list(spec.nodes)
+ out: dict[str, list[tuple[int, Edge]]] = {i: [] for i in ids}
+ for idx, e in enumerate(spec.edges):
+ out[e.src].append((idx, e))
+
+ back: set[int] = set()
+ state: dict[str, int] = {}
+
+ def dfs(u: str) -> None:
+ state[u] = 1
+ for idx, e in out[u]:
+ if state.get(e.dst) == 1:
+ back.add(idx)
+ elif e.dst not in state:
+ dfs(e.dst)
+ state[u] = 2
+
+ for u in ids:
+ if u not in state:
+ dfs(u)
+
+ fwd = [(i, e) for i, e in enumerate(spec.edges) if i not in back]
+ fpred: dict[str, list[Edge]] = {i: [] for i in ids}
+ fsucc: dict[str, list[Edge]] = {i: [] for i in ids}
+ primary: dict[str, str] = {}
+ for _, e in fwd:
+ if e.src == e.dst:
+ continue
+ fpred[e.dst].append(e)
+ fsucc[e.src].append(e)
+ primary.setdefault(e.src, e.dst)
+
+ def is_drop(e: Edge) -> bool:
+ v = spec.nodes[e.dst]
+ if v.layout == "beside":
+ return False
+ if v.layout == "below":
+ return True
+ return primary[e.src] != e.dst and len(fpred[e.dst]) == 1 and not fsucc[e.dst]
+
+ order_index = {nid: i for i, nid in enumerate(ids)}
+ indeg = {i: len(fpred[i]) for i in ids}
+ layer: dict[str, int] = {}
+ pref: dict[str, int] = {}
+ placed: dict[str, Placement] = {}
+ occupied: set[tuple[int, int]] = set()
+ ready: list[tuple[int, int, int, str]] = []
+
+ def push(v: str) -> None:
+ if fpred[v]:
+ layer[v] = max(layer[e.src] + (0 if is_drop(e) else 1) for e in fpred[v])
+ anchor = fpred[v][0]
+ pref[v] = placed[anchor.src].row + (1 if is_drop(anchor) else 0)
+ else:
+ layer[v] = 0
+ pref[v] = 0
+ heapq.heappush(ready, (layer[v], pref[v], order_index[v], v))
+
+ for v in ids:
+ if indeg[v] == 0:
+ push(v)
+ while ready:
+ _, _, _, v = heapq.heappop(ready)
+ r = pref[v]
+ while (layer[v], r) in occupied:
+ r += 1
+ occupied.add((layer[v], r))
+ placed[v] = Placement(layer[v], r)
+ for e in fsucc[v]:
+ indeg[e.dst] -= 1
+ if indeg[e.dst] == 0:
+ push(e.dst)
+ for v in ids:
+ if v not in placed:
+ warn(f"node '{v}' could not be placed (cycle without entry?)")
+ placed[v] = Placement(0, len(placed))
+ return placed, back
+
+
+# ---------------------------------------------------------------- routing
+
+Cell = tuple[int, int]
+OPPOSITE = {"L": "R", "R": "L", "T": "B", "B": "T"}
+
+
+@dataclass
+class Piece:
+ sides: frozenset[str]
+ arrow: str | None
+ dashed: bool
+ fail: bool
+ label: str = ""
+
+ def same_geometry(self, other: "Piece") -> bool:
+ return (self.sides, self.arrow, self.dashed, self.fail) == (
+ other.sides,
+ other.arrow,
+ other.dashed,
+ other.fail,
+ )
+
+
+def side_toward(frm: Cell, to: Cell) -> str:
+ if to[0] > frm[0]:
+ return "R"
+ if to[0] < frm[0]:
+ return "L"
+ if to[1] > frm[1]:
+ return "B"
+ return "T"
+
+
+def walk(points: list[Cell]) -> list[Cell] | None:
+ cells: list[Cell] = []
+ for (x0, y0), (x1, y1) in zip(points, points[1:]):
+ if x0 != x1 and y0 != y1:
+ return None
+ dx = (x1 > x0) - (x1 < x0)
+ dy = (y1 > y0) - (y1 < y0)
+ x, y = x0, y0
+ while (x, y) != (x1, y1):
+ x += dx
+ y += dy
+ cells.append((x, y))
+ return cells
+
+
+def candidate_paths(a: Cell, b: Cell, direction: str) -> list[list[Cell]]:
+ ax, ay = a
+ bx, by = b
+ paths: list[list[Cell]] = []
+
+ def add(pts: list[Cell]) -> None:
+ dedup: list[Cell] = []
+ for p in pts:
+ if not dedup or dedup[-1] != p:
+ dedup.append(p)
+ if len(dedup) >= 2 and dedup not in paths:
+ paths.append(dedup)
+
+ if ax == bx or ay == by:
+ add([a, b])
+ rows = [ay, by, ay + 1, by + 1, ay - 1, by - 1]
+ cols = [ax, bx, ax + 1, bx - 1, ax - 1, bx + 1]
+ row_paths = [[a, (ax, cy), (bx, cy), b] for cy in rows]
+ col_paths = [[a, (cx, ay), (cx, by), b] for cx in cols]
+ for p in row_paths + col_paths if direction == "LR" else col_paths + row_paths:
+ add(p)
+ return paths
+
+
+def route(
+ a: Cell, b: Cell, edge: Edge, direction: str, blocked: set[Cell], pieces: dict[Cell, Piece]
+) -> list[tuple[Cell, Piece]]:
+ fallback: list[tuple[Cell, Piece]] | None = None
+ for pts in candidate_paths(a, b, direction):
+ cells = walk(pts)
+ if cells is None or cells[-1] != b:
+ continue
+ body = cells[:-1]
+ if a in body or b in body or len(set(body)) != len(body):
+ continue
+ result: list[tuple[Cell, Piece]] = []
+ prev = a
+ ok = True
+ for i, c in enumerate(body):
+ nxt = body[i + 1] if i + 1 < len(body) else b
+ entry = side_toward(c, prev)
+ exit_ = side_toward(c, nxt)
+ piece = Piece(
+ frozenset({entry, exit_}),
+ exit_ if nxt == b else None,
+ edge.dashed,
+ edge.fail,
+ )
+ existing = pieces.get(c)
+ if c in blocked or (existing is not None and not existing.same_geometry(piece)):
+ ok = False
+ result.append((c, piece))
+ prev = c
+ if fallback is None:
+ fallback = result
+ if ok:
+ return result
+ if fallback is None:
+ raise ValueError(f"no route from {a} to {b}")
+ warn(f"edge {edge.src} -> {edge.dst} overlaps another element; check the figure")
+ return fallback
+
+
+def attach_label(result: list[tuple[Cell, Piece]], label: str) -> None:
+ if not label:
+ return
+ def straight(sides: str, wide_axis: int) -> list[Piece]:
+ cells = [(c, p) for c, p in result if p.sides == frozenset(sides)]
+ wide = [p for c, p in cells if c[wide_axis] % 2 == 0]
+ return wide or [p for _, p in cells]
+
+ target = (straight("LR", 0) or straight("TB", 1) or [result[0][1]])[0]
+ target.label = label
+
+
+# ---------------------------------------------------------------- render
+
+ARROW = {
+ "R": f"border-left:12px solid {{c}};border-top:8px solid transparent;border-bottom:8px solid transparent;",
+ "L": f"border-right:12px solid {{c}};border-top:8px solid transparent;border-bottom:8px solid transparent;",
+ "B": f"border-top:12px solid {{c}};border-left:8px solid transparent;border-right:8px solid transparent;",
+ "T": f"border-bottom:12px solid {{c}};border-left:8px solid transparent;border-right:8px solid transparent;",
+}
+
+
+def esc(s: str) -> str:
+ return html.escape(s, quote=True)
+
+
+def arrow_div(side: str, color: str, extra: str = "") -> str:
+ return f''
+
+
+def label_div(label: str, fail: bool, extra: str = "") -> str:
+ if not label:
+ return ""
+ m = re.match(r"^\[(.+)\]$", label)
+ if m:
+ return (
+ f'
{esc(m.group(1))}
'
+ )
+ color = FAIL_INK if fail else MUTED
+ return (
+ f'
{esc(label)}
'
+ )
+
+
+def line_style(horizontal: bool, dashed: bool, color: str) -> str:
+ if horizontal:
+ return f"flex:1;height:0;border-top:3px dashed {color};" if dashed else f"flex:1;height:3px;background:{color};"
+ return f"flex:1;width:0;border-left:3px dashed {color};" if dashed else f"flex:1;width:3px;background:{color};"
+
+
+def render_piece(p: Piece, area: str) -> str:
+ color = FAIL if p.fail else INK
+ common = f"{area}align-self:stretch;justify-self:stretch;min-width:0;min-height:0;"
+ if p.sides == frozenset("LR"):
+ return (
+ f'
'
+ f'{label_div(p.label, p.fail, "grid-row:1;align-self:end;justify-self:center;padding-bottom:5px;white-space:normal;text-align:center;max-width:100%;")}'
+ f'
"
+ )
+
+
+FONT_FILE = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "fonts", "rubik-latin.woff2"
+)
+
+
+def font_face() -> str:
+ """Embed Rubik so the page renders offline and headless Chrome never waits on a webfont."""
+ if not os.path.exists(FONT_FILE):
+ return (
+ '\n'
+ ''
+ )
+ with open(FONT_FILE, "rb") as fh:
+ data = base64.b64encode(fh.read()).decode("ascii")
+ return (
+ ""
+ )
+
+
+def render(spec: Spec) -> str:
+ placed, back = assign_positions(spec)
+ if len(spec.nodes) > 9:
+ warn(f"{len(spec.nodes)} nodes; the style caps figures at nine")
+ accented = sum(1 for n in spec.nodes.values() if n.tone != "plain")
+ if accented > 3:
+ warn(f"{accented} accented nodes; the style allows at most three")
+
+ def cell_of(nid: str) -> Cell:
+ p = placed[nid]
+ return (2 * p.layer, 2 * p.row) if spec.direction == "LR" else (2 * p.row, 2 * p.layer)
+
+ node_cells = {nid: cell_of(nid) for nid in spec.nodes}
+ blocked = set(node_cells.values())
+ pieces: dict[Cell, Piece] = {}
+ ports: dict[str, dict[str, Port]] = {nid: {} for nid in spec.nodes}
+ for e in spec.edges:
+ if e.src == e.dst:
+ warn(f"self-loop on '{e.src}' skipped")
+ continue
+ if spec.nodes[e.dst].tone == "fail":
+ e.fail = True
+ result = route(node_cells[e.src], node_cells[e.dst], e, spec.direction, blocked, pieces)
+ attach_label(result, e.label)
+ port: Port = (FAIL if e.fail else INK, e.dashed)
+ ports[e.src][side_toward(node_cells[e.src], result[0][0])] = port
+ ports[e.dst][side_toward(node_cells[e.dst], result[-1][0])] = port
+ if e.fail and spec.direction == "LR" and node_cells[e.dst][1] < node_cells[e.src][1]:
+ warn(f"failure edge {e.src} -> {e.dst} routes upward; the style says never do that")
+ for c, piece in result:
+ existing = pieces.get(c)
+ if existing is None:
+ pieces[c] = piece
+ elif piece.label and not existing.label:
+ existing.label = piece.label
+
+ xs = [c[0] for c in list(blocked) + list(pieces)]
+ ys = [c[1] for c in list(blocked) + list(pieces)]
+ minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys)
+ cols = " ".join(NODE_COL if x % 2 == 0 else EDGE_COL for x in range(minx, maxx + 1))
+ rows = " ".join(NODE_ROW if y % 2 == 0 else EDGE_ROW for y in range(miny, maxy + 1))
+
+ def area(c: Cell, span: Cell | None = None) -> str:
+ x0, y0 = c[0] - minx + 1, c[1] - miny + 1
+ if span is None:
+ return f"grid-column:{x0};grid-row:{y0};"
+ x1, y1 = span[0] - minx + 2, span[1] - miny + 2
+ return f"grid-column:{x0}/{x1};grid-row:{y0}/{y1};"
+
+ steps = [s.strip() for s in spec.meta.get("steps", "").split(",") if s.strip()]
+ step_of = {nid: i + 1 for i, nid in enumerate(steps)}
+ for nid in steps:
+ if nid not in spec.nodes:
+ warn(f"steps: unknown node '{nid}'")
+
+ def group_chain(nid: str) -> list[str]:
+ chain: list[str] = []
+ gid = spec.nodes[nid].group
+ while gid is not None:
+ chain.append(gid)
+ gid = spec.groups[gid].parent
+ return chain
+
+ members_of = {
+ gid: [nid for nid in spec.nodes if gid in group_chain(nid)] for gid in spec.groups
+ }
+
+ def nesting_depth(gid: str) -> int:
+ children = [g.id for g in spec.groups.values() if g.parent == gid and members_of[g.id]]
+ return 1 + max(nesting_depth(c) for c in children) if children else 0
+
+ items: list[str] = []
+ for gid in sorted(spec.groups, key=nesting_depth, reverse=True):
+ members = [node_cells[nid] for nid in members_of[gid]]
+ if not members:
+ continue
+ lo = (min(c[0] for c in members), min(c[1] for c in members))
+ hi = (max(c[0] for c in members), max(c[1] for c in members))
+ for nid, c in node_cells.items():
+ if nid not in members_of[gid] and lo[0] <= c[0] <= hi[0] and lo[1] <= c[1] <= hi[1]:
+ warn(f"group '{gid}' box also covers node '{nid}'")
+ items.append(render_group(spec.groups[gid], area(lo, hi), 14 + 22 * nesting_depth(gid)))
+ for nid, n in spec.nodes.items():
+ items.append(render_node(n, area(node_cells[nid]), step_of.get(nid), ports[nid]))
+ for c, p in pieces.items():
+ items.append(render_piece(p, area(c)))
+
+ grid = (
+ f'