@principal-ai/subsystems-react 0.55.2 → 0.56.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 (107) hide show
  1. package/dist/index.d.ts +12 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreFileView.js +3 -3
  6. package/dist/pierre/PierreFileView.js.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.d.ts +1 -6
  8. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  9. package/dist/pierre/PierreTrailCodeView.js +21 -18
  10. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  11. package/dist/pierre/fileAvailability.d.ts +4 -7
  12. package/dist/pierre/fileAvailability.d.ts.map +1 -1
  13. package/dist/pierre/fileAvailability.js +5 -10
  14. package/dist/pierre/fileAvailability.js.map +1 -1
  15. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  16. package/dist/subsystem/C4Graph.js +1 -1
  17. package/dist/subsystem/C4Graph.js.map +1 -1
  18. package/dist/subsystem/DecorationPanel.d.ts +37 -0
  19. package/dist/subsystem/DecorationPanel.d.ts.map +1 -0
  20. package/dist/subsystem/DecorationPanel.js +147 -0
  21. package/dist/subsystem/DecorationPanel.js.map +1 -0
  22. package/dist/subsystem/FileDrawer.d.ts +3 -1
  23. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  24. package/dist/subsystem/FileDrawer.js +2 -2
  25. package/dist/subsystem/FileDrawer.js.map +1 -1
  26. package/dist/subsystem/IssueList.d.ts.map +1 -1
  27. package/dist/subsystem/IssueList.js +26 -0
  28. package/dist/subsystem/IssueList.js.map +1 -1
  29. package/dist/subsystem/SubsystemComponentGraph.d.ts +9 -5
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +37 -47
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/TrailStackBreadcrumb.d.ts +21 -0
  34. package/dist/subsystem/TrailStackBreadcrumb.d.ts.map +1 -0
  35. package/dist/subsystem/TrailStackBreadcrumb.js +77 -0
  36. package/dist/subsystem/TrailStackBreadcrumb.js.map +1 -0
  37. package/dist/subsystem/TrailStepsNav.d.ts +4 -3
  38. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  39. package/dist/subsystem/TrailStepsNav.js +13 -16
  40. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  41. package/dist/subsystem/TrailsPanel.d.ts +9 -7
  42. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  43. package/dist/subsystem/TrailsPanel.js +126 -76
  44. package/dist/subsystem/TrailsPanel.js.map +1 -1
  45. package/dist/subsystem/decoration.d.ts +112 -0
  46. package/dist/subsystem/decoration.d.ts.map +1 -0
  47. package/dist/subsystem/decoration.js +54 -0
  48. package/dist/subsystem/decoration.js.map +1 -0
  49. package/dist/subsystem/model.d.ts +5 -18
  50. package/dist/subsystem/model.d.ts.map +1 -1
  51. package/dist/subsystem/model.js +3 -11
  52. package/dist/subsystem/model.js.map +1 -1
  53. package/dist/subsystem/nodes.d.ts.map +1 -1
  54. package/dist/subsystem/nodes.js +17 -24
  55. package/dist/subsystem/nodes.js.map +1 -1
  56. package/dist/subsystem/stepEmphasis.d.ts +78 -0
  57. package/dist/subsystem/stepEmphasis.d.ts.map +1 -0
  58. package/dist/subsystem/stepEmphasis.js +89 -0
  59. package/dist/subsystem/stepEmphasis.js.map +1 -0
  60. package/dist/subsystem/trailBrief.d.ts.map +1 -1
  61. package/dist/subsystem/trailBrief.js +20 -0
  62. package/dist/subsystem/trailBrief.js.map +1 -1
  63. package/dist/subsystem/trailStack.d.ts +53 -0
  64. package/dist/subsystem/trailStack.d.ts.map +1 -0
  65. package/dist/subsystem/trailStack.js +154 -0
  66. package/dist/subsystem/trailStack.js.map +1 -0
  67. package/dist/subsystem/trailStepDisplay.d.ts +38 -0
  68. package/dist/subsystem/trailStepDisplay.d.ts.map +1 -0
  69. package/dist/subsystem/trailStepDisplay.js +41 -0
  70. package/dist/subsystem/trailStepDisplay.js.map +1 -0
  71. package/package.json +3 -3
  72. package/src/index.ts +47 -1
  73. package/src/pierre/PierreFileView.tsx +3 -3
  74. package/src/pierre/PierreTrailCodeView.tsx +25 -28
  75. package/src/pierre/fileAvailability.test.ts +0 -6
  76. package/src/pierre/fileAvailability.ts +5 -13
  77. package/src/stories/Canvases/Subsystem/Deprecated.stories.tsx +3 -17
  78. package/src/stories/Canvases/Subsystem/NodeAnatomy.stories.tsx +3 -31
  79. package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +528 -0
  80. package/src/stories/Canvases/Subsystem/Trails.stories.tsx +0 -187
  81. package/src/stories/Components/DecorationPanel.stories.tsx +160 -0
  82. package/src/subsystem/C4Graph.tsx +0 -1
  83. package/src/subsystem/DecorationPanel.test.tsx +128 -0
  84. package/src/subsystem/DecorationPanel.tsx +334 -0
  85. package/src/subsystem/FileDrawer.tsx +6 -0
  86. package/src/subsystem/IssueList.tsx +26 -0
  87. package/src/subsystem/SubsystemComponentGraph.tsx +68 -65
  88. package/src/subsystem/TrailStackBreadcrumb.test.tsx +109 -0
  89. package/src/subsystem/TrailStackBreadcrumb.tsx +172 -0
  90. package/src/subsystem/TrailStepsNav.test.tsx +35 -0
  91. package/src/subsystem/TrailStepsNav.tsx +18 -19
  92. package/src/subsystem/TrailsPanel.test.tsx +149 -0
  93. package/src/subsystem/TrailsPanel.tsx +165 -39
  94. package/src/subsystem/decoration.test.ts +72 -0
  95. package/src/subsystem/decoration.ts +151 -0
  96. package/src/subsystem/model.test.ts +7 -22
  97. package/src/subsystem/model.ts +6 -20
  98. package/src/subsystem/nodes.tsx +19 -28
  99. package/src/subsystem/stepEmphasis.test.ts +110 -0
  100. package/src/subsystem/stepEmphasis.ts +151 -0
  101. package/src/subsystem/trailBrief.test.ts +29 -0
  102. package/src/subsystem/trailBrief.ts +20 -0
  103. package/src/subsystem/trailStack.test.ts +133 -0
  104. package/src/subsystem/trailStack.ts +229 -0
  105. package/src/subsystem/trailStepDisplay.ts +68 -0
  106. package/src/stories/Canvases/Subsystem/Proposed.stories.tsx +0 -256
  107. package/src/stories/Canvases/Subsystem/ProposedTrails.stories.tsx +0 -184
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { SubsystemTrail } from './model';
3
+ import {
4
+ decorationModelIds,
5
+ decorationTrailId,
6
+ decorationTrailKey,
7
+ findDecorationTrail,
8
+ groupDecorationTrailsByModel,
9
+ type DecorationTrailRef,
10
+ } from './decoration';
11
+
12
+ function ref(modelId: string, trailId: string): DecorationTrailRef {
13
+ return { kind: 'base', modelId, trailId };
14
+ }
15
+
16
+ const draftTrail: SubsystemTrail = {
17
+ id: 'planned-open',
18
+ title: 'Planned open',
19
+ steps: [],
20
+ };
21
+
22
+ describe('decoration trail refs', () => {
23
+ test('keys are scoped by model', () => {
24
+ expect(decorationTrailKey(ref('model-a', 'open'))).toBe('model-a:open');
25
+ expect(decorationTrailKey(ref('model-a', 'open'))).not.toBe(
26
+ decorationTrailKey(ref('model-b', 'open')),
27
+ );
28
+ });
29
+
30
+ test('a draft trail keys like a base trail', () => {
31
+ const draft: DecorationTrailRef = {
32
+ kind: 'draft',
33
+ modelId: 'model-a',
34
+ trail: draftTrail,
35
+ };
36
+ expect(decorationTrailKey(draft)).toBe('model-a:planned-open');
37
+ expect(decorationTrailId(draft)).toBe('planned-open');
38
+ });
39
+
40
+ test('groups trails by their own model, preserving authored order', () => {
41
+ const groups = groupDecorationTrailsByModel([
42
+ ref('web', 'open'),
43
+ ref('server', 'handle'),
44
+ ref('web', 'save'),
45
+ ref('worker', 'consume'),
46
+ ]);
47
+ expect(groups.map((g) => g.modelId)).toEqual(['web', 'server', 'worker']);
48
+ expect(groups[0]!.trails.map((t) => decorationTrailId(t))).toEqual([
49
+ 'open',
50
+ 'save',
51
+ ]);
52
+ expect(groups[1]!.trails.map((t) => decorationTrailId(t))).toEqual([
53
+ 'handle',
54
+ ]);
55
+ });
56
+
57
+ test('lists distinct models in first-seen order', () => {
58
+ expect(
59
+ decorationModelIds([
60
+ ref('web', 'open'),
61
+ ref('server', 'handle'),
62
+ ref('web', 'save'),
63
+ ]),
64
+ ).toEqual(['web', 'server']);
65
+ });
66
+
67
+ test('finds a trail by model and trail id, not trail id alone', () => {
68
+ const refs = [ref('web', 'open'), ref('server', 'open')];
69
+ expect(findDecorationTrail(refs, 'server', 'open')?.modelId).toBe('server');
70
+ expect(findDecorationTrail(refs, 'worker', 'open')).toBeUndefined();
71
+ });
72
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Subsystem decoration — PROTOTYPE contract for the ephemeral, unverified layer.
3
+ *
4
+ * A decoration composes trails from one or more models, carries a markdown
5
+ * description, and may add draft structure. The base model stays verified-only;
6
+ * a decoration is never audited against source.
7
+ *
8
+ * **Each trail ref names its own model.** There is no decoration-level `base`
9
+ * field: a decoration can mix models by construction, and a single-model
10
+ * decoration is just the case where every ref happens to share a `modelId`.
11
+ *
12
+ * A trail can be either:
13
+ * - `base` — a reference to a trail that already exists in a model; or
14
+ * - `draft` — a trail that only exists in the decoration. This is what
15
+ * migration needs when a base trail depends on draft components: the whole
16
+ * trail moves into the decoration rather than dangling in the base.
17
+ *
18
+ * The contract is deliberately host-local and not part of the portable
19
+ * subsystem document. If decorations become shareable, `modelId` needs a
20
+ * portable identity key (the local `sg-...` ids are re-minted).
21
+ */
22
+
23
+ import type { SubsystemTrail, SubsystemTrailMechanism } from './model';
24
+
25
+ export interface DecorationStepAnchor {
26
+ /** Claim fingerprint of the anchored step (see `stepClaimKey`). */
27
+ stepKey: string;
28
+ /** Optional per-anchor note. The decoration description carries the prose. */
29
+ note?: string;
30
+ }
31
+
32
+ interface DecorationTrailRefBase {
33
+ /** Store-local model id. The trail id is only unique within this model. */
34
+ modelId: string;
35
+ /** Optional step-level anchors inside this trail. */
36
+ stepAnchors?: DecorationStepAnchor[];
37
+ }
38
+
39
+ /** A trail that already exists in the base model. */
40
+ export interface DecorationBaseTrailRef extends DecorationTrailRefBase {
41
+ kind: 'base';
42
+ trailId: string;
43
+ }
44
+
45
+ /** A speculative trail that exists only in the decoration. */
46
+ export interface DecorationDraftTrailRef extends DecorationTrailRefBase {
47
+ kind: 'draft';
48
+ trail: SubsystemTrail;
49
+ }
50
+
51
+ export type DecorationTrailRef =
52
+ | DecorationBaseTrailRef
53
+ | DecorationDraftTrailRef;
54
+
55
+ /**
56
+ * Draft structure: a component that does not exist in source yet. No
57
+ * `file`/`symbol` — those are the verification coordinates the base model
58
+ * requires, and a draft component deliberately has none.
59
+ */
60
+ export interface DecorationDraftComponent {
61
+ alias: string;
62
+ name: string;
63
+ /** Intended construct shape (`class`, `function`, …). */
64
+ construct?: string;
65
+ purpose?: string;
66
+ module?: string;
67
+ process?: string;
68
+ }
69
+
70
+ /** A speculative edge between base or draft aliases. */
71
+ export interface DecorationDraftEdge {
72
+ from: string;
73
+ to: string;
74
+ mechanism: SubsystemTrailMechanism;
75
+ }
76
+
77
+ export interface DecorationDraft {
78
+ components?: DecorationDraftComponent[];
79
+ edges?: DecorationDraftEdge[];
80
+ }
81
+
82
+ export interface DecorationProvenance {
83
+ source: 'author' | 'agent';
84
+ author?: string;
85
+ /** ISO timestamp. */
86
+ at: string;
87
+ }
88
+
89
+ export interface SubsystemDecoration {
90
+ version: 1;
91
+ id: string;
92
+ title: string;
93
+ /** Markdown, rendered like the model description. */
94
+ description: string;
95
+ /**
96
+ * Ordered trails the decoration cares about. Order is the reading order;
97
+ * `modelId` may vary from ref to ref. A trail is either a base ref or a
98
+ * draft trail that lives only here.
99
+ */
100
+ trails: DecorationTrailRef[];
101
+ /** Optional draft delta. Absent = a pure reading path. */
102
+ draft?: DecorationDraft;
103
+ provenance: DecorationProvenance;
104
+ }
105
+
106
+ /** The trail id for either kind of ref. */
107
+ export function decorationTrailId(ref: DecorationTrailRef): string {
108
+ return ref.kind === 'base' ? ref.trailId : ref.trail.id;
109
+ }
110
+
111
+ /** Stable key for a trail ref across models. `trailId` alone is not enough. */
112
+ export function decorationTrailKey(
113
+ ref: Pick<DecorationTrailRef, 'modelId'> & { trailId?: string; trail?: SubsystemTrail },
114
+ ): string {
115
+ const trailId = ref.trailId ?? ref.trail?.id ?? '';
116
+ return `${ref.modelId}:${trailId}`;
117
+ }
118
+
119
+ /**
120
+ * Group trail refs by their owning model, preserving first-seen model order
121
+ * and the authored trail order within each group. The UI uses this to render
122
+ * "which trails it cares about" without a separate model list.
123
+ */
124
+ export function groupDecorationTrailsByModel(
125
+ refs: readonly DecorationTrailRef[],
126
+ ): Array<{ modelId: string; trails: DecorationTrailRef[] }> {
127
+ const groups = new Map<string, DecorationTrailRef[]>();
128
+ for (const ref of refs) {
129
+ const existing = groups.get(ref.modelId);
130
+ if (existing) existing.push(ref);
131
+ else groups.set(ref.modelId, [ref]);
132
+ }
133
+ return [...groups].map(([modelId, trails]) => ({ modelId, trails }));
134
+ }
135
+
136
+ /** The distinct models a decoration touches, in first-seen order. */
137
+ export function decorationModelIds(
138
+ refs: readonly DecorationTrailRef[],
139
+ ): string[] {
140
+ return [...new Set(refs.map((ref) => ref.modelId))];
141
+ }
142
+
143
+ export function findDecorationTrail(
144
+ refs: readonly DecorationTrailRef[],
145
+ modelId: string,
146
+ trailId: string,
147
+ ): DecorationTrailRef | undefined {
148
+ return refs.find(
149
+ (ref) => ref.modelId === modelId && decorationTrailId(ref) === trailId,
150
+ );
151
+ }
@@ -98,7 +98,7 @@ describe('subsystem graph model', () => {
98
98
  const withExternal: SubsystemComponent[] = [
99
99
  ...comps,
100
100
  {
101
- alias: "proposed-watcher",
101
+ alias: "external-watcher",
102
102
  name: "watchDir",
103
103
  construct: "external",
104
104
  // Intentionally omit file/purl — agents often leave these off for externals.
@@ -111,10 +111,10 @@ describe('subsystem graph model', () => {
111
111
  trails: [{
112
112
  id: 'w1',
113
113
  title: 'feed',
114
- steps: [{ from: 'reader', to: 'proposed-watcher', mechanism: 'feeds', file: 'x.ts' }],
114
+ steps: [{ from: 'reader', to: 'external-watcher', mechanism: 'feeds', file: 'x.ts' }],
115
115
  }],
116
116
  });
117
- expect(nodes.find((n) => n.id === 'proposed-watcher')).toBeDefined();
117
+ expect(nodes.find((n) => n.id === 'external-watcher')).toBeDefined();
118
118
  });
119
119
 
120
120
  test('buildSubsystemGraph creates external stub nodes for non-component targets', async () => {
@@ -223,33 +223,18 @@ describe('subsystem graph model', () => {
223
223
  });
224
224
  expect(withRole).toBeGreaterThan(stereotype);
225
225
 
226
- const withProposed = nodeMinWidthForBadges({
227
- construct: 'function',
228
- proposed: true,
229
- });
230
- expect(withProposed).toBeGreaterThan(plain);
231
-
232
- const withBoth = nodeMinWidthForBadges({
233
- construct: 'function',
234
- role: 'entry',
235
- proposed: true,
236
- });
237
- // proposed-only label is shorter than entry · proposed; still wider than plain
238
- expect(withBoth).toBe(withProposed);
239
-
240
226
  // The pair floor must leave a real gap between the left and right badge,
241
227
  // over and above both insets — regression: the node border was omitted from
242
228
  // the floor, and the char width was underestimated, so the two badges met.
243
229
  const left = estimateBadgeLabelWidth(constructBadgeLabel({ construct: 'function' }));
244
- const right = estimateBadgeLabelWidth(rightBadgeLabel({ proposed: true })!);
245
- expect(withBoth).toBeGreaterThan(left + right + 2 * BADGE_EDGE_INSET);
230
+ const right = estimateBadgeLabelWidth(rightBadgeLabel({ role: 'entry' })!);
231
+ expect(withRole).toBeGreaterThan(left + right + 2 * BADGE_EDGE_INSET);
246
232
  });
247
233
 
248
- test('rightBadgeLabel prefers proposed over role', () => {
234
+ test('rightBadgeLabel reports the role', () => {
249
235
  expect(rightBadgeLabel({})).toBeNull();
250
- expect(rightBadgeLabel({ proposed: true })).toBe('proposed');
251
236
  expect(rightBadgeLabel({ role: 'entry' })).toBe('entry');
252
- expect(rightBadgeLabel({ role: 'service', proposed: true })).toBe('proposed');
237
+ expect(rightBadgeLabel({ role: 'service' })).toBe('service');
253
238
  });
254
239
 
255
240
  test('deriveNameFromSymbol falls back to existing name when no symbol', () => {
@@ -101,6 +101,8 @@ export type {
101
101
  SubsystemModelDocument,
102
102
  SubsystemTrail,
103
103
  SubsystemTrailStep,
104
+ SubsystemSiteConstruct,
105
+ SubsystemTargetConstruct,
104
106
  SubsystemComponentRole,
105
107
  SubsystemFramework,
106
108
  SubsystemStereotype,
@@ -1156,7 +1158,7 @@ export type SubsystemIssueRung =
1156
1158
  | 'signature';
1157
1159
 
1158
1160
  /**
1159
- * Per-node diagnostics badge — the proposed issue overlay (severity ring +
1161
+ * Per-node diagnostics badge — the issue overlay (severity ring +
1160
1162
  * earliest-rung corner chip). The graph derives one per component from the
1161
1163
  * audit issues whose component target resolves to that node. Absent when the
1162
1164
  * node has no rung-mapped findings.
@@ -1226,12 +1228,6 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
1226
1228
  /** True while another edge is selected — render this edge (and its label)
1227
1229
  * dimmed to focus the selected relationship. */
1228
1230
  dimmed?: boolean;
1229
- /**
1230
- * A backing trail step declares itself `proposed` — a planned seam between
1231
- * otherwise-real components. The SVG edge and its label draw dashed;
1232
- * distinct from a proposed ENDPOINT, which dashes the node instead.
1233
- */
1234
- proposedStep?: boolean;
1235
1231
  /** ELK-computed label midpoint (from the actual edge path, not node centers). */
1236
1232
  labelX?: number;
1237
1233
  labelY?: number;
@@ -1522,9 +1518,6 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
1522
1518
  service: 'service',
1523
1519
  };
1524
1520
 
1525
- /** Right-badge accent for `proposed: true` (no role, or combined with role). */
1526
- export const PROPOSED_COLOR = '#b8860b'; // darkgoldenrod — not-yet-in-source
1527
-
1528
1521
  /**
1529
1522
  * Border accent for `deprecated: true` — the claim was real, its source is gone.
1530
1523
  * A desaturated slate rather than true silver: role badges already own silver
@@ -1545,25 +1538,19 @@ export function deprecatedBadgeColor(component: {
1545
1538
  }
1546
1539
 
1547
1540
  /**
1548
- * Top-right badge label: proposed wins over role when both are set
1549
- * (proposed is the temporary exception to scan for; role stays on the model).
1550
- * Returns null when neither applies.
1541
+ * Top-right badge label: the role, when set. Returns null otherwise.
1551
1542
  */
1552
1543
  export function rightBadgeLabel(component: {
1553
1544
  role?: SubsystemComponentRole;
1554
- proposed?: boolean;
1555
1545
  }): string | null {
1556
- if (component.proposed) return 'proposed';
1557
1546
  if (component.role != null) return ROLE_LABEL[component.role];
1558
1547
  return null;
1559
1548
  }
1560
1549
 
1561
- /** Color for the top-right badge (role and/or proposed). */
1550
+ /** Color for the top-right role badge. */
1562
1551
  export function rightBadgeColor(component: {
1563
1552
  role?: SubsystemComponentRole;
1564
- proposed?: boolean;
1565
1553
  }): string | null {
1566
- if (component.proposed) return PROPOSED_COLOR;
1567
1554
  if (component.role != null) return ROLE_COLOR[component.role];
1568
1555
  return null;
1569
1556
  }
@@ -1593,7 +1580,7 @@ function storeStorage(component: {
1593
1580
 
1594
1581
  /** Top-right storage badge for `construct: 'store'` nodes with an authored
1595
1582
  * `storage` declaration — how the retained state is backed. Sits beside the
1596
- * role/proposed badge (storage is orthogonal to topology). Null otherwise. */
1583
+ * role badge (storage is orthogonal to topology). Null otherwise. */
1597
1584
  export function storageBadgeLabel(component: {
1598
1585
  construct: SubsystemComponentConstruct;
1599
1586
  declaration?: AnyComponentDetail;
@@ -1688,7 +1675,6 @@ export function nodeMinWidthForBadges(component: {
1688
1675
  framework?: string;
1689
1676
  stereotype?: string;
1690
1677
  role?: SubsystemComponentRole;
1691
- proposed?: boolean;
1692
1678
  deprecated?: boolean;
1693
1679
  entityKind?: string;
1694
1680
  declaration?: AnyComponentDetail;
@@ -20,7 +20,6 @@ import {
20
20
  edgeColor,
21
21
  edgeStrokeStyle,
22
22
  DEPRECATED_COLOR,
23
- PROPOSED_COLOR,
24
23
  deprecatedBadgeColor,
25
24
  deprecatedBadgeLabel,
26
25
  constructBadgeColor,
@@ -221,7 +220,7 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
221
220
  // `symbol` is the source of truth; `name` is derived from it consistently.
222
221
  const displayName = deriveNameFromSymbol(c.symbol, c.construct, c.name, c.file, c.stereotype);
223
222
  // Top badges are absolutely positioned — widen the node so they nowrap
224
- // instead of wrapping, including when construct + role/proposed share the top.
223
+ // instead of wrapping, including when construct + role share the top.
225
224
  const badgeMinWidth = nodeMinWidthForBadges(c);
226
225
  const topRightLabel = rightBadgeLabel(c);
227
226
  const topRightColor = rightBadgeColor(c);
@@ -243,17 +242,14 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
243
242
  const nodeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
244
243
  const hoverBg =
245
244
  theme.colors.backgroundHover ?? theme.colors.backgroundTertiary ?? nodeBg;
246
- // Border: proposed uses the goldenrod accent (dashed), deprecated the slate
247
- // accent (dotted, same family as issue) since both mean "not a live claim";
248
- // construct color stays on the left badge. File-open spotlight still wins
249
- // with primary.
245
+ // Border: deprecated uses the slate accent (dotted, same family as issue)
246
+ // since it means "not a live claim"; construct color stays on the left
247
+ // badge. File-open spotlight still wins with primary.
250
248
  const borderColor = fileMatch
251
249
  ? theme.colors.primary
252
- : c.proposed
253
- ? PROPOSED_COLOR
254
- : c.deprecated
255
- ? DEPRECATED_COLOR
256
- : color;
250
+ : c.deprecated
251
+ ? DEPRECATED_COLOR
252
+ : color;
257
253
  // Selection is stamped into data by the graph component — React Flow's own
258
254
  // `selected` never updates because the node stops click propagation.
259
255
  const isSelected = selected || (data.isSelected as boolean | undefined) === true;
@@ -269,21 +265,19 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
269
265
  const borderW = isSelected || fileMatch ? 4 : 2;
270
266
  const badgeTop = -9 - borderW;
271
267
  const badgeEdge = -borderW;
272
- // A node with diagnostics turns its border dotted — a "something's off"
273
- // sibling of `proposed`'s dashed, but distinct so the two don't read alike.
274
- // The border keeps its construct color; the severity rides on the chip.
275
- // Verification skips `proposed` nodes, so the two rarely stack — issue wins.
268
+ // A node with diagnostics turns its border dotted — an "something's off"
269
+ // signal. The border keeps its construct color; the severity rides on the
270
+ // chip. Deprecated is also dotted, but slate rather than construct-colored:
271
+ // "this claim was retired" is closer to a diagnostic than to a placeholder.
276
272
  const hasIssue = data.issue != null;
277
273
  // Deprecated is dotted like issue but slate rather than construct-colored:
278
274
  // "this claim was retired" is closer to a diagnostic than to a placeholder,
279
275
  // and verification skips deprecated nodes so the two never stack in practice.
280
276
  const borderStyle = hasIssue
281
277
  ? 'dotted'
282
- : c.proposed
283
- ? 'dashed'
284
- : c.deprecated
285
- ? 'dotted'
286
- : 'solid';
278
+ : c.deprecated
279
+ ? 'dotted'
280
+ : 'solid';
287
281
 
288
282
  return (
289
283
  <div
@@ -352,9 +346,9 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
352
346
  {constructBadgeLabel(c)}
353
347
  </div>
354
348
 
355
- {/* Secondary badge cluster — proposed wins over role when both are set;
356
- store storage (retention backing) sits beside, since it is
357
- orthogonal to topology. Persistent; pointer-events pass through.
349
+ {/* Secondary badge cluster — the role badge, with store storage
350
+ (retention backing) beside it since it is orthogonal to topology.
351
+ Persistent; pointer-events pass through.
358
352
  Sits astride the bottom edge, centered (mirroring the top construct
359
353
  badge's straddle), so the top edge stays free for the construct tag. */}
360
354
  {(topRightLabel != null || storageLabel != null || deprecatedLabel != null) && (
@@ -869,11 +863,8 @@ export function SubsystemEdge({
869
863
  const edgeRef = { mechanism, provenance: data?.provenance };
870
864
  const color = edgeColor(edgeRef);
871
865
  // Dash style encodes provenance (graphify) and relationship kind (dashed =
872
- // inverted-control or observational: hierarchy, registration, watches). A
873
- // step-proposed edge also dashes — the seam itself is planned — matching
874
- // the dashed edge label and a proposed node's dashed border.
875
- const isDashed =
876
- edgeStrokeStyle(edgeRef) === 'dashed' || data?.proposedStep === true;
866
+ // inverted-control or observational: hierarchy, registration, watches).
867
+ const isDashed = edgeStrokeStyle(edgeRef) === 'dashed';
877
868
  const dimmed = data?.dimmed === true;
878
869
  // Dim the stroke by color, never via path `opacity`. SVG markers are shared
879
870
  // by id; opacity on the referencing path paints every arrowhead that uses
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { SubsystemTrail } from './model';
3
+ import {
4
+ buildStepEmphasisMap,
5
+ emphasisForTrail,
6
+ emphasisKey,
7
+ findStepEmphasis,
8
+ orphanedEmphasis,
9
+ stepClaimKey,
10
+ type StepEmphasisRecord,
11
+ } from './stepEmphasis';
12
+
13
+ function step(overrides: Partial<SubsystemTrail['steps'][number]> = {}): SubsystemTrail['steps'][number] {
14
+ return {
15
+ from: 'ui',
16
+ to: 'api',
17
+ mechanism: 'calls',
18
+ file: 'src/ui/login.tsx',
19
+ span: { startLine: 42, endLine: 42, contentHash: 'abc' },
20
+ purl: 'pkg:github/acme/app#src/ui/login.tsx',
21
+ symbol: 'Login.submit',
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function trail(id: string, steps: SubsystemTrail['steps']): SubsystemTrail {
27
+ return { id, title: id, steps };
28
+ }
29
+
30
+ function record(trailId: string, stepKey: string): StepEmphasisRecord {
31
+ return {
32
+ trailId,
33
+ stepKey,
34
+ guidance: 'Why this step matters.',
35
+ provenance: { source: 'author', at: '2026-10-19T00:00:00.000Z' },
36
+ };
37
+ }
38
+
39
+ describe('stepClaimKey', () => {
40
+ test('is stable for the same claim', () => {
41
+ expect(stepClaimKey(step())).toBe(stepClaimKey(step()));
42
+ });
43
+
44
+ test('ignores annotation, which is not the claim', () => {
45
+ expect(stepClaimKey(step({ annotation: 'why' }))).toBe(
46
+ stepClaimKey(step()),
47
+ );
48
+ });
49
+
50
+ test('changes when the claim changes', () => {
51
+ const base = stepClaimKey(step());
52
+ expect(base).not.toBe(stepClaimKey(step({ from: 'other' })));
53
+ expect(base).not.toBe(stepClaimKey(step({ to: 'other' })));
54
+ expect(base).not.toBe(stepClaimKey(step({ mechanism: 'feeds' })));
55
+ expect(base).not.toBe(stepClaimKey(step({ file: 'src/other.ts' })));
56
+ expect(base).not.toBe(stepClaimKey(step({ symbol: 'Other.submit' })));
57
+ expect(base).not.toBe(
58
+ stepClaimKey(step({ span: { startLine: 43, endLine: 43, contentHash: 'abc' } })),
59
+ );
60
+ expect(base).not.toBe(
61
+ stepClaimKey(step({ span: { startLine: 42, endLine: 42, contentHash: 'changed' } })),
62
+ );
63
+ });
64
+ });
65
+
66
+ describe('step emphasis lookup', () => {
67
+ test('resolves after steps are reordered', () => {
68
+ const first = step({ symbol: 'first' });
69
+ const second = step({ symbol: 'second' });
70
+ const map = buildStepEmphasisMap([record('flow', stepClaimKey(first))]);
71
+ const reordered = trail('flow', [second, first]);
72
+ expect(findStepEmphasis(map, 'flow', reordered.steps[1]!)).toBeDefined();
73
+ expect(findStepEmphasis(map, 'flow', reordered.steps[0]!)).toBeUndefined();
74
+ });
75
+
76
+ test('emphasisForTrail reports marks in current step order', () => {
77
+ const a = step({ symbol: 'a' });
78
+ const b = step({ symbol: 'b' });
79
+ const c = step({ symbol: 'c' });
80
+ const map = buildStepEmphasisMap([
81
+ record('flow', stepClaimKey(c)),
82
+ record('flow', stepClaimKey(a)),
83
+ ]);
84
+ expect(emphasisForTrail(map, trail('flow', [b, a, c])).map((r) => r.stepIndex)).toEqual([
85
+ 1, 2,
86
+ ]);
87
+ });
88
+
89
+ test('an edited claim orphans its mark rather than re-binding it', () => {
90
+ const original = step();
91
+ const edited = step({ file: 'src/ui/login-v2.tsx' });
92
+ const records = [record('flow', stepClaimKey(original))];
93
+ const orphans = orphanedEmphasis(records, [trail('flow', [edited])]);
94
+ expect(orphans).toHaveLength(1);
95
+ expect(orphans[0]!.stepKey).toBe(stepClaimKey(original));
96
+ });
97
+
98
+ test('live marks are not reported as orphans', () => {
99
+ const live = step();
100
+ const records = [record('flow', stepClaimKey(live))];
101
+ expect(orphanedEmphasis(records, [trail('flow', [live])])).toEqual([]);
102
+ });
103
+
104
+ test('keys are scoped by trail id', () => {
105
+ const shared = step();
106
+ const map = buildStepEmphasisMap([record('flow-a', stepClaimKey(shared))]);
107
+ expect(emphasisKey('flow-b', shared)).not.toBe(emphasisKey('flow-a', shared));
108
+ expect(findStepEmphasis(map, 'flow-b', shared)).toBeUndefined();
109
+ });
110
+ });