@principal-ai/subsystems-react 0.51.0 → 0.51.1

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 (87) hide show
  1. package/dist/index.d.ts +6 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
  6. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.js +44 -21
  8. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  9. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
  10. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
  11. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
  12. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
  13. package/dist/subsystem/C4Graph.d.ts +2 -2
  14. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  15. package/dist/subsystem/C4Graph.js +25 -16
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +71 -36
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  19. package/dist/subsystem/C4NodeCard.js +88 -94
  20. package/dist/subsystem/C4NodeCard.js.map +1 -1
  21. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  22. package/dist/subsystem/FileDrawer.js +20 -21
  23. package/dist/subsystem/FileDrawer.js.map +1 -1
  24. package/dist/subsystem/IssueList.js +1 -1
  25. package/dist/subsystem/IssueList.js.map +1 -1
  26. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  27. package/dist/subsystem/SubsystemComponentGraph.js +13 -4
  28. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  29. package/dist/subsystem/c4.d.ts +272 -0
  30. package/dist/subsystem/c4.d.ts.map +1 -0
  31. package/dist/subsystem/c4.js +322 -0
  32. package/dist/subsystem/c4.js.map +1 -0
  33. package/dist/subsystem/c4Evidence.d.ts +143 -0
  34. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  35. package/dist/subsystem/c4Evidence.js +240 -0
  36. package/dist/subsystem/c4Evidence.js.map +1 -0
  37. package/dist/subsystem/model.d.ts +2 -6
  38. package/dist/subsystem/model.d.ts.map +1 -1
  39. package/dist/subsystem/model.js.map +1 -1
  40. package/package.json +4 -3
  41. package/src/index.ts +26 -28
  42. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  43. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +167 -79
  44. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  45. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  46. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  47. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
  48. package/src/subsystem/C4Graph.tsx +39 -24
  49. package/src/subsystem/C4NodeCard.test.ts +169 -64
  50. package/src/subsystem/C4NodeCard.tsx +102 -108
  51. package/src/subsystem/FileDrawer.tsx +32 -34
  52. package/src/subsystem/IssueList.tsx +1 -1
  53. package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
  54. package/src/subsystem/c4.test.ts +573 -0
  55. package/src/subsystem/c4.ts +558 -0
  56. package/src/subsystem/c4Evidence.test.ts +386 -0
  57. package/src/subsystem/c4Evidence.ts +329 -0
  58. package/src/subsystem/model.ts +2 -6
  59. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +0 -95
  60. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +0 -1
  61. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +0 -340
  62. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +0 -1
  63. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -7
  64. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  65. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -7335
  66. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  67. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +0 -2
  68. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +0 -1
  69. package/dist/stories/Subsystem/C4Graph/generateFixture.js +0 -145
  70. package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +0 -1
  71. package/dist/subsystem/c4Associations.d.ts +0 -186
  72. package/dist/subsystem/c4Associations.d.ts.map +0 -1
  73. package/dist/subsystem/c4Associations.js +0 -284
  74. package/dist/subsystem/c4Associations.js.map +0 -1
  75. package/dist/subsystem/toC4.d.ts +0 -214
  76. package/dist/subsystem/toC4.d.ts.map +0 -1
  77. package/dist/subsystem/toC4.js +0 -282
  78. package/dist/subsystem/toC4.js.map +0 -1
  79. package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +0 -273
  80. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  81. package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +0 -344
  82. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -7339
  83. package/src/stories/Subsystem/C4Graph/generateFixture.ts +0 -161
  84. package/src/subsystem/c4Associations.test.ts +0 -484
  85. package/src/subsystem/c4Associations.ts +0 -398
  86. package/src/subsystem/toC4.test.ts +0 -114
  87. package/src/subsystem/toC4.ts +0 -478
@@ -1,8 +1,17 @@
1
1
  import React from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
  import { ThemeProvider, defaultEditorTheme, useTheme } from '@principal-ade/industry-theme';
