@principal-ai/subsystems-react 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.d.ts +8 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +95 -0
  6. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +1 -0
  7. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +340 -0
  8. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +1 -0
  9. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +4 -0
  10. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -1
  11. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +7334 -1
  12. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -1
  13. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +2 -0
  14. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +1 -0
  15. package/dist/stories/Subsystem/C4Graph/generateFixture.js +145 -0
  16. package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +1 -0
  17. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  18. package/dist/subsystem/C4Graph.js +16 -60
  19. package/dist/subsystem/C4Graph.js.map +1 -1
  20. package/dist/subsystem/C4NodeCard.d.ts +84 -0
  21. package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
  22. package/dist/subsystem/C4NodeCard.js +229 -0
  23. package/dist/subsystem/C4NodeCard.js.map +1 -0
  24. package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
  25. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  26. package/dist/subsystem/DiagnosticToggle.js +40 -6
  27. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  28. package/dist/subsystem/c4Associations.d.ts +186 -0
  29. package/dist/subsystem/c4Associations.d.ts.map +1 -0
  30. package/dist/subsystem/c4Associations.js +284 -0
  31. package/dist/subsystem/c4Associations.js.map +1 -0
  32. package/dist/subsystem/model.d.ts +25 -1
  33. package/dist/subsystem/model.d.ts.map +1 -1
  34. package/dist/subsystem/model.js +15 -0
  35. package/dist/subsystem/model.js.map +1 -1
  36. package/dist/subsystem/nodes.d.ts.map +1 -1
  37. package/dist/subsystem/nodes.js +78 -7
  38. package/dist/subsystem/nodes.js.map +1 -1
  39. package/dist/subsystem/toC4.d.ts +119 -0
  40. package/dist/subsystem/toC4.d.ts.map +1 -1
  41. package/dist/subsystem/toC4.js +95 -5
  42. package/dist/subsystem/toC4.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/index.ts +39 -0
  45. package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +273 -0
  46. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +325 -0
  47. package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +344 -0
  48. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +7337 -4
  49. package/src/stories/Subsystem/C4Graph/generateFixture.ts +161 -0
  50. package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
  51. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
  52. package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +4 -4
  53. package/src/subsystem/C4Graph.tsx +51 -92
  54. package/src/subsystem/C4NodeCard.test.ts +196 -0
  55. package/src/subsystem/C4NodeCard.tsx +307 -0
  56. package/src/subsystem/DiagnosticToggle.tsx +58 -4
  57. package/src/subsystem/c4Associations.test.ts +484 -0
  58. package/src/subsystem/c4Associations.ts +398 -0
  59. package/src/subsystem/model.ts +34 -1
  60. package/src/subsystem/nodes.tsx +124 -5
  61. package/src/subsystem/toC4.ts +210 -5
