spinagram 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. spinagram/__init__.py +11 -0
  2. spinagram/agent_skills/spinagram/SKILL.md +244 -0
  3. spinagram/agent_skills/spinagram-css/SKILL.md +155 -0
  4. spinagram/agent_skills/spinagram-css/examples/aws.css +46 -0
  5. spinagram/agent_skills/spinagram-css/examples/aws_network_hub.py +51 -0
  6. spinagram/agent_skills/spinagram-css/examples/aws_three_tier.py +53 -0
  7. spinagram/agent_skills/spinagram-css/examples/campus.css +19 -0
  8. spinagram/agent_skills/spinagram-css/examples/campus_network.py +45 -0
  9. spinagram/agent_skills/spinagram-css/examples/evpn_fabric.py +68 -0
  10. spinagram/agent_skills/spinagram-css/examples/fabric.css +27 -0
  11. spinagram/agent_skills/spinagram-css/examples/gcp.css +34 -0
  12. spinagram/agent_skills/spinagram-css/examples/gcp_global_app.py +49 -0
  13. spinagram/agent_skills/spinagram-css/examples/homelab.css +26 -0
  14. spinagram/agent_skills/spinagram-css/examples/homelab.py +49 -0
  15. spinagram/agent_skills/spinagram-css/examples/k8s.css +48 -0
  16. spinagram/agent_skills/spinagram-css/examples/k8s_networking.py +50 -0
  17. spinagram/agent_skills/spinagram-css/examples/k8s_platform.py +52 -0
  18. spinagram/agent_skills/spinagram-css/examples/notes.css +9 -0
  19. spinagram/agent_skills/spinagram-css/examples/notes.py +23 -0
  20. spinagram/agent_skills/spinagram-css/examples/showcase.css +17 -0
  21. spinagram/agent_skills/spinagram-css/references/recipes.css +72 -0
  22. spinagram/annotate.py +539 -0
  23. spinagram/api.py +81 -0
  24. spinagram/autolayout/__init__.py +442 -0
  25. spinagram/autolayout/engines/__init__.py +54 -0
  26. spinagram/autolayout/engines/d2.py +296 -0
  27. spinagram/autolayout/engines/dot.py +179 -0
  28. spinagram/autolayout/engines/elk.py +170 -0
  29. spinagram/autolayout/engines/elk_runner.mjs +35 -0
  30. spinagram/autolayout/engines/flex.py +97 -0
  31. spinagram/autolayout/engines/layered.py +679 -0
  32. spinagram/autolayout/graph.py +60 -0
  33. spinagram/autolayout/route.py +146 -0
  34. spinagram/autolayout/sizing.py +77 -0
  35. spinagram/backends/__init__.py +48 -0
  36. spinagram/backends/figma.py +589 -0
  37. spinagram/backends/png.py +40 -0
  38. spinagram/backends/pptx.py +555 -0
  39. spinagram/backends/svg.py +257 -0
  40. spinagram/behavior.py +311 -0
  41. spinagram/cli.py +344 -0
  42. spinagram/css.py +706 -0
  43. spinagram/deck.py +241 -0
  44. spinagram/facts.py +195 -0
  45. spinagram/icon_packs/aws.yaml +43 -0
  46. spinagram/icon_packs/azure.yaml +12 -0
  47. spinagram/icon_packs/gcp.yaml +11 -0
  48. spinagram/icon_packs/k8s.yaml +9 -0
  49. spinagram/icon_packs/network.yaml +30 -0
  50. spinagram/icon_packs/oci.yaml +9 -0
  51. spinagram/icon_packs/openstack.yaml +8 -0
  52. spinagram/icon_packs/selfhosted.yaml +28 -0
  53. spinagram/icon_packs/terrastruct.yaml +84 -0
  54. spinagram/icons.py +438 -0
  55. spinagram/inspect_fmt.py +193 -0
  56. spinagram/layout.py +501 -0
  57. spinagram/metadata.py +66 -0
  58. spinagram/model.py +604 -0
  59. spinagram/qa.py +73 -0
  60. spinagram/scene.py +239 -0
  61. spinagram/shapes.py +248 -0
  62. spinagram/skills.py +99 -0
  63. spinagram/text.py +83 -0
  64. spinagram/theme.py +291 -0
  65. spinagram/themes/default/theme.yaml +114 -0
  66. spinagram/ui/__init__.py +5 -0
  67. spinagram/ui/catalog.py +118 -0
  68. spinagram/ui/events.py +48 -0
  69. spinagram/ui/server.py +271 -0
  70. spinagram/ui/static/assets/index-BDoYKVM5.css +2 -0
  71. spinagram/ui/static/assets/index-DAg_fACF.js +11 -0
  72. spinagram/ui/static/index.html +13 -0
  73. spinagram/ui/themes.py +152 -0
  74. spinagram/ui/workspace.py +340 -0
  75. spinagram/vectorize.py +158 -0
  76. spinagram/yaml_io.py +322 -0
  77. spinagram-0.1.0.dist-info/METADATA +225 -0
  78. spinagram-0.1.0.dist-info/RECORD +81 -0
  79. spinagram-0.1.0.dist-info/WHEEL +4 -0
  80. spinagram-0.1.0.dist-info/entry_points.txt +9 -0
  81. spinagram-0.1.0.dist-info/licenses/LICENSE +21 -0
