spinagram 0.1.0__tar.gz

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 (281) hide show
  1. spinagram-0.1.0/.github/workflows/ci.yml +90 -0
  2. spinagram-0.1.0/.github/workflows/docs.yml +50 -0
  3. spinagram-0.1.0/.github/workflows/release.yml +58 -0
  4. spinagram-0.1.0/.gitignore +6 -0
  5. spinagram-0.1.0/.specs/LESSONS.md +153 -0
  6. spinagram-0.1.0/.specs/features/annotations/design.md +140 -0
  7. spinagram-0.1.0/.specs/features/annotations/spec.md +230 -0
  8. spinagram-0.1.0/.specs/features/annotations/tasks.md +563 -0
  9. spinagram-0.1.0/.specs/features/annotations/validation.md +177 -0
  10. spinagram-0.1.0/.specs/features/auto-layout/context.md +9 -0
  11. spinagram-0.1.0/.specs/features/auto-layout/design.md +113 -0
  12. spinagram-0.1.0/.specs/features/auto-layout/spec.md +69 -0
  13. spinagram-0.1.0/.specs/features/auto-layout/tasks.md +17 -0
  14. spinagram-0.1.0/.specs/features/behavior-diagrams/design.md +93 -0
  15. spinagram-0.1.0/.specs/features/behavior-diagrams/spec.md +237 -0
  16. spinagram-0.1.0/.specs/features/behavior-diagrams/tasks.md +241 -0
  17. spinagram-0.1.0/.specs/features/behavior-diagrams/validation.md +151 -0
  18. spinagram-0.1.0/.specs/features/catalog-previews/spec.md +100 -0
  19. spinagram-0.1.0/.specs/features/catalog-previews/tasks.md +280 -0
  20. spinagram-0.1.0/.specs/features/catalog-previews/validation.md +150 -0
  21. spinagram-0.1.0/.specs/features/css-inspector/spec.md +112 -0
  22. spinagram-0.1.0/.specs/features/css-inspector/tasks.md +196 -0
  23. spinagram-0.1.0/.specs/features/css-inspector/validation.md +109 -0
  24. spinagram-0.1.0/.specs/features/css-styling/design.md +76 -0
  25. spinagram-0.1.0/.specs/features/css-styling/spec.md +223 -0
  26. spinagram-0.1.0/.specs/features/css-styling/tasks.md +206 -0
  27. spinagram-0.1.0/.specs/features/css-styling/validation.md +128 -0
  28. spinagram-0.1.0/.specs/features/diagram-catalog/design.md +168 -0
  29. spinagram-0.1.0/.specs/features/diagram-catalog/spec.md +205 -0
  30. spinagram-0.1.0/.specs/features/diagram-catalog/tasks.md +360 -0
  31. spinagram-0.1.0/.specs/features/diagram-catalog/validation.md +200 -0
  32. spinagram-0.1.0/.specs/features/diagram-import/context.md +64 -0
  33. spinagram-0.1.0/.specs/features/diagram-import/design.md +246 -0
  34. spinagram-0.1.0/.specs/features/diagram-import/spec.md +289 -0
  35. spinagram-0.1.0/.specs/features/engine-v1/design.md +31 -0
  36. spinagram-0.1.0/.specs/features/engine-v1/spec.md +26 -0
  37. spinagram-0.1.0/.specs/features/engine-v1/tasks.md +20 -0
  38. spinagram-0.1.0/.specs/features/layered-layout/design.md +95 -0
  39. spinagram-0.1.0/.specs/features/layered-layout/spec.md +188 -0
  40. spinagram-0.1.0/.specs/features/layered-layout/tasks.md +149 -0
  41. spinagram-0.1.0/.specs/features/layered-layout/validation.md +137 -0
  42. spinagram-0.1.0/.specs/features/legend-css/spec.md +114 -0
  43. spinagram-0.1.0/.specs/features/legend-css/tasks.md +237 -0
  44. spinagram-0.1.0/.specs/features/legend-css/validation.md +164 -0
  45. spinagram-0.1.0/.specs/features/live-ui/context.md +43 -0
  46. spinagram-0.1.0/.specs/features/live-ui/design.md +78 -0
  47. spinagram-0.1.0/.specs/features/live-ui/spec.md +132 -0
  48. spinagram-0.1.0/.specs/features/live-ui/tasks.md +22 -0
  49. spinagram-0.1.0/.specs/features/png-to-svg/design.md +38 -0
  50. spinagram-0.1.0/.specs/features/png-to-svg/spec.md +55 -0
  51. spinagram-0.1.0/.specs/features/png-to-svg/tasks.md +26 -0
  52. spinagram-0.1.0/.specs/features/pptx-svg/spec.md +44 -0
  53. spinagram-0.1.0/.specs/features/selfhosted-icons/spec.md +142 -0
  54. spinagram-0.1.0/.specs/features/selfhosted-icons/tasks.md +312 -0
  55. spinagram-0.1.0/.specs/features/selfhosted-icons/validation.md +225 -0
  56. spinagram-0.1.0/.specs/lessons.json +405 -0
  57. spinagram-0.1.0/.specs/project/PROJECT.md +25 -0
  58. spinagram-0.1.0/.specs/project/STATE.md +84 -0
  59. spinagram-0.1.0/CHANGELOG.md +153 -0
  60. spinagram-0.1.0/LICENSE +21 -0
  61. spinagram-0.1.0/PKG-INFO +225 -0
  62. spinagram-0.1.0/README.md +175 -0
  63. spinagram-0.1.0/cliff.toml +57 -0
  64. spinagram-0.1.0/docs/.gitignore +26 -0
  65. spinagram-0.1.0/docs/app/(home)/layout.tsx +6 -0
  66. spinagram-0.1.0/docs/app/(home)/page.tsx +67 -0
  67. spinagram-0.1.0/docs/app/api/search/route.ts +9 -0
  68. spinagram-0.1.0/docs/app/docs/[[...slug]]/page.tsx +60 -0
  69. spinagram-0.1.0/docs/app/docs/layout.tsx +11 -0
  70. spinagram-0.1.0/docs/app/global.css +12 -0
  71. spinagram-0.1.0/docs/app/layout.tsx +23 -0
  72. spinagram-0.1.0/docs/app/llms-full.txt/route.ts +7 -0
  73. spinagram-0.1.0/docs/app/llms.mdx/docs/[[...slug]]/route.ts +24 -0
  74. spinagram-0.1.0/docs/app/llms.txt/route.ts +7 -0
  75. spinagram-0.1.0/docs/bun.lock +939 -0
  76. spinagram-0.1.0/docs/components/mdx.tsx +21 -0
  77. spinagram-0.1.0/docs/components/provider.tsx +8 -0
  78. spinagram-0.1.0/docs/components/search.tsx +38 -0
  79. spinagram-0.1.0/docs/content/docs/ai-skills.mdx +72 -0
  80. spinagram-0.1.0/docs/content/docs/annotations.mdx +169 -0
  81. spinagram-0.1.0/docs/content/docs/auto-layout.mdx +92 -0
  82. spinagram-0.1.0/docs/content/docs/behavior.mdx +62 -0
  83. spinagram-0.1.0/docs/content/docs/cli.mdx +48 -0
  84. spinagram-0.1.0/docs/content/docs/css-reference.mdx +168 -0
  85. spinagram-0.1.0/docs/content/docs/development.mdx +67 -0
  86. spinagram-0.1.0/docs/content/docs/extending.mdx +40 -0
  87. spinagram-0.1.0/docs/content/docs/facts.mdx +25 -0
  88. spinagram-0.1.0/docs/content/docs/figma.mdx +33 -0
  89. spinagram-0.1.0/docs/content/docs/getting-started.mdx +84 -0
  90. spinagram-0.1.0/docs/content/docs/icons.mdx +102 -0
  91. spinagram-0.1.0/docs/content/docs/index.mdx +44 -0
  92. spinagram-0.1.0/docs/content/docs/live-ui.mdx +38 -0
  93. spinagram-0.1.0/docs/content/docs/manual-layout.mdx +87 -0
  94. spinagram-0.1.0/docs/content/docs/meta.json +30 -0
  95. spinagram-0.1.0/docs/content/docs/outputs.mdx +28 -0
  96. spinagram-0.1.0/docs/content/docs/python.mdx +191 -0
  97. spinagram-0.1.0/docs/content/docs/styling.mdx +58 -0
  98. spinagram-0.1.0/docs/content/docs/themes.mdx +29 -0
  99. spinagram-0.1.0/docs/content/docs/yaml.mdx +45 -0
  100. spinagram-0.1.0/docs/diagrams/overview.yaml +24 -0
  101. spinagram-0.1.0/docs/lib/cn.ts +1 -0
  102. spinagram-0.1.0/docs/lib/layout.shared.tsx +18 -0
  103. spinagram-0.1.0/docs/lib/shared.ts +20 -0
  104. spinagram-0.1.0/docs/lib/source.ts +30 -0
  105. spinagram-0.1.0/docs/next.config.mjs +21 -0
  106. spinagram-0.1.0/docs/package.json +32 -0
  107. spinagram-0.1.0/docs/postcss.config.mjs +7 -0
  108. spinagram-0.1.0/docs/public/assets/css/g01.svg +1 -0
  109. spinagram-0.1.0/docs/public/assets/css/g02.svg +1 -0
  110. spinagram-0.1.0/docs/public/assets/css/g03.svg +1 -0
  111. spinagram-0.1.0/docs/public/assets/css/g04.svg +1 -0
  112. spinagram-0.1.0/docs/public/assets/css/g05.svg +1 -0
  113. spinagram-0.1.0/docs/public/assets/css/g06.svg +1 -0
  114. spinagram-0.1.0/docs/public/assets/css/g07.svg +1 -0
  115. spinagram-0.1.0/docs/public/assets/css/g08.svg +1 -0
  116. spinagram-0.1.0/docs/public/assets/css/inspector.webp +0 -0
  117. spinagram-0.1.0/docs/public/assets/demo/b01.svg +1 -0
  118. spinagram-0.1.0/docs/public/assets/demo/b02.svg +1 -0
  119. spinagram-0.1.0/docs/public/assets/demo/b03.svg +1 -0
  120. spinagram-0.1.0/docs/public/assets/demo/b04.svg +1 -0
  121. spinagram-0.1.0/docs/public/assets/demo/c01.svg +1 -0
  122. spinagram-0.1.0/docs/public/assets/logo.svg +19 -0
  123. spinagram-0.1.0/docs/public/assets/overview.svg +1 -0
  124. spinagram-0.1.0/docs/public/assets/quickstart.svg +1 -0
  125. spinagram-0.1.0/docs/scripts/css_gallery.py +102 -0
  126. spinagram-0.1.0/docs/tsconfig.json +34 -0
  127. spinagram-0.1.0/examples/aws_layout.yaml +37 -0
  128. spinagram-0.1.0/examples/behavior/activity.py +27 -0
  129. spinagram-0.1.0/examples/behavior/flowchart.py +23 -0
  130. spinagram-0.1.0/examples/behavior/mixed.py +24 -0
  131. spinagram-0.1.0/examples/behavior/state_machine.py +31 -0
  132. spinagram-0.1.0/examples/decision.yaml +24 -0
  133. spinagram-0.1.0/examples/packs/multicloud.yaml +19 -0
  134. spinagram-0.1.0/examples/quickstart.py +24 -0
  135. spinagram-0.1.0/examples/quickstart.yaml +31 -0
  136. spinagram-0.1.0/examples/release_flow.py +24 -0
  137. spinagram-0.1.0/examples/showcase/aws.css +46 -0
  138. spinagram-0.1.0/examples/showcase/aws_network_hub.py +51 -0
  139. spinagram-0.1.0/examples/showcase/aws_three_tier.py +53 -0
  140. spinagram-0.1.0/examples/showcase/campus.css +19 -0
  141. spinagram-0.1.0/examples/showcase/campus_network.py +45 -0
  142. spinagram-0.1.0/examples/showcase/evpn_fabric.py +68 -0
  143. spinagram-0.1.0/examples/showcase/fabric.css +27 -0
  144. spinagram-0.1.0/examples/showcase/gcp.css +34 -0
  145. spinagram-0.1.0/examples/showcase/gcp_global_app.py +49 -0
  146. spinagram-0.1.0/examples/showcase/homelab.css +26 -0
  147. spinagram-0.1.0/examples/showcase/homelab.py +49 -0
  148. spinagram-0.1.0/examples/showcase/k8s.css +48 -0
  149. spinagram-0.1.0/examples/showcase/k8s_networking.py +50 -0
  150. spinagram-0.1.0/examples/showcase/k8s_platform.py +52 -0
  151. spinagram-0.1.0/examples/showcase/notes.css +9 -0
  152. spinagram-0.1.0/examples/showcase/notes.py +23 -0
  153. spinagram-0.1.0/examples/showcase/showcase.css +17 -0
  154. spinagram-0.1.0/examples/software_architecture.yaml +25 -0
  155. spinagram-0.1.0/examples/styling/brand.css +18 -0
  156. spinagram-0.1.0/examples/styling/gallery/01-boxes.css +4 -0
  157. spinagram-0.1.0/examples/styling/gallery/02-dashes.css +5 -0
  158. spinagram-0.1.0/examples/styling/gallery/03-shadows.css +4 -0
  159. spinagram-0.1.0/examples/styling/gallery/04-edges.css +8 -0
  160. spinagram-0.1.0/examples/styling/gallery/05-text.css +6 -0
  161. spinagram-0.1.0/examples/styling/gallery/06-tokens.css +5 -0
  162. spinagram-0.1.0/examples/styling/gallery/07-selectors.css +7 -0
  163. spinagram-0.1.0/examples/styling/gallery/08-behavior.css +6 -0
  164. spinagram-0.1.0/examples/styling/gallery/gallery.py +85 -0
  165. spinagram-0.1.0/examples/styling/styled.py +19 -0
  166. spinagram-0.1.0/pyproject.toml +84 -0
  167. spinagram-0.1.0/src/spinagram/__init__.py +11 -0
  168. spinagram-0.1.0/src/spinagram/agent_skills/spinagram/SKILL.md +244 -0
  169. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/SKILL.md +155 -0
  170. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/aws.css +46 -0
  171. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/aws_network_hub.py +51 -0
  172. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/aws_three_tier.py +53 -0
  173. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/campus.css +19 -0
  174. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/campus_network.py +45 -0
  175. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/evpn_fabric.py +68 -0
  176. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/fabric.css +27 -0
  177. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/gcp.css +34 -0
  178. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/gcp_global_app.py +49 -0
  179. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/homelab.css +26 -0
  180. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/homelab.py +49 -0
  181. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/k8s.css +48 -0
  182. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/k8s_networking.py +50 -0
  183. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/k8s_platform.py +52 -0
  184. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/notes.css +9 -0
  185. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/notes.py +23 -0
  186. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/examples/showcase.css +17 -0
  187. spinagram-0.1.0/src/spinagram/agent_skills/spinagram-css/references/recipes.css +72 -0
  188. spinagram-0.1.0/src/spinagram/annotate.py +539 -0
  189. spinagram-0.1.0/src/spinagram/api.py +81 -0
  190. spinagram-0.1.0/src/spinagram/autolayout/__init__.py +442 -0
  191. spinagram-0.1.0/src/spinagram/autolayout/engines/__init__.py +54 -0
  192. spinagram-0.1.0/src/spinagram/autolayout/engines/d2.py +296 -0
  193. spinagram-0.1.0/src/spinagram/autolayout/engines/dot.py +179 -0
  194. spinagram-0.1.0/src/spinagram/autolayout/engines/elk.py +170 -0
  195. spinagram-0.1.0/src/spinagram/autolayout/engines/elk_runner.mjs +35 -0
  196. spinagram-0.1.0/src/spinagram/autolayout/engines/flex.py +97 -0
  197. spinagram-0.1.0/src/spinagram/autolayout/engines/layered.py +679 -0
  198. spinagram-0.1.0/src/spinagram/autolayout/graph.py +60 -0
  199. spinagram-0.1.0/src/spinagram/autolayout/route.py +146 -0
  200. spinagram-0.1.0/src/spinagram/autolayout/sizing.py +77 -0
  201. spinagram-0.1.0/src/spinagram/backends/__init__.py +48 -0
  202. spinagram-0.1.0/src/spinagram/backends/figma.py +589 -0
  203. spinagram-0.1.0/src/spinagram/backends/png.py +40 -0
  204. spinagram-0.1.0/src/spinagram/backends/pptx.py +555 -0
  205. spinagram-0.1.0/src/spinagram/backends/svg.py +257 -0
  206. spinagram-0.1.0/src/spinagram/behavior.py +311 -0
  207. spinagram-0.1.0/src/spinagram/cli.py +344 -0
  208. spinagram-0.1.0/src/spinagram/css.py +706 -0
  209. spinagram-0.1.0/src/spinagram/deck.py +241 -0
  210. spinagram-0.1.0/src/spinagram/facts.py +195 -0
  211. spinagram-0.1.0/src/spinagram/icon_packs/aws.yaml +43 -0
  212. spinagram-0.1.0/src/spinagram/icon_packs/azure.yaml +12 -0
  213. spinagram-0.1.0/src/spinagram/icon_packs/gcp.yaml +11 -0
  214. spinagram-0.1.0/src/spinagram/icon_packs/k8s.yaml +9 -0
  215. spinagram-0.1.0/src/spinagram/icon_packs/network.yaml +30 -0
  216. spinagram-0.1.0/src/spinagram/icon_packs/oci.yaml +9 -0
  217. spinagram-0.1.0/src/spinagram/icon_packs/openstack.yaml +8 -0
  218. spinagram-0.1.0/src/spinagram/icon_packs/selfhosted.yaml +28 -0
  219. spinagram-0.1.0/src/spinagram/icon_packs/terrastruct.yaml +84 -0
  220. spinagram-0.1.0/src/spinagram/icons.py +438 -0
  221. spinagram-0.1.0/src/spinagram/inspect_fmt.py +193 -0
  222. spinagram-0.1.0/src/spinagram/layout.py +501 -0
  223. spinagram-0.1.0/src/spinagram/metadata.py +66 -0
  224. spinagram-0.1.0/src/spinagram/model.py +604 -0
  225. spinagram-0.1.0/src/spinagram/qa.py +73 -0
  226. spinagram-0.1.0/src/spinagram/scene.py +239 -0
  227. spinagram-0.1.0/src/spinagram/shapes.py +248 -0
  228. spinagram-0.1.0/src/spinagram/skills.py +99 -0
  229. spinagram-0.1.0/src/spinagram/text.py +83 -0
  230. spinagram-0.1.0/src/spinagram/theme.py +291 -0
  231. spinagram-0.1.0/src/spinagram/themes/default/theme.yaml +114 -0
  232. spinagram-0.1.0/src/spinagram/ui/__init__.py +5 -0
  233. spinagram-0.1.0/src/spinagram/ui/catalog.py +118 -0
  234. spinagram-0.1.0/src/spinagram/ui/events.py +48 -0
  235. spinagram-0.1.0/src/spinagram/ui/server.py +271 -0
  236. spinagram-0.1.0/src/spinagram/ui/static/assets/index-BDoYKVM5.css +2 -0
  237. spinagram-0.1.0/src/spinagram/ui/static/assets/index-DAg_fACF.js +11 -0
  238. spinagram-0.1.0/src/spinagram/ui/static/index.html +13 -0
  239. spinagram-0.1.0/src/spinagram/ui/themes.py +152 -0
  240. spinagram-0.1.0/src/spinagram/ui/workspace.py +340 -0
  241. spinagram-0.1.0/src/spinagram/vectorize.py +158 -0
  242. spinagram-0.1.0/src/spinagram/yaml_io.py +322 -0
  243. spinagram-0.1.0/tests/js/figma_mock.mjs +43 -0
  244. spinagram-0.1.0/tests/test_annotations.py +958 -0
  245. spinagram-0.1.0/tests/test_autolayout.py +223 -0
  246. spinagram-0.1.0/tests/test_behavior.py +728 -0
  247. spinagram-0.1.0/tests/test_css.py +642 -0
  248. spinagram-0.1.0/tests/test_css_inspector.py +387 -0
  249. spinagram-0.1.0/tests/test_engine.py +517 -0
  250. spinagram-0.1.0/tests/test_icon_packs.py +179 -0
  251. spinagram-0.1.0/tests/test_layered.py +542 -0
  252. spinagram-0.1.0/tests/test_legend_css.py +269 -0
  253. spinagram-0.1.0/tests/test_metadata.py +276 -0
  254. spinagram-0.1.0/tests/test_pptx_svg.py +135 -0
  255. spinagram-0.1.0/tests/test_skills.py +87 -0
  256. spinagram-0.1.0/tests/test_ui.py +427 -0
  257. spinagram-0.1.0/tests/test_web_unit.py +16 -0
  258. spinagram-0.1.0/uv.lock +944 -0
  259. spinagram-0.1.0/web/.gitignore +2 -0
  260. spinagram-0.1.0/web/bun.lock +252 -0
  261. spinagram-0.1.0/web/index.html +12 -0
  262. spinagram-0.1.0/web/package.json +24 -0
  263. spinagram-0.1.0/web/src/App.tsx +254 -0
  264. spinagram-0.1.0/web/src/CatalogShell.tsx +66 -0
  265. spinagram-0.1.0/web/src/DiagramView.tsx +235 -0
  266. spinagram-0.1.0/web/src/DiagramsPage.tsx +151 -0
  267. spinagram-0.1.0/web/src/Inspector.tsx +346 -0
  268. spinagram-0.1.0/web/src/PackView.tsx +148 -0
  269. spinagram-0.1.0/web/src/PacksPage.tsx +91 -0
  270. spinagram-0.1.0/web/src/ThemeView.tsx +193 -0
  271. spinagram-0.1.0/web/src/ThemesPage.tsx +64 -0
  272. spinagram-0.1.0/web/src/api.ts +173 -0
  273. spinagram-0.1.0/web/src/catalog.test.ts +136 -0
  274. spinagram-0.1.0/web/src/catalog.ts +134 -0
  275. spinagram-0.1.0/web/src/hooks.test.ts +30 -0
  276. spinagram-0.1.0/web/src/hooks.ts +170 -0
  277. spinagram-0.1.0/web/src/index.css +24 -0
  278. spinagram-0.1.0/web/src/main.tsx +10 -0
  279. spinagram-0.1.0/web/src/previews.test.ts +91 -0
  280. spinagram-0.1.0/web/tsconfig.json +18 -0
  281. spinagram-0.1.0/web/vite.config.ts +12 -0
