@principal-ai/subsystems-react 0.50.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 (83) hide show
  1. package/dist/index.d.ts +8 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +7 -0
  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 +38 -73
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +119 -0
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
  19. package/dist/subsystem/C4NodeCard.js +223 -0
  20. package/dist/subsystem/C4NodeCard.js.map +1 -0
  21. package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
  22. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  23. package/dist/subsystem/DiagnosticToggle.js +40 -6
  24. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  25. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  26. package/dist/subsystem/FileDrawer.js +20 -21
  27. package/dist/subsystem/FileDrawer.js.map +1 -1
  28. package/dist/subsystem/IssueList.js +1 -1
  29. package/dist/subsystem/IssueList.js.map +1 -1
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +13 -4
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/c4.d.ts +272 -0
  34. package/dist/subsystem/c4.d.ts.map +1 -0
  35. package/dist/subsystem/c4.js +322 -0
  36. package/dist/subsystem/c4.js.map +1 -0
  37. package/dist/subsystem/c4Evidence.d.ts +143 -0
  38. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  39. package/dist/subsystem/c4Evidence.js +240 -0
  40. package/dist/subsystem/c4Evidence.js.map +1 -0
  41. package/dist/subsystem/model.d.ts +27 -7
  42. package/dist/subsystem/model.d.ts.map +1 -1
  43. package/dist/subsystem/model.js +15 -0
  44. package/dist/subsystem/model.js.map +1 -1
  45. package/dist/subsystem/nodes.d.ts.map +1 -1
  46. package/dist/subsystem/nodes.js +78 -7
  47. package/dist/subsystem/nodes.js.map +1 -1
  48. package/package.json +3 -2
  49. package/src/index.ts +37 -0
  50. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  51. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +413 -0
  52. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  53. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  54. package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
  55. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
  56. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  57. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
  58. package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +4 -4
  59. package/src/subsystem/C4Graph.tsx +81 -107
  60. package/src/subsystem/C4NodeCard.test.ts +301 -0
  61. package/src/subsystem/C4NodeCard.tsx +301 -0
  62. package/src/subsystem/DiagnosticToggle.tsx +58 -4
  63. package/src/subsystem/FileDrawer.tsx +32 -34
  64. package/src/subsystem/IssueList.tsx +1 -1
  65. package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
  66. package/src/subsystem/c4.test.ts +573 -0
  67. package/src/subsystem/c4.ts +558 -0
  68. package/src/subsystem/c4Evidence.test.ts +386 -0
  69. package/src/subsystem/c4Evidence.ts +329 -0
  70. package/src/subsystem/model.ts +36 -7
  71. package/src/subsystem/nodes.tsx +124 -5
  72. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -3
  73. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  74. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -2
  75. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  76. package/dist/subsystem/toC4.d.ts +0 -95
  77. package/dist/subsystem/toC4.d.ts.map +0 -1
  78. package/dist/subsystem/toC4.js +0 -192
  79. package/dist/subsystem/toC4.js.map +0 -1
  80. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  81. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -6
  82. package/src/subsystem/toC4.test.ts +0 -114
  83. package/src/subsystem/toC4.ts +0 -273
@@ -68,15 +68,10 @@ function stepIndexFromItemContext(context: unknown): number | null {
68
68
  return Number.isFinite(index) ? index : null;
69
69
  }
70
70
 
71
- /**
72
- * Assumed height of the compact file header, in px.
73
- *
74
- * Must track the header chrome passed to `buildPierreOptions` (fontSizes[0] at
75
- * line-height 1.15 plus 2px vertical padding) — CodeView sizes its
76
- * virtualization window and sticky offset from this, and the library's own
77
- * default of 44 is more than double the compact header.
78
- */
79
- const TRAIL_HEADER_HEIGHT = 18;
71
+ /** Line-height multiplier for the compact per-file header. */
72
+ const TRAIL_HEADER_LINE_HEIGHT = 1.15;
73
+ /** Vertical padding on the compact per-file header, px (each side). */
74
+ const TRAIL_HEADER_PAD_Y = 2;
80
75
 