4
- import { C4NodeCard, nodeStyle, nodeSubtitle, nodeTag, nodeMissing, NODE_W } from '../../../subsystem/C4NodeCard';
5
- import type { C4Node, C4ElementType } from '../../../subsystem/toC4';
4
+ import {
5
+ C4NodeCard,
6
+ nodeStyle,
7
+ nodeSubtitle,
8
+ nodeTag,
9
+ nodeShape,
10
+ nodeSize,
11
+ nodeMissing,
12
+ NODE_W,
13
+ } from '../../../subsystem/C4NodeCard';
14
+ import type { C4Node } from '../../../subsystem/c4';
6
15
 
7
16
  // ---------------------------------------------------------------------------
8
17
  // A card gallery: every C4 box variant, outside the graph.
@@ -14,6 +23,10 @@ import type { C4Node, C4ElementType } from '../../../subsystem/toC4';
14
23
  // Rendering the card directly (not through React Flow) is the point: the node
15
24
  // is a plain presentational component, so it can be reviewed and diffed
16
25
  // without a layout engine in the way.
26
+ //
27
+ // Every card here is a flattened `C4Node` — exactly what `buildC4Model`
28
+ // produces from an authored element, with the per-kind fields already resolved
29
+ // to the one shape the card draws with.
17
30
  // ---------------------------------------------------------------------------
18
31
 
19
32
  const meta = {
@@ -39,33 +52,27 @@ function node(over: Partial<C4Node> & { id: string; label: string }): C4Node {
39
52
  kind: 'container',
40
53
  members: [],
41
54
  constructs: [],
42
- isStore: false,
55
+ state: 'proposed',
43
56
  ...over,
44
57
  };
45
58
  }
46
59
 
47
- const CONFIRMED = {
48
- id: 'container:studio-host',
49
- label: 'Studio Host',
50
- type: 'application' as C4ElementType,
51
- technology: 'Bun + Electrobun',
52
- description: 'Runs the audit pipeline, owns the model store, serves the RPC surface.',
53
- state: 'accepted' as const,
54
- };
55
-
56
60
  const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
57
61
  // `id` on each variant is its caption key, not a component field.
62
+
58
63
  // --- confirmation state: the axis that matters most --------------------
59
64
  {
60
65
  group: 'Confirmation state',
61
- note: 'The whole point of the association layer. Solid = a person signed off. Dashed = an agent asked, nobody answered. Muted = raw derivation, never reviewed.',
66
+ note: 'Solid = a person signed off. Dashed = an agent asked and nobody answered. This is the only field that changes how a box is drawn, so it is the only one a confirm UI needs to move.',
62
67
  node: node({
63
68
  id: 'a1',
64
- label: 'subsystems-studio/host',
65
- key: 'subsystems-studio/host',
69
+ label: 'Subsystem Studio — host',
66
70
  members: ['a', 'b', 'c'],
67
71
  constructs: ['function', 'store'],
68
- decoration: CONFIRMED,
72
+ containerKind: 'application',
73
+ technology: 'Bun + Electrobun',
74
+ description: 'Runs the audit pipeline, owns the model store, serves the RPC surface.',
75
+ state: 'accepted',
69
76
  }),
70
77
  },
71
78
  {
@@ -73,16 +80,12 @@ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
73
80
  note: '',
74
81
  node: node({
75
82
  id: 'a2',
76
- label: 'subsystems-core/types',
77
- key: 'subsystems-core/types',
83
+ label: 'subsystems-react (proposed container?)',
78
84
  members: ['x', 'y'],
79
- constructs: ['interface', 'type_alias'],
80
- decoration: {
81
- id: 'container:subsystems-core/types',
82
- label: 'subsystems-core/types',
83
- type: 'library',
84
- state: 'proposed',
85
- },
85
+ constructs: ['function', 'interface'],
86
+ containerKind: 'application',
87
+ technology: 'React',
88
+ state: 'proposed',
86
89
  }),
87
90
  },