@@ -0,0 +1,90 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ci-${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
15
+
16
+ env:
17
+ UV_VERSION: "0.10.4"
18
+ BUN_VERSION: "1.4.2"
19
+
20
+ jobs:
21
+ web:
22
+ name: UI bundle (bun)
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v5
26
+ - uses: oven-sh/setup-bun@v2
27
+ with:
28
+ bun-version: ${{ env.BUN_VERSION }}
29
+ - run: bun install --frozen-lockfile
30
+ working-directory: web
31
+ - run: bun run build # tsc -b && vite build -> src/spinagram/ui/static
32
+ working-directory: web
33
+ - name: Committed bundle matches web/
34
+ run: |
35
+ if [ -n "$(git status --porcelain -- src/spinagram/ui/static)" ]; then
36
+ git status --short -- src/spinagram/ui/static
37
+ echo "::error::src/spinagram/ui/static is stale: run 'bun run build' in web/ and commit it"
38
+ exit 1
39
+ fi
40
+
41
+ lint:
42
+ name: Lint (ruff)
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v5
46
+ - uses: astral-sh/setup-uv@v6
47
+ with:
48
+ version: ${{ env.UV_VERSION }}
49
+ enable-cache: true
50
+ - run: uv run --locked --only-group dev ruff check src tests examples
51
+
52
+ test:
53
+ name: Tests (Python ${{ matrix.python }})
54
+ runs-on: ubuntu-latest
55
+ strategy:
56
+ fail-fast: false
57
+ matrix:
58
+ python: ["3.11", "3.12", "3.13"]
59
+ steps:
60
+ - uses: actions/checkout@v5
61
+ - uses: astral-sh/setup-uv@v6
62
+ with:
63
+ version: ${{ env.UV_VERSION }}
64
+ python-version: ${{ matrix.python }}
65
+ enable-cache: true
66
+ - run: uv sync --locked --all-extras
67
+ - run: uv run pytest -q
68
+
69
+ build:
70
+ name: Build sdist + wheel (uv)
71
+ needs: [web, lint, test]
72
+ runs-on: ubuntu-latest
73
+ steps:
74
+ - uses: actions/checkout@v5
75
+ - uses: astral-sh/setup-uv@v6
76
+ with:
77
+ version: ${{ env.UV_VERSION }}
78
+ - run: uv build
79
+ - name: Wheel ships the UI bundle
80
+ run: unzip -l dist/*.whl | grep -q 'spinagram/ui/static/index.html'
81
+ - name: Smoke test the built wheel
82
+ run: |
83
+ uv run --isolated --no-project --with "$(ls dist/*.whl)[ui]" spinagram --help
84
+ uv run --isolated --no-project --with "$(ls dist/*.whl)" spinagram render examples/decision.yaml -f svg -o /tmp/out
85
+ test -n "$(ls /tmp/out/*.svg)"
86
+ - uses: actions/upload-artifact@v4
87
+ with:
88
+ name: dist
89
+ path: dist/
90
+ if-no-files-found: error
@@ -0,0 +1,50 @@
1
+ name: Docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: docs-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ build:
18
+ name: Build site (Fumadocs)
19
+ runs-on: ubuntu-latest
20
+ defaults:
21
+ run:
22
+ working-directory: docs
23
+ steps:
24
+ - uses: actions/checkout@v5
25
+ - uses: oven-sh/setup-bun@v2
26
+ with:
27
+ bun-version: "1.4.2"
28
+ - run: bun install --frozen-lockfile
29
+ - name: Static export with the search index
30
+ run: bun run build
31
+ env:
32
+ NEXT_PUBLIC_BASE_PATH: /spinagram # served at https://dangerousplay.github.io/spinagram/
33
+ - uses: actions/upload-pages-artifact@v3
34
+ with:
35
+ path: docs/out/
36
+
37
+ deploy:
38
+ name: Deploy to GitHub Pages
39
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
40
+ needs: build
41
+ runs-on: ubuntu-latest
42
+ permissions:
43
+ pages: write
44
+ id-token: write
45
+ environment:
46
+ name: github-pages
47
+ url: ${{ steps.deployment.outputs.page_url }}
48
+ steps:
49
+ - id: deployment
50
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,58 @@
1
+ name: Release
2
+
3
+ # Push a tag that matches the version in pyproject.toml, with the changelog regenerated for it:
4
+ # uv version --bump minor
5
+ # uvx git-cliff --tag "v$(uv version --short)" -o CHANGELOG.md
6
+ # git commit -am "chore(release): v$(uv version --short)"
7
+ # git tag "v$(uv version --short)" && git push --follow-tags
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ ci:
17
+ uses: ./.github/workflows/ci.yml
18
+
19
+ publish:
20
+ name: Publish to PyPI
21
+ needs: ci
22
+ runs-on: ubuntu-latest
23
+ environment:
24
+ name: pypi
25
+ url: https://pypi.org/project/spinagram/
26
+ permissions:
27
+ id-token: write # PyPI trusted publishing (OIDC), no API token stored
28
+ contents: write # GitHub release
29
+ steps:
30
+ - uses: actions/checkout@v5
31
+ with:
32
+ fetch-depth: 0 # git-cliff reads the whole history and the tags
33
+ - uses: astral-sh/setup-uv@v6
34
+ with:
35
+ version: "0.10.4"
36
+ - name: Tag matches project version
37
+ run: |
38
+ v="$(uv version --short)"
39
+ if [ "v$v" != "$GITHUB_REF_NAME" ]; then
40
+ echo "::error::tag $GITHUB_REF_NAME does not match pyproject version $v"
41
+ exit 1
42
+ fi
43
+ - uses: actions/download-artifact@v4
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+ - run: uv publish --trusted-publishing always
48
+ # The newest section of the committed changelog. `git-cliff --latest` would split at the
49
+ # last pre-release tag even though cliff.toml ignores it, leaving a final release almost empty.
50
+ - name: Release notes (CHANGELOG.md)
51
+ run: awk '/^## \[/ { n++ } n == 1' CHANGELOG.md > RELEASE_NOTES.md
52
+ - name: GitHub release
53
+ env:
54
+ GH_TOKEN: ${{ github.token }}
55
+ run: |
56
+ pre=""
57
+ case "$GITHUB_REF_NAME" in *a[0-9]*|*b[0-9]*|*rc[0-9]*|*.dev[0-9]*) pre="--prerelease" ;; esac
58
+ gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md $pre
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ out/
6
+ .pytest_cache/
@@ -0,0 +1,153 @@
1
+ # LESSONS - auto-maintained by scripts/lessons.py
2
+
3
+ > Machine-owned. Do NOT hand-edit. Changes are overwritten on the next `lessons.py` write.
4
+ > Canonical state lives in `.specs/lessons.json`. Edit lessons only via the script.
5
+ > promote_threshold=2 distinct features · window_days=45 · quarantine_threshold=2
6
+
7
+ ## Confirmed (load these at Specify/Design)
8
+
9
+ Corroborated across multiple features. Safe to apply as guidance.
10
+
11
+ _none_
12
+
13
+ ## Candidates (under observation - do NOT load as guidance yet)
14
+
15
+ Seen once or not yet corroborated. Tracked, not trusted.
16
+
17
+ ### L-001 - Pin determinism with a golden output hash checked across many PYTHONHASHSEED values, not a few in-process runs.
18
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
19
+ - features: layered-layout
20
+ - evidence: M9b (hash tie-break) (tests)
21
+ - last seen: 2026-10-12T04:39:39Z
22
+
23
+ ### L-002 - Give placement ACs a measurable bound (e.g. pill within 6 px of its segment) before writing the test.
24
+ - signal: `spec_precision_gap` · recurrence: 1 feature(s) · scope: `spec` · harmful: 0
25
+ - features: layered-layout
26
+ - evidence: LAY-12 (spec)
27
+ - last seen: 2026-10-12T04:39:39Z
28
+
29
+ ### L-003 - Guard output-unchanged-without-the-feature with a golden render captured before the feature, not an identity check.
30
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
31
+ - features: css-styling
32
+ - evidence: M15 (CSS-27) (tests)
33
+ - last seen: 2026-10-12T04:39:39Z
34
+
35
+ ### L-004 - When an AC describes geometry that depends on layout (bars spanning branches), test the laid-out result, not the default size.
36
+ - signal: `ac_gap` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
37
+ - features: behavior-diagrams
38
+ - evidence: BHV-02 / G1 (tests)
39
+ - last seen: 2026-10-12T04:39:39Z
40
+
41
+ ### L-005 - Never pipe the test command into tail before chaining a commit; check pytest's own exit code.
42
+ - signal: `gate_fail` · recurrence: 1 feature(s) · scope: `process` · harmful: 0
43
+ - features: layered-layout
44
+ - evidence: 99549c5 (process)
45
+ - last seen: 2026-10-12T04:39:39Z
46
+
47
+ ### L-006 - Parse value formats with a full match; lenient prefix parsing hides upstream bugs from tests.
48
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `src` · harmful: 0
49
+ - features: css-styling
50
+ - evidence: F9 / R3 (CSS-16) (src)
51
+ - last seen: 2026-10-12T04:41:36Z
52
+
53
+ ### L-007 - Check optional spec keys with is-None, not truthiness, so falsy wrong-typed values like 0, false or {} still fail validation
54
+ - signal: `ac_gap` · recurrence: 1 feature(s) · scope: `yaml-loaders` · harmful: 0
55
+ - features: diagram-catalog
56
+ - evidence: META-03 src/spinagram/yaml_io.py:55 (yaml-loaders)
57
+ - last seen: 2026-10-12T17:25:04Z
58
+
59
+ ### L-008 - Keep every data transformation an AC names (sorting, filtering, sampling) in the unit-tested pure-logic module, not inline in a component
60
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `web-ui` · harmful: 0
61
+ - features: catalog-previews
62
+ - evidence: M16 web/src/PacksPage.tsx:45 (PREV-07) (web-ui)
63
+ - last seen: 2026-10-12T17:38:46Z
64
+
65
+ ### L-009 - When an AC requires an error response to carry the error message, assert the message text, not just that an error field is present
66
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `routes` · harmful: 0
67
+ - features: catalog-previews
68
+ - evidence: M17 tests/test_ui.py:161 (PREV-03) (routes)
69
+ - last seen: 2026-10-12T17:38:46Z
70
+
71
+ ### L-010 - Assert that styled values reach the rendered scene shapes, not only the computed style map
72
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `css` · harmful: 0
73
+ - features: annotations
74
+ - evidence: validation.md M35/M36 src/spinagram/annotate.py:207,228 (css)
75
+ - last seen: 2026-10-12T18:18:23Z
76
+
77
+ ### L-011 - Derive expected geometry independently instead of reusing the function under test
78
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
79
+ - features: annotations
80
+ - evidence: validation.md M07 tests/test_annotations.py:330 (tests)
81
+ - last seen: 2026-10-12T18:18:23Z
82
+
83
+ ### L-012 - Assert the exact numeric value in diagnostic messages, not just the message prefix
84
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `qa` · harmful: 0
85
+ - features: annotations
86
+ - evidence: validation.md M37 tests/test_annotations.py:726 (qa)
87
+ - last seen: 2026-10-12T18:18:23Z
88
+
89
+ ### L-013 - Cover first-match rules with a fixture that has at least two candidates
90
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
91
+ - features: annotations
92
+ - evidence: validation.md M38 src/spinagram/model.py:492 (tests)
93
+ - last seen: 2026-10-12T18:18:23Z
94
+
95
+ ### L-014 - Give every clause of a multi-clause acceptance criterion its own assertion
96
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
97
+ - features: annotations
98
+ - evidence: validation.md M21/M39/M40 ANN-03 ANN-10 ANN-22 (tests)
99
+ - last seen: 2026-10-12T18:18:23Z
100
+
101
+ ### L-015 - When a spec says first-use order over mixed element types, state the order across types
102
+ - signal: `spec_precision_gap` · recurrence: 1 feature(s) · scope: `spec` · harmful: 0
103
+ - features: annotations
104
+ - evidence: validation.md ANN-01 src/spinagram/annotate.py:84 (spec)
105
+ - last seen: 2026-10-12T18:18:23Z
106
+
107
+ ### L-016 - Copy fixture paths from the real upstream source so every naming form it actually uses, including optional-group branches, is exercised
108
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
109
+ - features: selfhosted-icons
110
+ - evidence: validation.md M02 src/spinagram/icon_packs/network.yaml:13 tests/test_icon_packs.py:51 (ICON-09) (tests)
111
+ - last seen: 2026-10-12T19:04:51Z
112
+
113
+ ### L-017 - Test each layout property on every band side or branch it applies to, not only the default side
114
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
115
+ - features: legend-css
116
+ - evidence: validation.md M12/M13 src/spinagram/annotate.py:187,370 (LCSS-08) (tests)
117
+ - last seen: 2026-10-12T20:03:34Z
118
+
119
+ ### L-018 - When an explicit setting overrides an automatic rule, test it under the condition that triggers the rule
120
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
121
+ - features: legend-css
122
+ - evidence: validation.md M15 src/spinagram/annotate.py:354 (LCSS-08) (tests)
123
+ - last seen: 2026-10-12T20:03:34Z
124
+
125
+ ### L-019 - When a default moves from code to the stylesheet, test that every loader path, including YAML, leaves the value unset
126
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `yaml-loaders` · harmful: 0
127
+ - features: legend-css
128
+ - evidence: validation.md M30 src/spinagram/yaml_io.py:140 (LCSS-04) (yaml-loaders)
129
+ - last seen: 2026-10-12T20:03:34Z
130
+
131
+ ### L-020 - When new enum values are added, cover each one in every consumer that branches on it, including qa checks
132
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
133
+ - features: legend-css
134
+ - evidence: validation.md M20/M21 src/spinagram/annotate.py:525-526 (ANN-12 for left/top) (tests)
135
+ - last seen: 2026-10-12T20:03:34Z
136
+
137
+ ### L-021 - Give every clause of a multi-clause acceptance criterion its own assertion
138
+ - signal: `ac_gap` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
139
+ - features: legend-css
140
+ - evidence: validation.md LCSS-05 clause 2 tests/test_legend_css.py:42 (tests)
141
+ - last seen: 2026-10-12T20:03:34Z
142
+
143
+ ### L-022 - When one function reserves space and another draws into it, assert both against the same independently derived size
144
+ - signal: `surviving_mutant` · recurrence: 1 feature(s) · scope: `tests` · harmful: 0
145
+ - features: legend-css
146
+ - evidence: validation.md iter2 M12 src/spinagram/annotate.py:187 (LCSS-08) (tests)
147
+ - last seen: 2026-10-12T20:09:01Z
148
+
149
+ ## Quarantined (failed when applied - ignore)
150
+
151
+ A confirmed lesson that recurred alongside failure. Kept for the maintainer to review.
152
+
153
+ _none_
@@ -0,0 +1,140 @@
1
+ # Annotations Design
2
+
3
+ **Spec**: `.specs/features/annotations/spec.md`
4
+ **Status**: Approved
5
+
6
+ ---
7
+
8
+ ## Architecture Overview
9
+
10
+ Annotations are model data (`Diagram.annotations`) turned into ordinary scene primitives by one new module,
11
+ `spinagram/annotate.py`. Because the scene already renders natively in SVG, PNG, PowerPoint and Figma (rect and
12
+ pill shapes with labels, icons and shadows; connectors with markers; text), every annotation is native in every
13
+ backend without backend changes. CSS reaches annotations through new elements in the style tree.
14
+
15
+ ```mermaid
16
+ graph TD
17
+ API[Python: add_legend / step / callout / note variant] --> M[model.Annotations]
18
+ YAML[YAML: annotations:, kind: note + variant] --> M
19
+ M --> CSS[css.tree: legend, step, callout elements]
20
+ M --> BAND[annotate.band: side + size]
21
+ BAND --> AREA[autolayout._area minus band]
22
+ AREA --> LAYOUT[engines place content]
23
+ LAYOUT --> B[layout._Builder.build]
24
+ B --> ANN[annotate.Annotator: panels, badges, callouts]
25
+ CSS --> ANN
26
+ ANN --> SCENE[Shapes, Connectors, Text in the scene]
27
+ SCENE --> SVG & PPTX & FIGMA
28
+ ANN --> QA[qa.check: legend unused, steps duplicate, band overflow/overlap, callout overlap]
29
+ ```
30
+
31
+ Order inside `build`: zones → nodes → tables → edges → **step badges** → **panels** → **callouts** → chrome.
32
+ Callouts come last among content so they are placed against every other shape.
33
+
34
+ ---
35
+
36
+ ## Code Reuse Analysis
37
+
38
+ | Component | Location | How to Use |
39
+ | --------- | -------- | ---------- |
40
+ | `_Builder.pill`, `_decorate`, `_css`, `_text` | `src/spinagram/layout.py:58-96` | Badges are pills (w = h → circle); panels and bubbles are rect shapes decorated with computed CSS |
41
+ | `Connector` with markers | `src/spinagram/scene.py:110` | Legend edge samples, callout leader lines (`marker_end="circle"`) |
42
+ | `Label`/`TextBlock`/`wrap_paragraphs` | `src/spinagram/scene.py`, `text.py` | Panel rows, step texts, bubble text |
43
+ | `_area` | `src/spinagram/autolayout/__init__.py:236` | Subtract the band |
44
+ | `css.tree` / `compute` | `src/spinagram/css.py:521` | Add annotation elements; computed styles under keys `legend`, `step`, `callout:<i>` |
45
+ | `qa.check` / `Issue` | `src/spinagram/qa.py:49` | New checks fed by `scene.annotation_issues` |
46
+ | Node kind `note` | `src/spinagram/model.py:315` | Variants as a node attribute; CSS class added |
47
+
48
+ ---
49
+
50
+ ## Components
51
+
52
+ ### model: Annotations
53
+
54
+ - **Location**: `src/spinagram/model.py`
55
+ - **Interfaces**:
56
+ - `LegendSpec(title="Legend", items: dict[str, str] | None, position="right")`
57
+ - `Step(n: int, text: str, edge: int | None, node: str | None)`
58
+ - `Callout(target: str, text: str, title: str | None, kind="info", side="auto", cls=None, css=None)`
59
+ - `Annotations(legend: LegendSpec | None, steps: list[Step], callouts: list[Callout])` as `Diagram.annotations`
60
+ - `Diagram.add_legend(items=None, *, title="Legend", position="right")`
61
+ - `Diagram.step(text, *, on, n=None)`; `on` = `Edge`, `Node`, node id, or `(src, dst)`; `Edge.step(text, n=None)`
62
+ - `Diagram.callout(target, text, *, title=None, kind="info", side="auto", class_=None, css=None)`
63
+ - `Diagram.note(..., variant="sticky")`; validated against `NOTE_VARIANTS`
64
+
65
+ ### annotate module
66
+
67
+ - **Location**: `src/spinagram/annotate.py`
68
+ - **Interfaces**:
69
+ - `legend_entries(d) -> list[Entry]` (key, label, kind node|edge|style|icon, element key or None)
70
+ - `band(d, theme) -> tuple[str, float] | None` side and thickness before layout
71
+ - `class Annotator(builder)`: `badges(connectors) -> list[Item]`, `panels() -> list[Item]`, `callouts(obstacles) -> list[Item]`, `issues -> list[tuple[str, str, str]]`
72
+ - **Constants**: `BAND_WIDTH = 360`, `BAND_GAP = 32`, `BADGE = 30`, `CALLOUT_W = 320`, `KINDS = {info: primary, tip: success, warning: warning, risk: alert}`
73
+
74
+ ### Theme
75
+
76
+ - **Location**: `src/spinagram/themes/default/theme.yaml`
77
+ - **Adds**: colours `success`, `success-bg`, `success-text`; `defaults.annotations: {band: 360, gap: 32, badge: 30, callout_width: 320, title_size: 20, text_size: 16}`
78
+
79
+ ### CSS elements
80
+
81
+ - **Location**: `src/spinagram/css.py`
82
+ - **Adds** under the root: `legend` (key `legend`), `step` (key `step:<n>`), `callout` (classes = kind + `class_`, key `callout:<i>`); note nodes get the variant as a class.
83
+
84
+ ---
85
+
86
+ ## Data Models
87
+
88
+ ```python
89
+ @dataclass
90
+ class Annotations:
91
+ legend: LegendSpec | None = None
92
+ steps: list[Step] = field(default_factory=list)
93
+ callouts: list[Callout] = field(default_factory=list)
94
+ ```
95
+
96
+ YAML:
97
+
98
+ ```yaml
99
+ annotations:
100
+ legend: {title: Legend, position: right, items: {".security": Security control, "edge.async": Async}}
101
+ steps: [{on: "users -> dns", text: Users resolve the domain}, {on: alb, n: 3, text: ...}]
102
+ callouts: [{on: pvc, text: Snapshots every 6 h, kind: warning, side: auto}]
103
+ nodes:
104
+ - {id: n1, kind: note, variant: card, title: Why, text: ...}
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Error Handling Strategy
110
+
111
+ | Error Scenario | Handling | User Impact |
112
+ | -------------- | -------- | ----------- |
113
+ | Unknown callout kind / note variant | `ValueError` naming allowed values | Spec load / build error |
114
+ | Step `on` matches no edge or node | `ValueError` naming the reference | Spec load error |
115
+ | Callout target from another diagram | `ValueError` | Build error |
116
+ | Legend key unused | Drawn from theme default; `check` issue | Warning in check |
117
+ | Panels exceed the band | Drawn clipped to content; `check` overflow | Warning in check |
118
+ | Callout has no free side | Placed on first side; `check` overlap | Warning in check |
119
+
120
+ ---
121
+
122
+ ## Risks & Concerns
123
+
124
+ | Concern | Location (file:line) | Impact | Mitigation |
125
+ | ------- | -------------------- | ------ | ---------- |
126
+ | `_Builder.build` is long and order-sensitive | `src/spinagram/layout.py:304` | New steps could disturb chrome/z-order | Annotations live in `annotate.py`; build only calls three methods at fixed points |
127
+ | `build_scene` is `lru_cache`d on the diagram | `src/spinagram/layout.py:441` | Mutable annotation lists must hash/compare like other fields | Dataclasses with `eq=True` default; covered by existing cache behaviour (diagram is copied in apply_layout) |
128
+ | Manual-layout diagrams have no area to shrink | `src/spinagram/autolayout/__init__.py:236` | Band can overlap content | Spec ANN-12: draw the band at the edge and report overlap |
129
+ | Golden SVG tests for unannotated diagrams | `tests/` | Accidental output change | No annotations → no new items; full suite gates every task |
130
+ | PowerPoint circle badges | `src/spinagram/backends/pptx.py` | Pill with w = h must stay a circle | Integration test inspects the PPTX shape geometry |
131
+
132
+ ---
133
+
134
+ ## Tech Decisions
135
+
136
+ | Decision | Choice | Rationale |
137
+ | -------- | ------ | --------- |
138
+ | Render path | Compose existing scene primitives | Native in all four backends with no backend code |
139
+ | Module boundary | One `annotate.py` | Keeps `layout.py` from growing further |
140
+ | Placement of callouts | Greedy side order with rectangle overlap tests | Deterministic, explainable, cheap |