dsh-model-everything 0.4.1 → 0.5.0

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 (78) hide show
  1. package/README.en-US.md +63 -19
  2. package/README.md +373 -345
  3. package/cordis.patch.yml +9 -0
  4. package/diagram/package.json +5 -0
  5. package/lib/client.js +1273 -0
  6. package/lib/diagram-diff-tool.js +90 -0
  7. package/lib/diagram-render-tool.js +102 -0
  8. package/lib/diagram-review-tool.js +99 -0
  9. package/lib/diagram.js +43 -0
  10. package/lib/gate.js +14 -12
  11. package/lib/presentation.js +10 -0
  12. package/lib/render/budget.js +99 -0
  13. package/lib/render/collapse.js +163 -0
  14. package/lib/render/diff.js +138 -0
  15. package/lib/render/from-mermaid.js +211 -0
  16. package/lib/render/from-plantuml.js +158 -0
  17. package/lib/render/index.js +42 -0
  18. package/lib/render/layout.js +52 -0
  19. package/lib/render/model.js +17 -0
  20. package/lib/render/paint.js +194 -0
  21. package/lib/render/palette.js +127 -0
  22. package/lib/render/place.js +210 -0
  23. package/lib/render/svg.js +107 -0
  24. package/lib/render/viewmodel.js +126 -0
  25. package/lib/render/write.js +14 -0
  26. package/lib/scan.js +248 -8
  27. package/lib/structure-tool.js +2 -2
  28. package/lib/types/client.d.ts +83 -0
  29. package/lib/types/diagram-diff-tool.d.ts +8 -0
  30. package/lib/types/diagram-render-tool.d.ts +8 -0
  31. package/lib/types/diagram-review-tool.d.ts +8 -0
  32. package/lib/types/diagram.d.ts +34 -0
  33. package/lib/types/presentation.d.ts +7 -0
  34. package/lib/types/render/budget.d.ts +55 -0
  35. package/lib/types/render/collapse.d.ts +23 -0
  36. package/lib/types/render/diff.d.ts +44 -0
  37. package/lib/types/render/from-mermaid.d.ts +27 -0
  38. package/lib/types/render/from-plantuml.d.ts +27 -0
  39. package/lib/types/render/index.d.ts +38 -0
  40. package/lib/types/render/layout.d.ts +22 -0
  41. package/lib/types/render/model.d.ts +56 -0
  42. package/lib/types/render/paint.d.ts +94 -0
  43. package/lib/types/render/palette.d.ts +41 -0
  44. package/lib/types/render/place.d.ts +39 -0
  45. package/lib/types/render/svg.d.ts +19 -0
  46. package/lib/types/render/viewmodel.d.ts +20 -0
  47. package/lib/types/render/write.d.ts +14 -0
  48. package/lib/types/scan.d.ts +10 -2
  49. package/lib/types/structure.d.ts +1 -0
  50. package/package.json +35 -19
  51. package/skills/model-everything/SKILL.md +454 -454
  52. package/skills/model-everything/references/architecture-domains.md +137 -0
  53. package/skills/model-everything/references/architecture-views.md +119 -0
  54. package/skills/model-everything-diagram/SKILL.md +82 -0
  55. package/src/client.ts +307 -0
  56. package/src/diagram-diff-tool.ts +94 -0
  57. package/src/diagram-render-tool.ts +108 -0
  58. package/src/diagram-review-tool.ts +109 -0
  59. package/src/diagram.ts +61 -0
  60. package/src/gate.ts +14 -12
  61. package/src/presentation.ts +12 -0
  62. package/src/render/budget.ts +135 -0
  63. package/src/render/collapse.ts +155 -0
  64. package/src/render/diff.ts +162 -0
  65. package/src/render/from-mermaid.ts +236 -0
  66. package/src/render/from-plantuml.ts +178 -0
  67. package/src/render/index.ts +73 -0
  68. package/src/render/layout.ts +62 -0
  69. package/src/render/model.ts +67 -0
  70. package/src/render/paint.ts +290 -0
  71. package/src/render/palette.ts +164 -0
  72. package/src/render/place.ts +234 -0
  73. package/src/render/svg.ts +127 -0
  74. package/src/render/viewmodel.ts +136 -0
  75. package/src/render/write.ts +17 -0
  76. package/src/scan.ts +807 -564
  77. package/src/structure-tool.ts +2 -2
  78. package/src/structure.ts +1 -1
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Verdict to colour, in the two surfaces the page can be in, and in the scheme a reader prefers.
3
+ *
4
+ * A drawing is read before it is parsed, so the first thing it says is the answer. The roles are named rather
5
+ * than coloured: a scheme maps a role to a value, and every scheme is chosen so the drawing survives both a
6
+ * light and a dark page - the reader never has to fix a palette to read a result.
7
+ *
8
+ * The mode is read at render time from the page's own background (see `client.ts`). The host publishes its
9
+ * palette as CSS variables whose names are private to it, and a drawing that hard-coded them would break on
10
+ * the next theme; the luminance of the surface is the one fact that cannot drift.
11
+ *
12
+ * Pure, and tested: a wrong role is a wrong verdict shown as a shape.
13
+ */
14
+
15
+ export type ColourRole =
16
+ | 'pass'
17
+ | 'findings'
18
+ | 'fail'
19
+ | 'undecided'
20
+ | 'muted'
21
+ | 'text'
22
+ | 'edge'
23
+ | 'surface'
24
+ | 'background'
25
+
26
+ export type Palette = Record<ColourRole, string>
27
+
28
+ /** Which surface the drawing will sit on. */
29
+ export type PaletteMode = 'light' | 'dark'
30
+
31
+ /** A named set of colours. A scheme with both variants follows the page; one with only light does not. */
32
+ export type PaletteScheme = 'default' | 'github' | 'high-contrast' | 'print'
33
+
34
+ /** Ours: a quiet neutral drawing, with a green/amber/red answer. */
35
+ const NEUTRAL_LIGHT: Palette = {
36
+ pass: '#2f8f5b',
37
+ findings: '#b7791f',
38
+ fail: '#c0392b',
39
+ undecided: '#6b7280',
40
+ muted: '#9aa3ad',
41
+ text: '#1f2933',
42
+ edge: '#6b7280',
43
+ surface: 'transparent',
44
+ background: '#ffffff',
45
+ }
46
+
47
+ const NEUTRAL_DARK: Palette = {
48
+ pass: '#4ac486',
49
+ findings: '#e0b050',
50
+ fail: '#f2705f',
51
+ undecided: '#9aa3ad',
52
+ muted: '#7d8794',
53
+ text: '#e6e8eb',
54
+ edge: '#8b949e',
55
+ surface: 'transparent',
56
+ background: '#0d1117',
57
+ }
58
+
59
+ /**
60
+ * GitHub's own palette, light: the colours a reader already knows from the place they keep the diagram.
61
+ * Values are Primer's foreground and canvas tokens, not invented for this plugin.
62
+ */
63
+ const GITHUB_LIGHT: Palette = {
64
+ pass: '#1a7f37',
65
+ findings: '#9a6700',
66
+ fail: '#cf222e',
67
+ undecided: '#656d76',
68
+ muted: '#8c959f',
69
+ text: '#1f2328',
70
+ edge: '#656d76',
71
+ surface: 'transparent',
72
+ background: '#ffffff',
73
+ }
74
+
75
+ /** GitHub dark default: canvas #0d1117, foreground #e6edf3. */
76
+ const GITHUB_DARK: Palette = {
77
+ pass: '#3fb950',
78
+ findings: '#d29922',
79
+ fail: '#f85149',
80
+ undecided: '#7d8590',
81
+ muted: '#6e7681',
82
+ text: '#e6edf3',
83
+ edge: '#7d8590',
84
+ surface: 'transparent',
85
+ background: '#0d1117',
86
+ }
87
+
88
+ /** Black on white, for a projector or a poor print. The shape carries the meaning, not the hue. */
89
+ const HIGH_CONTRAST: Palette = {
90
+ pass: '#0a5c35',
91
+ findings: '#7a4300',
92
+ fail: '#a30000',
93
+ undecided: '#333333',
94
+ muted: '#444444',
95
+ text: '#000000',
96
+ edge: '#000000',
97
+ surface: 'transparent',
98
+ background: '#ffffff',
99
+ }
100
+
101
+ /** Greyscale throughout: a drawing that will be printed on a black-and-white printer. */
102
+ const PRINT: Palette = {
103
+ pass: '#333333',
104
+ findings: '#555555',
105
+ fail: '#000000',
106
+ undecided: '#666666',
107
+ muted: '#777777',
108
+ text: '#000000',
109
+ edge: '#333333',
110
+ surface: 'transparent',
111
+ background: '#ffffff',
112
+ }
113
+
114
+ interface Scheme {
115
+ light: Palette
116
+ /** Absent when the scheme is deliberately the same on both surfaces. */
117
+ dark?: Palette
118
+ }
119
+
120
+ const SCHEMES: Record<PaletteScheme, Scheme> = {
121
+ default: { light: NEUTRAL_LIGHT, dark: NEUTRAL_DARK },
122
+ github: { light: GITHUB_LIGHT, dark: GITHUB_DARK },
123
+ 'high-contrast': { light: HIGH_CONTRAST },
124
+ print: { light: PRINT },
125
+ }
126
+
127
+ export interface PaletteOptions {
128
+ mode?: PaletteMode
129
+ scheme?: PaletteScheme
130
+ }
131
+
132
+ /**
133
+ * Resolve a palette.
134
+ *
135
+ * `overrides` win over everything, so a caller can retheme one role without inventing a scheme. A scheme with
136
+ * a dark variant follows the surface; one without stays as declared - high-contrast and print are what they
137
+ * say they are, on purpose. An unknown name falls back to the default palette rather than throwing, because
138
+ * the value can arrive from a DOM control.
139
+ */
140
+ export function palette(overrides: Partial<Palette> = {}, options: PaletteOptions = {}): Palette {
141
+ const scheme = options.scheme ?? 'default'
142
+ const mode = options.mode ?? 'light'
143
+ const chosen = SCHEMES[scheme] ?? SCHEMES.default
144
+ const base = mode === 'dark' ? (chosen.dark ?? chosen.light) : chosen.light
145
+ return { ...base, ...overrides }
146
+ }
147
+
148
+ /**
149
+ * The role a verdict earns.
150
+ *
151
+ * `pass_with_findings` is deliberately not `pass`: it reads as the middle band, because a card that paints a
152
+ * report with warnings the same colour as a clean one has stopped carrying the answer.
153
+ */
154
+ export function roleForVerdict(verdict: string | undefined): ColourRole {
155
+ if (verdict === 'pass') return 'pass'
156
+ if (verdict === 'pass_with_findings') return 'findings'
157
+ if (verdict === 'fail') return 'fail'
158
+ return 'undecided'
159
+ }
160
+
161
+ /** Every scheme by name, so a picker does not hard-code the list. */
162
+ export function schemeNames(): PaletteScheme[] {
163
+ return Object.keys(SCHEMES) as PaletteScheme[]
164
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Where the boxes go.
3
+ *
4
+ * Two placements, chosen by the source:
5
+ *
6
+ * - **layered** (no groups): breadth-first rows, centred, no containment - the shape a state machine wants.
7
+ * - **tree** (groups present): a group is a block, its members stack inside it, a nested group is a block inside
8
+ * its parent, and the top level stacks - the shape an architecture diagram wants, because a package whose
9
+ * members float somewhere else on the page is not showing containment at all.
10
+ *
11
+ * Split out of the painter so the two questions stay separate: this module decides positions, `paint.ts` decides
12
+ * what to draw between them. A change to edge routing cannot move a box, and a change to nesting cannot move a
13
+ * line.
14
+ */
15
+
16
+ import { rankStates } from './layout.js'
17
+ import type { Diagram, DiagramGroup, DiagramNode } from './model.js'
18
+
19
+ /** Geometry of one placed box, before anything is drawn. */
20
+ export type Box = { x: number; y: number; width: number; height: number }
21
+
22
+ /** What a placement returns: every box, every drawn boundary, and how wide the content is. */
23
+ export interface Placement {
24
+ boxes: Map<string, Box>
25
+ groupRects: Map<string, Box>
26
+ width: number
27
+ }
28
+
29
+ /** Geometry constants shared by both placements. */
30
+ export const MARGIN = 20
31
+ const NODE_HEIGHT = 34
32
+ const ROW_GAP = 58
33
+ const COLUMN_GAP = 30
34
+ const MIN_NODE_WIDTH = 88
35
+ const MAX_NODE_WIDTH = 220
36
+ const CHAR_WIDTH = 7.2
37
+ /** A boundary's inner padding and the strip its name sits in. */
38
+ const GROUP_PADDING = 10
39
+ const GROUP_HEADER = 16
40
+ /** Between two blocks stacked inside a boundary, and between two top-level blocks. */
41
+ const BLOCK_GAP = 14
42
+
43
+ /** A box wide enough for its label, clamped so a long id cannot dominate the page. */
44
+ export const nodeWidth = (label: string): number => Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, label.length * CHAR_WIDTH + 26))
45
+
46
+
47
+
48
+ /** The layered placement: breadth-first rows, centred, no containment. */
49
+ function placeByRanks(diagram: Diagram, nodes: DiagramNode[], top: number): Placement {
50
+ const ranked = rankStates(diagram.nodes, diagram.edges, diagram.init)
51
+ const rows: string[][] = [...ranked.rows]
52
+ if (ranked.unreachable.length > 0) rows.push(ranked.unreachable)
53
+
54
+ const boxes = new Map<string, Box>()
55
+ let widest = 0
56
+ rows.forEach((row, index) => {
57
+ const widths = row.map((id) => nodeWidth(id))
58
+ widest = Math.max(widest, widths.reduce((total, width) => total + width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP)
59
+ let x = 0
60
+ row.forEach((id, position) => {
61
+ boxes.set(id, { x, y: top + index * (NODE_HEIGHT + ROW_GAP), width: widths[position], height: NODE_HEIGHT })
62
+ x += widths[position] + COLUMN_GAP
63
+ })
64
+ })
65
+ rows.forEach((row) => {
66
+ const rowWidth = row.reduce((total, id) => total + (boxes.get(id) as Box).width, 0) + Math.max(0, row.length - 1) * COLUMN_GAP
67
+ const offset = (widest - rowWidth) / 2
68
+ for (const id of row) (boxes.get(id) as Box).x += offset + MARGIN
69
+ })
70
+ return { boxes, groupRects: new Map(), width: widest + MARGIN * 2 }
71
+ }
72
+
73
+ /**
74
+ * Which group each id belongs to, and which group contains which.
75
+ *
76
+ * Membership is read from `members`; containment is read from `parent`, because only the parser knows where a
77
+ * boundary was written. A model assembled by hand may omit `parent`, and then containment is inferred from
78
+ * member sets - the tightest superset wins - which is the best such a model can offer.
79
+ *
80
+ * Guards, because a model is data and data can be wrong: a parent that does not exist is ignored, a group that
81
+ * contains itself or closes a cycle is treated as a root, and a member claimed by two groups belongs to the
82
+ * tightest one, so a nested boundary is not drawn around nothing.
83
+ */
84
+ function groupTree(groups: DiagramGroup[]): { owner: Map<string, string>; children: Map<string, string[]>; roots: string[] } {
85
+ const byId = new Map(groups.map((group) => [group.id, group] as const))
86
+
87
+ const owner = new Map<string, string>()
88
+ const bySize = [...groups].sort((left, right) => left.members.length - right.members.length)
89
+ for (const group of bySize) {
90
+ for (const member of group.members) if (!owner.has(member)) owner.set(member, group.id)
91
+ }
92
+
93
+ const declared = (group: DiagramGroup): string | undefined => {
94
+ const parent = group.parent
95
+ if (parent === undefined || parent === group.id) return undefined
96
+ return byId.has(parent) ? parent : undefined
97
+ }
98
+
99
+ const members = new Map(groups.map((group) => [group.id, new Set(group.members)] as const))
100
+ const infer = (group: DiagramGroup): string | undefined => {
101
+ let best: { id: string; size: number } | undefined
102
+ for (const other of groups) {
103
+ if (other.id === group.id) continue
104
+ const outer = members.get(other.id) as Set<string>
105
+ const inner = members.get(group.id) as Set<string>
106
+ if (inner.size === 0 || inner.size >= outer.size) continue
107
+ let contained = true
108
+ for (const member of inner) {
109
+ if (!outer.has(member)) {
110
+ contained = false
111
+ break
112
+ }
113
+ }
114
+ if (contained && (best === undefined || outer.size < best.size)) best = { id: other.id, size: outer.size }
115
+ }
116
+ return best?.id
117
+ }
118
+
119
+ const parentOf = new Map<string, string>()
120
+ for (const group of groups) {
121
+ const parent = declared(group) ?? infer(group)
122
+ if (parent !== undefined) parentOf.set(group.id, parent)
123
+ }
124
+ // A cycle would recurse forever: walk up from each group and drop the link that closes the loop.
125
+ for (const group of groups) {
126
+ const seen = new Set<string>([group.id])
127
+ let cursor = parentOf.get(group.id)
128
+ while (cursor !== undefined) {
129
+ if (seen.has(cursor)) {
130
+ parentOf.delete(cursor)
131
+ break
132
+ }
133
+ seen.add(cursor)
134
+ cursor = parentOf.get(cursor)
135
+ }
136
+ }
137
+
138
+ const children = new Map<string, string[]>()
139
+ const roots: string[] = []
140
+ for (const group of groups) {
141
+ const parent = parentOf.get(group.id)
142
+ if (parent === undefined) roots.push(group.id)
143
+ else children.set(parent, [...(children.get(parent) ?? []), group.id])
144
+ }
145
+ return { owner, children, roots }
146
+ }
147
+
148
+ interface Measured {
149
+ width: number
150
+ height: number
151
+ }
152
+
153
+ /** The tree placement: a boundary is a block, its contents stack inside it, the top level stacks. */
154
+ function placeByGroups(diagram: Diagram, nodes: DiagramNode[], top: number): Placement {
155
+ const groups = diagram.groups as DiagramGroup[]
156
+ const byId = new Map(groups.map((group) => [group.id, group] as const))
157
+ const { owner, children, roots } = groupTree(groups)
158
+
159
+ // Nodes that belong to no group, and the first-seen order that keeps a source's own ordering visible.
160
+ const order = new Map(nodes.map((node, index) => [node.id, index] as const))
161
+ const loose = nodes.filter((node) => !owner.has(node.id)).map((node) => node.id)
162
+
163
+ const nodeIds = new Set(nodes.map((node) => node.id))
164
+ const rawContents = (groupId: string): string[] => {
165
+ const group = byId.get(groupId) as DiagramGroup
166
+ const own = [...new Set(group.members)].filter((member) => owner.get(member) === groupId && nodeIds.has(member))
167
+ const nested = children.get(groupId) ?? []
168
+ return [...own, ...nested]
169
+ }
170
+ // A boundary with nothing inside it is not drawn: an empty box claims a containment the source never stated.
171
+ const contents = (groupId: string): string[] => rawContents(groupId).filter((child) => !byId.has(child) || rawContents(child).length > 0)
172
+
173
+ const measure = (id: string): Measured => {
174
+ if (!byId.has(id)) return { width: nodeWidth(id), height: NODE_HEIGHT }
175
+ const items = contents(id)
176
+ const sizes = items.map(measure)
177
+ return {
178
+ width: Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width)) + GROUP_PADDING * 2,
179
+ height: GROUP_HEADER + GROUP_PADDING * 2 + sizes.reduce((total, size) => total + size.height, 0) + Math.max(0, items.length - 1) * BLOCK_GAP,
180
+ }
181
+ }
182
+
183
+ const boxes = new Map<string, Box>()
184
+ const groupRects = new Map<string, Box>()
185
+
186
+ const place = (id: string, x: number, y: number): Measured => {
187
+ const size = measure(id)
188
+ if (!byId.has(id)) {
189
+ boxes.set(id, { x, y, width: size.width, height: NODE_HEIGHT })
190
+ return size
191
+ }
192
+ groupRects.set(id, { x, y, width: size.width, height: size.height })
193
+ let cursor = y + GROUP_HEADER + GROUP_PADDING
194
+ const inner = size.width - GROUP_PADDING * 2
195
+ const ordered = [...contents(id)].sort((left, right) => firstIndex(left) - firstIndex(right))
196
+ for (const child of ordered) {
197
+ const childSize = measure(child)
198
+ place(child, x + GROUP_PADDING + (inner - childSize.width) / 2, cursor)
199
+ cursor += childSize.height + BLOCK_GAP
200
+ }
201
+ return size
202
+ }
203
+
204
+ // Top level: loose nodes and root groups, in the order the source named them.
205
+ const visibleRoots = roots.filter((id) => contents(id).length > 0)
206
+ // A block takes the earliest position of anything inside it, so a group is drawn where its first member was
207
+ // named - not after every loose node, which put a downstream box above the packages it depends on.
208
+ const firstIndex = (id: string): number => {
209
+ // `order` holds every node, and a block is only ever asked about when it has contents.
210
+ if (!byId.has(id)) return order.get(id) as number
211
+ return Math.min(...contents(id).map(firstIndex))
212
+ }
213
+ const topItems = [...loose, ...visibleRoots].sort((left, right) => firstIndex(left) - firstIndex(right))
214
+ const sizes = topItems.map(measure)
215
+ const width = Math.max(MIN_NODE_WIDTH, ...sizes.map((size) => size.width))
216
+ let cursor = top
217
+ for (const item of topItems) {
218
+ const size = measure(item)
219
+ place(item, MARGIN + (width - size.width) / 2, cursor)
220
+ cursor += size.height + BLOCK_GAP
221
+ }
222
+ return { boxes, groupRects, width: width + MARGIN * 2 }
223
+ }
224
+
225
+ /**
226
+ * Place every box of a diagram.
227
+ *
228
+ * The choice of placement is a property of the source, not of the caller: a drawing that declares boundaries is
229
+ * read as containers and gets the tree; one that does not is read as a machine and gets the ranks.
230
+ */
231
+ export function place(diagram: Diagram, top: number): Placement {
232
+ const groups = diagram.groups ?? []
233
+ return groups.length > 0 ? placeByGroups(diagram, diagram.nodes, top) : placeByRanks(diagram, diagram.nodes, top)
234
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shapes to SVG markup.
3
+ *
4
+ * The one module that knows tags, so it is also the one place where a shape can silently disappear if a
5
+ * writer forgets a case: the writer table has an entry per shape kind, and the caller counts the drops
6
+ * instead of hiding them.
7
+ *
8
+ * Three containers read this output - an inline card, a full-screen view, and a sidebar tab - so the markup
9
+ * carries no size of its own beyond its `viewBox`: the container decides how large the drawing is, and the
10
+ * same string serves a thumbnail and a whiteboard.
11
+ *
12
+ * Pure, and the output is asserted as text: escaping, geometry, arrowheads, wrapped captions.
13
+ */
14
+
15
+ import type { Canvas, Shape } from './paint.js'
16
+ import type { Palette } from './palette.js'
17
+
18
+ /** One decimal place is enough at any zoom, and it keeps the markup readable. */
19
+ const n = (value: number): string => {
20
+ const rounded = Math.round(value * 10) / 10
21
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
22
+ }
23
+
24
+ const escape = (value: string): string =>
25
+ value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
26
+
27
+ type Writer<K extends Shape['kind']> = (shape: Extract<Shape, { kind: K }>, colours: Palette) => string
28
+
29
+ const writers: { [K in Shape['kind']]: Writer<K> } = {
30
+ // A boundary is a dashed rectangle with its name in the top-left corner, drawn under everything else.
31
+ group: (shape, colours) => {
32
+ const stroke = colours[shape.colour]
33
+ // The id travels in the markup so a container can fold a boundary by delegation, without re-deriving which
34
+ // group a click landed on from its position or its label.
35
+ return (
36
+ '<g data-group-id="' + escape(shape.id) + '" style="cursor:pointer">' +
37
+ '<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
38
+ '" rx="8" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-dasharray="4 3" />' +
39
+ '<text x="' + n(shape.x + 6) + '" y="' + n(shape.y + 12) + '" font-size="10" fill="' + stroke + '">' + escape(shape.label) + '</text>' +
40
+ '</g>'
41
+ )
42
+ },
43
+ box: (shape, colours) => {
44
+ const stroke = colours[shape.colour]
45
+ const width = shape.initial ? 2.5 : 1
46
+ return (
47
+ '<rect x="' + n(shape.x) + '" y="' + n(shape.y) + '" width="' + n(shape.width) + '" height="' + n(shape.height) +
48
+ '" rx="6" fill="' + colours.surface + '" stroke="' + stroke + '" stroke-width="' + n(width) + '" />' +
49
+ '<text x="' + n(shape.x + shape.width / 2) + '" y="' + n(shape.y + shape.height / 2 + 4) +
50
+ '" font-size="11" text-anchor="middle" fill="' + stroke + '">' + escape(shape.label) + '</text>'
51
+ )
52
+ },
53
+ link: (shape, colours) => {
54
+ const stroke = colours[shape.colour]
55
+ const arrow = arrowHead(shape.x1, shape.y1, shape.x2, shape.y2, stroke)
56
+ return (
57
+ '<line x1="' + n(shape.x1) + '" y1="' + n(shape.y1) + '" x2="' + n(shape.x2) + '" y2="' + n(shape.y2) +
58
+ '" stroke="' + stroke + '" stroke-width="1" />' + arrow + edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
59
+ )
60
+ },
61
+ path: (shape, colours) => {
62
+ const stroke = colours[shape.colour]
63
+ const points = shape.points.map((point) => n(point.x) + ',' + n(point.y)).join(' ')
64
+ const last = shape.points[shape.points.length - 1]
65
+ const before = shape.points[shape.points.length - 2] ?? last
66
+ return (
67
+ '<polyline points="' + points + '" fill="none" stroke="' + stroke + '" stroke-width="1" stroke-linejoin="round" />' +
68
+ arrowHead(before.x, before.y, last.x, last.y, stroke) + edgeLabel(shape.label, shape.labelX, shape.labelY, colours)
69
+ )
70
+ },
71
+ title: (shape, colours) => {
72
+ return '<text x="' + n(shape.x) + '" y="' + n(shape.y) + '" font-size="12" fill="' + colours[shape.colour] + '">' + escape(shape.text) + '</text>'
73
+ },
74
+ caption: (shape, colours) => {
75
+ const colour = colours[shape.colour]
76
+ const lines = shape.lines
77
+ .map((line, index) => '<tspan x="' + n(shape.x) + '" dy="' + (index === 0 ? 0 : 15) + '">' + escape(line) + '</tspan>')
78
+ .join('')
79
+ return '<text x="' + n(shape.x) + '" y="' + n(shape.y + 11) + '" font-size="10" fill="' + colour + '">' + lines + '</text>'
80
+ },
81
+ }
82
+
83
+ /** A small filled triangle at the end of a segment, pointing along it. */
84
+ function arrowHead(x1: number, y1: number, x2: number, y2: number, stroke: string): string {
85
+ const length = Math.hypot(x2 - x1, y2 - y1)
86
+ if (length < 1) return ''
87
+ const ux = (x2 - x1) / length
88
+ const uy = (y2 - y1) / length
89
+ const size = 7
90
+ const half = 3.2
91
+ const baseX = x2 - ux * size
92
+ const baseY = y2 - uy * size
93
+ const left = n(baseX - uy * half) + ',' + n(baseY + ux * half)
94
+ const right = n(baseX + uy * half) + ',' + n(baseY - ux * half)
95
+ return '<polygon points="' + n(x2) + ',' + n(y2) + ' ' + left + ' ' + right + '" fill="' + stroke + '" />'
96
+ }
97
+
98
+ /** An edge label, or nothing when the edge carries no event. */
99
+ function edgeLabel(label: string, x: number, y: number, colours: Palette): string {
100
+ // Total on purpose: a malformed edge must lose its label, not the whole drawing.
101
+ if (typeof label !== 'string' || label === '') return ''
102
+ return '<text x="' + n(x) + '" y="' + n(y) + '" font-size="10" fill="' + colours.muted + '">' + escape(label) + '</text>'
103
+ }
104
+
105
+ export function toSvg(canvas: Canvas, colours: Palette, label?: string): { markup: string; dropped: number } {
106
+ let dropped = 0
107
+ const body: string[] = []
108
+ for (const shape of canvas.shapes) {
109
+ const writer = writers[shape.kind] as ((shape: Shape, colours: Palette) => string) | undefined
110
+ if (writer === undefined) {
111
+ dropped += 1
112
+ continue
113
+ }
114
+ body.push(writer(shape, colours))
115
+ }
116
+ const header =
117
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + n(canvas.width) + ' ' + n(canvas.height) +
118
+ '" width="' + n(canvas.width) + '" height="' + n(canvas.height) + '" role="img"' +
119
+ (label === undefined ? '' : ' aria-label="' + escape(label) + '"') +
120
+ ' font-family="system-ui, -apple-system, Segoe UI, sans-serif">'
121
+ // The surface is part of the drawing, not the container's business: a page that paints its own layers - a
122
+ // wallpaper, a themed panel - would otherwise change the contrast of a picture it knows nothing about.
123
+ const background =
124
+ '<rect x="0" y="0" width="' + n(canvas.width) + '" height="' + n(canvas.height) +
125
+ '" fill="' + colours.background + '" />'
126
+ return { markup: header + background + body.join('') + '</svg>', dropped }
127
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * What a tool card shows, reduced to a drawing.
3
+ *
4
+ * The input is what the host hands a settled tool view: a phase and a block whose shape differs per phase.
5
+ * A settled call carries the paired call's arguments as a **lazy view** (`PartialArguments`) and the result
6
+ * as content blocks, so nothing here reads `block.args.model`: the view is asked, and the report text is
7
+ * parsed for the two facts a drawing needs. Every reader is total - an absent field removes a shape rather
8
+ * than throwing, because the same view renders during streaming.
9
+ *
10
+ * Pure. Everything decided here is a fact a test can assert: which nodes exist, which edges, why they are
11
+ * coloured the way they are.
12
+ */
13
+
14
+ const record = (value: unknown): Record<string, unknown> | undefined =>
15
+ typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
16
+
17
+ const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
18
+
19
+ const text = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined)
20
+
21
+ /** The report text a settled call carries, joined: a card reads words, not blocks. */
22
+ function reportText(content: unknown): string {
23
+ const parts: string[] = []
24
+ for (const entry of array(content)) {
25
+ const block = record(entry)
26
+ const body = text(block?.text)
27
+ if (body !== undefined) parts.push(body)
28
+ }
29
+ return parts.join(String.fromCharCode(10))
30
+ }
31
+
32
+ /** Two facts out of this plugin's own report format: the verdict, and the first error it named. */
33
+ function readReport(content: unknown): { verdict?: string; code?: string; message?: string } {
34
+ const body = reportText(content)
35
+ if (body === '') return {}
36
+ const verdict = /verdict=(\w+)/.exec(body)?.[1]
37
+ const named = /\[error\/([A-Z0-9_]+)\]\s*(.*)/.exec(body)
38
+ if (named !== null) return { verdict, code: named[1], message: named[2] }
39
+ const first = /first error:\s*([A-Z0-9_]+)/.exec(body)
40
+ return first === null ? { verdict } : { verdict, code: first[1] }
41
+ }
42
+
43
+ /**
44
+ * The `model` argument out of the host's lazy arguments view.
45
+ *
46
+ * A settled call exposes `value(key)`; a plain object (a test, or an older shape) is read directly. The
47
+ * view answers `undefined` until the field closes, which is exactly when a drawing should be attempted.
48
+ */
49
+ function readModel(args: unknown): Record<string, unknown> | undefined {
50
+ const view = record(args)
51
+ if (view === undefined) return undefined
52
+ const read = view.value
53
+ if (typeof read === 'function') return record((read as (key: string) => unknown)('model'))
54
+ return record(view.model)
55
+ }
56
+
57
+ /** The states of a model: a machine, or nothing when the call carried none. */
58
+ function machineOf(model: Record<string, unknown> | undefined): { nodes: DiagramNode[]; edges: DiagramEdge[]; init?: string } | undefined {
59
+ if (model === undefined) return undefined
60
+ const states = array(model.states).map(record).filter((entry): entry is Record<string, unknown> => entry !== undefined && typeof entry.id === 'string')
61
+ if (states.length === 0) return undefined
62
+ const edges = array(model.transitions)
63
+ .map(record)
64
+ .filter((entry): entry is Record<string, unknown> => entry !== undefined && typeof entry.from === 'string' && typeof entry.to === 'string')
65
+ .map((entry) => ({ from: String(entry.from), to: String(entry.to), label: text(entry.event) ?? '' }))
66
+ return {
67
+ nodes: states.map((state) => ({ id: String(state.id), role: 'normal' as const })),
68
+ edges,
69
+ ...(typeof model.init === 'string' ? { init: model.init } : {}),
70
+ }
71
+ }
72
+
73
+ /** The caption: the verdict, then the first error's code and message when the report named one. */
74
+ function captionOf(report: { verdict?: string; code?: string; message?: string }): string {
75
+ const verdict = report.verdict ?? 'no report yet'
76
+ if (report.code === undefined) return verdict
77
+ const message = report.message ?? ''
78
+ return verdict + ' — ' + report.code + (message === '' ? '' : ': ' + message)
79
+ }
80
+
81
+ /**
82
+ * Which nodes the report blames.
83
+ *
84
+ * A finding names a state inside its message as `"x"`; matching that is a heuristic, so it only ever *adds*
85
+ * emphasis and never decides the verdict.
86
+ */
87
+ function blame(nodes: DiagramNode[], report: { message?: string }): DiagramNode[] {
88
+ if (report.message === undefined) return nodes
89
+ const message = report.message
90
+ // Whole words only: a report does not quote the state, and a state called `do` must not match `done`.
91
+ const named = nodes
92
+ .filter((node) => {
93
+ const escaped = node.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94
+ return new RegExp('(?<![A-Za-z0-9_])' + escaped + '(?![A-Za-z0-9_])').test(message)
95
+ })
96
+ .map((node) => node.id)
97
+ if (named.length === 0) return nodes
98
+ return nodes.map((node) => (named.includes(node.id) ? { ...node, role: 'error' as const } : node))
99
+ }
100
+
101
+ import type { Diagram, DiagramEdge, DiagramNode } from './model.js'
102
+
103
+ export interface ViewInput {
104
+ phase?: unknown
105
+ toolName?: unknown
106
+ /** The whole block the host hands a view; its shape depends on the phase. */
107
+ block?: unknown
108
+ }
109
+
110
+ export function viewModel(input: ViewInput): Diagram {
111
+ const block = record(input.block)
112
+ const title = text(block?.name) ?? text(input.toolName) ?? 'tool'
113
+ const report = readReport(block?.content)
114
+ // A shape that still carries a parsed report object is read from there; it is what older callers pass.
115
+ const parsed = record(block?.result)
116
+ const findings = array(parsed?.findings).map(record)
117
+ const firstError = findings.find((finding) => finding?.severity === 'error')
118
+ const merged = {
119
+ verdict: text(parsed?.verdict) ?? report.verdict,
120
+ ...(report.code === undefined && typeof firstError?.code === 'string' ? { code: firstError.code } : { code: report.code }),
121
+ ...(report.message === undefined && typeof firstError?.message === 'string' ? { message: firstError.message } : { message: report.message }),
122
+ }
123
+ const caption = captionOf(merged)
124
+ const machine = machineOf(readModel(block?.args))
125
+ if (machine === undefined) {
126
+ return { kind: 'none', title, nodes: [], edges: [], caption }
127
+ }
128
+ return {
129
+ kind: 'machine',
130
+ title,
131
+ ...(machine.init === undefined ? {} : { init: machine.init }),
132
+ nodes: blame(machine.nodes, merged),
133
+ edges: machine.edges,
134
+ caption,
135
+ }
136
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Writing a drawing to disk: the CLI's job, kept out of the renderer.
3
+ *
4
+ * `render/index.ts` is bundled into the page as well as run in Node, so it may not import a filesystem;
5
+ * everything that touches one lives here, where only the host reaches it.
6
+ */
7
+
8
+ import { writeFileSync } from 'node:fs'
9
+ import { render, type RenderInput, type Rendered } from './index.js'
10
+ import type { Palette, PaletteOptions } from './palette.js'
11
+
12
+ /** Write the drawing where a document can link to it. Returns the path, the byte count and the render. */
13
+ export function writeSvg(input: RenderInput, path: string, overrides: Partial<Palette> = {}, options: PaletteOptions = {}): { path: string; bytes: number; rendered: Rendered } {
14
+ const rendered = render(input, overrides, options)
15
+ writeFileSync(path, rendered.markup, 'utf8')
16
+ return { path, bytes: rendered.markup.length, rendered }
17
+ }