88
91
  {
@@ -90,45 +93,56 @@ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
90
93
  note: '',
91
94
  node: node({
92
95
  id: 'a3',
93
- label: 'principal-studio-cli',
94
- key: 'principal-studio-cli',
96
+ label: 'Subsystem Modeling CLI',
95
97
  members: ['cli-create'],
96
98
  constructs: ['function'],
99
+ containerKind: 'application',
100
+ technology: 'Node',
101
+ description: 'Creates and updates models from the terminal.',
102
+ state: 'accepted',
97
103
  }),
98
104
  },
99
105
  {
100
106
  group: '',
101
- note: '',
107
+ note: 'Rejected elements are not drawn at all. This card exists to prove the fallback branch exists, not because a diagram ever shows one.',
102
108
  node: node({
103
109
  id: 'a4',
104
110
  label: 'graphify/extract',
105
- key: 'graphify/extract',
106
111
  members: ['extract'],
107
112
  constructs: ['function'],
108
- decoration: {
109
- id: 'container:graphify/extract',
110
- label: 'graphify/extract',
111
- type: 'application',
112
- state: 'rejected',
113
- },
113
+ containerKind: 'application',
114
+ technology: 'Bun',
115
+ state: 'rejected',
114
116
  }),
115
117
  },
116
118
  {
117
119
  group: '',
118
- note: '',
119
- node: node({ id: 'a5', label: 'Studio Host', key: 'p1 + p2', sourceKeys: ['p1', 'p2'], members: ['a'], constructs: ['function'], decoration: CONFIRMED }),
120
+ note: 'A box that consolidated two derived keys says so — the inputs are hidden once merged, and silently losing them would be a lie about provenance.',
121
+ node: node({
122
+ id: 'a5',
123
+ label: 'Subsystem Studio — host',
124
+ derivedFrom: ['subsystems-studio/host', 'principal-studio/host'],
125
+ members: ['a'],
126
+ constructs: ['function'],
127
+ containerKind: 'application',
128
+ technology: 'Bun + Electrobun',
129
+ state: 'accepted',
130
+ }),
120
131
  },
121
132
 
122
- // --- C4 element type ----------------------------------------------------
133
+ // --- the four kinds ------------------------------------------------------
123
134
  {
124
- group: 'C4 element type',
125
- note: 'C4 requires a type on every element. The document has no such field, so this is always a confirmation.',
135
+ group: 'The four kinds',
136
+ note: 'C4 has a closed vocabulary: software system, container, component, code. Outside the boundary it adds people. There is no "library" kind — c4model says a module typically is not an element at all, so a library is a review comment, not a fifth box.',
126
137
  node: node({
127
138
  id: 'b1',
128
139
  label: 'Subsystem model store',
129
140
  members: ['s1'],
130
141
  constructs: ['store'],
131
- decoration: { id: 'container:ds', label: 'Subsystem model store', type: 'data-store', technology: 'JSON files on disk', state: 'accepted', description: 'Every stored model plus its index.' },
142
+ containerKind: 'data-store',
143
+ technology: 'JSON files on disk',
144
+ description: 'Every stored model plus its index.',
145
+ state: 'accepted',
132
146
  }),
133
147
  },
134
148
  {
@@ -139,30 +153,39 @@ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
139
153
  label: 'Audit correction queue',
140
154
  members: ['s2'],
141
155
  constructs: ['store'],
142
- decoration: { id: 'container:q', label: 'Audit correction queue', type: 'queue', technology: 'agent session transport', state: 'accepted', description: 'Proposals awaiting a human decision.' },
156
+ containerKind: 'queue',
157
+ technology: 'agent session transport',
158
+ description: 'Proposals awaiting a human decision.',
159
+ state: 'accepted',
143
160
  }),
