@principal-ai/subsystems-react 0.51.2 → 0.51.4

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 (85) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +12 -1
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +115 -22
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +22 -22
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +56 -77
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.js +32 -13
  15. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  16. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  17. package/dist/subsystem/TrailsPanel.js +8 -4
  18. package/dist/subsystem/TrailsPanel.js.map +1 -1
  19. package/dist/subsystem/c4.d.ts +24 -86
  20. package/dist/subsystem/c4.d.ts.map +1 -1
  21. package/dist/subsystem/c4.js +6 -285
  22. package/dist/subsystem/c4.js.map +1 -1
  23. package/dist/subsystem/c4Evidence.js +1 -1
  24. package/dist/subsystem/c4Evidence.js.map +1 -1
  25. package/dist/subsystem/model.d.ts +14 -0
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js.map +1 -1
  28. package/dist/subsystem/nodes.d.ts.map +1 -1
  29. package/dist/subsystem/nodes.js +4 -2
  30. package/dist/subsystem/nodes.js.map +1 -1
  31. package/dist/subsystem/techIcons/bun.d.ts +11 -0
  32. package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
  33. package/dist/subsystem/techIcons/bun.js +11 -0
  34. package/dist/subsystem/techIcons/bun.js.map +1 -0
  35. package/dist/subsystem/techIcons/index.d.ts +36 -0
  36. package/dist/subsystem/techIcons/index.d.ts.map +1 -0
  37. package/dist/subsystem/techIcons/index.js +55 -0
  38. package/dist/subsystem/techIcons/index.js.map +1 -0
  39. package/dist/subsystem/techIcons/node.d.ts +12 -0
  40. package/dist/subsystem/techIcons/node.d.ts.map +1 -0
  41. package/dist/subsystem/techIcons/node.js +39 -0
  42. package/dist/subsystem/techIcons/node.js.map +1 -0
  43. package/dist/subsystem/techIcons/react.d.ts +12 -0
  44. package/dist/subsystem/techIcons/react.d.ts.map +1 -0
  45. package/dist/subsystem/techIcons/react.js +19 -0
  46. package/dist/subsystem/techIcons/react.js.map +1 -0
  47. package/dist/utils/edgeLabel.d.ts +1 -1
  48. package/dist/utils/edgeLabel.d.ts.map +1 -1
  49. package/dist/utils/edgeLabel.js +1 -1
  50. package/dist/utils/edgeLabel.js.map +1 -1
  51. package/dist/utils/elkLayout.d.ts +18 -0
  52. package/dist/utils/elkLayout.d.ts.map +1 -1
  53. package/dist/utils/elkLayout.js +90 -9
  54. package/dist/utils/elkLayout.js.map +1 -1
  55. package/package.json +3 -3
  56. package/src/index.ts +4 -6
  57. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
  58. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +20 -10
  59. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
  60. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
  61. package/src/subsystem/C4Graph.tsx +171 -19
  62. package/src/subsystem/C4NodeCard.test.ts +71 -77
  63. package/src/subsystem/C4NodeCard.tsx +65 -95
  64. package/src/subsystem/SubsystemComponentGraph.tsx +41 -13
  65. package/src/subsystem/TrailsPanel.tsx +10 -5
  66. package/src/subsystem/c4.ts +23 -357
  67. package/src/subsystem/c4Evidence.test.ts +1 -1
  68. package/src/subsystem/c4Evidence.ts +1 -1
  69. package/src/subsystem/model.ts +14 -0
  70. package/src/subsystem/nodes.tsx +5 -2
  71. package/src/subsystem/techIcons/bun.ts +10 -0
  72. package/src/subsystem/techIcons/index.tsx +73 -0
  73. package/src/subsystem/techIcons/node.ts +38 -0
  74. package/src/subsystem/techIcons/react.ts +18 -0
  75. package/src/subsystem/techIcons/techIcons.test.ts +39 -0
  76. package/src/utils/edgeLabel.ts +1 -1
  77. package/src/utils/elkLayout.test.ts +34 -0
  78. package/src/utils/elkLayout.ts +88 -9
  79. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
  80. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
  81. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
  82. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
  83. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
  84. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
  85. package/src/subsystem/c4.test.ts +0 -573