@@ -0,0 +1,398 @@
1
+ /**
2
+ * C4 associations — the confirmed, human-approved layer.
3
+ *
4
+ * `toC4.ts` derives a C4 projection from the flat document, but a derivation
5
+ * cannot be right about architecture on its own. The document says *what a
6
+ * symbol is* and *where it runs*; it does not say whether two runs are one
7
+ * deployable unit, whether a library is a container, or whether a store is a
8
+ * C4 data store.
9
+ *
10
+ * So the derivation proposes, and this module records what a person (or an
11
+ * agent a person approved) concluded:
12
+ *
13
+ * process key derived key association
14
+ * ─────────────────────────────────────────────────────────
15
+ * subsystems-studio/host ─┐
16
+ * ├─────► container:studio-host
17
+ * principal-studio/host ─┘ (state: accepted)
18
+ *
19
+ * The association is the join point. It names one C4 element and lists the
20
+ * derived keys that belong to it, which is how dedup works without inventing
21
+ * anything: several keys claimed by one id collapse into one box.
22
+ *
23
+ * ## Why this is not in the subsystem document
24
+ *
25
+ * A `C4Association` is a claim about *architecture*, not about *code*. Putting
26
+ * it on the document would mean every re-audit had to re-derive and re-defend
27
+ * it, and it would make the shared standard carry an opinion the models have
28
+ * no evidence for. Keeping it separate means the portable document stays
29
+ * source-anchored and this layer stays revisable on its own.
30
+ *
31
+ * ## What an agent may and may not do
32
+ *
33
+ * An agent may *propose* associations — `state: 'proposed'` — including merges,
34
+ * including retyping a store as a data store, including writing `technology`
35
+ * and `description` from the source it read. It may not set `state:
36
+ * 'accepted'`. That field is the human's, and it is the only field that
37
+ * changes what is drawn: a proposed association shows as a proposal, an
38
+ * accepted one is part of the architecture, and a rejected one is not drawn.
39
+ */
40
+
41
+ import type { C4Association, C4ElementType, C4Model } from './toC4';
42
+
43
+ /** The sidecar: a set of associations for one repo (or one composed graph). */
44
+ export interface C4AssociationSet {
45
+ /** Repo key this set describes, e.g. `pkg:github/owner/name`. */
46
+ repoKey: string;
47
+ /** ISO timestamp of the last change. */
48
+ updatedAt?: string;
49
+ associations: C4Association[];
50
+ }
51
+
52
+ /** Why an agent proposes what it proposes — the reviewable part. */
53
+ export interface C4AssociationReason {
54
+ /** Stable id of the derived key or association this concerns. */
55
+ target: string;
56
+ kind: C4ConcernKind;
57
+ message: string;
58
+ /** Aliases of the components that triggered the concern. */
59
+ members?: string[];
60
+ }
61
+
62
+ /**
63
+ * The questions worth a human's attention, derived mechanically.
64
+ *
65
+ * These are the checks from the C4 notation rules that the document cannot
66
+ * answer on its own. An agent turns each into a proposal; a person answers.
67
+ */
68
+ export type C4ConcernKind =
69
+ /** two or more process keys may be the same deployable unit */
70
+ | 'merge_candidate'
71
+ /** exactly one member — a lone function is not a deployable unit */
72
+ | 'singleton'
73
+ /** no member that executes at runtime — a library is not a container */
74
+ | 'library_shaped'
75
+ /** C4 requires technology on every container */
76
+ | 'missing_technology'
77
+ /** members claim more than one repo — a container cannot straddle systems */
78
+ | 'spans_repos'
79
+ /** a store whose storage kind was never decided */
80
+ | 'store_unclassified'
81
+ /** external with no resolvable identity */
82
+ | 'external_unidentified'
83
+ /** a runtime component with no process at all */
84
+ | 'unassigned';
85
+
86
+ /** Construct that executes at runtime, i.e. can be a deployable unit's code. */
87
+ const RUNTIME_CONSTRUCTS = new Set(['function', 'class', 'custom_entity']);
88
+
89
+ /** The fields `deriveConcerns` reads off a component. Structural on purpose:
90
+ * it accepts a real `SubsystemComponent` as well as a hand-built literal.
91
+ * `declaration` is the full construct-declaration union, so a component
92
+ * passes straight through without the caller reshaping it. */
93
+ export interface C4MemberView {
94
+ alias: string;
95
+ construct: string;
96
+ purl: string;
97
+ process?: string;
98
+ framework?: string;
99
+ declaration?: unknown;
100
+ }
101
+
102
+ /** A store's declared storage kind, or undefined when it never declared one. */
103
+ function storageOf(m: C4MemberView): string | undefined {
104
+ const d = m.declaration as { kind?: string; storage?: string } | undefined;
105
+ return d?.kind === 'store' ? d.storage : undefined;
106
+ }
107
+
108
+ /**
109
+ * Derive the questions worth asking about one container key.
110
+ *
111
+ * Pure over the rollup, so it runs identically against a single model, the 40
112
+ * composed models, or a fixture.
113
+ */
114
+ export function deriveConcerns(
115
+ key: string,
116
+ members: readonly C4MemberView[],
117
+ tech: { frameworks: readonly string[] } = { frameworks: [] },
118
+ ): C4AssociationReason[] {
119
+ const out: C4AssociationReason[] = [];
120
+ const aliases = members.map((m) => m.alias);
121
+ const runtime = members.filter((m) => RUNTIME_CONSTRUCTS.has(m.construct));
122
+
123
+ if (members.length === 1) {
124
+ out.push({
125
+ target: key,
126
+ kind: 'singleton',
127
+ message: `process "${key}" has exactly one member (${aliases[0]}) — a lone function is not a deployable unit`,
128
+ members: aliases,
129
+ });
130
+ }
131
+
132
+ // No member executes at runtime ⇒ this cannot be a deployable unit,
133
+ // regardless of how many members it has. A lone interface and a package of
134
+ // interfaces fail the same way.
135
+ if (members.length > 0 && runtime.length === 0) {
136
+ out.push({
137
+ target: key,
138
+ kind: 'library_shaped',
139
+ 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`,
140
+ members: aliases,
141
+ });
142
+ }
143
+
144
+ if (runtime.length > 0 && tech.frameworks.length === 0) {
145
+ out.push({
146
+ target: key,
147
+ kind: 'missing_technology',
148
+ message: `container "${key}" has no framework signal — C4 requires a technology on every container`,
149
+ members: aliases,
150
+ });
151
+ }
152
+
153
+ const repos = new Set(members.map((m) => (m.purl ?? '').split('#')[0]).filter((r) => r && !r.startsWith('external:')));
154
+ if (repos.size > 1) {
155
+ out.push({
156
+ target: key,
157
+ kind: 'spans_repos',
158
+ message: `process "${key}" spans ${repos.size} repo keys (${[...repos].join(', ')}) — a C4 container cannot straddle a system boundary`,
159
+ members: aliases,
160
+ });
161
+ }
162
+
163
+ const stores = members.filter((m) => m.construct === 'store');
164
+ const unclassified = stores.filter((m) => !storageOf(m));
165
+ if (unclassified.length > 0) {
166
+ out.push({
167
+ target: key,
168
+ kind: 'store_unclassified',
169
+ 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`,
170
+ members: unclassified.map((m) => m.alias),
171
+ });
172
+ }
173
+
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Suggest merges between container keys that share a trailing role.
179
+ *
180
+ * `subsystems-studio/host` and `principal-studio/host` are the same role under
181
+ * two app names. This is a *suggestion*, never a merge — the whole question is
182
+ * whether they are one process or two, and only the user knows.
183
+ */
184
+ export function suggestMerges(
185
+ rollup: ReadonlyArray<{ key: string; memberCount: number }>,
186
+ ): Array<{ role: string; keys: string[]; reason: string }> {
187
+ const byRole = new Map<string, string[]>();
188
+ for (const { key } of rollup) {
189
+ const slash = key.lastIndexOf('/');
190
+ if (slash <= 0) continue; // a bare key has no app/role shape
191
+ const role = key.slice(slash + 1);
192
+ const list = byRole.get(role) ?? [];
193
+ list.push(key);
194
+ byRole.set(role, list);
195
+ }
196
+ const out: Array<{ role: string; keys: string[]; reason: string }> = [];
197
+ for (const [role, keys] of [...byRole].sort((a, b) => a[0].localeCompare(b[0]))) {
198
+ if (keys.length < 2) continue;
199
+ out.push({
200
+ role,
201
+ keys: [...keys].sort(),
202
+ reason: `${keys.length} process keys end in "/${role}" (${keys.sort().join(', ')}) — one deployable unit under two names, or two units?`,
203
+ });
204
+ }
205
+ return out;
206
+ }
207
+
208
+ /**
209
+ * Compose a suggested association set from a derived rollup.
210
+ *
211
+ * This is the agent's starting point: every derived key becomes one proposed
212
+ * association, shaped by the mechanical evidence. Nothing here is accepted —
213
+ * `buildAssociations` is a convenience for authoring, not an oracle.
214
+ */
215
+ export function buildAssociations(
216
+ options: {
217
+ repoKey: string;
218
+ rollup: ReadonlyArray<{
219
+ key: string;
220
+ members: readonly C4MemberView[];
221
+ frameworks: readonly string[];
222
+ }>;
223
+ /** Merges a person has already decided on: groups of keys → one id. */
224
+ merges?: ReadonlyArray<{ id: string; keys: string[] }>;
225
+ author?: string;
226
+ },
227
+ ): C4AssociationSet {
228
+ const merged = new Map<string, string>();
229
+ for (const m of options.merges ?? []) {
230
+ for (const k of m.keys) merged.set(k, m.id);
231
+ }
232
+
233
+ const byId = new Map<string, C4Association>();
234
+ for (const entry of options.rollup) {
235
+ const assocId = merged.get(entry.key);
236
+ const id = assocId ?? `container:${entry.key}`;
237
+ const concerns = deriveConcerns(entry.key, entry.members, { frameworks: entry.frameworks });
238
+ const hasStore = entry.members.some((m) => m.construct === 'store');
239
+ const tech = entry.frameworks.length > 0 ? entry.frameworks.join(' + ') : undefined;
240
+
241
+ const existing = byId.get(id);
242
+ if (existing) {
243
+ // Folded into an existing merge group — keep one element, add the key.
244
+ if (!existing.sourceKeys.includes(entry.key)) existing.sourceKeys.push(entry.key);
245
+ continue;
246
+ }
247
+
248
+ byId.set(id, {
249
+ id,
250
+ level: 'container',
251
+ sourceKeys: [entry.key],
252
+ label: entry.key,
253
+ type: inferType(entry.members, concerns),
254
+ technology: tech,
255
+ state: 'proposed',
256
+ rationale: concerns.map((c) => c.message).join(' ') || undefined,
257
+ author: options.author,
258
+ });
259
+ void hasStore;
260
+ }
261
+
262
+ return {
263
+ repoKey: options.repoKey,
264
+ associations: [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * How much evidence backs one association.
270
+ *
271
+ * Computed from the rollup rather than stored, so it never goes stale as
272
+ * models change. Used to pick the survivor of a merge.
273
+ */
274
+ export function associationWeight(
275
+ a: C4Association,
276
+ memberCount: ReadonlyMap<string, number>,
277
+ ): number {
278
+ let n = 0;
279
+ for (const k of a.sourceKeys) n += memberCount.get(k) ?? 0;
280
+ return n;
281
+ }
282
+
283
+ /** Best mechanical guess at a C4 element type, from the evidence available. */
284
+ function inferType(
285
+ members: readonly C4MemberView[],
286
+ concerns: readonly C4AssociationReason[],
287
+ ): C4ElementType {
288
+ if (concerns.some((c) => c.kind === 'library_shaped')) return 'library';
289
+ const stores = members.filter((m) => m.construct === 'store');
290
+ if (stores.length > 0) {
291
+ // Only call it a data store when the storage kind says so; otherwise the
292
+ // store may just be an in-memory detail of the application.
293
+ const persists = stores.some((m) => storageOf(m) === 'external' || storageOf(m) === 'disk');
294
+ return persists ? 'data-store' : 'application';
295
+ }
296
+ return 'application';
297
+ }
298
+
299
+ /**
300
+ * Fold a decided merge into an association set.
301
+ *
302
+ * Two derived keys that are really one container have to become ONE
303
+ * association claiming BOTH keys — not two associations, and not one
304
+ * association whose key was rewritten to the other's id. `toC4` collapses a
305
+ * box per association id, so a key must stay a key all the way through or the
306
+ * merge silently does nothing.
307
+ *
308
+ * ## Which side survives
309
+ *
310
+ * The **largest** side wins, not the caller's choice. `subsystems-studio/host`
311
+ * holds 113 components and `principal-studio/host` holds 1; folding the big
312
+ * one into the small one would leave the box labelled after an almost-empty
313
+ * process and lose its confirmed technology. Merging is a dedup, so the
314
+ * survivor should be the one carrying the evidence.
315
+ *
316
+ * Returns a new set; the input is untouched. Idempotent: merging an already
317
+ * merged set changes nothing.
318
+ */
319
+ export function mergeAssociations(
320
+ associations: readonly C4Association[],
321
+ keys: readonly string[],
322
+ /**
323
+ * Optional caller override — skip it to get the evidence-driven default.
324
+ */
325
+ preferKey?: string,
326
+ /**
327
+ * Components per derived key, from the rollup. This is what decides the
328
+ * survivor when every side claims a single key, which is the common case:
329
+ * `subsystems-studio/host` and `principal-studio/host` each hold exactly one
330
+ * derived key, so key count cannot break the tie.
331
+ */
332
+ memberCount?: ReadonlyMap<string, number>,
333
+ ): C4Association[] {
334
+ const wanted = [...new Set(keys.map((k) => k.trim()).filter(Boolean))];
335
+ if (wanted.length < 2) return [...associations];
336
+
337
+ const involved = associations.filter((a) => a.sourceKeys.some((k) => wanted.includes(k)));
338
+ if (involved.length < 2) return [...associations];
339
+
340
+ const survivor =
341
+ (preferKey ? involved.find((a) => a.sourceKeys.includes(preferKey.trim())) : undefined) ??
342
+ [...involved].sort((a, b) => {
343
+ const wa = associationWeight(a, memberCount ?? new Map());
344
+ const wb = associationWeight(b, memberCount ?? new Map());
345
+ if (wa !== wb) return wb - wa;
346
+ // No evidence either way: stable, and least surprising.
347
+ return a.id.localeCompare(b.id);
348
+ })[0]!;
349
+
350
+ const absorbed = involved.filter((a) => a.id !== survivor.id);
351
+ const absorbedIds = new Set(absorbed.map((a) => a.id));
352
+
353
+ const merged: C4Association = {
354
+ ...survivor,
355
+ sourceKeys: [...new Set([...survivor.sourceKeys, ...absorbed.flatMap((a) => a.sourceKeys)])].sort(),
356
+ rationale:
357
+ [survivor.rationale, ...absorbed.map((a) => a.rationale)].filter(Boolean).join(' | ') || undefined,
358
+ };
359
+
360
+ return [...associations.filter((a) => !absorbedIds.has(a.id) && a.id !== survivor.id), merged].sort(
361
+ (a, b) => a.id.localeCompare(b.id),
362
+ );
363
+ }
364
+
365
+ /** Summary counts for the confirm UI: how much is proposed vs accepted. */
366
+ export function summarizeAssociations(
367
+ model: C4Model,
368
+ associations: readonly C4Association[],
369
+ ): {
370
+ confirmed: number;
371
+ proposed: number;
372
+ rejected: number;
373
+ mergedAway: number;
374
+ missingTechnology: number;
375
+ needsDecision: number;
376
+ } {
377
+ const accepted = associations.filter((a) => a.state === 'accepted');
378
+ const proposed = associations.filter((a) => a.state === 'proposed');
379
+ const rejected = associations.filter((a) => a.state === 'rejected');
380
+ const confirmedIds = new Set(accepted.map((a) => a.id));
381
+ const mergedAway = accepted.reduce((n, a) => n + (a.sourceKeys.length - 1), 0);
382
+
383
+ // A raw derived key with no association is unconfirmed, not wrong.
384
+ const derivedKeys = new Set<string>();
385
+ for (const n of model.nodes) if (n.kind === 'container') derivedKeys.add(n.key ?? n.id);
386
+ const unconfirmed = [...derivedKeys].filter((k) => ![...confirmedIds].some((id) => associations.find((a) => a.id === id)?.sourceKeys.includes(k))).length;
387
+
388
+ const missingTechnology = model.nodes.filter((n) => n.kind === 'container' && !n.decoration?.technology).length;
389
+
390
+ return {
391
+ confirmed: accepted.length,
392
+ proposed: proposed.length,
393
+ rejected: rejected.length,
394
+ mergedAway,
395
+ missingTechnology,
396
+ needsDecision: proposed.length + unconfirmed,
397
+ };
398
+ }
@@ -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
@@ -1706,6 +1718,25 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
1706
1718
  /** Right-badge accent for `proposed: true` (no role, or combined with role). */
1707
1719
  export const PROPOSED_COLOR = '#b8860b'; // darkgoldenrod — not-yet-in-source
1708
1720
 
1721
+ /**
1722
+ * Border accent for `deprecated: true` — the claim was real, its source is gone.
1723
+ * A desaturated slate rather than true silver: role badges already own silver
1724
+ * (`ROLE_COLOR.entry`), and a retired claim should not read as a boundary
1725
+ * element. Transitional state — a cleanup pass removes the node.
1726
+ */
1727
+ export const DEPRECATED_COLOR = '#6b7280'; // slate — retired, awaiting removal
1728
+
1729
+ /** Right-badge label + accent for `deprecated: true`; null when not deprecated. */
1730
+ export function deprecatedBadgeLabel(component: { deprecated?: boolean }): string | null {
1731
+ return component.deprecated === true ? 'deprecated' : null;
1732
+ }
1733
+
1734
+ export function deprecatedBadgeColor(component: {
1735
+ deprecated?: boolean;
1736
+ }): string | null {
1737
+ return component.deprecated === true ? DEPRECATED_COLOR : null;
1738
+ }
1739
+
1709
1740
  /**
1710
1741
  * Top-right badge label: proposed wins over role when both are set
1711
1742
  * (proposed is the temporary exception to scan for; role stays on the model).
@@ -1851,6 +1882,7 @@ export function nodeMinWidthForBadges(component: {
1851
1882
  stereotype?: string;
1852
1883
  role?: SubsystemComponentRole;
1853
1884
  proposed?: boolean;
1885
+ deprecated?: boolean;
1854
1886
  entityKind?: string;
1855
1887
  declaration?: AnyComponentDetail;
1856
1888
  }): number {
@@ -1858,6 +1890,7 @@ export function nodeMinWidthForBadges(component: {
1858
1890
  const rightBadges = [
1859
1891
  storageBadgeLabel(component),
1860
1892
  rightBadgeLabel(component),
1893
+ deprecatedBadgeLabel(component),
1861
1894
  ].filter((l): l is string => l != null);
1862
1895
  if (rightBadges.length === 0) {
1863
1896
  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