144
161
  },
145
162
  {
146
163
  group: '',
147
- note: '',
164
+ note: 'A component is a real square, not a smaller rectangle. Border weight drops to 1px as a second signal, so the level survives grayscale.',
148
165
  node: node({
149
166
  id: 'b3',
150
- label: 'subsystems-core',
151
- members: ['i1', 'i2'],
152
- constructs: ['interface', 'function'],
153
- decoration: { id: 'container:lib', label: 'subsystems-core', type: 'library', technology: 'TypeScript', state: 'accepted', description: 'Schema and validation. Not a deployable unit.' },
167
+ label: 'Boundary layout builder',
168
+ kind: 'component',
169
+ parentId: 'container:shared-ui',
170
+ members: ['h1', 'h2'],
171
+ constructs: ['function'],
172
+ technology: 'React + ELK',
173
+ description: 'Turns claims into nested compound frames.',
174
+ state: 'accepted',
154
175
  }),
155
176
  },
156
177
  {
157
178
  group: '',
158
- note: '',
179
+ note: 'An external system keeps the rectangle but goes dashed — it is outside the boundary, which is a different statement from "inside, and unconfirmed".',
159
180
  node: node({
160
181
  id: 'b4',
161
182
  label: 'OpenCode v2 service',
162
- kind: 'external',
183
+ kind: 'external-system',
163
184
  members: ['oc2'],
164
185
  constructs: ['external'],
165
- decoration: { id: 'external:oc2', label: 'OpenCode v2 service', type: 'software-system', technology: 'HTTP', state: 'accepted', description: 'Runs maintain agent sessions.' },
186
+ technology: 'HTTP',
187
+ description: 'Runs maintain agent sessions.',
188
+ state: 'accepted',
166
189
  }),
167
190
  },
168
191
  {
@@ -171,24 +194,26 @@ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
171
194
  node: node({
172
195
  id: 'b5',
173
196
  label: 'Maintain operator',
174
- kind: 'actor',
197
+ kind: 'person',
175
198
  members: ['a'],
176
199
  constructs: ['custom_entity'],
177
- decoration: { id: 'actor:a', label: 'Maintain operator', type: 'person', state: 'proposed', description: 'Reviews and accepts correction proposals.' },
200
+ description: 'Reviews and accepts correction proposals.',
201
+ state: 'proposed',
178
202
  }),
179
203
  },
180
204
 
181
205
  // --- the gaps C4 keeps asking about ------------------------------------
182
206
  {
183
207
  group: 'Notation gaps',
184
- note: 'C4 requires technology and description on every container. A confirmed box that never got either says so on its face rather than looking finished.',
208
+ note: 'C4 requires technology and description on every container. A box that never got either says so on its face rather than looking finished — and `technology` is required by the type, so the only way to have the gap is an empty string.',
185
209
  node: node({
186
210
  id: 'c1',
187
- label: 'site/web',
188
- key: 'site/web',
211
+ label: 'In-memory renderer state',
189
212
  members: ['w1', 'w2', 'w3'],
190
- constructs: ['function'],
191
- decoration: { id: 'container:site/web', label: 'site/web', type: 'application', state: 'accepted' },
213
+ constructs: ['store'],
214
+ containerKind: 'application',
215
+ technology: '',
216
+ state: 'accepted',
192
217
  }),
193
218
  },
194
219
  {
@@ -196,25 +221,50 @@ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
196
221
  note: '',
197
222
  node: node({
198
223
  id: 'c2',
199
- label: 'subsystems-studio/renderer',
200
- key: 'subsystems-studio/renderer',
224
+ label: 'Subsystem Studio — renderer',
201
225
  members: ['r1', 'r2'],
202
226
  constructs: ['function'],
203
- decoration: { id: 'container:renderer', label: 'subsystems-studio/renderer', type: 'application', technology: 'React', state: 'accepted' },
227
+ containerKind: 'application',
228
+ technology: 'React 19',
229
+ state: 'accepted',
204
230
  }),
205
231
  },