spinagram/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """spinagram — diagrams as code, rendered editable to SVG, PNG, PowerPoint and Figma."""
2
+ from .api import render, scenes
3
+ from .behavior import Activity, Flowchart, StateMachine
4
+ from .layout import build_scene
5
+ from .model import Diagram, Edge, Legend, Node, Table, Zone
6
+ from .theme import Theme
7
+ from .yaml_io import load
8
+
9
+ __all__ = ["Activity", "Diagram", "Flowchart", "StateMachine", "Edge", "Legend", "Node", "Table", "Zone", "Theme", "build_scene", "load", "render",
10
+ "scenes"]
11
+ __version__ = "0.1.0"
@@ -0,0 +1,244 @@
1
+ ---
2
+ name: spinagram
3
+ description: Author professional diagrams as code with spinagram (YAML or Python) and deliver them as SVG, PNG, editable PowerPoint and editable Figma. Use when asked to create, update or export flowcharts, decision trees, process/approval flows, swimlanes, today-vs-target comparisons, org/structure charts, matrices or tables-as-diagrams, especially for slide decks, studies or Figma. Covers layout grid, text fitting, edge routing, themes, icons, QA, and the Figma hand-off (through a Figma MCP server when the agent has one). For CSS styling use the spinagram-css skill. Not for data charts (bar/line) or auto-laid-out graphs of hundreds of nodes.
4
+ ---
5
+
6
+ # spinagram: diagrams as code with editable outputs
7
+
8
+ You write a **spec** (YAML or Python). A **theme** directory supplies the brand. spinagram resolves both into one scene and renders it through backends: `svg`, `png`, `pptx`, `figma`.
9
+
10
+ ```
11
+ spec (YAML/.py) + theme dir ──► layout (fonts, wrap, anchors) ──► svg · png · pptx · figma
12
+ ```
13
+
14
+ ## Workflow
15
+
16
+ 1. **Message first.** State the single question the diagram answers. That becomes the `subtitle`, a full sentence with the conclusion. The `title` names the topic in 2–6 words.
17
+ 2. **Pick a pattern** (table below) and sketch the grid on paper: columns, rows, gaps.
18
+ 3. **Write the spec.** Prefer Python: `a >> b` arrows, `with d.group(...)` zones, loops for repetition, and automatic layout when no node has coordinates. Use YAML when another tool generates the diagram.
19
+ 4. **`spinagram check`** must report 0 issues (text overflow).
20
+ 5. **Render PNG and look at it.** Check overlaps, arrows crossing text, label pills on borders, and empty zones. Fix the spec, never the output.
21
+ 6. **Deliver.** Use `-f svg,png,pptx` for decks. Add `figma` and follow the Figma hand-off below.
22
+
23
+ ```bash
24
+ spinagram render specs/ -t <theme-dir> -f svg,png,pptx,figma -o out/
25
+ spinagram check specs/ -t <theme-dir>
26
+ spinagram render specs/ --only D04 -f png # iterate on one
27
+ ```
28
+
29
+ ## Patterns
30
+
31
+ | Need | Pattern | Building blocks |
32
+ |---|---|---|
33
+ | Linear process | Row of 4–6 boxes and `chain` | `row()`, `chain()` |
34
+ | Decision | Diamonds with `yes`/`no` labels; branches leave from different sides | `kind: diamond`, side anchors |
35
+ | Today vs target | Two zones, paired cards, one arrow per pair | `zones`, mirrored `x` |
36
+ | Process + per-context detail | Row on top, table below | `tables` with an `icons` column |
37
+ | Lifecycle / loop | Clockwise ring; the dashed return arrow carries the loop condition | `dashed: true`, `via` |
38
+ | Org / governance | Tree top-down: global body on top, one row per level | `column()` per branch, `"a.bottom -> b.top"` |
39
+ | Approval scenario | Request → engine → policy box → parallel approvers → outcomes | `style` = semantic state |
40
+
41
+ ## Diagram or UI mockup?
42
+
43
+ spinagram draws **diagrams**. For **product UI** such as an e-mail, a chat, a card or a screen, a diagram looks fake. Use an HTML page instead:
44
+
45
+ 1. Build it with Tailwind at the slide size (1920×1080). Use the same header and footer as the diagram slides, and the theme's tokens, fonts and icons.
46
+ 2. Screenshot it at 2x with Playwright.
47
+ 3. Add the screenshot to the deck as a full-bleed picture slide.
48
+
49
+ Keep the real, implementable template (an e-mail HTML with inline CSS, or an Adaptive Card) next to it, so the mockup is never the only artifact.
50
+
51
+ ## Layout rules
52
+
53
+ - **Canvas** is 1920×1080 with an 80 px side margin. Content goes in `y` 170–1000, between the header and the footer rule.
54
+ - **Use a grid.** Gaps between boxes are 28–40 in dense rows, 60–100 between steps and 40 between rows. Equal sizes inside a row.
55
+ - **Size boxes for text.** Allow at most 3 body lines. If `check` reports `overflow-y`, enlarge the box or cut text. Do not shrink the font below the theme default.
56
+ - **Icons** scale with box height (theme `icon_scale`, `icon_max`). Use `icon_size` only to make one emphasis node larger. Every box in a row has an icon, or none does.
57
+ - **One reading direction:** left→right for flows, top→bottom for hierarchies. Return arrows go dashed.
58
+ - **Layered landscape:** put the layer name in a left column (free `label`) and shift the boxes right. A zone label at the top collides with vertical arrows between layers.
59
+ - **Short vertical gaps:** put the edge label beside the line (`label_at` offset in x), not on it. Otherwise the pill hides the arrowhead.
60
+ - **Don't let tall boxes float text:** a 590 px box holding 8 lines reads as empty. Split the content into several boxes (one per source or route), or shrink the box to fit its text.
61
+ - **Leave white space.** If a zone is half empty, shrink it. Do not stretch cards to fill it.
62
+
63
+ ## Text rules
64
+
65
+ - **One idea per box.** Titles have 1–4 words. The body is a short phrase, not a sentence list.
66
+ - **Numbers** in a diagram must come from the study/report sources. Put the source in `source:` (footer).
67
+ - **Same vocabulary across all diagrams in a set:** one term per concept.
68
+ - Edge labels are 1–2 words (`yes`, `no`, `affects`, `rejected`).
69
+
70
+ ## Edges
71
+
72
+ - Prefer `"a -> b"` (sides auto-picked) or `"a.right -> b.left"`. Use `@0.3` to spread several edges leaving one side.
73
+ - Side-centre anchors become **bound connectors in PowerPoint**: moving a box drags the arrow. Off-centre (`@frac`), `via` and `curve` edges are drawn exactly but stay unbound.
74
+ - Route around boxes with `via: [[x, y], ...]`. Do not cross text. Place labels with `label_at` when the midpoint lands on a box.
75
+ - Colours are semantic: default = normal flow, alert/orange = risk path, gray = alternative or neutral.
76
+
77
+ ## Styles (semantic, theme-mapped)
78
+
79
+ | Style | Use |
80
+ |---|---|
81
+ | `default` | Normal step |
82
+ | `dark` | Engine / central decision maker (one or two per diagram) |
83
+ | `soft` | Background grouping, decision diamonds |
84
+ | `warning` | Needs review / attention |
85
+ | `alert` | Risk, problem, blocking body (review board, incident) |
86
+ | `neutral` | Outcome or side note |
87
+
88
+ ## Numbers are facts
89
+
90
+ Never type a number from data into a spec. Write `{{fact_id}}` and declare `facts:` (plus `locale:`) in `_defaults.yaml`. Each fact must carry its `query` or `formula` and its `computed_at`.
91
+
92
+ - Forecasts are derived facts whose formula states the assumption, e.g. `0.3*critical_changes`.
93
+ - `spinagram facts` lists every fact used; the generated `fontes.md` (sources) is the reviewer's document.
94
+ - Use `|raw` to get a plain number for CSS widths in HTML bar charts.
95
+
96
+ ## Story decks
97
+
98
+ **HTML deck** (`spinagram deck deck.yaml -t <theme> [-o out.html]`): one self-contained page — cover
99
+ (title, subtitle, date, authors with photo and role), a full-colour divider per section, then one slide
100
+ per diagram (title band + one-line message + the diagram at full width). Fonts, logos, photos and
101
+ diagrams are embedded; printing gives one slide per page. `deck.yaml` lists `specs:` (dirs, relative to
102
+ the file), `authors:` and `sections: [{title, subtitle, slides: [{diagram: <id>, title, message}]}]`;
103
+ `image: svg` (vector, default) or `png` (smaller with many fonts). Colours and logos come from the
104
+ theme's `deck:` block (`accent`, `strong`, `deep`, `page`, `logo`, `logo_negative`).
105
+
106
+ For PowerPoint decks:
107
+
108
+ When the diagrams form a presentation:
109
+
110
+ - Keep a `deck.yaml` with sections, slide order and a `talk` line per slide; it becomes the speaker notes.
111
+ - Open with a cover and a roadmap. Close with a case exercise, the benefits and the asks.
112
+ - Slides not listed go to an appendix.
113
+
114
+ ## YAML pitfalls
115
+
116
+ - **Quote values in flow mappings** (`{...}`) that contain `,`, `:`, `?` or `#`. For example, `text: "a, b, c"` or `title: ["Any", "trigger?"]`. Unquoted commas silently split the value; the loader now rejects the resulting unknown keys.
117
+ - `yes`, `no`, `on` and `off` are booleans in YAML; never use them unquoted as ids (the loader rejects them).
118
+ - `at: [x, y, w, h]` is shorthand for the four keys.
119
+ - A `_defaults.yaml` in the specs folder supplies keys every diagram shares (`kicker`, `source`).
120
+
121
+ ## Live preview
122
+
123
+ While iterating, run `spinagram serve <specs> --theme <theme>` (extra `ui`): the browser re-renders on every
124
+ save, shows load/render errors inline, the theme token sheet (colour token names, styles) and the icon packs
125
+ (search names, fetch missing packs). Use it to pick tokens and icon names instead of guessing.
126
+
127
+ ## Icon packs
128
+
129
+ Use `icon: "<pack>:<name>"` for vendor icons: `aws`, `gcp`, `azure`, `k8s`, `oci`, `openstack`, `terrastruct`,
130
+ `selfhosted` (Proxmox, OPNsense, VMware ESXi, TrueNAS, UniFi… for homelabs and private clouds) and `network`
131
+ (router, switch, firewall… as `network:router`, `-green`, `-circle`, `-plain`) are built in
132
+ and download on first use (`spinagram icons fetch`). Find names with `spinagram icons search <words>`;
133
+ short aliases exist for common services (`aws:s3`, `aws:alb`, `aws:kms`, `gcp:gke`, `azure:aks`, `k8s:pod`).
134
+ For general software architecture (languages, databases, caches, queues) use `terrastruct`:
135
+ `terrastruct:java|python|go|nodejs|react`, `terrastruct:postgres|mysql|mongo|redis|oracle`,
136
+ `terrastruct:kafka|sqs|sns|service-bus|pubsub`, `terrastruct:tech-server`, `terrastruct:db`. It has no
137
+ native Kafka/RabbitMQ logo: `kafka` maps to the AWS MSK icon.
138
+ Register a brand or extra set with `spinagram icons add <name> --dir|--zip|--npm|--github …`
139
+ (`--project` writes `./spinagram-icons.yaml`, shareable with the repo). Prefer SVG packs; PNG packs
140
+ (oci, openstack) carry a dark variant used automatically on dark fills.
141
+
142
+ **PNG → SVG:** with the `vector` extra, PNG icons (theme, spec paths, packs) become traced SVG when a
143
+ fidelity gate passes — crisper output and editable vectors in Figma (no PNG uploads). Check a set with
144
+ `spinagram vectorize <dir>`; disable with `SPINAGRAM_VECTORIZE=0` or `icons: {vectorize: false}`.
145
+
146
+ ## Automatic layout (no coordinates)
147
+
148
+ For architecture views (Region › VPC › AZ) and flows, prefer `layout:` and `groups:` over hand-placed
149
+ coordinates. spinagram sizes and places everything, and each group is drawn as a zone.
150
+
151
+ - **Engine:**
152
+ - `dot` for ranked flows and AWS views; it gives clean orthogonal routes.
153
+ - `elk` for many edges crossing group borders.
154
+ - `flex` (no dependencies) for grids of cards; add `route: ortho` for elbowed edges.
155
+ - `d2` when you need D2's layouts.
156
+ - **Compare engines** before choosing: `spinagram render spec.yaml --layout dot|elk|flex|d2`.
157
+ - **Group hints for flex:** `direction` (row|column), `columns`, `gap`, `padding`, `align`. Sibling groups stretch to equal size.
158
+ - **Leave edges unanchored** (`a -> b`) so the engine routes them. An anchor (`a.right`) or `via` opts that edge out.
159
+ - **Mixed slides:** a legend or note with explicit `at:` stays where you put it; the auto content fills `layout.area`.
160
+ - **Hand-tuning:** `spinagram layout spec.yaml -o frozen.yaml` freezes the result; edit the frozen spec.
161
+ - `check` reports `layout: overflow` when the content does not fit. Change the direction, split the diagram or shorten titles.
162
+
163
+ ## Groups with outlines (AWS-style)
164
+
165
+ Zone styles may declare `stroke`, `dashed`, `icon`, `radius`, `upper` and `fill: none`, which draws
166
+ cloud-provider groups the way AWS Architecture Icons do: an outline, no fill, the group icon flush in
167
+ the top-left corner and the label beside it (`d.zone("AZ 1a", ..., color="aws-az")`). Nest them
168
+ Account › Region › VPC › AZ; regional services (S3, KMS) sit inside the Region but outside the VPC.
169
+ Keep AZ labels short (`AZ 1a`) so arrows entering the boxes below do not cross them.
170
+
171
+ ## Python DSL
172
+
173
+ Automatic layout (no coordinates anywhere: the diagram is laid out with `flex`):
174
+
175
+ ```python
176
+ from spinagram import Diagram
177
+ d = Diagram("Q01", "Web app on AWS")
178
+ users = d.box("Users", icon="aws:users")
179
+ with d.group("VPC", style="aws-vpc", direction="right"):
180
+ api = d.box("API", text="FastAPI", icon="terrastruct:python")
181
+ db, cache = d.box("Orders DB", icon="terrastruct:postgres"), d.box("Cache", icon="terrastruct:redis")
182
+ users.to(api, "HTTPS") >> [db, cache] # a >> b, a << b, a - b (no head); lists fan out/in
183
+ diagram = d
184
+ ```
185
+
186
+ Fixed coordinates:
187
+
188
+ ```python
189
+ from spinagram import Diagram
190
+ d = Diagram("D01", "Release flow", subtitle="Evidence decides the path")
191
+ a, b, c = d.row(["Build", "Stress & chaos", "Risk gate"], x=80, y=220, w=440, h=220, gap=100,
192
+ styles=[None, None, "dark"])
193
+ d.chain(a, b, c)
194
+ d.edge(c.bottom, d.box("Deploy", x=700, y=560, w=500, h=150).top, label="green")
195
+ diagram = d # the CLI picks up `diagram`, `diagrams` or `build()`
196
+ ```
197
+
198
+ Annotate with `d.add_legend()`, `a.to(b, step="…")`, `d.callout(node, "…", kind=...)` and
199
+ `d.note(..., variant="card"|"quote")`; see the spinagram-css skill.
200
+
201
+ Give diagrams a `category=` and `tags=` (YAML `category:`, `tags:`) so `spinagram serve` groups them and
202
+ `--tag`/`--category` filter them; vendor icons already add `aws`, `gcp`, `kubernetes`… tags.
203
+
204
+ Helpers next to the spec are importable (`from _layout import spread`); files starting with `_`
205
+ are not loaded as specs.
206
+
207
+ ## Outputs
208
+
209
+ - **PowerPoint:** one slide per diagram, with native shapes and the text inside each shape. Tables are native, and the diagram `source` goes into the speaker notes. Viewers need the theme fonts installed.
210
+ - **Figma:**
211
+ - **Scripts:** `out/figma/<slug>-NN.js` (run in order) and `<slug>.assets.json`. They run through the
212
+ Figma MCP server's `use_figma` tool. Without that server, hand the scripts to the user.
213
+ 1. If the agent has Figma's `figma-use` skill, load it.
214
+ 2. Run each script with `use_figma` (`fileKey` from the URL).
215
+ - **Container:** the diagram becomes a frame (Design file), a slide (Slides file) or a section (FigJam board, `figma.com/board/...`) named `spinagram:<id>`. Re-running it rebuilds the content in place.
216
+ - **FigJam:** edges whose both ends sit on box sides become native connectors attached to the boxes (they follow when a box moves); free edges stay vectors. Load the `figma-use-figjam` skill too, when available.
217
+ - **SVG icons** are rebuilt as editable vectors inside the script (`createNodeFromSvg`) — no upload. Only raster icons (PNG) need `upload_assets`.
218
+ - **FigJam in one step:** `-f figjam` writes library-mode scripts, `figma/icons/` (every icon used),
219
+ `icon-library.js` (finds the uploaded layers by file name) and a `README.md` with the order of calls.
220
+ - **Icon library (recommended for many diagrams):** upload every distinct icon once with `upload_assets` (multipart, the file name becomes the layer name), run `icon_library_js({file: nodeId})` to rename them `dk-icon:<file>` inside a section `spinagram:icons`, then generate scripts with `FigmaBackend(library=True)` / `to_figma_js(scene, library=True)`: icons are cloned from the library — exact bytes, much smaller scripts (≈30 KB instead of 45 KB+ with inline SVG).
221
+ - **FigJam gotchas:** connector endpoints use `magnet` (TOP/BOTTOM/LEFT/RIGHT) for side centres; `position` is in **pixels** from the node's top-left, not 0–1. Elbow routes are auto-computed — if one crosses a box, re-anchor that end to another side. `setPluginData` is not available in `use_figma` (placeholders carry ` · asset:<file>` in the layer name instead).
222
+ - **Images:**
223
+ - The script returns `imageTargets`.
224
+ - Call `upload_assets(fileKey, count=n, nodeIds=[...])`.
225
+ - POST each file listed in `assets.json` to its URL.
226
+ - **Edges (Design/Slides):** drawn as vectors, because the connector node is FigJam-only.
227
+ - **SVG** is self-contained (fonts and images embedded). **PNG** is rendered at 2x (3840×2160) for slides.
228
+
229
+ ## CSS styling
230
+
231
+ Stylesheets restyle any diagram without touching its code: `d.stylesheet("brand.css")`, `class_="critical"`,
232
+ `edge.async { stroke-dasharray: 6 4 }`. For anything beyond a rule or two, load the **spinagram-css**
233
+ skill: selectors, properties, recipes, layout hints for dense architecture slides and the debugging loop.
234
+
235
+ ## Themes
236
+
237
+ Use `extends: default` and override `colors` (tokens may alias tokens), `fonts` (with files, for metrics and embedding), `icons` (`dir` + `pattern` `{name}-{variant}.png`, variants per background tone), `styles.*` and `chrome` (bar, title, kicker template, footer logo). Keep brand themes **outside** the engine repository.
238
+
239
+ ## Done when
240
+
241
+ - `check` reports 0 issues.
242
+ - The PNG has been reviewed: no overlap, no clipped text, and arrows do not cross labels.
243
+ - Requested formats are written. PPTX opens.
244
+ - The Figma scripts ran, or the hand-off steps were given to the user.
@@ -0,0 +1,155 @@
1
+ ---
2
+ name: spinagram-css
3
+ description: Style spinagram diagrams with CSS (selectors, cascade, custom properties, shadows, dashes, markers) and lay out dense cloud architecture slides (AWS, Google Cloud, Azure, Kubernetes) that fit one 1920×1080 canvas. Use when asked to make spinagram diagrams look polished or on-brand, write or debug a stylesheet, build a dark or vendor-coloured architecture diagram, or explain why an element looks the way it does. Includes recipes, worked examples and layout hints. Requires the spinagram CLI.
4
+ ---
5
+
6
+ # Style spinagram diagrams with CSS
7
+
8
+ A stylesheet restyles a diagram without touching its code. The same rules render in SVG, PNG,
9
+ PowerPoint and Figma. This skill covers the CSS subset, the recipes that look good, and the layout
10
+ habits that make dense architecture diagrams fit one slide. For general spinagram authoring, see
11
+ the `spinagram` skill.
12
+
13
+ Files next to this one:
14
+
15
+ - `references/recipes.css`: tested rules to copy (base cards, dark canvas, tiers, edge semantics,
16
+ vendor accents).
17
+ - `examples/`: six complete diagrams with their stylesheets: AWS three-tier and hub-and-spoke networking
18
+ (legends, numbered steps), Kubernetes workload and service networking and a Google Cloud global app
19
+ (legends, callouts), and an architecture-decisions slide of designed notes.
20
+ Render them with `spinagram render examples/ -f png -o out/`.
21
+
22
+ ## Workflow
23
+
24
+ 1. **Structure first, style second.** Write the diagram with groups and edges; give elements
25
+ semantic classes (`class_="security"`, `class_="async"`), never colours.
26
+ 2. **One base sheet plus one accent sheet.** The base sets cards, type and edges for every diagram
27
+ in the set. The accent sheet adds the vendor or brand palette. Attach both in cascade order:
28
+ `d.stylesheet("base.css")` then `d.stylesheet("aws.css")`.
29
+ 3. **`spinagram check <spec>`** must report 0 issues. `info: unused selector` lines are hints,
30
+ not failures.
31
+ 4. **Render a PNG and look at it** if you can view images; otherwise ask the user to. Check that
32
+ edges don't cross boxes, labels don't cover text and nothing is clipped.
33
+ 5. **Explain a value** with `spinagram check --styles <spec> --select "#api"`, or press **Inspect**
34
+ in `spinagram serve <specs>`, which reloads on every save of a spec or stylesheet.
35
+
36
+ ## Attaching styles
37
+
38
+ | Where | Python | YAML |
39
+ |---|---|---|
40
+ | Diagram | `d.stylesheet("brand.css")`, `Diagram(..., css="...")` | `stylesheets: [brand.css]`, `css:` |
41
+ | Element | `class_="critical"`, `css="stroke-width: 6"` | `class:`, `css:` |
42
+ | Folder | — | `stylesheets:` in `_defaults.yaml` |
43
+ | Theme | `theme.css` next to `theme.yaml` | same |
44
+
45
+ Paths are relative to the spec. Later sheets win at equal specificity; inline `css=` beats every
46
+ sheet rule and `!important` beats inline. Classes work on nodes, edges and groups
47
+ (`d.group("VPC", style="aws-vpc", class_="private")` keeps the AWS outline and icon and adds the class).
48
+
49
+ ## Selectors
50
+
51
+ | Selector | Matches |
52
+ |---|---|
53
+ | `node`, `edge`, `group`, `region`, `lane`, `diagram` | elements by type |
54
+ | `box`, `data`, `state`, `decision`, … | nodes of that kind |
55
+ | `.critical`, `#api`, `*` | class, id, anything |
56
+ | `[icon]`, `[kind=data]`, `edge[from=api]`, `edge[to=db]`, `[label="yes"]` | attributes |
57
+ | `group.vpc node`, `region > state` | descendant, child |
58
+ | `:first-child`, `:last-child`, `:not(.x)`, `:root` | pseudo-classes |
59
+ | `node::title`, `node::body`, `edge::label`, `group::label` | text parts |
60
+
61
+ Node ids come from titles (`"Orders DB"` → `#orders-db`) unless you pass `id=`.
62
+
63
+ ## Properties
64
+
65
+ | Applies to | Properties |
66
+ |---|---|
67
+ | Nodes, groups | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-dasharray`, `border-radius`, `opacity`, `box-shadow` (one: `x y blur colour`) |
68
+ | Text (`::title`, `::body`, `::label`) | `color`, `font-size`, `font-weight`, `font-style`; inherited from groups |
69
+ | Edges | `stroke`, `stroke-width`, `stroke-dasharray`, `opacity`, `marker-start`, `marker-end` (`none`, `arrow`, `open-arrow`, `diamond`, `open-diamond`, `circle`, `bar`) |
70
+
71
+ Colours: hex, `rgb()`/`rgba()`, CSS names, theme tokens (`fill: primary-soft`) and `var(--x)`, which
72
+ falls back to the theme token `x`. Lengths are canvas pixels (`4` or `4px`).
73
+
74
+ ## Recipes
75
+
76
+ All are in `references/recipes.css`.
77
+
78
+ - **Layout-only group.** `fill: none` is rejected. A group that only arranges its children uses
79
+ `group.bare { fill-opacity: 0; stroke-width: 0; box-shadow: none; }`.
80
+ - **Dark canvas.** Zones are translucent by default, so a dark group looks grey until it sets
81
+ `opacity: 1; fill-opacity: 1`. Restyle what sits inside with descendant selectors:
82
+ `group.night node::title { color: #F8FAFC }`.
83
+ - **Hero element.** One glow per diagram: `box-shadow: 0 0 22px rgba(140, 79, 255, 0.45)` with a
84
+ thicker stroke. More than one and nothing stands out.
85
+ - **External actors.** Dashed outline and no shadow (`.external`), so they read as outside the system.
86
+ - **Edge semantics.** Solid for requests, dashed for async or control (`edge.async`, `edge.control`),
87
+ a colour for one special path (`edge.hot`), `marker-start` for two-way flows (`edge.replication`).
88
+ - **Tiers.** Tint subnets or zones by tier (`group.public`, `group.private`, `group.data`) and colour
89
+ their labels to match.
90
+ - **Vendor palettes.** AWS colours by service category (compute orange, network purple, database
91
+ magenta, storage green); Google Cloud uses its four brand colours; Kubernetes uses `#326CE5`.
92
+
93
+ ## Annotations
94
+
95
+ Legends, numbered steps, callouts and notes make a reference architecture explain itself; all are styled
96
+ by CSS and native in every output.
97
+
98
+ - **Legend**: `d.add_legend()` lists the classes and styles in use, labelled from their names, so name
99
+ classes for meaning (`.web-tier` → "Web tier", `edge.backup-path` → "Backup path"). Pass
100
+ `{".class": "Label", "edge.class": ..., "style:dark": ..., "aws:s3": ...}` to choose entries.
101
+ - **Steps**: `a.to(b, "HTTPS", step="Users call the API")`, `d.step("…", on=node)`. Badges sit beside edge
102
+ labels or on node corners; the Steps panel lists them in order. Keep each step to one line (~38 chars).
103
+ - **Callouts**: `d.callout(node, "text", title=..., kind="info|tip|warning|risk")`. Auto placement needs
104
+ ~360 px of clear space beside the node; if `check` reports `callout: overlap`, target a node with room
105
+ around it.
106
+ - **Notes**: `d.note(..., variant="card" | "quote")` for decision cards and quotes on narrative slides.
107
+ - **Legend from CSS**: `legend { placement: bottom; columns: 4 }` moves and reshapes it (also `width`, `gap`,
108
+ `padding`, `swatch-width`, `swatch-height`, `display: none`, `legend::body { font-size }`); an explicit
109
+ `add_legend(position=...)` wins.
110
+ - **Band budget**: legend and steps take a 392 px band on the right. Plan content for about 1416 px of width
111
+ (with `layout.area` set to `[56, 176, 1808, 830]`), or put the legend at the bottom.
112
+ - **Style**: `legend`, `legend::title`, `legend::body`, `step`, `step::label`, `callout.warning`,
113
+ `callout::body`, `note.card`. A dark legend matches a dark cluster (see `examples/k8s.css`).
114
+ - **Specificity**: a rule like `group.cluster node` (0,1,2) beats `.networking` (0,1,0); write
115
+ `group.cluster .networking` so class colours, and with them the legend, still show.
116
+
117
+ ## Fitting dense diagrams on one slide
118
+
119
+ The canvas is fixed at 1920×1080 and content is not scaled down. `check` reports
120
+ `layout: overflow` with the excess in pixels.
121
+
122
+ - **Size cards yourself.** Automatic nodes are at least 220 px wide. Pass a size to every node,
123
+ `N = {"w": 210, "h": 84}` then `d.box(..., **N)`, and widen only the few that need it.
124
+ - **Text room with an icon** is about `w − 102` px. A single word longer than that is reported as
125
+ `overflow-x`: widen that node or pick a shorter word. Titles wrap onto two lines; bodies keep to one.
126
+ - **Smaller type shrinks boxes**: `node::title { font-size: 19px }` and `node::body { font-size: 14px }`.
127
+ - **Tighten spacing** with group hints (`gap=24`, `padding=0`) and the root `"gap"` in `layout`.
128
+ - **Use the margins** when needed: `layout={"area": [56, 176, 1808, 830]}` (x, y, w, h).
129
+ - **Turn a row into a column.** When the width overflows and there is room below, stack: AZs as rows
130
+ of tier subnets, regions stacked, the control plane as a top row.
131
+
132
+ ## Edges that don't cross boxes
133
+
134
+ - `flex` with `"route": "ortho"` draws elbowed edges but does **not** route around boxes. Connect
135
+ neighbours only: arrange columns so every edge goes to the next column or the next box in a stack.
136
+ - An edge that would skip over a box: reorder (chain `users → dns → cdn` rather than fanning out),
137
+ move one end to another column, or drop the edge and show the relationship with text or a class
138
+ (a `.blocked` pod instead of a "deny" arrow).
139
+ - Anchored edges (`d.edge(a.left, b.left)`) are drawn straight, not routed. Use them only between
140
+ boxes that face each other.
141
+ - `elk` routes around boxes but lays out wider and ignores group `direction`. Compare with
142
+ `spinagram render spec.py --layout elk` before switching.
143
+
144
+ ## Backends
145
+
146
+ SVG and PNG draw everything exactly. In PowerPoint and Figma, `open-diamond` becomes a filled
147
+ diamond and `bar` is dropped, each with a warning; `opacity` blends the fill only. `font-family` is
148
+ checked but not applied: text keeps the theme font. PowerPoint may warn that a PNG fallback for some
149
+ Kubernetes SVG icons could not be made; recent PowerPoint versions show the SVG anyway.
150
+
151
+ ## Done when
152
+
153
+ - `spinagram check` reports 0 issues.
154
+ - The PNG shows no overlaps, clipped text or edges crossing boxes.
155
+ - Classes describe meaning (`security`, `async`), and colours live only in stylesheets.
@@ -0,0 +1,46 @@
1
+ /* AWS accents, layered on showcase.css. Colours follow the AWS architecture icon categories.
2
+ Class names double as legend labels: `d.add_legend()` turns `.web-tier` into "Web tier". */
3
+ :root { --compute: #ED7100; --network: #8C4FFF; --database: #C925D1; --storage: #7AA116; --hybrid: #0EA5E9; }
4
+
5
+ .external { fill: #F8FAFC; stroke: #94A3B8; stroke-dasharray: 6 4; box-shadow: none; }
6
+ .edge-service { stroke: var(--network); }
7
+ .security { stroke: var(--sec); fill: #FFF5F5; }
8
+ .load-balancer { stroke: var(--network); fill: #F5F3FF; }
9
+ .web-tier { stroke: var(--compute); }
10
+ .app-tier { stroke: var(--compute); fill: #FFF7ED; }
11
+ .database { stroke: var(--database); fill: #FDF4FF; }
12
+ .storage { stroke: var(--storage); fill: #F7FEE7; }
13
+
14
+ group.edge { fill: #F8FAFC; stroke: var(--card-stroke); border-radius: 16px; }
15
+ group.edge::label { color: var(--muted); }
16
+ group.bare { fill-opacity: 0; stroke-width: 0; box-shadow: none; }
17
+ group.public { fill: #F0FDF4; stroke: var(--storage); }
18
+ group.public::label { color: #3F6212; }
19
+ group.private { fill: #EFF6FF; stroke: #147EBA; }
20
+ group.private::label { color: #1E40AF; }
21
+ group.data { fill: #FAF5FF; stroke: var(--database); }
22
+ group.data::label { color: #86198F; }
23
+
24
+ edge.replication { stroke: var(--database); stroke-dasharray: 6 4; marker-start: arrow; }
25
+ edge.replication::label { color: #86198F; }
26
+
27
+ /* Networking */
28
+ group.onprem { fill: #F8FAFC; stroke: #475569; stroke-dasharray: 2 4; border-radius: 0; }
29
+ group.onprem::label { color: #334155; }
30
+ .on-premises { stroke: #475569; }
31
+ .hybrid-link { stroke: var(--hybrid); fill: #F0F9FF; }
32
+ .transit-hub { stroke: var(--network); stroke-width: 3; fill: #F5F3FF; box-shadow: 0 0 22px rgba(140, 79, 255, 0.45); }
33
+ .transit-hub::title { color: #5B21B6; }
34
+ .egress { stroke: var(--network); }
35
+ .workload { border-radius: 6px; stroke: #16A34A; fill: #F0FDF4; }
36
+ #staging-vpc { stroke: #D97706; fill: #FFFBEB; }
37
+ #shared-services { stroke: #0891B2; fill: #ECFEFF; }
38
+ edge.private-circuit { stroke: var(--hybrid); stroke-width: 3; }
39
+ edge.backup-path { stroke: var(--hybrid); stroke-width: 3; stroke-dasharray: 8 5; }
40
+ edge.inspection { stroke: var(--sec); stroke-width: 3; }
41
+ edge.inspection::label { color: var(--sec); }
42
+
43
+ /* Annotations */
44
+ step { fill: #232F3E; stroke: #FFFFFF; }
45
+ step::label { color: #FF9900; }
46
+ legend::title { color: #232F3E; }
@@ -0,0 +1,51 @@
1
+ """AWS hub-and-spoke network: Transit Gateway with centralized inspection and egress, after the AWS
2
+ reference architecture for multi-VPC networking, with an automatic legend and numbered traffic steps."""
3
+ from spinagram import Diagram
4
+
5
+ N = {"w": 200, "h": 80} # compact cards: the whole network fits one slide
6
+
7
+ d = Diagram("AWS02", "Hub-and-spoke networking on AWS",
8
+ subtitle="Transit Gateway hub · centralized inspection and egress · hybrid connectivity",
9
+ layout={"engine": "flex", "direction": "right", "gap": 32, "route": "ortho",
10
+ "area": [56, 176, 1808, 830]})
11
+ d.stylesheet("showcase.css")
12
+ d.stylesheet("aws.css")
13
+
14
+ with d.group(id="hybrid", class_="bare", direction="right", gap=32, align="center"):
15
+ with d.group("Corporate data center", id="dc", class_="onprem", direction="down", gap=24,
16
+ icon="aws:group-corporatedatacenter"):
17
+ office = d.box("Branch offices", text="SD-WAN", icon="aws:officebuilding", class_="external", **N)
18
+ cgw = d.box("Customer gateway", text="BGP ASN 65010", icon="aws:vpc-customer-gateway",
19
+ class_="on-premises", **N)
20
+ with d.group(id="links", class_="bare", direction="down", gap=24):
21
+ dx = d.box("Direct Connect", text="2 × 10 Gbps", icon="aws:direct-connect", class_="hybrid-link", **N)
22
+ vpn = d.box("VPN", text="IPsec backup", icon="aws:aws-siteto-site-vpn", class_="hybrid-link", **N)
23
+ with d.group("us-east-1 · network account", style="aws-region", direction="down", gap=28, padding=16):
24
+ with d.group(id="services", class_="bare", direction="right", gap=24):
25
+ with d.group("Inspection VPC", style="aws-vpc"):
26
+ nfw = d.box("Network Firewall", text="stateful IPS", icon="aws:network-firewall", class_="security", **N)
27
+ with d.group("Egress VPC", style="aws-vpc", direction="down", gap=20):
28
+ nat = d.box("NAT gateway", text="per AZ", icon="aws:vpcnat-gateway", class_="egress", **N)
29
+ igw = d.box("Internet gateway", text="to 0.0.0.0/0", icon="aws:vpc-internet-gateway", class_="egress", **N)
30
+ with d.group(id="core", class_="bare", direction="right", gap=60, align="center"):
31
+ tgw = d.box("Transit Gateway", text="routing hub", icon="aws:aws-transit-gateway",
32
+ class_="transit-hub", **N)
33
+ with d.group(id="spokes", class_="bare", direction="down", gap=14):
34
+ spokes = [d.box(name, text=cidr, icon="aws:group-virtualprivatecloud-vpc", class_="workload", **N)
35
+ for name, cidr, env in (("Prod VPC", "10.1.0.0/16", "prod"),
36
+ ("Staging VPC", "10.2.0.0/16", "staging"),
37
+ ("Shared services", "10.3.0.0/16", "shared"))]
38
+
39
+ office >> cgw
40
+ cgw.to(dx, "primary", class_="private-circuit")
41
+ cgw.to(vpn, "backup", class_="backup-path")
42
+ dx.to(tgw, "DX gateway", class_="private-circuit", step="Branch traffic reaches AWS over DX")
43
+ vpn.to(tgw, class_="backup-path")
44
+ for s in spokes:
45
+ tgw.to(s, arrow="both")
46
+ tgw.to(nfw, "inspect", class_="inspection", step="TGW sends traffic to inspection")
47
+ nfw.to(nat, step="Allowed flows leave via NAT")
48
+ nat.to(igw, step="The internet gateway egresses")
49
+ d.add_legend()
50
+
51
+ diagram = d
@@ -0,0 +1,53 @@
1
+ """AWS three-tier web application, after the AWS reference architecture: an automatic legend from the CSS
2
+ classes and numbered steps along the request path."""
3
+ from spinagram import Diagram
4
+
5
+ N = {"w": 196, "h": 80} # compact cards: diagram, legend and steps share one slide
6
+
7
+ d = Diagram("AWS01", "Three-tier web application on AWS",
8
+ subtitle="Edge, web, app and data tiers across two Availability Zones",
9
+ layout={"engine": "flex", "direction": "right", "gap": 32, "route": "ortho",
10
+ "area": [56, 176, 1808, 830]})
11
+ d.stylesheet("showcase.css")
12
+ d.stylesheet("aws.css")
13
+
14
+ with d.group("Internet edge", id="edge", class_="edge", direction="down", gap=22, padding=16):
15
+ users = d.box("Users", text="web, mobile", icon="aws:users", class_="external", **N)
16
+ dns = d.box("Route 53", text="latency DNS", icon="aws:amazon-route53", class_="edge-service", **N)
17
+ cdn = d.box("CloudFront", text="CDN, TLS", icon="aws:cloudfront", class_="edge-service", w=216, h=80)
18
+ waf = d.box("AWS WAF", text="managed rules", icon="aws:awswaf", class_="security", **N)
19
+ s3 = d.box("S3", text="static origin", icon="aws:s3", class_="storage", **N)
20
+ with d.group("us-east-1", style="aws-region", padding=16):
21
+ with d.group("VPC 10.0.0.0/16", style="aws-vpc", direction="right", align="center", gap=12, padding=16):
22
+ alb = d.box("ALB", text="HTTPS :443", icon="aws:alb", class_="load-balancer", **N)
23
+ with d.group(id="azs", class_="bare", direction="down", gap=20, padding=0):
24
+ tiers = {}
25
+ for az, n in (("a", 0), ("b", 1)):
26
+ with d.group(f"Availability Zone {az.upper()}", style="aws-az", direction="right", gap=12, padding=16):
27
+ with d.group(f"Public 10.0.{n}.0/24", style="aws-subnet", class_="public", padding=14):
28
+ web = d.box("Web", text="Nginx · ASG", icon="aws:amazon-ec2", class_="web-tier",
29
+ id=f"web-{az}", **N)
30
+ with d.group(f"Private 10.0.1{n}.0/24", style="aws-subnet", class_="private", padding=14):
31
+ app = d.box("App", text="ECS Fargate", icon="aws:amazon-elastic-container-service",
32
+ class_="app-tier", id=f"app-{az}", **N)
33
+ with d.group(f"Data 10.0.2{n}.0/24", style="aws-subnet", class_="data", padding=14):
34
+ db = d.box("Aurora " + ("writer" if az == "a" else "reader"), text="PostgreSQL",
35
+ icon="aws:amazon-aurora", class_="database", id=f"db-{az}", **N)
36
+ tiers[az] = (web, app, db)
37
+
38
+ users.to(dns, "resolve", step="Users resolve the domain")
39
+ dns.to(cdn, "alias", step="Route 53 aliases to CloudFront")
40
+ cdn.to(waf, step="AWS WAF filters each request")
41
+ waf.to(alb, step="The ALB balances across zones")
42
+ for az, (web, app, db) in tiers.items():
43
+ alb >> web
44
+ if az == "a":
45
+ web.to(app, step="Web tier calls the app tier")
46
+ app.to(db, "SQL", step="App tier writes to Aurora")
47
+ else:
48
+ web >> app
49
+ app.to(db, "SQL")
50
+ d.edge(tiers["a"][2], tiers["b"][2], "replication", class_="replication")
51
+ d.add_legend()
52
+
53
+ diagram = d
@@ -0,0 +1,19 @@
1
+ /* Campus network: clean tiers, network symbols carry the colour. */
2
+ group.tier { fill: #F8FAFC; stroke: #E2E8F0; border-radius: 16px; }
3
+ group.tier::label { color: #64748B; }
4
+ group.bare { fill-opacity: 0; stroke-width: 0; box-shadow: none; }
5
+
6
+ .internet { fill: #F8FAFC; stroke: #94A3B8; stroke-dasharray: 6 4; box-shadow: none; }
7
+ .edge-gear { stroke: #DC2626; }
8
+ .core { stroke: #16A34A; stroke-width: 2.5; }
9
+ .access { stroke: #2563EB; }
10
+ .endpoint { border-radius: 40px; stroke: #94A3B8; }
11
+
12
+ edge { stroke: #94A3B8; stroke-width: 2.5; }
13
+ edge.uplink { stroke: #16A34A; stroke-width: 3; }
14
+ edge.redundant-link { stroke: #16A34A; stroke-width: 3; stroke-dasharray: 8 6; marker-start: arrow; }
15
+
16
+ /* The legend is placed and laid out here, not in the diagram code. */
17
+ legend { placement: bottom; columns: 7; padding: 18; swatch-width: 64; swatch-height: 24; fill: #F8FAFC; box-shadow: none; }
18
+ legend::title { font-size: 16px; color: #475569; }
19
+ legend::body { font-size: 15px; }
@@ -0,0 +1,45 @@
1
+ """A campus network in three tiers: WAN edge, a redundant core and access switches serving Wi-Fi, phones and
2
+ desks, drawn with the Affinity symbols of the `network` pack in their colour and circle variants."""
3
+ from spinagram import Diagram
4
+
5
+ N = {"w": 220, "h": 92}
6
+ E = {"w": 210, "h": 80}
7
+
8
+ d = Diagram("NET01", "Campus network", subtitle="Redundant core, access layer and endpoints, left to right",
9
+ layout={"engine": "flex", "direction": "right", "rank_gap": 64, "route": "ortho",
10
+ "area": [56, 176, 1808, 830]})
11
+ d.stylesheet("showcase.css")
12
+ d.stylesheet("campus.css")
13
+
14
+ with d.group("WAN edge", id="edge", class_="tier", direction="down", gap=40, align="center"):
15
+ isp = d.box("ISP", text="2 × 10 Gb/s", icon="network:internet", class_="internet", **N)
16
+ router = d.box("Edge router", text="BGP, NAT", icon="network:router-red", class_="edge-gear", **N)
17
+ fw = d.box("Firewall", text="HA pair", icon="network:firewall-red", class_="edge-gear", **N)
18
+ with d.group("Core", id="core", class_="tier", direction="down", gap=72, align="center"):
19
+ core_a = d.box("Core A", text="L3, 100 Gb/s", icon="network:switch-multilayer-green", class_="core", **N)
20
+ core_b = d.box("Core B", text="L3, 100 Gb/s", icon="network:switch-multilayer-green", class_="core", **N)
21
+ with d.group("Access", id="access", class_="tier", direction="down", gap=24, align="center"):
22
+ floors = [d.box(f"Floor {i}", text="48 × 2.5 Gb/s", icon="network:switch", class_="access", id=f"floor-{i}",
23
+ **N) for i in (1, 2, 3)]
24
+ wlc = d.box("Wireless LAN", text="controller", icon="network:wlc", class_="access", **N)
25
+ with d.group("Endpoints", id="endpoints", class_="tier", direction="down", gap=24, align="center"):
26
+ ap = d.box("Access points", text="Wi-Fi 7", icon="network:wifi-plain", class_="endpoint", **E)
27
+ phone = d.box("IP phones", text="PoE", icon="network:ip-phone-circle", class_="endpoint", **E)
28
+ desk = d.box("Desks", text="1 Gb/s", icon="network:client-circle", class_="endpoint", **E)
29
+ printer = d.box("Printers", text="VLAN 40", icon="network:printer-circle", class_="endpoint", **E)
30
+
31
+ isp >> router >> fw
32
+ fw.to(core_a, class_="uplink")
33
+ fw.to(core_b, class_="uplink")
34
+ d.edge(core_a, core_b, "VSS", class_="redundant-link")
35
+ core_a.to([floors[0], floors[1]], class_="uplink")
36
+ core_b.to([floors[2], wlc], class_="uplink")
37
+ floors[0] >> ap
38
+ floors[1] >> phone
39
+ floors[2] >> desk
40
+ wlc.to(printer, "Wi-Fi")
41
+ d.callout(core_b, "Either core carries the whole campus: links and supervisors are redundant.",
42
+ title="No single point of failure", kind="info")
43
+ d.add_legend()
44
+
45
+ diagram = d