@@ -4,7 +4,7 @@ import {
4
4
  nodeSubtitle,
5
5
  nodeTag,
6
6
  nodeKindIcon,
7
- nodeTypeLabel,
7
+ nodeTopLabel,
8
8
  nodeShape,
9
9
  nodeMissing,
10
10
  NODE_W,
@@ -113,14 +113,19 @@ describe('nodeTag — what the box IS', () => {
113
113
  });
114
114
  });
115
115
 
116
- describe('nodeKindIcon — the kind as a glyph, not a word', () => {
117
- test('every kind has its own icon', () => {
118
- const icons = (['container', 'component', 'external-system', 'person'] as const).map((kind) =>
119
- nodeKindIcon(node({ kind })),
120
- );
121
- for (const icon of icons) expect(icon).toBeDefined();
122
- // Different kinds must not share a glyph, or the icon says nothing.
123
- expect(new Set(icons).size).toBe(4);
116
+ describe('nodeKindIcon — a container’s sort, not its C4 kind', () => {
117
+ test('application and data-store get different icons', () => {
118
+ const app = nodeKindIcon(node({ containerKind: 'application' }));
119
+ const store = nodeKindIcon(node({ containerKind: 'data-store' }));
120
+ expect(app).toBeDefined();
121
+ expect(store).toBeDefined();
122
+ expect(app).not.toBe(store);
123
+ });
124
+
125
+ test('only containers get an icon — the shape carries the rest', () => {
126
+ for (const kind of ['component', 'external-system', 'person'] as const) {
127
+ expect(nodeKindIcon(node({ kind }))).toBeUndefined();
128
+ }
124
129
  });
125
130
 
126
131
  test('the icon does not depend on confirmation state', () => {
@@ -130,16 +135,22 @@ describe('nodeKindIcon — the kind as a glyph, not a word', () => {
130
135
  });
131
136
  });
132
137
 
133
- describe('nodeTypeLabel — the one word in the icon row', () => {
134
- test('a container names its kind', () => {
135
- expect(nodeTypeLabel(node({ containerKind: 'application' }))).toBe('application');
136
- expect(nodeTypeLabel(node({ containerKind: 'data-store' }))).toBe('data-store');
138
+ describe('nodeTopLabel — the technology, or the kind when none is stated', () => {
139
+ test('a stated technology is the label', () => {
140
+ expect(
141
+ nodeTopLabel(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
142
+ ).toBe('Bun + Electrobun');
143
+ expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
137
144
  });
138
145
 
139
- test('the other three kinds have no subtype, so no word', () => {
140
- for (const kind of ['component', 'external-system', 'person'] as const) {
141
- expect(nodeTypeLabel(node({ kind }))).toBeUndefined();
142
- }
146
+ test('a container with no technology falls back to its kind', () => {
147
+ expect(nodeTopLabel(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
148
+ });
149
+
150
+ test('the other kinds fall back to their C4 kind', () => {
151
+ expect(nodeTopLabel(node({ kind: 'component' }))).toBe('component');
152
+ expect(nodeTopLabel(node({ kind: 'external-system' }))).toBe('external-system');
153
+ expect(nodeTopLabel(node({ kind: 'person' }))).toBe('person');
143
154
  });
144
155
  });
145
156
 
@@ -174,52 +185,28 @@ describe('nodeShape — kind without relying on colour or dash', () => {
174
185
  });
175
186
 
176
187
  describe('nodeSubtitle', () => {
177
- test('shows the technology, and does not repeat the container kind', () => {
178
- const s = nodeSubtitle(
179
- node({ containerKind: 'application', technology: 'Bun + Electrobun' }),
180
- );
181
- expect(s).toBe('Bun + Electrobun');
182
- expect(s).not.toContain('application');
183
- });
184
-
185
- test('a container with no technology falls back to its construct breakdown', () => {
186
- const s = nodeSubtitle(
187
- node({ containerKind: 'data-store', technology: '', constructs: ['store'], members: ['a'] }),
188
- );
189
- expect(s).toBe('1 store · 1 component');
190
- });
191
-
192
- test('falls back to the construct breakdown when neither is stated', () => {
193
- const s = nodeSubtitle(node({ constructs: ['function', 'store'], members: ['a', 'b'] }));
194
- expect(s).toContain('function');
195
- expect(s).toContain('store');
196
- expect(s).toContain('2 components');
197
- });
198
-
199
- test('singularises a single member', () => {
200
- const s = nodeSubtitle(node({ constructs: ['function'], members: ['a'] }));
201
- expect(s).toContain('1 component');
202
- expect(s).not.toContain('1 components');
203
- });
204
-
205
- test('a component shows its construct and file basename', () => {
206
- const s = nodeSubtitle(
207
- node({
208
- kind: 'component',
209
- component: {
210
- alias: 'a',
211
- name: 'buildGroups',
212
- construct: 'function',
213
- file: 'src/subsystem/model.ts',
214
- purl: 'pkg:x#src/subsystem/model.ts',
215
- },
216
- }),
217
- );
218
- expect(s).toBe('function · model.ts');
219
- });
220
-
221
- test('a component shows its technology when it has one', () => {
222
- expect(nodeSubtitle(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
188
+ test('is the element description when it has one', () => {
189
+ expect(
190
+ nodeSubtitle(node({ technology: 'Bun', description: 'Runs the audit pipeline.' })),
191
+ ).toBe('Runs the audit pipeline.');
192
+ });
193
+
194
+ test('is empty when nothing is stated — there is no fallback', () => {
195
+ const withTech = node({ technology: 'Bun + Electrobun' });
196
+ const withMembers = node({ constructs: ['function', 'store'], members: ['a', 'b'] });
197
+ const component = node({
198
+ kind: 'component',
199
+ component: {
200
+ alias: 'a',
201
+ name: 'buildGroups',
202
+ construct: 'function',
203
+ file: 'src/subsystem/model.ts',
204
+ purl: 'pkg:x#src/subsystem/model.ts',
205
+ },
206
+ });
207
+ for (const n of [withTech, withMembers, component]) {
208
+ expect(nodeSubtitle(n)).toBe('');
209
+ }
223
210
  });
224
211
  });
225
212
 
@@ -285,8 +272,8 @@ describe('nodeSize — a component is a proper square', () => {
285
272
 
286
273
  describe('card metrics', () => {
287
274
  test('exposes the size the graph lays out against', () => {
288
- expect(NODE_W).toBe(210);
289
- expect(NODE_H).toBe(138);
275
+ expect(NODE_W).toBe(250);
276
+ expect(NODE_H).toBe(150);
290
277
  });
291
278
 
292
279
  test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
@@ -295,23 +282,23 @@ describe('card metrics', () => {
295
282
  });
296
283
 
297
284
  test('is near-square, not a wide strip', () => {
298
- // The card carries four fields (tag, label, kind+technology, notation
299
- // gap). A 3:1 strip truncates the last two; this is the aspect ratio that
300
- // lets each sit on its own line.
285
+ // The card carries three fields (technology row, label, description). A
286
+ // 3:1 strip wraps the last two hard; this is the aspect ratio that lets
287
+ // each sit on its own line.
301
288
  const ratio = NODE_W / NODE_H;
302
289
  expect(ratio).toBeGreaterThan(1);
303
290
  expect(ratio).toBeLessThan(1.8);
304
291
  });
305
292
 
306
- test('is tall enough for four lines of content', () => {
307
- // tag + 2-line label + subtitle + gap marker, plus 8px padding top/bottom.
293
+ test('is tall enough for three lines of content', () => {
294
+ // technology row + 2-line label + description, plus padding.
308
295
  expect(NODE_H).toBeGreaterThanOrEqual(130);
309
296
  });
310
297
 
311
- test('leaves breathing room around four lines of text', () => {
298
+ test('leaves breathing room around three lines of text', () => {
312
299
  // Top-aligned now, not centred, so this is about slack rather than
313
- // balance: tag + 2-line label slot + subtitle + gap slot + padding.
314
- const contentBudget = 16 + 40 + 16 + 18 + 20; // lines + 10px padding ×2
300
+ // balance: technology row + 2-line label slot + description + padding.
301
+ const contentBudget = 16 + 40 + 16 + 20; // lines + 10px padding ×2
315
302
  expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
316
303
  });
317
304
 
@@ -323,13 +310,20 @@ describe('card metrics', () => {
323
310
  });
324
311
 
325
312
  describe('container kinds', () => {
326
- test('every container kind renders as the icon-row label without special-casing', () => {
327
- const kinds = ['application', 'data-store'] as const;
328
- for (const containerKind of kinds) {
329
- expect(nodeTypeLabel(node({ containerKind }))).toBe(containerKind);
313
+ test('each container kind has an icon and a fallback label', () => {
314
+ for (const containerKind of ['application', 'data-store'] as const) {
315
+ expect(nodeKindIcon(node({ containerKind }))).toBeDefined();
316
+ // No technology stated, so the label falls back to the kind.
317
+ expect(nodeTopLabel(node({ containerKind, technology: '' }))).toBe(containerKind);
330
318
  }
331
319
  });
332
320
 
321
+ test('application and data-store do not share an icon', () => {
322
+ expect(nodeKindIcon(node({ containerKind: 'application' }))).not.toBe(
323
+ nodeKindIcon(node({ containerKind: 'data-store' })),
324
+ );
325
+ });
326
+
333
327
  test('a library is not one of them', () => {
334
328
  // C4 says a module "typically" is not an element at all, so there is no
335
329
  // kind to render. A library-shaped group is a review comment.
@@ -7,24 +7,24 @@
7
7
  *
8
8
  * The encoding has one job: make confirmation state legible at a glance. A box
9
9
  * nobody has looked at must not read the same as a box a person signed off.
10
- * Kind says what the box is — shape, size, and a written tag — and everything
11
- * else in C4 notation (technology, description) is surfaced in the order a
12
- * reader wants it.
10
+ * Kind is carried by shape and size — plus an icon for a container's sort,
11
+ * application vs data-store — and C4 notation is surfaced in the order a reader
12
+ * wants it: technology on the top row, description under the label.
13
13
  */
14
14
 
15
15
  import { useTheme } from '@principal-ade/industry-theme';
16
- import { Cloud, Component, Container, UserRound } from 'lucide-react';
16
+ import { AppWindow, Database } from 'lucide-react';
17
17
  import type { LucideIcon } from 'lucide-react';
18
- import { describeConstructBreakdown } from './model';
18
+ import { TechMark, technologyBrand } from './techIcons';
19
19
  import type { C4Node } from './c4';
20
20
 
21
21
  /**
22
22
  * Card box for a container: a wide rectangle. A container carries four things
23
- * at once (tag, label, type + technology, and the notation gap it still has),
24
- * and a strip would force the last two to truncate.
23
+ * at once (technology row, label, description, and the notation gap it still
24
+ * has), and a strip would force the last two to wrap hard.
25
25
  */
26
- export const NODE_W = 210;
27
- export const NODE_H = 138;
26
+ export const NODE_W = 250;
27
+ export const NODE_H = 150;
28
28
 
29
29
  /**
30
30
  * A component is a proper square — equal sides, and visibly smaller than the
@@ -32,7 +32,7 @@ export const NODE_H = 138;
32
32
  * these are different C4 levels, and it survives grayscale and colour-blind
33
33
  * reading where a border-weight difference alone does not.
34
34
  */
35
- export const COMPONENT_SIZE = 132;
35
+ export const COMPONENT_SIZE = 140;
36
36
 
37
37
  /**
38
38
  * Per-level box size. The graph lays out against these, so a square component
@@ -45,12 +45,10 @@ export function nodeSize(node: C4Node): { width: number; height: number } {
45
45
  }
46
46
 
47
47
  /**
48
- * Fixed slot heights, so rows line up across every card regardless of which
49
- * optional fields are present. Without these a card missing its notation-gap
50
- * marker is two lines shorter and a row of cards reads as ragged.
48
+ * Fixed label slot, so the description below sits at the same y on every card
49
+ * even when a label is one line rather than two.
51
50
  */
52
51
  const LABEL_SLOT_H = 40;
53
- const GAP_SLOT_H = 18;
54
52
 
55
53
  type Theme = ReturnType<typeof useTheme>['theme'];
56
54
 
@@ -91,32 +89,18 @@ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeSty
91
89
  // Dashed — an agent asked; a human has not answered.
92
90
  return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
93
91
  case 'rejected':
94
- // Should never be drawn (buildC4Model drops it); belt-and-braces.
92
+ // Should never be drawn (a rejected element is not projected); belt-and-braces.
95
93
  return { color: muted, dash: 'dotted', width: 1 };
96
94
  }
97
95
  }
98
96
 
99
- function baseName(path: string): string {
100
- const parts = path.split('/').filter(Boolean);
101
- return parts[parts.length - 1] ?? path;
102
- }
103
-
104
97
  /**
105
- * The kind + technology line under the label.
106
- *
107
- * The container kind moved up to the icon row (see `nodeTypeLabel`), so it is
108
- * not repeated here. Technology is the remaining confirmed attribute; without
109
- * one we fall back to what the document told us.
98
+ * The line under the label: the element's one-line responsibility, which C4
99
+ * asks every element to carry. No fallback — when nothing is stated, the
100
+ * notation-gap row says so rather than the card inventing a summary.
110
101
  */
111
102
  export function nodeSubtitle(node: C4Node): string {
112
- if (node.technology) return node.technology;
113
-
114
- // No stated technology: fall back to what the document told us.
115
- if (node.kind === 'component') {
116
- return `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`;
117
- }
118
- const count = node.members.length;
119
- return `${describeConstructBreakdown(node.constructs)}${count > 0 ? ` · ${count} component${count === 1 ? '' : 's'}` : ''}`;
103
+ return node.description ?? '';
120
104
  }
121
105
 
122
106
  /**
@@ -130,31 +114,26 @@ export function nodeTag(node: C4Node): string {
130
114
  }
131
115
 
132
116
  /**
133
- * The glyph for the C4 kind, shown in place of a word. The kind is already
134
- * carried by the box shape (see `nodeShape`); the icon lets the top row spend
135
- * its one piece of text on the container's kind instead of repeating
136
- * "container".
117
+ * The icon on the top row: a container's sort, **application vs data-store**.
118
+ *
119
+ * The C4 kind itself does not need a glyph — the box shape already carries it
120
+ * (square vs rectangle vs pill), and the row's one text slot is worth more
121
+ * spent on the technology. Only containers have a C4-recognised split, so the
122
+ * other three kinds return nothing.
137
123
  */
138
- export function nodeKindIcon(node: C4Node): LucideIcon {
139
- switch (node.kind) {
140
- case 'container':
141
- return Container;
142
- case 'component':
143
- return Component;
144
- case 'external-system':
145
- return Cloud;
146
- case 'person':
147
- return UserRound;
148
- }
124
+ export function nodeKindIcon(node: C4Node): LucideIcon | undefined {
125
+ if (node.kind !== 'container') return undefined;
126
+ return node.containerKind === 'data-store' ? Database : AppWindow;
149
127
  }
150
128
 
151
129
  /**
152
- * The word beside the kind icon: what sort of container this is — C4's two,
153
- * `application` or `data-store`. The other three kinds have no such subtype,
154
- * so they show nothing; the icon and shape are the whole label.
130
+ * The text on the top row: the technology the box is built with (Bun, React,
131
+ * Postgres…). Falls back to the container kind / C4 kind when no technology is
132
+ * stated, so the row is never blank.
155
133
  */
156
- export function nodeTypeLabel(node: C4Node): string | undefined {
157
- return node.kind === 'container' ? node.containerKind : undefined;
134
+ export function nodeTopLabel(node: C4Node): string {
135
+ if (node.technology) return node.technology;
136
+ return node.kind === 'container' ? (node.containerKind ?? 'container') : node.kind;
158
137
  }
159
138
 
160
139
  /** How a box is drawn. Shape carries the C4 kind, independent of colour. */
@@ -226,11 +205,15 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
226
205
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
227
206
  const style = nodeStyle(node, theme, selected);
228
207
  const shape = nodeShape(node);
229
- const missing = nodeMissing(node);
230
208
  const Icon = nodeKindIcon(node);
231
- const typeLabel = nodeTypeLabel(node);
209
+ const brand = technologyBrand(node.technology);
210
+ const topLabel = nodeTopLabel(node);
211
+ const subtitle = nodeSubtitle(node);
232
212
 
233
213
  const size = nodeSize(node);
214
+ // A pill (a person) has no straight left edge, so left-aligned text reads as
215
+ // spilling out of the oval. Centre the text and inset it from the curve.
216
+ const centered = shape.kind === 'pill';
234
217
 
235
218
  return (
236
219
  <div
@@ -246,7 +229,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
246
229
  // height on every card. In a graph grid that makes the row unscannable.
247
230
  justifyContent: 'flex-start',
248
231
  gap: 3,
249
- padding: '10px 12px',
232
+ padding: centered ? '10px 30px' : '10px 12px',
250
233
  borderRadius: shape.radius,
251
234
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
252
235
  border: `${style.width} ${style.dash} ${style.color}`,
@@ -255,30 +238,26 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
255
238
  fontFamily: theme.fonts.body,
256
239
  }}
257
240
  >
258
- {/* The kind is carried by the icon and the box shape; the one word here
259
- is the container's kind. Confirmation state is carried by the border
260
- (see `nodeStyle`), not by a word. */}
241
+ {/* Left: the brand mark when we hold the official one, then the
242
+ technology. Right: a container's C4 sort, application vs data-store.
243
+ The kind itself stays on the shape, confirmation on the border (see
244
+ `nodeShape`, `nodeStyle`). */}
261
245
  <span
262
246
  style={{
263
247
  display: 'flex',
264
248
  alignItems: 'center',
249
+ justifyContent: centered ? 'center' : 'space-between',
265
250
  gap: 6,
266
251
  fontFamily: theme.fonts.monospace,
267
252
  fontSize: theme.fontSizes[0],
268
- color: style.color,
253
+ color: muted,
269
254
  letterSpacing: 0.5,
270
255
  textTransform: 'uppercase',
271
256
  minWidth: 0,
272
257
  }}
273
258
  >
274
- <span
275
- title={nodeTag(node)}
276
- aria-label={nodeTag(node)}
277
- style={{ display: 'inline-flex', flexShrink: 0 }}
278
- >
279
- <Icon size={13} strokeWidth={2.25} />
280
- </span>
281
- {typeLabel && (
259
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
260
+ {brand && <TechMark brand={brand} />}
282
261
  <span
283
262
  style={{
284
263
  fontWeight: 600,
@@ -288,7 +267,16 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
288
267
  minWidth: 0,
289
268
  }}
290
269
  >
291
- {typeLabel}
270
+ {topLabel}
271
+ </span>
272
+ </span>
273
+ {Icon && (
274
+ <span
275
+ title={node.containerKind ?? node.kind}
276
+ aria-label={node.containerKind ?? node.kind}
277
+ style={{ display: 'inline-flex', flexShrink: 0, color: style.color }}
278
+ >
279
+ <Icon size={13} strokeWidth={2.25} />
292
280
  </span>
293
281
  )}
294
282
  </span>
@@ -307,43 +295,25 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
307
295
  WebkitLineClamp: 2,
308
296
  WebkitBoxOrient: 'vertical',
309
297
  overflow: 'hidden',
298
+ textAlign: centered ? 'center' : 'left',
310
299
  }}
311
300
  title={node.label}
312
301
  >
313
302
  {node.label}
314
303
  </span>
315
- <span
316
- style={{
317
- fontFamily: theme.fonts.monospace,
318
- fontSize: theme.fontSizes[0],
319
- color: muted,
320
- whiteSpace: 'nowrap',
321
- overflow: 'hidden',
322
- textOverflow: 'ellipsis',
323
- }}
324
- title={node.description ?? node.members.join(', ')}
325
- >
326
- {nodeSubtitle(node)}
327
- </span>
328
- {/* Reserved row: without it, a card with no notation gap is two lines
329
- shorter and the row of cards reads as ragged. */}
330
- <span style={{ height: GAP_SLOT_H }} aria-hidden="true" />
331
- {missing.length > 0 && (
304
+ {subtitle && (
332
305
  <span
333
- title={`C4 requires a ${missing.join(' and a ')} on every container`}
334
306
  style={{
335
307
  fontFamily: theme.fonts.monospace,
336
308
  fontSize: theme.fontSizes[0],
337
- color: theme.colors.warning ?? '#e8a33a',
338
- lineHeight: 1.3,
339
- height: GAP_SLOT_H,
340
- display: '-webkit-box',
341
- WebkitLineClamp: 2,
342
- WebkitBoxOrient: 'vertical',
343
- overflow: 'hidden',
309
+ color: muted,
310
+ lineHeight: 1.35,
311
+ whiteSpace: 'normal',
312
+ overflowWrap: 'anywhere',
313
+ textAlign: centered ? 'center' : 'left',
344
314
  }}
345
315
  >
346
- needs {missing.join(' + ')}
316
+ {subtitle}
347
317
  </span>
348
318
  )}
349
319
  {handles}
@@ -1190,18 +1190,15 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1190
1190
  const stepIssueBadgeByEdgeId = useMemo(() => {
1191
1191
  const map = new Map<string, { kind: 'step_unconfirmed' | 'step_stale'; severity: 'info' | 'error'; count: number }>();
1192
1192
  if (!issues?.length || !trails?.length) {
1193
- console.log('[stepIssueBadge] early return: issues=', issues?.length, 'trails=', trails?.length);
1194
1193
  return map;
1195
1194
  }
1196
1195
  for (const issue of issues) {
1197
1196
  if (issue.kind !== 'step_unconfirmed' && issue.kind !== 'step_stale') continue;
1198
1197
  const stepInfo = issueStep(issue);
1199
- console.log('[stepIssueBadge] issue', issue.id, 'kind=', issue.kind, 'stepInfo=', stepInfo);
1200
1198
  if (!stepInfo) continue;
1201
1199
  const step = stepInfo.trail.steps[stepInfo.stepIndex];
1202
1200
  if (!step) continue;
1203
1201
  const edgeId = trailStepGraphEdgeId(step);
1204
- console.log('[stepIssueBadge] -> edgeId=', edgeId);
1205
1202
  const prev = map.get(edgeId);
1206
1203
  // step_stale is worse than step_unconfirmed
1207
1204
  const isWorse = issue.kind === 'step_stale' && prev?.kind !== 'step_stale';
@@ -1211,10 +1208,25 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1211
1208
  count: (prev?.count ?? 0) + 1,
1212
1209
  });
1213
1210
  }
1214
- console.log('[stepIssueBadge] final map size=', map.size);
1215
1211
  return map;
1216
1212
  }, [issues, trails, issueStep]);
1217
1213
 
1214
+ // Edges backed by a step that ITSELF declares `proposed` — a planned seam
1215
+ // between otherwise-real components. Distinct from a proposed ENDPOINT
1216
+ // (the node is the placeholder there, and draws its own dashed border):
1217
+ // here the seam is the claim that is not real yet, so the edge label draws
1218
+ // dashed. Proposed steps are never verified, so this never competes with
1219
+ // the step-issue badge.
1220
+ const proposedEdgeIds = useMemo(() => {
1221
+ const set = new Set<string>();
1222
+ for (const trail of trails ?? []) {
1223
+ for (const step of trail.steps) {
1224
+ if (step.proposed) set.add(trailStepGraphEdgeId(step));
1225
+ }
1226
+ }
1227
+ return set;
1228
+ }, [trails]);
1229
+
1218
1230
  // Expanded verification layers in the diagnostics list. When any layer with
1219
1231
  // findings is expanded, the canvas dims everything that layer does not
1220
1232
  // implicate (see `issueFocus`). The list publishes this through
@@ -1534,6 +1546,17 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1534
1546
  () => convertedEdges.map((e) => e.id).sort().join(','),
1535
1547
  [convertedEdges],
1536
1548
  );
1549
+ // Stamp `proposedStep` onto edge data so the SVG edge itself (not just its
1550
+ // label) draws dashed when a backing trail step declares itself proposed.
1551
+ const proposedStampedEdges = useMemo(
1552
+ () =>
1553
+ baseEdges.map((e) =>
1554
+ proposedEdgeIds.has(e.id)
1555
+ ? { ...e, data: { ...(e.data as object), proposedStep: true } }
1556
+ : e,
1557
+ ),
1558
+ [baseEdges, proposedEdgeIds],
1559
+ );
1537
1560
 
1538
1561
  const dispEdges = useMemo(() => {
1539
1562
  const paint = (e: Edge, dimmed: boolean): Edge => {
@@ -1562,7 +1585,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1562
1585
  return isTrailMechanism(mechanism);
1563
1586
  };
1564
1587
  if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
1565
- return baseEdges.map((e) => {
1588
+ return proposedStampedEdges.map((e) => {
1566
1589
  const vis = flowElementVisibility({
1567
1590
  inOpened: openedEdgeIds?.has(e.id) === true,
1568
1591
  inSelected: focusEdgeIds?.has(e.id) === true,
@@ -1576,13 +1599,13 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1576
1599
  });
1577
1600
  }
1578
1601
  if (selectedEdgeId) {
1579
- return baseEdges.map((e) => ({
1602
+ return proposedStampedEdges.map((e) => ({
1580
1603
  ...paint(e, e.id !== selectedEdgeId),
1581
1604
  hidden: !edgeInView(e),
1582
1605
  }));
1583
1606
  }
1584
- return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1585
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1607
+ return proposedStampedEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1608
+ }, [proposedStampedEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1586
1609
 
1587
1610
  const onNodesChange = useCallback(
1588
1611
  (changes: NodeChange[]) => {
@@ -2499,7 +2522,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2499
2522
  pathLength?: number;
2500
2523
  } | undefined;
2501
2524
  const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
2502
- console.log('[edgeLabels] edge', e.id, 'stepIssue=', stepIssue);
2525
+ const proposed = proposedEdgeIds.has(e.id);
2503
2526
  return {
2504
2527
  id: e.id,
2505
2528
  mechanism: d?.mechanism ?? 'uses',
@@ -2510,11 +2533,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2510
2533
  pathLength: d?.pathLength ?? 0,
2511
2534
  stepNos: selectedFlowStepNos?.get(e.id),
2512
2535
  stepIssue,
2536
+ proposed,
2513
2537
  };
2514
2538
  });
2515
- console.log('[edgeLabels] total labels=', labels.length, 'with stepIssue=', labels.filter(l => l.stepIssue).length);
2516
2539
  return labels;
2517
- }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
2540
+ }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId, proposedEdgeIds]);
2518
2541
 
2519
2542
  // Unique source files across components → sidebar file trees.
2520
2543
  const treeFilePaths = treeFiles;
@@ -2941,7 +2964,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2941
2964
  crisp rounded box; soft / ambiguous ones (uses, feeds,
2942
2965
  watches …) get a real lobed cloud silhouette — ambiguous, not
2943
2966
  a dashed proposal (or a pill, which border-radius can only
2944
- ever make). */}
2967
+ ever make). A step that ITSELF declares `proposed` dashes the
2968
+ silhouette — the seam is planned, distinct from a proposed
2969
+ ENDPOINT, which dashes the node instead. */}
2945
2970
  {verifiable ? (
2946
2971
  <span
2947
2972
  aria-hidden
@@ -2949,7 +2974,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2949
2974
  position: 'absolute',
2950
2975
  inset: 0,
2951
2976
  borderRadius: 4,
2952
- border: `0.5px solid ${color}`,
2977
+ border: lbl.proposed
2978
+ ? `1.5px dashed ${color}`
2979
+ : `0.5px solid ${color}`,
2953
2980
  background:
2954
2981
  theme.colors.backgroundSecondary ?? theme.colors.background,
2955
2982
  pointerEvents: 'none',
@@ -2974,6 +3001,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2974
3001
  stroke={hexWithAlpha(color, 0.75)}
2975
3002
  strokeWidth={1.2}
2976
3003
  strokeLinejoin="round"
3004
+ strokeDasharray={lbl.proposed ? '4 3' : undefined}
2977
3005
  />
2978
3006
  </svg>
2979
3007
  )}