206
232
 
207
- // --- component level ---------------------------------------------------
233
+ // --- the pair that was confusing, side by side ------------------------
208
234
  {
209
- group: 'Component level',
210
- note: 'Same card, one level down. A component has no type requirement — it inherits from its container.',
235
+ group: 'Container vs component',
236
+ note: 'The case that started this. Same border colour and same state, previously identical. Now a wide rectangle says "container" and a square says "component", so the level is readable before the text is.',
211
237
  node: node({
212
- id: 'd1',
213
- label: 'buildBoundaryLayoutGroups',
238
+ id: 'e1',
239
+ label: 'Subsystem Studio — host',
240
+ members: ['a', 'b', 'c'],
241
+ constructs: ['function', 'store'],
242
+ containerKind: 'application',
243
+ technology: 'Bun + Electrobun',
244
+ description: 'Runs the audit pipeline.',
245
+ state: 'accepted',
246
+ }),
247
+ },
248
+ {
249
+ group: '',
250
+ note: '',
251
+ node: node({
252
+ id: 'e2',
253
+ label: 'Studio Host',
214
254
  kind: 'component',
215
- members: ['b1'],
255
+ parentId: 'container:studio-host',
256
+ members: ['h1'],
216
257
  constructs: ['function'],
217
- component: { alias: 'b1', name: 'buildBoundaryLayoutGroups', construct: 'function', file: 'src/subsystem/model.ts', purl: 'pkg:x#src/subsystem/model.ts' },
258
+ technology: 'Bun',
259
+ description: 'Resolves the model root and registers repos.',
260
+ state: 'accepted',
261
+ component: {
262
+ alias: 'h1',
263
+ name: 'StudioHost',
264
+ construct: 'function',
265
+ file: 'packages/subsystems-studio/src/bun/index.ts',
266
+ purl: 'pkg:x#packages/subsystems-studio/src/bun/index.ts',
267
+ },
218
268
  }),
219
269
  },
220
270
  ];
