@principal-ai/subsystems-react 0.19.2 → 0.20.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 (45) hide show
  1. package/dist/graphify/consolidated.d.ts +16 -1
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/index.d.ts +1 -1
  4. package/dist/graphify/index.d.ts.map +1 -1
  5. package/dist/graphify/index.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/pierre/constructColors.d.ts +10 -0
  11. package/dist/pierre/constructColors.d.ts.map +1 -1
  12. package/dist/pierre/constructColors.js +15 -0
  13. package/dist/pierre/constructColors.js.map +1 -1
  14. package/dist/subsystem/ComponentDeclaration.js +2 -2
  15. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  16. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.js +2 -1
  18. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  19. package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
  20. package/dist/subsystem/formatDeclaration.js +13 -0
  21. package/dist/subsystem/formatDeclaration.js.map +1 -1
  22. package/dist/subsystem/model.d.ts +21 -2
  23. package/dist/subsystem/model.d.ts.map +1 -1
  24. package/dist/subsystem/model.js +5 -1
  25. package/dist/subsystem/model.js.map +1 -1
  26. package/dist/subsystem/nodes.d.ts.map +1 -1
  27. package/dist/subsystem/nodes.js +4 -3
  28. package/dist/subsystem/nodes.js.map +1 -1
  29. package/dist/subsystem/tokenizeComponent.d.ts.map +1 -1
  30. package/dist/subsystem/tokenizeComponent.js +4 -0
  31. package/dist/subsystem/tokenizeComponent.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/graphify/consolidated.ts +18 -0
  34. package/src/graphify/index.ts +2 -0
  35. package/src/index.ts +3 -1
  36. package/src/pierre/constructColors.test.ts +32 -2
  37. package/src/pierre/constructColors.ts +18 -0
  38. package/src/stories/Subsystem/ComponentGraph/CustomEntities.stories.tsx +184 -0
  39. package/src/subsystem/ComponentDeclaration.tsx +2 -2
  40. package/src/subsystem/formatDeclaration.test.ts +40 -0
  41. package/src/subsystem/formatDeclaration.ts +13 -0
  42. package/src/subsystem/model.test.ts +9 -0
  43. package/src/subsystem/model.ts +25 -2
  44. package/src/subsystem/nodes.tsx +4 -3
  45. package/src/subsystem/tokenizeComponent.ts +5 -0
@@ -43,6 +43,10 @@ export async function tokenizeComponent(component, printWidth = 80, themeName =
43
43
  const escaped = label.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
44
44
  return tokenizeFormatted(`external '${escaped}'`, themeName);
45
45
  }
46
+ // Custom entity — an actor (Person/agent/queue), not code; bypass Prettier.
47
+ if (kind === 'custom_entity') {
48
+ return tokenizeFormatted(generateDeclarationString(component), themeName);
49
+ }
46
50
  // Generate → format → tokenize
47
51
  const raw = generateDeclarationString(component);
48
52
  const { prettier, plugins } = await getPrettier();
