@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
@@ -0,0 +1,329 @@
1
+ /**
2
+ * C4 evidence — what an agent needs in order to propose elements.
3
+ *
4
+ * This module does not decide anything. It answers "what is worth asking
5
+ * about?" so an agent can go and read the source, then propose a
6
+ * `C4Element` with a rationale. The questions come from the C4 notation
7
+ * rules the document cannot satisfy on its own:
8
+ *
9
+ * - every container needs a technology
10
+ * - every element needs a description
11
+ * - a container is a runtime boundary, so a library is not one
12
+ * - a container cannot straddle two software systems
13
+ *
14
+ * The output is a set of concerns. Resolving one is the agent's job, and the
15
+ * answer lands in an element's `rationale`.
16
+ */
17
+
18
+ import type { C4Container, C4ContainerKind, C4Element, C4ElementSet } from './c4';
19
+
20
+ /** What a concern is about — a derived key, or an existing element id. */
21
+ export interface C4Concern {
22
+ /** The process key, module path, or element id this concerns. */
23
+ target: string;
24
+ kind: C4ConcernKind;
25
+ message: string;
26
+ /** Aliases that triggered it. */
27
+ members?: string[];
28
+ }
29
+
30
+ /**
31
+ * The questions worth a human's attention.
32
+ *
33
+ * Each maps to something an agent cannot settle by reading a string — it has
34
+ * to open the source, or ask the user.
35
+ */
36
+ export type C4ConcernKind =
37
+ /** two or more process keys may be the same deployable unit */
38
+ | 'merge_candidate'
39
+ /** exactly one member — a lone function is not a deployable unit */
40
+ | 'singleton'
41
+ /** no member executes at runtime — a library is not a container */
42
+ | 'library_shaped'
43
+ /** a store whose storage kind was never decided */
44
+ | 'store_unclassified'
45
+ /** members claim more than one repo — a container cannot straddle systems */
46
+ | 'spans_repos'
47
+ /** an external with no resolvable identity */
48
+ | 'external_unidentified'
49
+ /** a runtime component with no process at all */
50
+ | 'unassigned'
51
+ /** C4 requires technology on every container */
52
+ | 'missing_technology';
53
+
54
+ /** The fields a concern check reads off a component. */
55
+ export interface C4MemberView {
56
+ alias: string;
57
+ construct: string;
58
+ purl: string;
59
+ process?: string;
60
+ framework?: string;
61
+ module?: string;
62
+ role?: string;
63
+ declaration?: unknown;
64
+ }
65
+
66
+ /** Constructs that execute at runtime — the only things a container can be. */
67
+ const RUNTIME_CONSTRUCTS = new Set(['function', 'class', 'custom_entity']);
68
+
69
+ function storageOf(m: C4MemberView): string | undefined {
70
+ const d = m.declaration as { kind?: string; storage?: string } | undefined;
71
+ return d?.kind === 'store' ? d.storage : undefined;
72
+ }
73
+
74
+ /** True when a component runs, and so can be part of a deployable unit. */
75
+ export function isRuntimeConstruct(construct: string): boolean {
76
+ return RUNTIME_CONSTRUCTS.has(construct);
77
+ }
78
+
79
+ /**
80
+ * Derive the questions worth asking about one candidate element.
81
+ *
82
+ * Pure over the rollup, so it runs identically against a single model, the 40
83
+ * composed models, or a fixture. Pure *questions* — it never proposes an
84
+ * answer, because the answers are architectural.
85
+ */
86
+ export function deriveConcerns(
87
+ key: string,
88
+ members: readonly C4MemberView[],
89
+ tech: { frameworks: readonly string[] } = { frameworks: [] },
90
+ ): C4Concern[] {
91
+ const out: C4Concern[] = [];
92
+ const aliases = members.map((m) => m.alias);
93
+ const runtime = members.filter((m) => isRuntimeConstruct(m.construct));
94
+
95
+ if (members.length === 1) {
96
+ out.push({
97
+ target: key,
98
+ kind: 'singleton',
99
+ message: `process "${key}" has exactly one member (${aliases[0]}) — a lone function is not a deployable unit`,
100
+ members: aliases,
101
+ });
102
+ }
103
+
104
+ // No member executes at runtime ⇒ this cannot be a deployable unit,
105
+ // regardless of how many members it has. A lone interface and a package of
106
+ // interfaces fail the same way.
107
+ if (members.length > 0 && runtime.length === 0) {
108
+ out.push({
109
+ target: key,
110
+ kind: 'library_shaped',
111
+ message: `process "${key}" has ${members.length} member(s) but none execute at runtime (${[...new Set(members.map((m) => m.construct))].join(', ')}) — this reads as a library, not a container`,
112
+ members: aliases,
113
+ });
114
+ }
115
+
116
+ if (runtime.length > 0 && tech.frameworks.length === 0) {
117
+ out.push({
118
+ target: key,
119
+ kind: 'missing_technology',
120
+ message: `container "${key}" has no framework signal — C4 requires a technology on every container`,
121
+ members: aliases,
122
+ });
123
+ }
124
+
125
+ const repos = new Set(
126
+ members
127
+ .map((m) => (m.purl ?? '').split('#')[0])
128
+ .filter((r) => r && !r.startsWith('external:')),
129
+ );
130
+ if (repos.size > 1) {
131
+ out.push({
132
+ target: key,
133
+ kind: 'spans_repos',
134
+ message: `process "${key}" spans ${repos.size} repo keys (${[...repos].join(', ')}) — a C4 container cannot straddle a system boundary`,
135
+ members: aliases,
136
+ });
137
+ }
138
+
139
+ const unclassified = members.filter((m) => m.construct === 'store' && !storageOf(m));
140
+ if (unclassified.length > 0) {
141
+ out.push({
142
+ target: key,
143
+ kind: 'store_unclassified',
144
+ message: `${unclassified.length} store(s) in "${key}" declare no storage kind (${unclassified.map((m) => m.alias).join(', ')}) — decide whether each is a C4 data store or an internal detail`,
145
+ members: unclassified.map((m) => m.alias),
146
+ });
147
+ }
148
+
149
+ const unidentified = members.filter(
150
+ (m) => m.construct === 'external' && (!m.purl || m.purl === 'external' || m.purl.startsWith('external:proposed')),
151
+ );
152
+ if (unidentified.length > 0) {
153
+ out.push({
154
+ target: key,
155
+ kind: 'external_unidentified',
156
+ message: `${unidentified.length} external(s) have no resolvable identity (${unidentified.map((m) => m.alias).join(', ')}) — a C4 box named "external" is not a diagram`,
157
+ members: unidentified.map((m) => m.alias),
158
+ });
159
+ }
160
+
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Suggest merges between keys that share a trailing role.
166
+ *
167
+ * `subsystems-studio/host` and `principal-studio/host` are the same role under
168
+ * two app names. This is a *question*, never a merge — only the user knows
169
+ * whether they are one process or two.
170
+ */
171
+ export function suggestMerges(
172
+ rollup: ReadonlyArray<{ key: string; memberCount: number }>,
173
+ ): Array<{ role: string; keys: string[]; reason: string }> {
174
+ const byRole = new Map<string, string[]>();
175
+ for (const { key } of rollup) {
176
+ const slash = key.lastIndexOf('/');
177
+ if (slash <= 0) continue; // a bare key has no app/role shape
178
+ const role = key.slice(slash + 1);
179
+ const list = byRole.get(role) ?? [];
180
+ list.push(key);
181
+ byRole.set(role, list);
182
+ }
183
+ const out: Array<{ role: string; keys: string[]; reason: string }> = [];
184
+ for (const [role, keys] of [...byRole].sort((a, b) => a[0].localeCompare(b[0]))) {
185
+ if (keys.length < 2) continue;
186
+ out.push({
187
+ role,
188
+ keys: [...keys].sort(),
189
+ reason: `${keys.length} process keys end in "/${role}" (${[...keys].sort().join(', ')}) — one deployable unit under two names, or two units?`,
190
+ });
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * Runtime components that state no `process` at all.
197
+ *
198
+ * These are the components an agent cannot place by inference, because there
199
+ * is nothing to infer from. They are a standing question, not a per-element
200
+ * concern.
201
+ */
202
+ export function findUnassigned(members: readonly C4MemberView[]): C4MemberView[] {
203
+ return members.filter((m) => !m.process && isRuntimeConstruct(m.construct));
204
+ }
205
+
206
+ /**
207
+ * Fold a decided merge into an element set.
208
+ *
209
+ * Two process keys that are really one container become ONE element holding
210
+ * both sets of members. The survivor keeps the id, label, and confirmed
211
+ * attributes; the other disappears and its members move across.
212
+ *
213
+ * Which side survives is the **evidence-heavy** one — the element holding more
214
+ * members — because folding the 113-member host into the 1-member one would
215
+ * leave the box labelled after an almost-empty process and lose its confirmed
216
+ * technology. Pass `preferId` to override.
217
+ *
218
+ * Returns a new set; the input is untouched. Idempotent.
219
+ */
220
+ export function mergeElements(
221
+ elements: readonly C4Element[],
222
+ derivedFromKeys: readonly string[],
223
+ preferId?: string,
224
+ memberCountByKey?: ReadonlyMap<string, number>,
225
+ ): C4Element[] {
226
+ const wanted = new Set(derivedFromKeys.map((k) => k.trim()).filter(Boolean));
227
+ if (wanted.size < 2) return [...elements];
228
+
229
+ const involved = elements.filter((e) => e.derivedFrom?.some((k) => wanted.has(k)));
230
+ if (involved.length < 2) return [...elements];
231
+ // Only containers merge. A component has a required `container` field, and
232
+ // folding one into another would silently repoint it at a new parent.
233
+ if (involved.some((e) => e.kind !== 'container')) return [...elements];
234
+
235
+ const weight = (e: C4Element): number => {
236
+ if (memberCountByKey) {
237
+ let n = 0;
238
+ for (const k of e.derivedFrom ?? []) n += memberCountByKey.get(k) ?? 0;
239
+ return n;
240
+ }
241
+ return e.members.length;
242
+ };
243
+
244
+ const survivor =
245
+ (preferId ? involved.find((e) => e.id === preferId) : undefined) ??
246
+ [...involved].sort((a, b) => weight(b) - weight(a) || a.id.localeCompare(b.id))[0]!;
247
+
248
+ const absorbed = involved.filter((e) => e.id !== survivor.id);
249
+ const absorbedIds = new Set(absorbed.map((e) => e.id));
250
+
251
+ const merged: C4Container = {
252
+ ...(survivor as C4Container),
253
+ members: [...new Set([...survivor.members, ...absorbed.flatMap((e) => e.members)])],
254
+ derivedFrom: [
255
+ ...new Set([...(survivor.derivedFrom ?? []), ...absorbed.flatMap((e) => e.derivedFrom ?? [])]),
256
+ ],
257
+ rationale:
258
+ [survivor.rationale, ...absorbed.map((e) => e.rationale)].filter(Boolean).join(' | ') ||
259
+ undefined,
260
+ };
261
+
262
+ return [...elements.filter((e) => e.id !== survivor.id && !absorbedIds.has(e.id)), merged].sort(
263
+ (a, b) => a.id.localeCompare(b.id),
264
+ );
265
+ }
266
+
267
+ /**
268
+ * A starting point for an agent: one proposed element per derived key, shaped
269
+ * by the mechanical evidence, with the open questions in the rationale.
270
+ *
271
+ * Every element comes back `proposed`. This is a scaffold to edit and argue
272
+ * with — not an answer, and never `accepted`.
273
+ */
274
+ export function proposeElements(
275
+ options: {
276
+ repoKey: string;
277
+ rollup: ReadonlyArray<{ key: string; members: readonly C4MemberView[]; frameworks: readonly string[] }>;
278
+ author?: string;
279
+ },
280
+ ): C4ElementSet {
281
+ const elements = options.rollup.map(({ key, members, frameworks }) => {
282
+ const concerns = deriveConcerns(key, members, { frameworks });
283
+ return {
284
+ id: `container:${key}`,
285
+ kind: 'container' as const,
286
+ members: members.map((m) => m.alias),
287
+ derivedFrom: [key],
288
+ label: key,
289
+ containerKind: suggestContainerKind(members),
290
+ // C4 requires a technology on every container. With no framework signal
291
+ // the honest value is empty and `summarizeElements` reports the gap —
292
+ // inventing "TypeScript" here would be exactly the derivation this
293
+ // module stopped doing.
294
+ technology: frameworks.length > 0 ? frameworks.join(' + ') : '',
295
+ state: 'proposed' as const,
296
+ rationale: concerns.map((c) => c.message).join(' ') || undefined,
297
+ author: options.author,
298
+ };
299
+ });
300
+
301
+ return {
302
+ repoKey: options.repoKey,
303
+ elements: elements.sort((a, b) => a.id.localeCompare(b.id)),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * A first guess at what sort of container this is, offered as a *default* for
309
+ * the agent to override.
310
+ *
311
+ * Note there is no `library` answer, even for a library-shaped group. C4 says
312
+ * a module "typically" is not an element at all, so the honest response to a
313
+ * library is a review comment: the `library_shaped` concern carries the
314
+ * question, and the element is proposed as `application` for a person to
315
+ * reject or demote. There is deliberately no `concerns` parameter here for the
316
+ * caller to consult — passing one would only invite a fifth kind back into the
317
+ * vocabulary.
318
+ *
319
+ * The store split is deliberately conservative: only durable storage becomes
320
+ * `data-store`, because over-claiming that is the more damaging error.
321
+ */
322
+ export function suggestContainerKind(members: readonly C4MemberView[]): C4ContainerKind {
323
+ const stores = members.filter((m) => m.construct === 'store');
324
+ if (stores.length > 0) {
325
+ const persists = stores.some((m) => storageOf(m) === 'external' || storageOf(m) === 'disk');
326
+ return persists ? 'data-store' : 'application';
327
+ }
328
+ return 'application';
329
+ }
@@ -20,7 +20,10 @@ import {
20
20
  import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
21
21
  import { EDGE_LABEL_SIDE_PADDING, EDGE_ARROW_INSET } from '../utils/edgeLabel';
22
22
  import type { GraphifyComponentDetail } from '../graphify';
23
- import type { SubsystemConstructDeclaration } from '@principal-ai/subsystems-core';
23
+ import type {
24
+ SubsystemConstructDeclaration,
25
+ SubsystemComponentRemoval,
26
+ } from '@principal-ai/subsystems-core';
24
27
  import type { SubsystemDeclarationRef } from './declarationRef';
25
28
  import { purlOwnerName, purlRepoKey } from './paths';
26
29
 
@@ -242,6 +245,15 @@ export interface SubsystemComponent {
242
245
  * `construct` (intended shape) and `role` (topology).
243
246
  */
244
247
  proposed?: boolean;
248
+ /**
249
+ * The claim was real but its source is gone upstream — deleted or renamed.
250
+ * The inverse of `proposed`; verification skips source checks the same way.
251
+ * Renders with a slate border and a `deprecated` badge that opens the removal
252
+ * provenance. Transitional — a cleanup pass removes the node.
253
+ */
254
+ deprecated?: boolean;
255
+ /** Which commit removed this claim's source, and why. Present when deprecated. */
256
+ removedIn?: SubsystemComponentRemoval;
245
257
  /**
246
258
  * Semantic role — where the node sits in the topology (boundary element,
247
259
  * external system), orthogonal to `construct` (what it is). Drives the
@@ -312,12 +324,8 @@ export interface SubsystemComponent {
312
324
  */
313
325
  declaration?: SubsystemConstructDeclaration;
314
326
  /**
315
- * Where the structured `declaration` came from. `verified` = extracted from
316
- * source by tooling (graphify AST, signature extraction) — may be trusted
317
- * as matching the code. `authored` = written by the authoring agent/human
318
- * to highlight specific inputs/outputs — informative, not checked against
319
- * source. Declaration without provenance is treated as `authored`; only
320
- * tooling may claim `verified`.
327
+ * @deprecated No longer used — verification now tracks at the model level
328
+ * via `verifiedAtCommits`. Keeping the field temporarily for migration.
321
329
  */
322
330
  declarationProvenance?: SubsystemDeclarationProvenance;
323
331
  /**
@@ -1706,6 +1714,25 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
1706
1714
  /** Right-badge accent for `proposed: true` (no role, or combined with role). */
1707
1715
  export const PROPOSED_COLOR = '#b8860b'; // darkgoldenrod — not-yet-in-source
1708
1716
 
1717
+ /**
1718
+ * Border accent for `deprecated: true` — the claim was real, its source is gone.
1719
+ * A desaturated slate rather than true silver: role badges already own silver
1720
+ * (`ROLE_COLOR.entry`), and a retired claim should not read as a boundary
1721
+ * element. Transitional state — a cleanup pass removes the node.
1722
+ */
1723
+ export const DEPRECATED_COLOR = '#6b7280'; // slate — retired, awaiting removal
1724
+
1725
+ /** Right-badge label + accent for `deprecated: true`; null when not deprecated. */
1726
+ export function deprecatedBadgeLabel(component: { deprecated?: boolean }): string | null {
1727
+ return component.deprecated === true ? 'deprecated' : null;
1728
+ }
1729
+
1730
+ export function deprecatedBadgeColor(component: {
1731
+ deprecated?: boolean;
1732
+ }): string | null {
1733
+ return component.deprecated === true ? DEPRECATED_COLOR : null;
1734
+ }
1735
+
1709
1736
  /**
1710
1737
  * Top-right badge label: proposed wins over role when both are set
1711
1738
  * (proposed is the temporary exception to scan for; role stays on the model).
@@ -1851,6 +1878,7 @@ export function nodeMinWidthForBadges(component: {
1851
1878
  stereotype?: string;
1852
1879
  role?: SubsystemComponentRole;
1853
1880
  proposed?: boolean;
1881
+ deprecated?: boolean;
1854
1882
  entityKind?: string;
1855
1883
  declaration?: AnyComponentDetail;
1856
1884
  }): number {
@@ -1858,6 +1886,7 @@ export function nodeMinWidthForBadges(component: {
1858
1886
  const rightBadges = [
1859
1887
  storageBadgeLabel(component),
1860
1888
  rightBadgeLabel(component),
1889
+ deprecatedBadgeLabel(component),
1861
1890
  ].filter((l): l is string => l != null);
1862
1891
  if (rightBadges.length === 0) {
1863
1892
  return Math.max(
@@ -19,7 +19,10 @@ import { useTheme } from '@principal-ade/industry-theme';
19
19
  import {
20
20
  edgeColor,
21
21
  edgeStrokeStyle,
22
+ DEPRECATED_COLOR,
22
23
  PROPOSED_COLOR,
24
+ deprecatedBadgeColor,
25
+ deprecatedBadgeLabel,
23
26
  constructBadgeColor,
24
27
  constructBadgeLabel,
25
28
  rightBadgeLabel,
@@ -217,6 +220,13 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
217
220
  // Store retention backing badge — memory/disk/db, beside role (orthogonal).
218
221
  const storageLabel = storageBadgeLabel(c);
219
222
  const storageColor = storageBadgeColor(c);
223
+ // Deprecated badge — clickable, unlike every other badge here (they opt out
224
+ // of pointer events so clicks fall through to the node). Clicking opens the
225
+ // removal provenance, which is the whole reason the badge exists.
226
+ const deprecatedLabel = deprecatedBadgeLabel(c);
227
+ const deprecatedColor = deprecatedBadgeColor(c);
228
+ const removal = c.removedIn;
229
+ const [showRemoval, setShowRemoval] = useState(false);
220
230
  // Set while a file is open in the drawer: true → spotlight, false → dim,
221
231
  // absent (no file open) → neutral.
222
232
  const fileMatch = data.fileMatch as boolean | undefined;
@@ -225,13 +235,17 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
225
235
  const nodeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
226
236
  const hoverBg =
227
237
  theme.colors.backgroundHover ?? theme.colors.backgroundTertiary ?? nodeBg;
228
- // Border: proposed uses the goldenrod accent (dashed); construct color stays
229
- // on the left badge. File-open spotlight still wins with primary.
238
+ // Border: proposed uses the goldenrod accent (dashed), deprecated the slate
239
+ // accent (dotted, same family as issue) since both mean "not a live claim";
240
+ // construct color stays on the left badge. File-open spotlight still wins
241
+ // with primary.
230
242
  const borderColor = fileMatch
231
243
  ? theme.colors.primary
232
244
  : c.proposed
233
245
  ? PROPOSED_COLOR
234
- : color;
246
+ : c.deprecated
247
+ ? DEPRECATED_COLOR
248
+ : color;
235
249
  // Selection is stamped into data by the graph component — React Flow's own
236
250
  // `selected` never updates because the node stops click propagation.
237
251
  const isSelected = selected || (data.isSelected as boolean | undefined) === true;
@@ -252,7 +266,16 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
252
266
  // The border keeps its construct color; the severity rides on the chip.
253
267
  // Verification skips `proposed` nodes, so the two rarely stack — issue wins.
254
268
  const hasIssue = data.issue != null;
255
- const borderStyle = hasIssue ? 'dotted' : c.proposed ? 'dashed' : 'solid';
269
+ // Deprecated is dotted like issue but slate rather than construct-colored:
270
+ // "this claim was retired" is closer to a diagnostic than to a placeholder,
271
+ // and verification skips deprecated nodes so the two never stack in practice.
272
+ const borderStyle = hasIssue
273
+ ? 'dotted'
274
+ : c.proposed
275
+ ? 'dashed'
276
+ : c.deprecated
277
+ ? 'dotted'
278
+ : 'solid';
256
279
 
257
280
  return (
258
281
  <div
@@ -326,7 +349,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
326
349
  orthogonal to topology. Persistent; pointer-events pass through.
327
350
  Sits astride the bottom edge, centered (mirroring the top construct
328
351
  badge's straddle), so the top edge stays free for the construct tag. */}
329
- {(topRightLabel != null || storageLabel != null) && (
352
+ {(topRightLabel != null || storageLabel != null || deprecatedLabel != null) && (
330
353
  <div
331
354
  style={{
332
355
  position: 'absolute',
@@ -375,6 +398,102 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
375
398
  {topRightLabel}
376
399
  </span>
377
400
  )}
401
+ {deprecatedLabel != null && deprecatedColor != null && (
402
+ // Its own click target: stops propagation so opening the provenance
403
+ // doesn't also select the node, and toggles a popover rather than
404
+ // leaning on a title tooltip (which truncates the commit subject).
405
+ <span
406
+ role="button"
407
+ tabIndex={0}
408
+ aria-expanded={showRemoval}
409
+ aria-label={
410
+ removal
411
+ ? `Deprecated — removed in ${removal.commit}. Show removal details.`
412
+ : 'Deprecated — show removal details.'
413
+ }
414
+ onClick={(e) => {
415
+ e.stopPropagation();
416
+ setShowRemoval((v) => !v);
417
+ }}
418
+ onKeyDown={(e) => {
419
+ if (e.key === 'Enter' || e.key === ' ') {
420
+ e.stopPropagation();
421
+ e.preventDefault();
422
+ setShowRemoval((v) => !v);
423
+ }
424
+ }}
425
+ style={{
426
+ position: 'relative',
427
+ fontFamily: theme.fonts.monospace,
428
+ fontSize: theme.fontSizes[1],
429
+ letterSpacing: 0.5,
430
+ textTransform: 'uppercase',
431
+ lineHeight: '17px',
432
+ color: deprecatedColor,
433
+ background: nodeBg,
434
+ border: `2px solid ${deprecatedColor}`,
435
+ borderRadius: badgeRadius,
436
+ padding: '2px 8px',
437
+ cursor: 'pointer',
438
+ }}
439
+ >
440
+ {deprecatedLabel}
441
+ </span>
442
+ )}
443
+ </div>
444
+ )}
445
+
446
+ {/* Removal provenance popover — anchored above the deprecated badge, so it
447
+ opens away from the node body and stays inside the canvas. */}
448
+ {showRemoval && deprecatedLabel != null && (
449
+ <div
450
+ role="dialog"
451
+ aria-label="Removal provenance"
452
+ onClick={(e) => e.stopPropagation()}
453
+ style={{
454
+ position: 'absolute',
455
+ bottom: '100%',
456
+ left: '50%',
457
+ transform: 'translateX(-50%)',
458
+ marginBottom: 6,
459
+ zIndex: 20,
460
+ width: 260,
461
+ padding: '8px 10px',
462
+ borderRadius: 6,
463
+ background: nodeBg,
464
+ border: `1px solid ${DEPRECATED_COLOR}`,
465
+ boxShadow: '0 6px 20px rgba(0,0,0,0.35)',
466
+ fontFamily: theme.fonts.body,
467
+ fontSize: theme.fontSizes[1],
468
+ color: theme.colors.text,
469
+ textAlign: 'left',
470
+ whiteSpace: 'normal',
471
+ cursor: 'default',
472
+ }}
473
+ >
474
+ <div
475
+ style={{
476
+ fontWeight: 600,
477
+ marginBottom: 4,
478
+ color: DEPRECATED_COLOR,
479
+ }}
480
+ >
481
+ Deprecated
482
+ </div>
483
+ {removal ? (
484
+ <>
485
+ <div style={{ fontFamily: theme.fonts.monospace, marginBottom: 4 }}>
486
+ removed in {removal.commit}
487
+ </div>
488
+ {removal.reason && (
489
+ <div style={{ color: theme.colors.textSecondary }}>{removal.reason}</div>
490
+ )}
491
+ </>
492
+ ) : (
493
+ <div style={{ color: theme.colors.textSecondary }}>
494
+ This claim's source was removed upstream.
495
+ </div>
496
+ )}
378
497
  </div>
379
498
  )}
380
499
 
@@ -1,3 +0,0 @@
1
- import type { SubsystemModelDocument } from "../../../subsystem/model";
2
- export declare const c4FixtureModel: SubsystemModelDocument;
3
- //# sourceMappingURL=c4Fixture.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"c4Fixture.d.ts","sourceRoot":"","sources":["../../../../src/stories/Subsystem/C4Graph/c4Fixture.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAEvE,eAAO,MAAM,cAAc,EAAE,sBAE5B,CAAC"}