@@ -242,6 +292,8 @@ function Case({ caption, children }: { caption: string; children: React.ReactNod
242
292
  return (
243
293
  <div style={{ display: 'flex', flexDirection: 'column', gap: 5, alignItems: 'flex-start' }}>
244
294
  {children}
295
+ {/* Caption spans the container box so every card's caption starts at the
296
+ same x — otherwise the narrower square column looks misaligned. */}
245
297
  <span
246
298
  style={{
247
299
  fontFamily: 'monospace',
@@ -257,14 +309,16 @@ function Case({ caption, children }: { caption: string; children: React.ReactNod
257
309
  );
258
310
  }
259
311
 
312
+ const KINDS = ['container', 'component', 'external-system', 'person'] as const;
313
+
260
314
  function Gallery() {
261
315
  const { theme } = useTheme();
262
316
  const muted = theme.colors.textMuted ?? '#9a9aa0';
263
317
 
264
318
  const legend: Array<[string, string]> = [
265
- ['accepted', nodeStyle({ id: 'x', kind: 'container', label: '', members: [], constructs: [], isStore: false, decoration: CONFIRMED } as C4Node, theme).color],
266
- ['proposed', nodeStyle({ id: 'x', kind: 'container', label: '', members: [], constructs: [], isStore: false, decoration: { id: 'y', state: 'proposed' } } as C4Node, theme).color],
267
- ['unconfirmed', nodeStyle({ id: 'x', kind: 'container', label: '', members: [], constructs: [], isStore: false } as C4Node, theme).color],
319
+ ['accepted', nodeStyle(node({ id: 'x', label: '', state: 'accepted' }), theme).color],
320
+ ['proposed', nodeStyle(node({ id: 'x', label: '', state: 'proposed' }), theme).color],
321
+ ['rejected', nodeStyle(node({ id: 'x', label: '', state: 'rejected' }), theme).color],
268
322
  ];
269
323
 
270
324
  let currentGroup = '';
@@ -277,13 +331,45 @@ function Gallery() {
277
331
  encoding.
278
332
  </p>
279
333
 
280
- <div style={{ display: 'flex', gap: 16, fontFamily: 'monospace', fontSize: 11, marginBottom: 26 }}>
281
- {legend.map(([name, color]) => (
282
- <span key={name} style={{ display: 'flex', alignItems: 'center', gap: 6, color: muted }}>
283
- <span style={{ width: 22, height: 0, borderTop: `3px solid ${color}`, display: 'inline-block' }} />
284
- {name}
285
- </span>
286
- ))}
334
+ <div style={{ fontFamily: 'monospace', fontSize: 11, marginBottom: 26, lineHeight: 1.9 }}>
335
+ <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
336
+ <span style={{ color: '#e8e8ea' }}>KIND — shape and border weight</span>
337
+ </div>
338
+ <div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 4 }}>
339
+ {KINDS.map((kind) => {
340
+ const s = nodeShape(node({ id: 'x', label: '', kind }));
341
+ const size = nodeSize(node({ id: 'x', label: '', kind }));
342
+ const color = nodeStyle(node({ id: 'x', label: '', kind }), theme).color;
343
+ const weight = kind === 'component' ? 1 : 2;
344
+ // Drawn at the real aspect ratio — the whole signal is square vs
345
+ // rectangle, so a legend that flattened both would lie.
346
+ return (
347
+ <span key={kind} style={{ display: 'flex', alignItems: 'center', gap: 6, color: muted }}>
348
+ <span
349
+ style={{
350
+ width: 28,
351
+ height: Math.round((28 * size.height) / size.width),
352
+ borderRadius: Math.min(s.radius, 9),
353
+ border: `${weight} ${s.dash} ${color}`,
354
+ display: 'inline-block',
355
+ }}
356
+ />
357
+ {kind}
358
+ </span>
359
+ );
360
+ })}
361
+ </div>
362
+ <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', marginTop: 14 }}>
363
+ <span style={{ color: '#e8e8ea' }}>CONFIRMATION — colour and dash, never the shape</span>
364
+ </div>
365
+ <div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 4 }}>
366
+ {legend.map(([name, color]) => (
367
+ <span key={name} style={{ display: 'flex', alignItems: 'center', gap: 6, color: muted }}>
368
+ <span style={{ width: 22, height: 0, borderTop: `3px solid ${color}`, display: 'inline-block' }} />
369
+ {name}
370
+ </span>
371
+ ))}
372
+ </div>
287
373
  </div>
288
374
 
289
375
  <Row>
@@ -291,14 +377,16 @@ function Gallery() {
291
377
  const showHeader = v.group && v.group !== currentGroup;
292
378
  if (v.group) currentGroup = v.group;
293
379
  return (
294
- <React.Fragment key={v.id ?? v.node.id}>
380
+ <React.Fragment key={v.node.id}>
295
381
  {showHeader && (
296
382
  <div style={{ width: '100%' }}>
297
383
  <Section title={v.group} note={v.note} />
298
384
  </div>
299
385
  )}
300
386
  <Case
301
- caption={`${v.node.id} · tag "${nodeTag(v.node)}" · ${nodeSubtitle(v.node)}${
387
+ caption={`${v.node.id} · "${nodeTag(v.node)}" · ${v.node.state} · ${nodeShape(v.node).kind} ${
388
+ nodeSize(v.node).width
389
+ }×${nodeSize(v.node).height} · ${nodeSubtitle(v.node)}${
302
390
  nodeMissing(v.node).length ? ` · missing ${nodeMissing(v.node).join('+')}` : ''
303
391
  }`}
304
392
  >