81
76
  /** Pointer/dotted-underline affordance for a clickable construct token. */
82
77
  function paintSymbolToken(el: HTMLElement | undefined, active: boolean): void {
@@ -208,7 +203,7 @@ export function PierreTrailCodeView({
208
203
  stepIndex,
209
204
  readFile,
210
205
  contextLines = 8,
211
- background,
206
+ background: backgroundProp,
212
207
  onOpenFile,
213
208
  onVisibleStepChange,
214
209
  proposedAliases,
@@ -216,6 +211,14 @@ export function PierreTrailCodeView({
216
211
  onSymbolClick,
217
212
  }: PierreTrailCodeViewProps) {
218
213
  const { theme, mode } = useTheme();
214
+ const background = backgroundProp ?? theme.colors.background;
215
+ // The per-file header renders the step's file path (Pierre's `[data-title]`),
216
+ // so this font size is what the path reads at. It also drives CodeView's
217
+ // virtualization window and sticky offset via `diffHeaderHeight`, so derive
218
+ // the header height from it rather than tracking two numbers by hand.
219
+ const headerFontSize = theme.fontSizes[1];
220
+ const headerHeight =
221
+ Math.round(headerFontSize * TRAIL_HEADER_LINE_HEIGHT) + 2 * TRAIL_HEADER_PAD_Y;
219
222
  const viewRef = useRef<CodeViewHandle<undefined>>(null);
220
223
  // CodeView's scroll container — also the box we measure items against.
221
224
  const containerRef = useRef<HTMLDivElement | null>(null);
@@ -261,10 +264,34 @@ export function PierreTrailCodeView({
261
264
  onVisibleStepChangeRef.current?.(next);
262
265
  }, []);
263
266
 
264
- const onCodeViewScroll = useCallback(() => {
265
- reportVisibleStep();
267
+ // Coalesce measurement to one report per frame, after layout settles.
268
+ // `onPostRender` is a CodeView *shared file callback*: it fires for every item
269
+ // as virtualization mounts/unmounts them mid-scroll. Measuring synchronously
270
+ // there read geometry before the browser had laid the newly-mounted items out,
271
+ // so the "topmost visible" step bounced between values instead of advancing
272
+ // one step at a time. Deferring to rAF measures settled geometry once.
273
+ const visibleReportRaf = useRef<number | null>(null);
274
+ const scheduleVisibleStepReport = useCallback(() => {
275
+ if (visibleReportRaf.current != null) return;
276
+ visibleReportRaf.current = window.requestAnimationFrame(() => {
277
+ visibleReportRaf.current = null;
278
+ reportVisibleStep();
279
+ });
266
280
  }, [reportVisibleStep]);
267
281
 
282
+ useEffect(
283
+ () => () => {
284
+ if (visibleReportRaf.current != null) {
285
+ window.cancelAnimationFrame(visibleReportRaf.current);
286
+ }
287
+ },
288
+ [],
289
+ );
290
+
291
+ const onCodeViewScroll = useCallback(() => {
292
+ scheduleVisibleStepReport();
293
+ }, [scheduleVisibleStepReport]);
294
+
268
295
  // A step onto a proposed component is planned work; label its missing file
269
296
  // accordingly instead of showing a bare "not found".
270
297
  const isProposedStep = useCallback(
@@ -404,14 +431,15 @@ export function PierreTrailCodeView({
404
431
  ) => {
405
432
  const id = context?.item?.id;
406
433
  // Item-level renders don't move the viewport, but the first paint does
407
- // decide which step is on screen.
408
- reportVisibleStep();
434
+ // decide which step is on screen. Schedule rather than measure here (see
435
+ // `scheduleVisibleStepReport`).
436
+ scheduleVisibleStepReport();
409
437
  if (id == null) return;
410
438
  const sliceStart = sliceStartByItemId.get(id);
411
439
  if (sliceStart == null) return;
412
440
  remapSnippetLineNumbers(node, sliceStart);
413
441
  },
414
- [sliceStartByItemId, reportVisibleStep],
442
+ [sliceStartByItemId, scheduleVisibleStepReport],
415
443
  );
416
444
 
417
445
  // No step numbers in the file header: the sidebar already numbers the steps,
@@ -546,22 +574,22 @@ export function PierreTrailCodeView({
546
574
  // PIERRE_BASE_OPTIONS and `disableFileHeader` must follow it.
547
575
  ...buildPierreOptions(background, {
548
576
  background: theme.colors.backgroundSecondary,
549
- fontSize: `${theme.fontSizes[0]}px`,
550
- lineHeight: '1.15',
551
- padding: '2px 8px',
577
+ fontSize: `${headerFontSize}px`,
578
+ lineHeight: String(TRAIL_HEADER_LINE_HEIGHT),
579
+ padding: `${TRAIL_HEADER_PAD_Y}px 8px`,
552
580
  }),
553
581
  disableFileHeader: false,
554
582
  // The library assumes a 44px header (DEFAULT_VIRTUAL_FILE_METRICS); the
555
- // compact chrome above is ~18px. CodeView uses this for its
583
+ // compact chrome above is `headerHeight`. CodeView uses this for its
556
584
  // virtualization window and sticky offset, so leaving it at 44 drifts.
557
- itemMetrics: { diffHeaderHeight: TRAIL_HEADER_HEIGHT },
585
+ itemMetrics: { diffHeaderHeight: headerHeight },
558
586
  layout: { paddingTop: 0, paddingBottom: 0, gap: 4 },
559
587
  onPostRender,
560
588
  ...(onOpenFile ? { onLineClick } : {}),
561
589
  ...symbolHandlers,
562
590
  ...(mode === 'light' || mode === 'dark' ? { themeType: mode } : {}),
563
591
  };
564
- }, [background, theme.colors.backgroundSecondary, theme.fontSizes, mode, onPostRender, onOpenFile, onLineClick, symbolHandlers]);
592
+ }, [background, headerFontSize, headerHeight, theme.colors.backgroundSecondary, mode, onPostRender, onOpenFile, onLineClick, symbolHandlers]);
565
593
 
566
594
  useEffect(() => {
567
595
  if (load.status !== 'ready' || stepIndex == null) return;
@@ -0,0 +1,413 @@
1
+ import React from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { ThemeProvider, defaultEditorTheme, useTheme } from '@principal-ade/industry-theme';
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';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // A card gallery: every C4 box variant, outside the graph.
18
+ //
19
+ // The graph answers "what does this system look like". This answers "what does
20
+ // a box MEAN" — which encoding maps to which C4 concept, and which gaps the
21
+ // notation still wants filled.
22
+ //
23
+ // Rendering the card directly (not through React Flow) is the point: the node
24
+ // is a plain presentational component, so it can be reviewed and diffed
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.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const meta = {
33
+ title: 'Subsystem/C4Graph/C4NodeGallery',
34
+ parameters: { layout: 'fullscreen' },
35
+ tags: ['autodocs'],
36
+ decorators: [
37
+ (Story) => (
38
+ <ThemeProvider theme={defaultEditorTheme}>
39
+ <Story />
40
+ </ThemeProvider>
41
+ ),
42
+ ],
43
+ } satisfies Meta;
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof meta>;
47
+
48
+ // --- sample nodes ---------------------------------------------------------
49
+
50
+ function node(over: Partial<C4Node> & { id: string; label: string }): C4Node {
51
+ return {
52
+ kind: 'container',
53
+ members: [],
54
+ constructs: [],
55
+ state: 'proposed',
56
+ ...over,
57
+ };
58
+ }
59
+
60
+ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
61
+ // `id` on each variant is its caption key, not a component field.
62
+
63
+ // --- confirmation state: the axis that matters most --------------------
64
+ {
65
+ group: 'Confirmation state',
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.',
67
+ node: node({
68
+ id: 'a1',
69
+ label: 'Subsystem Studio — host',
70
+ members: ['a', 'b', 'c'],
71
+ constructs: ['function', 'store'],
72
+ containerKind: 'application',
73
+ technology: 'Bun + Electrobun',
74
+ description: 'Runs the audit pipeline, owns the model store, serves the RPC surface.',
75
+ state: 'accepted',
76
+ }),
77
+ },
78
+ {
79
+ group: '',
80
+ note: '',
81
+ node: node({
82
+ id: 'a2',
83
+ label: 'subsystems-react (proposed container?)',
84
+ members: ['x', 'y'],
85
+ constructs: ['function', 'interface'],
86
+ containerKind: 'application',
87
+ technology: 'React',
88
+ state: 'proposed',
89
+ }),
90
+ },
91
+ {
92
+ group: '',
93
+ note: '',
94
+ node: node({
95
+ id: 'a3',
96
+ label: 'Subsystem Modeling CLI',
97
+ members: ['cli-create'],
98
+ constructs: ['function'],
99
+ containerKind: 'application',
100
+ technology: 'Node',
101
+ description: 'Creates and updates models from the terminal.',
102
+ state: 'accepted',
103
+ }),
104
+ },
105
+ {
106
+ group: '',
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.',
108
+ node: node({
109
+ id: 'a4',
110
+ label: 'graphify/extract',
111
+ members: ['extract'],
112
+ constructs: ['function'],
113
+ containerKind: 'application',
114
+ technology: 'Bun',
115
+ state: 'rejected',
116
+ }),
117
+ },
118
+ {
119
+ group: '',
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
+ }),
131
+ },
132
+
133
+ // --- the four kinds ------------------------------------------------------
134
+ {
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.',
137
+ node: node({
138
+ id: 'b1',
139
+ label: 'Subsystem model store',
140
+ members: ['s1'],
141
+ constructs: ['store'],
142
+ containerKind: 'data-store',
143
+ technology: 'JSON files on disk',
144
+ description: 'Every stored model plus its index.',
145
+ state: 'accepted',
146
+ }),
147
+ },
148
+ {
149
+ group: '',
150
+ note: '',
151
+ node: node({
152
+ id: 'b2',
153
+ label: 'Audit correction queue',
154
+ members: ['s2'],
155
+ constructs: ['store'],
156
+ containerKind: 'queue',
157
+ technology: 'agent session transport',
158
+ description: 'Proposals awaiting a human decision.',
159
+ state: 'accepted',
160
+ }),
161
+ },
162
+ {
163
+ group: '',
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.',
165
+ node: node({
166
+ id: 'b3',
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',
175
+ }),
176
+ },
177
+ {
178
+ group: '',
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".',
180
+ node: node({
181
+ id: 'b4',
182
+ label: 'OpenCode v2 service',
183
+ kind: 'external-system',
184
+ members: ['oc2'],
185
+ constructs: ['external'],
186
+ technology: 'HTTP',
187
+ description: 'Runs maintain agent sessions.',
188
+ state: 'accepted',
189
+ }),
190
+ },
191
+ {
192
+ group: '',
193
+ note: '',
194
+ node: node({
195
+ id: 'b5',
196
+ label: 'Maintain operator',
197
+ kind: 'person',
198
+ members: ['a'],
199
+ constructs: ['custom_entity'],
200
+ description: 'Reviews and accepts correction proposals.',
201
+ state: 'proposed',
202
+ }),
203
+ },
204
+
205
+ // --- the gaps C4 keeps asking about ------------------------------------
206
+ {
207
+ group: 'Notation gaps',
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.',
209
+ node: node({
210
+ id: 'c1',
211
+ label: 'In-memory renderer state',
212
+ members: ['w1', 'w2', 'w3'],
213
+ constructs: ['store'],
214
+ containerKind: 'application',
215
+ technology: '',
216
+ state: 'accepted',
217
+ }),
218
+ },
219
+ {
220
+ group: '',
221
+ note: '',
222
+ node: node({
223
+ id: 'c2',
224
+ label: 'Subsystem Studio — renderer',
225
+ members: ['r1', 'r2'],
226
+ constructs: ['function'],
227
+ containerKind: 'application',
228
+ technology: 'React 19',
229
+ state: 'accepted',
230
+ }),
231
+ },
232
+
233
+ // --- the pair that was confusing, side by side ------------------------
234
+ {
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.',
237
+ node: node({
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',
254
+ kind: 'component',
255
+ parentId: 'container:studio-host',
256
+ members: ['h1'],
257
+ constructs: ['function'],
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
+ },
268
+ }),
269
+ },
270
+ ];
271
+
272
+ function Section({ title, note }: { title?: string; note?: string }) {
273
+ if (!title) return null;
274
+ return (
275
+ <div style={{ marginBottom: 10, maxWidth: 720 }}>
276
+ <h2 style={{ margin: '0 0 3px', fontSize: 15, color: '#e8e8ea' }}>{title}</h2>
277
+ {note ? <p style={{ margin: 0, fontSize: 12, lineHeight: 1.45, color: '#9a9aa0' }}>{note}</p> : null}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ function Row({ children }: { children: React.ReactNode }) {
283
+ return (
284
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 14, alignItems: 'flex-start', marginBottom: 6 }}>
285
+ {children}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ /** Label under a card, so a reviewer can name the case they are looking at. */
291
+ function Case({ caption, children }: { caption: string; children: React.ReactNode }) {
292
+ return (
293
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 5, alignItems: 'flex-start' }}>
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. */}
297
+ <span
298
+ style={{
299
+ fontFamily: 'monospace',
300
+ fontSize: 10,
301
+ lineHeight: 1.4,
302
+ color: '#6a6a70',
303
+ width: NODE_W,
304
+ }}
305
+ >
306
+ {caption}
307
+ </span>
308
+ </div>
309
+ );
310
+ }
311
+
312
+ const KINDS = ['container', 'component', 'external-system', 'person'] as const;
313
+
314
+ function Gallery() {
315
+ const { theme } = useTheme();
316
+ const muted = theme.colors.textMuted ?? '#9a9aa0';
317
+
318
+ const legend: Array<[string, string]> = [
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],
322
+ ];
323
+
324
+ let currentGroup = '';
325
+ return (
326
+ <div style={{ padding: 28, background: theme.colors.background, minHeight: '100vh' }}>
327
+ <h1 style={{ margin: '0 0 4px', fontSize: 22, color: theme.colors.text }}>C4 node gallery</h1>
328
+ <p style={{ margin: '0 0 22px', fontSize: 13, color: muted, maxWidth: 720, lineHeight: 1.5 }}>
329
+ Every box the graph can draw, rendered on its own. The graph answers “what does this system
330
+ look like”; this answers “what does a box mean”. No layout engine, no edges — just the
331
+ encoding.
332
+ </p>
333
+
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>
373
+ </div>
374
+
375
+ <Row>
376
+ {VARIANTS.map((v) => {
377
+ const showHeader = v.group && v.group !== currentGroup;
378
+ if (v.group) currentGroup = v.group;
379
+ return (
380
+ <React.Fragment key={v.node.id}>
381
+ {showHeader && (
382
+ <div style={{ width: '100%' }}>
383
+ <Section title={v.group} note={v.note} />
384
+ </div>
385
+ )}
386
+ <Case
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)}${
390
+ nodeMissing(v.node).length ? ` · missing ${nodeMissing(v.node).join('+')}` : ''
391
+ }`}
392
+ >
393
+ <C4NodeCard node={v.node} />
394
+ </Case>
395
+ </React.Fragment>
396
+ );
397
+ })}
398
+ </Row>
399
+
400
+ <div style={{ marginTop: 22, maxWidth: 720 }}>
401
+ <Section
402
+ title="Selected"
403
+ note="A selected node overrides its confirmation styling, so a click target reads differently from the graph around it."
404
+ />
405
+ <C4NodeCard node={VARIANTS[0]!.node} selected />
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ export const All: Story = {
412
+ render: () => <Gallery />,
413
+ };