@@ -1 +1 @@
1
- {"version":3,"file":"tokenizeComponent.js","sourceRoot":"","sources":["../../src/subsystem/tokenizeComponent.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,8CAA8C;AAC9C,IAAI,eAAe,GAAyD,IAAI,CAAC;AACjF,IAAI,sBAAsB,GAGd,IAAI,CAAC;AAEjB,KAAK,UAAU,WAAW;IACxB,IAAI,CAAC,eAAe,EAAE;QACpB,eAAe,GAAG,MAAM,CAAC,qBAAqB,CAAC,CAAC;KACjD;IACD,IAAI,CAAC,sBAAsB,EAAE;QAC3B,sBAAsB,GAAG,OAAO,CAAC,GAAG,CAAC;YACnC,MAAM,CAAC,6BAA6B,CAAC;YACrC,MAAM,CAAC,yBAAyB,CAAC;SAClC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;KAC7D;IACD,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,eAAe,EAAE,sBAAsB,CAAC,CAAC,CAAC;IACzF,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;AAC/B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,SAA6B,EAC7B,UAAU,GAAG,EAAE,EACf,YAAmC,aAAa;IAEhD,sDAAsD;IACtD,IAAI,SAAS,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC,MAAM,CAAC;IAE9C,yDAAyD;IACzD,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,EAAE,IAAI,IAAI,SAAS,CAAC,SAAS,CAAC;IAC3D,IAAI,IAAI,KAAK,UAAU,EAAE;QACvB,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,EAAE,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC;QAC9F,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAClE,OAAO,iBAAiB,CAAC,aAAa,OAAO,GAAG,EAAE,SAAS,CAAC,CAAC;KAC9D;IAED,+BAA+B;IAC/B,MAAM,GAAG,GAAG,yBAAyB,CAAC,SAAS,CAAC,CAAC;IACjD,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,WAAW,EAAE,CAAC;IAClD,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,GAAG,EAAE;QAC3C,MAAM,EAAE,YAAY;QACpB,OAAO,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC;QAC7C,UAAU;KACX,CAAC,CAAC;IAEH,OAAO,iBAAiB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;AACjD,CAAC"}
1
+ {"version":3,"file":"tokenizeComponent.js","sourceRoot":"","sources":["../../src/subsystem/tokenizeComponent.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,8CAA8C;AAC9C,IAAI,eAAe,GAAyD,IAAI,CAAC;AACjF,IAAI,sBAAsB,GAGd,IAAI,CAAC;AAEjB,KAAK,UAAU,WAAW;IACxB,IAAI,CAAC,eAAe,EAAE;QACpB,eAAe,GAAG,MAAM,CAAC,qBAAqB,CAAC,CAAC;KACjD;IACD,IAAI,CAAC,sBAAsB,EAAE;QAC3B,sBAAsB,GAAG,OAAO,CAAC,GAAG,CAAC;YACnC,MAAM,CAAC,6BAA6B,CAAC;YACrC,MAAM,CAAC,yBAAyB,CAAC;SAClC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;KAC7D;IACD,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,eAAe,EAAE,sBAAsB,CAAC,CAAC,CAAC;IACzF,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;AAC/B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,SAA6B,EAC7B,UAAU,GAAG,EAAE,EACf,YAAmC,aAAa;IAEhD,sDAAsD;IACtD,IAAI,SAAS,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC,MAAM,CAAC;IAE9C,yDAAyD;IACzD,MAAM,IAAI,GAAG,SAAS,CAAC,MAAM,EAAE,IAAI,IAAI,SAAS,CAAC,SAAS,CAAC;IAC3D,IAAI,IAAI,KAAK,UAAU,EAAE;QACvB,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,EAAE,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC;QAC9F,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAClE,OAAO,iBAAiB,CAAC,aAAa,OAAO,GAAG,EAAE,SAAS,CAAC,CAAC;KAC9D;IAED,4EAA4E;IAC5E,IAAI,IAAI,KAAK,eAAe,EAAE;QAC5B,OAAO,iBAAiB,CAAC,yBAAyB,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,CAAC;KAC3E;IAED,+BAA+B;IAC/B,MAAM,GAAG,GAAG,yBAAyB,CAAC,SAAS,CAAC,CAAC;IACjD,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,WAAW,EAAE,CAAC;IAClD,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC,GAAG,EAAE;QAC3C,MAAM,EAAE,YAAY;QACpB,OAAO,EAAE,CAAC,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC;QAC7C,UAAU;KACX,CAAC,CAAC;IAEH,OAAO,iBAAiB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;AACjD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/subsystems-react",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "React UI for Subsystem Models — graph, Pierre code views, and session-event feeds",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -45,7 +45,7 @@
45
45
  "@pierre/diffs": "1.3.6",
46
46
  "@principal-ade/industry-theme": "0.1.7",
47
47
  "@principal-ai/agent-monitoring": "^0.3.38",
48
- "@principal-ai/subsystems-core": "0.30.0",
48
+ "@principal-ai/subsystems-core": "0.31.0",
49
49
  "@storybook/addon-docs": "10.1.2",
50
50
  "@storybook/addon-links": "10.1.2",
51
51
  "@storybook/addon-onboarding": "10.1.2",
@@ -190,6 +190,23 @@ export interface GraphifyExternalDetail {
190
190
  label: string;
191
191
  }
192
192
 
193
+ /** A single authored attribute on a custom entity — free-form key/value. */
194
+ export interface GraphifyCustomEntityAttribute {
195
+ key: string;
196
+ value: string;
197
+ }
198
+
199
+ /**
200
+ * A custom-entity (actor — Person/agent/queue) declaration. Authored, never
201
+ * extracted: there is no graphify node for a Person. `attributes` are
202
+ * free-form key/value pairs surfaced in the declaration panel; the base
203
+ * component fields carry the node identity (`entityKind`, `color`, `purpose`).
204
+ */
205
+ export interface GraphifyCustomEntityDetail {
206
+ kind: 'custom_entity';
207
+ attributes: GraphifyCustomEntityAttribute[];
208
+ }
209
+
193
210
  /**
194
211
  * A retained-state node (`kind: 'store'`): state and its contract, no
195
212
  * behavior. `kind` is the node's verifiable anchor — a store anchors to a
@@ -232,6 +249,7 @@ export type GraphifyComponentDetail =
232
249
  | GraphifyTypeDetail
233
250
  | GraphifyModuleDetail
234
251
  | GraphifyExternalDetail
252
+ | GraphifyCustomEntityDetail
235
253
  | GraphifyStoreDetail;
236
254
 
237
255
  /** A raw graphify edge backing a facet claim (verification + provenance). */
@@ -31,6 +31,8 @@ export type {
31
31
  GraphifyTypeDetail,
32
32
  GraphifyModuleDetail,
33
33
  GraphifyExternalDetail,
34
+ GraphifyCustomEntityDetail,
35
+ GraphifyCustomEntityAttribute,
34
36
  GraphifyStoreDetail,
35
37
  GraphifyComponentDetail,
36
38
  GraphifyEdgeRef,
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ export type {
43
43
  GraphifyTypeDetail,
44
44
  GraphifyModuleDetail,
45
45
  GraphifyExternalDetail,
46
+ GraphifyCustomEntityDetail,
47
+ GraphifyCustomEntityAttribute,
46
48
  GraphifyComponentDetail,
47
49
  GraphifyEdgeRef,
48
50
  } from './graphify';
@@ -92,7 +94,7 @@ export type {
92
94
  } from './subsystem/ComponentDeclaration';
93
95
  export { ComponentDeclaration } from './subsystem/ComponentDeclaration';
94
96
  export { MECHANISM_COLOR, MECHANISM_STYLE } from './subsystem/model';
95
- export { CONSTRUCT_COLOR, constructColorsFromPierreTheme } from './pierre/constructColors';
97
+ export { CONSTRUCT_COLOR, componentColor, constructColorsFromPierreTheme } from './pierre/constructColors';
96
98
  export {
97
99
  purlRepoKey,
98
100
  purlOwnerName,
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { CONSTRUCT_COLOR, constructColorsFromPierreTheme } from './constructColors';
2
+ import {
3
+ CONSTRUCT_COLOR,
4
+ componentColor,
5
+ constructColorsFromPierreTheme,
6
+ } from './constructColors';
3
7
  import type { SubsystemComponentConstruct } from '../subsystem/model';
4
8
 
5
9
  const CONSTRUCTS: SubsystemComponentConstruct[] = [
@@ -12,6 +16,7 @@ const CONSTRUCTS: SubsystemComponentConstruct[] = [
12
16
  'module',
13
17
  'store',
14
18
  'external',
19
+ 'custom_entity',
15
20
  ];
16
21
 
17
22
  const HEX = /^#[0-9a-f]{6}$/;
@@ -26,7 +31,7 @@ describe('constructColorsFromPierreTheme', () => {
26
31
  }
27
32
  });
28
33
 
29
- test('all seven constructs are distinguishable within a theme', () => {
34
+ test('all ten constructs are distinguishable within a theme', () => {
30
35
  for (const themeName of ['pierre-dark', 'pierre-light'] as const) {
31
36
  const colors = constructColorsFromPierreTheme(themeName);
32
37
  const values = CONSTRUCTS.map((c) => colors[c]);
@@ -49,4 +54,29 @@ describe('constructColorsFromPierreTheme', () => {
49
54
  test('the static dark instantiation matches the derived table', () => {
50
55
  expect(CONSTRUCT_COLOR).toEqual(constructColorsFromPierreTheme('pierre-dark'));
51
56
  });
57
+
58
+ test('componentColor inherits the construct color when no override', () => {
59
+ expect(componentColor({ construct: 'class' }, 'pierre-dark')).toBe(
60
+ constructColorsFromPierreTheme('pierre-dark').class,
61
+ );
62
+ expect(componentColor({ construct: 'custom_entity' }, 'pierre-light')).toBe(
63
+ constructColorsFromPierreTheme('pierre-light').custom_entity,
64
+ );
65
+ });
66
+
67
+ test('componentColor prefers an authored color override', () => {
68
+ expect(
69
+ componentColor(
70
+ { construct: 'custom_entity', color: '#ff00aa' },
71
+ 'pierre-dark',
72
+ ),
73
+ ).toBe('#ff00aa');
74
+ // same override in both themes — the explicit color wins outright
75
+ expect(
76
+ componentColor(
77
+ { construct: 'custom_entity', color: '#ff00aa' },
78
+ 'pierre-light',
79
+ ),
80
+ ).toBe('#ff00aa');
81
+ });
52
82
  });
@@ -19,6 +19,20 @@ import type { PierreSyntaxThemeName } from './pierreSyntaxTheme';
19
19
  import pierreDark from '@pierre/theme/pierre-dark';
20
20
  import pierreLight from '@pierre/theme/pierre-light';
21
21
 
22
+ /**
23
+ * Resolve a component's accent color: an authored `color` override wins;
24
+ * otherwise the construct color derived from the active Pierre syntax theme.
25
+ * Single source of truth so the node border/badges and the declaration panel
26
+ * agree on what a custom entity (or any component) renders as.
27
+ */
28
+ export function componentColor(
29
+ component: { color?: string; construct: SubsystemComponentConstruct },
30
+ themeName: PierreSyntaxThemeName,
31
+ ): string {
32
+ if (component.color) return component.color;
33
+ return constructColorsFromPierreTheme(themeName)[component.construct];
34
+ }
35
+
22
36
  interface PierreThemeInput {
23
37
  type: 'dark' | 'light';
24
38
  colors: Record<string, string>;
@@ -93,6 +107,10 @@ export function constructColorsFromPierreTheme(
93
107
  module: towardBackground(moduleColor, 0.18),
94
108
  store: pick('variable.other.constant'),
95
109
  external: pick('comment'),
110
+ // custom entities are actors, not code — the heading/emphasis coral reads
111
+ // as a highlight against the code-construct hues. Distinct from entry role
112
+ // orange (#ff6b35) which stays a badge, not a border.
113
+ custom_entity: pick('markup.heading'),
96
114
  };
97
115
  }
98
116
 
@@ -0,0 +1,184 @@
1
+ import React from 'react';
2
+ import '@xyflow/react/dist/style.css';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { SubsystemComponentGraph } from '../../../subsystem/SubsystemComponentGraph';
6
+ import type { SubsystemComponent, SubsystemComponentEdge } from '../../../subsystem/model';
7
+ import type { GraphifyCustomEntityDetail } from '../../../graphify';
8
+
9
+ /** Authored attributes surfaced in the declaration panel. */
10
+ function entityDetail(attributes: Array<[string, string]>): GraphifyCustomEntityDetail {
11
+ return {
12
+ kind: 'custom_entity',
13
+ attributes: attributes.map(([key, value]) => ({ key, value })),
14
+ };
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Custom-entity prototype — the NovaTech work-request chain as it would read
19
+ // once the `custom_entity` construct ships. Persons, an agent, and a queue are
20
+ // authored as `construct: 'custom_entity'` with an open-string `entityKind`
21
+ // (Person / agent / queue); every entity still groups by `process` and flows
22
+ // through the same edge vocabulary as code nodes.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const components: SubsystemComponent[] = [
26
+ {
27
+ id: 'wr',
28
+ name: 'WorkRequester',
29
+ construct: 'custom_entity',
30
+ entityKind: 'Person',
31
+ file: '',
32
+ purl: 'external',
33
+ purpose: 'submits a facilities work request',
34
+ role: 'entry',
35
+ detail: entityDetail([
36
+ ['vertical', 'buildings'],
37
+ ['escalationPath', 'FacilitiesTechnician → ShiftSupervisor'],
38
+ ]),
39
+ },
40
+ {
41
+ id: 'tech',
42
+ name: 'FacilitiesTechnician',
43
+ construct: 'custom_entity',
44
+ entityKind: 'Person',
45
+ file: '',
46
+ purl: 'pkg:github/novatech/facilities-ops',
47
+ purpose: 'performs repairs, resolves L1 requests',
48
+ process: 'novatech/core-ops',
49
+ layer: 1,
50
+ detail: entityDetail([
51
+ ['level', 'L1'],
52
+ ['projects', 'HVAC, plumbing'],
53
+ ]),
54
+ },
55
+ {
56
+ id: 'sup',
57
+ name: 'ShiftSupervisor',
58
+ construct: 'custom_entity',
59
+ entityKind: 'Person',
60
+ file: '',
61
+ purl: 'pkg:github/novatech/facilities-ops',
62
+ purpose: 'assigns technicians, approves out-of-scope work',
63
+ process: 'novatech/core-ops',
64
+ layer: 2,
65
+ detail: entityDetail([
66
+ ['level', 'L2'],
67
+ ['approvalLimit', '$500'],
68
+ ]),
69
+ },
70
+ {
71
+ id: 'mgr',
72
+ name: 'CampusOpsManager',
73
+ construct: 'custom_entity',
74
+ entityKind: 'Person',
75
+ file: '',
76
+ purl: 'pkg:github/novatech/facilities-ops',
77
+ purpose: 'owns the queue, escalates campus-wide issues',
78
+ process: 'novatech/core-ops',
79
+ layer: 3,
80
+ detail: entityDetail([
81
+ ['level', 'L3'],
82
+ ['approvalLimit', '$5,000'],
83
+ ]),
84
+ },
85
+ {
86
+ id: 'vp',
87
+ name: 'VPOperations',
88
+ construct: 'custom_entity',
89
+ entityKind: 'Person',
90
+ file: '',
91
+ purl: 'pkg:github/novatech/facilities-ops',
92
+ purpose: 'funds large projects, signs off L4 requests',
93
+ process: 'novatech/core-ops',
94
+ layer: 4,
95
+ detail: entityDetail([
96
+ ['level', 'L4'],
97
+ ['approvalLimit', 'unlimited'],
98
+ ]),
99
+ },
100
+ {
101
+ id: 'agent',
102
+ name: 'OpsInsightAgent',
103
+ construct: 'custom_entity',
104
+ entityKind: 'agent',
105
+ color: '#9d6afb',
106
+ file: '',
107
+ purl: 'pkg:github/novatech/facilities-ops',
108
+ purpose: 'proposes nudges from open-request patterns; never owns the queue',
109
+ process: 'novatech/core-ops',
110
+ layer: 2,
111
+ detail: entityDetail([
112
+ ['slack', 'novatech/facilities-ops'],
113
+ ['permission', 'propose-only'],
114
+ ]),
115
+ },
116
+ {
117
+ id: 'queue',
118
+ name: 'NudgeQueue',
119
+ construct: 'custom_entity',
120
+ entityKind: 'queue',
121
+ color: '#b48ead',
122
+ file: '',
123
+ purl: 'pkg:github/novatech/facilities-ops',
124
+ purpose: 'holds proposed nudges until a supervisor confirms them',
125
+ process: 'novatech/core-ops',
126
+ layer: 3,
127
+ detail: entityDetail([
128
+ ['durable', 'postgres + outbox'],
129
+ ['owner', 'CampusOpsManager'],
130
+ ]),
131
+ },
132
+ {
133
+ id: 'store',
134
+ name: 'WorkRequestStore',
135
+ construct: 'store',
136
+ file: 'src/ops/workRequests.ts',
137
+ purl: 'pkg:github/novatech/facilities-ops',
138
+ purpose: 'retained work-request state — submissions, assignments, approvals',
139
+ symbol: 'workRequests',
140
+ process: 'novatech/core-ops',
141
+ layer: 1,
142
+ },
143
+ ];
144
+
145
+ const edges: SubsystemComponentEdge[] = [
146
+ ['wr', 'store', 'feeds'],
147
+ ['store', 'tech', 'feeds'],
148
+ ['tech', 'sup', 'watches'],
149
+ ['sup', 'queue', 'watches'],
150
+ ['agent', 'store', 'reads'],
151
+ ['agent', 'queue', 'feeds'],
152
+ ['queue', 'mgr', 'feeds'],
153
+ ['mgr', 'vp', 'watches'],
154
+ ].map(([from, to, mechanism], i) => ({ id: `e${i}`, from, to, mechanism }));
155
+
156
+ const meta = {
157
+ title: 'Subsystem/ComponentGraph/CustomEntities',
158
+ component: SubsystemComponentGraph,
159
+ parameters: { layout: 'fullscreen' },
160
+ tags: ['autodocs'],
161
+ decorators: [
162
+ (Story) => (
163
+ <ThemeProvider theme={defaultEditorTheme}>
164
+ <Story />
165
+ </ThemeProvider>
166
+ ),
167
+ ],
168
+ } satisfies Meta<typeof SubsystemComponentGraph>;
169
+
170
+ export default meta;
171
+ type Story = StoryObj<typeof meta>;
172
+
173
+ export const NovaTechWorkRequests: Story = {
174
+ render: () => (
175
+ <div style={{ width: '100%', height: '100vh' }}>
176
+ <SubsystemComponentGraph
177
+ title="Custom entities — NovaTech work-request chain"
178
+ description="Persons, an agent, and a queue as `custom_entity` nodes (badge = entityKind). Actors keep the themed coral; the agent and queue wear authored `color` overrides. Click a node for its declaration — `entity 'Name' — kind` plus authored `attributes` (key: value)."
179
+ components={components}
180
+ edges={edges}
181
+ />
182
+ </div>
183
+ ),
184
+ };
@@ -18,7 +18,7 @@ import {
18
18
  import type { SubsystemOpenFileOptions } from './declarationRef';
19
19
  import { parseSourceLocation } from './declarationRef';
20
20
  import { tokenizeComponent } from './tokenizeComponent';
21
- import { constructColorsFromPierreTheme } from '../pierre/constructColors';
21
+ import { componentColor } from '../pierre/constructColors';
22
22
 
23
23
  /** Live / result state for the declaration-panel Verify control. */
24
24
  export type ComponentVerificationPhase =
@@ -263,7 +263,7 @@ export function ComponentDeclaration({
263
263
  const [showFile, setShowFile] = useState(false);
264
264
  const [showPurpose, setShowPurpose] = useState(false);
265
265
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
266
- const color = constructColorsFromPierreTheme(pierreSyntaxTheme)[component.construct];
266
+ const color = componentColor(component, pierreSyntaxTheme);
267
267
  const okColor = '#3d9a5f';
268
268
  const warnColor = theme.colors.textSecondary;
269
269
 
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { generateDeclarationString } from './formatDeclaration';
3
+ import type { SubsystemComponent } from './model';
4
+
5
+ describe('generateDeclarationString — custom_entity', () => {
6
+ test('renders entity identity + entityKind, no attributes when none authored', () => {
7
+ const component: SubsystemComponent = {
8
+ id: 'ct',
9
+ name: 'FacilitiesTechnician',
10
+ construct: 'custom_entity',
11
+ entityKind: 'Person',
12
+ file: '',
13
+ purl: 'pkg:github/novatech/facilities-ops',
14
+ };
15
+ expect(generateDeclarationString(component)).toBe(
16
+ "entity 'FacilitiesTechnician' — Person",
17
+ );
18
+ });
19
+
20
+ test('renders authored attributes as indented key: value lines', () => {
21
+ const component: SubsystemComponent = {
22
+ id: 'ct',
23
+ name: 'OpsInsightAgent',
24
+ construct: 'custom_entity',
25
+ entityKind: 'agent',
26
+ file: '',
27
+ purl: 'pkg:github/novatech/facilities-ops',
28
+ detail: {
29
+ kind: 'custom_entity',
30
+ attributes: [
31
+ { key: 'slack', value: 'novatech/facilities-ops' },
32
+ { key: 'permission', value: 'propose-only' },
33
+ ],
34
+ },
35
+ };
36
+ expect(generateDeclarationString(component)).toBe(
37
+ "entity 'OpsInsightAgent' — agent\n slack: novatech/facilities-ops\n permission: propose-only",
38
+ );
39
+ });
40
+ });
@@ -45,6 +45,19 @@ export function generateDeclarationString(component: SubsystemComponent): string
45
45
  case 'external':
46
46
  // Not valid TypeScript — caller should handle formatting.
47
47
  return `external '${detail?.kind === 'external' ? detail.label : name}'`;
48
+ case 'custom_entity':
49
+ // An actor, not code — no declaration to generate. Renders as a
50
+ // non-TypeScript block: `entity 'Name' — kind` followed by the authored
51
+ // attributes (key: value) indented beneath. Not valid TS — callers
52
+ // bypass Prettier.
53
+ {
54
+ const kindLabel = component.entityKind ? ` — ${component.entityKind}` : '';
55
+ const attrs = detail?.kind === 'custom_entity' ? detail.attributes : [];
56
+ const attrLines = attrs.map((a) => ` ${a.key}: ${a.value}`).join('\n');
57
+ return attrLines
58
+ ? `entity '${name}'${kindLabel}\n${attrLines}`
59
+ : `entity '${name}'${kindLabel}`;
60
+ }
48
61
  default:
49
62
  return `${construct} ${name}`;
50
63
  }
@@ -93,6 +93,8 @@ describe('subsystem graph model', () => {
93
93
  // falls back to existing name when no symbol (class stays bare).
94
94
  expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader');
95
95
  expect(deriveNameFromSymbol('', 'external', 'principal-studio-host')).toBe('principal-studio-host');
96
+ // custom entities render bare — no decoration, name is the identity
97
+ expect(deriveNameFromSymbol(undefined, 'custom_entity', 'FacilitiesTechnician')).toBe('FacilitiesTechnician');
96
98
  });
97
99
 
98
100
  test('executable constructs wear () on the node', () => {
@@ -127,6 +129,13 @@ describe('subsystem graph model', () => {
127
129
  expect(constructBadgeLabel({ construct: 'function', stereotype: 'hook' })).toBe('hook');
128
130
  expect(constructBadgeLabel({ construct: 'function' })).toBe('function');
129
131
  expect(constructBadgeLabel({ construct: 'type_alias' })).toBe('type alias');
132
+ // custom entities wear their entityKind as the badge
133
+ expect(
134
+ constructBadgeLabel({ construct: 'custom_entity', entityKind: 'Person' }),
135
+ ).toBe('Person');
136
+ expect(
137
+ constructBadgeLabel({ construct: 'custom_entity' }),
138
+ ).toBe('custom_entity');
130
139
  });
131
140
 
132
141
  test('nodeMinWidthForBadges widens for long construct badges and role pairs', () => {
@@ -30,7 +30,8 @@ export type SubsystemComponentConstruct =
30
30
  | 'enum'
31
31
  | 'module'
32
32
  | 'store'
33
- | 'external';
33
+ | 'external'
34
+ | 'custom_entity';
34
35
 
35
36
  /**
36
37
  * Semantic role — where the node sits in the topology, orthogonal to
@@ -145,6 +146,22 @@ export interface SubsystemComponent {
145
146
  * a React UI unit reads as "component" rather than "function".
146
147
  */
147
148
  stereotype?: SubsystemStereotype;
149
+ /**
150
+ * Open-string entity kind for `construct: 'custom_entity'` nodes — what the
151
+ * actor/entity is (e.g. `Person`, `agent`, `queue`). Not code; there is no
152
+ * graphify anchor. Contrast with `construct` (what the declaration is) and
153
+ * `role` (where it sits topologically). Empty/unset for code constructs.
154
+ */
155
+ entityKind?: string;
156
+ /**
157
+ * Authored color override for the node border + badges, when the themed
158
+ * construct color isn't wanted. Primarily for `construct: 'custom_entity'`
159
+ * (an author picks a fitting hue for a Person/agent/queue) — but usable on
160
+ * any construct. Any `#rrggbb` string; when set it wins over the
161
+ * construct-derived color, mirrors in the declaration panel. Leave unset to
162
+ * inherit the Pierre construct palette.
163
+ */
164
+ color?: string;
148
165
  /**
149
166
  * Runtime process membership — which deployment unit this node is a
150
167
  * member of (e.g. `principal-studio/host`, `principal-studio/renderer`). Nodes
@@ -512,13 +529,18 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
512
529
  * Primary badge text for a node: prefer framework stereotype over the
513
530
  * language construct so a React UI unit reads as "component" / "hook"
514
531
  * rather than "function". When both framework and stereotype are set,
515
- * show `framework · stereotype` (e.g. `react · component`).
532
+ * show `framework · stereotype` (e.g. `react · component`). A custom
533
+ * entity wears its `entityKind` (Person/agent/queue) as the badge.
516
534
  */
517
535
  export function constructBadgeLabel(component: {
518
536
  construct: SubsystemComponentConstruct;
519
537
  framework?: string;
520
538
  stereotype?: string;
539
+ entityKind?: string;
521
540
  }): string {
541
+ if (component.construct === 'custom_entity' && component.entityKind) {
542
+ return component.entityKind;
543
+ }
522
544
  const constructLabel =
523
545
  component.construct === 'type_alias' ? 'type alias' : component.construct;
524
546
  if (component.stereotype && component.framework) {
@@ -554,6 +576,7 @@ export function nodeMinWidthForBadges(component: {
554
576
  framework?: string;
555
577
  stereotype?: string;
556
578
  role?: SubsystemComponentRole;
579
+ entityKind?: string;
557
580
  }): number {
558
581
  const left = estimateBadgeLabelWidth(constructBadgeLabel(component));
559
582
  if (component.role == null) {
@@ -30,7 +30,7 @@ import {
30
30
  type SubsystemGroupNodeData,
31
31
  type SubsystemGraphEdge,
32
32
  } from './model';
33
- import { constructColorsFromPierreTheme } from '../pierre/constructColors';
33
+ import { componentColor } from '../pierre/constructColors';
34
34
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
35
35
 
36
36
  export const CONSTRUCT_LABEL: Record<string, string> = {
@@ -43,6 +43,7 @@ export const CONSTRUCT_LABEL: Record<string, string> = {
43
43
  module: 'module',
44
44
  store: 'store',
45
45
  external: 'external',
46
+ custom_entity: 'entity',
46
47
  };
47
48
 
48
49
  /** Insert zero-width spaces at identifier word boundaries so long names wrap
@@ -83,8 +84,8 @@ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeD
83
84
  // Construct owns node color, derived from the active Pierre syntax theme —
84
85
  // the same palette the declaration panel and file drawer render with. Role
85
86
  // shows as the hover badge, not as color — for now; a role glyph/accent may
86
- // come later.
87
- const color = constructColorsFromPierreTheme(resolvePierreSyntaxThemeName(mode))[c.construct];
87
+ // come later. A component-authored `color` override wins over the construct.
88
+ const color = componentColor(c, resolvePierreSyntaxThemeName(mode));
88
89
  const [hover, setHover] = useState(false);
89
90
  const configuredMax = SUBSYSTEM_CALLBACKS.maxNodeWidth;
90
91
  const maxWidth = configuredMax ?? 300;
@@ -57,6 +57,11 @@ export async function tokenizeComponent(
57
57
  return tokenizeFormatted(`external '${escaped}'`, themeName);
58
58
  }
59
59
 
60
+ // Custom entity — an actor (Person/agent/queue), not code; bypass Prettier.
61
+ if (kind === 'custom_entity') {
62
+ return tokenizeFormatted(generateDeclarationString(component), themeName);
63
+ }
64
+
60
65
  // Generate → format → tokenize
61
66
  const raw = generateDeclarationString(component);
62
67
  const { prettier, plugins } = await getPrettier();