@principal-ai/subsystems-react 0.24.8 → 0.24.10

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.
@@ -0,0 +1,508 @@
1
+ import React from 'react';
2
+ import '@xyflow/react/dist/style.css';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { ReactFlowProvider, type NodeProps } from '@xyflow/react';
5
+ import { ThemeProvider, defaultEditorTheme, useTheme } from '@principal-ade/industry-theme';
6
+ import { FileX, Search, MapPin, Shapes, Sigma } from 'lucide-react';
7
+ import { SubsystemComponentNode } from '../../../subsystem/nodes';
8
+ import type { GraphifyComponentDetail } from '../../../graphify';
9
+ import type { SubsystemComponent, SubsystemGraphNode } from '../../../subsystem/model';
10
+
11
+ /**
12
+ * NodeAnatomy — a dissection of the component node's visual language.
13
+ *
14
+ * Section 1 lays out every channel the node already spends (border, corners,
15
+ * left badge, right badge cluster, fill) so an added channel can be chosen
16
+ * against it. Section 2 shows the *proposed* diagnostics layer (severity ring +
17
+ * earliest-rung corner chip) composed over real nodes. Nothing here is wired
18
+ * into the production node yet — this is the reference to design against.
19
+ */
20
+ const meta = {
21
+ title: 'Subsystem/ComponentGraph/NodeAnatomy',
22
+ parameters: { layout: 'fullscreen' },
23
+ tags: ['autodocs'],
24
+ decorators: [
25
+ (Story) => (
26
+ <ThemeProvider theme={defaultEditorTheme}>
27
+ <ReactFlowProvider>
28
+ <Story />
29
+ </ReactFlowProvider>
30
+ </ThemeProvider>
31
+ ),
32
+ ],
33
+ } satisfies Meta;
34
+
35
+ export default meta;
36
+ type Story = StoryObj<typeof meta>;
37
+
38
+ const PURL = 'pkg:github/principal-ai/subsystem-modeling';
39
+ const NODE_W = 230;
40
+ const NODE_H = 84;
41
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
42
+
43
+ function nodeProps(
44
+ component: SubsystemComponent,
45
+ opts?: { selected?: boolean; width?: number; height?: number },
46
+ ): NodeProps<SubsystemGraphNode> {
47
+ return {
48
+ data: { component },
49
+ selected: opts?.selected ?? false,
50
+ width: opts?.width ?? NODE_W,
51
+ height: opts?.height ?? NODE_H,
52
+ } as unknown as NodeProps<SubsystemGraphNode>;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Section 1 — the channels the node already spends
57
+ // ---------------------------------------------------------------------------
58
+
59
+ type Channel = {
60
+ label: string;
61
+ note: string;
62
+ component: SubsystemComponent;
63
+ selected?: boolean;
64
+ };
65
+
66
+ const storeDetail: GraphifyComponentDetail = {
67
+ kind: 'store',
68
+ storage: 'memory',
69
+ } as GraphifyComponentDetail;
70
+
71
+ const CHANNELS: Channel[] = [
72
+ {
73
+ label: 'baseline — function',
74
+ note: 'border color = construct (function-indigo); left badge = construct.',
75
+ component: {
76
+ id: 'fn',
77
+ name: 'auditSubsystemModel',
78
+ construct: 'function',
79
+ file: 'src/bun/verify-subsystem-component.ts',
80
+ purl: PURL,
81
+ symbol: 'auditSubsystemModel',
82
+ },
83
+ },
84
+ {
85
+ label: 'class',
86
+ note: 'construct color changes per kind; nothing else moves.',
87
+ component: {
88
+ id: 'cls',
89
+ name: 'MaintainModelAgent',
90
+ construct: 'class',
91
+ file: 'src/bun/maintain-model.ts',
92
+ purl: PURL,
93
+ symbol: 'MaintainModelAgent',
94
+ },
95
+ },
96
+ {
97
+ label: 'external',
98
+ note: 'outside the system — square corners, no file.',
99
+ component: {
100
+ id: 'ext',
101
+ name: '@opencode-ai/cli',
102
+ construct: 'external',
103
+ file: '',
104
+ purl: 'pkg:npm/@opencode-ai/cli',
105
+ symbol: '',
106
+ },
107
+ },
108
+ {
109
+ label: 'role badge',
110
+ note: 'top-right badge = role.',
111
+ component: {
112
+ id: 'role',
113
+ name: 'handleRequest',
114
+ construct: 'function',
115
+ file: 'src/bun/http-server.ts',
116
+ purl: PURL,
117
+ symbol: 'handleRequest',
118
+ role: 'entry',
119
+ },
120
+ },
121
+ {
122
+ label: 'store + storage badge',
123
+ note: 'store backs a storage badge beside the role badge.',
124
+ component: {
125
+ id: 'store',
126
+ name: 'sessionCache',
127
+ construct: 'store',
128
+ file: 'src/bun/session-cache.ts',
129
+ purl: PURL,
130
+ symbol: 'sessionCache',
131
+ declaration: storeDetail,
132
+ },
133
+ },
134
+ {
135
+ label: 'proposed',
136
+ note: 'dashed goldenrod border + right badge; fileless.',
137
+ component: {
138
+ id: 'prop',
139
+ name: 'OpenCodeV2Lifecycle',
140
+ construct: 'function',
141
+ file: '',
142
+ purl: PURL,
143
+ symbol: 'OpenCodeV2Lifecycle',
144
+ proposed: true,
145
+ },
146
+ },
147
+ {
148
+ label: 'proposed + role',
149
+ note: 'proposed wins the right badge; role stays on the model.',
150
+ component: {
151
+ id: 'prop-role',
152
+ name: 'startMaintainFlow',
153
+ construct: 'function',
154
+ file: '',
155
+ purl: PURL,
156
+ symbol: 'startMaintainFlow',
157
+ proposed: true,
158
+ role: 'service',
159
+ },
160
+ },
161
+ {
162
+ label: 'framework stereotype',
163
+ note: 'left badge prefers "framework · stereotype"; border stays construct.',
164
+ component: {
165
+ id: 'react',
166
+ name: 'SubsystemComponentNode',
167
+ construct: 'function',
168
+ file: 'src/subsystem/nodes.tsx',
169
+ purl: PURL,
170
+ symbol: 'SubsystemComponentNode',
171
+ framework: 'react',
172
+ stereotype: 'component',
173
+ },
174
+ },
175
+ {
176
+ label: 'custom entity',
177
+ note: 'left badge wears the entityKind.',
178
+ component: {
179
+ id: 'entity',
180
+ name: 'WorkRequester',
181
+ construct: 'custom_entity',
182
+ file: '',
183
+ purl: 'external',
184
+ entityKind: 'Person',
185
+ },
186
+ },
187
+ {
188
+ label: 'selected',
189
+ note: 'selection only thickens the border (2 → 4); color stays construct.',
190
+ component: {
191
+ id: 'sel',
192
+ name: 'runMaintainModel',
193
+ construct: 'function',
194
+ file: 'src/bun/maintain-model.ts',
195
+ purl: PURL,
196
+ symbol: 'runMaintainModel',
197
+ },
198
+ selected: true,
199
+ },
200
+ ];
201
+
202
+ function ChannelCell({ channel }: { channel: Channel }) {
203
+ const { theme } = useTheme();
204
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
205
+ return (
206
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: 280 }}>
207
+ <div style={{ fontFamily: MONO, fontSize: 11, color: muted, minHeight: 28 }}>
208
+ {channel.label}
209
+ </div>
210
+ <div style={{ position: 'relative', width: NODE_W, height: NODE_H, margin: '14px 0 16px' }}>
211
+ <SubsystemComponentNode {...nodeProps(channel.component, { selected: channel.selected })} />
212
+ </div>
213
+ <div style={{ fontSize: 10, fontFamily: MONO, color: muted, lineHeight: 1.5 }}>
214
+ {channel.note}
215
+ </div>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ const LEGEND: Array<[string, string]> = [
221
+ ['border color', 'construct kind (Pierre palette) — or primary when a file is open (spotlight)'],
222
+ ['border style', 'solid normally; dashed goldenrod when `proposed`'],
223
+ ['border width', '2px; 4px when selected or file-matched'],
224
+ ['corners', '8px rounded; square for `external`'],
225
+ ['left badge (top edge)', 'construct / framework stereotype / entityKind'],
226
+ ['right badge (top edge)', 'role, or `proposed` (wins); storage sits beside it'],
227
+ ['bottom strip (bottom edge)', 'filename — hover only, or while that file is open'],
228
+ ['fill', 'backgroundSecondary; opacity 0.18 when dimmed'],
229
+ ['OCCUPIED', 'top-left · top-right · bottom-center · border · fill'],
230
+ ['FREE', 'an outer ring/halo, and the bottom-left / bottom-right corners'],
231
+ ];
232
+
233
+ /** The node's existing channels, one per cell, with a legend. */
234
+ export const Channels: Story = {
235
+ render: () => {
236
+ return (
237
+ <div style={{ padding: 32, minHeight: '100vh', boxSizing: 'border-box' }}>
238
+ <Legend />
239
+ <div
240
+ style={{
241
+ display: 'flex',
242
+ flexWrap: 'wrap',
243
+ gap: '40px 28px',
244
+ alignItems: 'flex-start',
245
+ marginTop: 28,
246
+ }}
247
+ >
248
+ {CHANNELS.map((c) => (
249
+ <ChannelCell key={c.component.id} channel={c} />
250
+ ))}
251
+ </div>
252
+ </div>
253
+ );
254
+ },
255
+ };
256
+
257
+ function Legend() {
258
+ const { theme } = useTheme();
259
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
260
+ return (
261
+ <div style={{ maxWidth: 720 }}>
262
+ <div
263
+ style={{
264
+ fontSize: 12,
265
+ fontFamily: MONO,
266
+ textTransform: 'uppercase',
267
+ letterSpacing: 0.4,
268
+ color: muted,
269
+ marginBottom: 10,
270
+ }}
271
+ >
272
+ Channel legend
273
+ </div>
274
+ <div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', rowGap: 4, columnGap: 12 }}>
275
+ {LEGEND.map(([k, v]) => (
276
+ <React.Fragment key={k}>
277
+ <span style={{ fontFamily: MONO, fontSize: 11, color: theme.colors.text }}>{k}</span>
278
+ <span style={{ fontSize: 11, color: muted, lineHeight: 1.5 }}>{v}</span>
279
+ </React.Fragment>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Section 2 — proposed diagnostics layer: severity ring + earliest-rung chip
288
+ // ---------------------------------------------------------------------------
289
+
290
+ type Severity = 'error' | 'warn' | 'info';
291
+ type Rung = 'file' | 'symbol' | 'declaration' | 'type' | 'signature';
292
+
293
+ const RUNG_ICON = {
294
+ file: FileX,
295
+ symbol: Search,
296
+ declaration: MapPin,
297
+ type: Shapes,
298
+ signature: Sigma,
299
+ } as const;
300
+
301
+ const RUNG_LABEL: Record<Rung, string> = {
302
+ file: 'file',
303
+ symbol: 'symbol',
304
+ declaration: 'declaration',
305
+ type: 'type',
306
+ signature: 'signature',
307
+ };
308
+
309
+ function IssueOverlayNode({
310
+ component,
311
+ severity,
312
+ rung,
313
+ count = 1,
314
+ }: {
315
+ component: SubsystemComponent;
316
+ severity: Severity;
317
+ rung: Rung;
318
+ count?: number;
319
+ }) {
320
+ const { theme } = useTheme();
321
+ const color =
322
+ severity === 'error'
323
+ ? (theme.colors.error ?? '#e5534b')
324
+ : severity === 'warn'
325
+ ? (theme.colors.warning ?? '#d4a017')
326
+ : (theme.colors.info ?? '#0893d2');
327
+ // Gaps (info) read hollow/dashed — "couldn't confirm"; errors/warns solid.
328
+ const dashed = severity === 'info';
329
+ const Icon = RUNG_ICON[rung];
330
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
331
+ return (
332
+ <div style={{ position: 'relative', width: NODE_W, height: NODE_H }}>
333
+ <SubsystemComponentNode {...nodeProps(component)} />
334
+ {/* severity ring — additive, outside the construct-colored border */}
335
+ <div
336
+ style={{
337
+ position: 'absolute',
338
+ inset: -5,
339
+ borderRadius: 11,
340
+ border: `2px ${dashed ? 'dashed' : 'solid'} ${color}`,
341
+ pointerEvents: 'none',
342
+ }}
343
+ />
344
+ {/* earliest-rung corner chip + count */}
345
+ <div
346
+ style={{
347
+ position: 'absolute',
348
+ right: -8,
349
+ bottom: -7,
350
+ display: 'inline-flex',
351
+ alignItems: 'center',
352
+ gap: 3,
353
+ padding: '1px 6px',
354
+ borderRadius: 999,
355
+ border: `1.5px solid ${color}`,
356
+ background: badgeBg,
357
+ color,
358
+ fontFamily: MONO,
359
+ fontSize: 10,
360
+ fontWeight: 600,
361
+ lineHeight: 1.4,
362
+ }}
363
+ >
364
+ <Icon size={11} color={color} />
365
+ {count > 1 ? <span>{count}</span> : null}
366
+ </div>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function IssueCell({
372
+ caption,
373
+ clean,
374
+ ...node
375
+ }: {
376
+ caption: string;
377
+ component: SubsystemComponent;
378
+ severity: Severity;
379
+ rung: Rung;
380
+ count?: number;
381
+ clean?: boolean;
382
+ }) {
383
+ const { theme } = useTheme();
384
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
385
+ return (
386
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: 280 }}>
387
+ <div style={{ fontFamily: MONO, fontSize: 11, color: muted, minHeight: 28 }}>{caption}</div>
388
+ <div style={{ position: 'relative', width: NODE_W, height: NODE_H, margin: '16px 0 18px' }}>
389
+ {clean ? (
390
+ <SubsystemComponentNode {...nodeProps(node.component)} />
391
+ ) : (
392
+ <IssueOverlayNode
393
+ component={node.component}
394
+ severity={node.severity}
395
+ rung={node.rung}
396
+ count={node.count}
397
+ />
398
+ )}
399
+ </div>
400
+ <div style={{ fontSize: 10, fontFamily: MONO, color: muted }}>
401
+ {clean
402
+ ? 'no issues'
403
+ : `${node.severity} · ${RUNG_LABEL[node.rung]}${
404
+ node.count && node.count > 1 ? ` · ${node.count} issues` : ''
405
+ }`}
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ const ISSUE_EXAMPLES: Array<{
412
+ caption: string;
413
+ component: SubsystemComponent;
414
+ severity: Severity;
415
+ rung: Rung;
416
+ count?: number;
417
+ clean?: boolean;
418
+ }> = [
419
+ {
420
+ caption: 'clean node — no ring, no chip',
421
+ component: CHANNELS[0]!.component,
422
+ severity: 'error',
423
+ rung: 'file',
424
+ clean: true,
425
+ },
426
+ {
427
+ caption: 'error · earliest rung is file (declaration file missing)',
428
+ component: { ...CHANNELS[0]!.component, id: 'e-file' },
429
+ severity: 'error',
430
+ rung: 'file',
431
+ count: 2,
432
+ },
433
+ {
434
+ caption: 'warn · earliest rung is symbol (multiple symbol matches)',
435
+ component: { ...CHANNELS[1]!.component, id: 'e-sym' },
436
+ severity: 'warn',
437
+ rung: 'symbol',
438
+ },
439
+ {
440
+ caption: 'info/gap · dashed ring · declaration drift',
441
+ component: { ...CHANNELS[1]!.component, id: 'e-decl' },
442
+ severity: 'info',
443
+ rung: 'declaration',
444
+ },
445
+ {
446
+ caption: 'error · type mismatch',
447
+ component: { ...CHANNELS[7]!.component, id: 'e-type' },
448
+ severity: 'error',
449
+ rung: 'type',
450
+ },
451
+ {
452
+ caption: 'error · signature mismatch (deepest rung)',
453
+ component: { ...CHANNELS[0]!.component, id: 'e-sig' },
454
+ severity: 'error',
455
+ rung: 'signature',
456
+ },
457
+ ];
458
+
459
+ /** Proposed diagnostics layer over real nodes — ring by severity, chip = earliest rung. */
460
+ export const IssueOverlayProposal: Story = {
461
+ render: () => {
462
+ return (
463
+ <div style={{ padding: 32, minHeight: '100vh', boxSizing: 'border-box' }}>
464
+ <div style={{ maxWidth: 720 }}>
465
+ <div
466
+ style={{
467
+ fontSize: 12,
468
+ fontFamily: MONO,
469
+ textTransform: 'uppercase',
470
+ letterSpacing: 0.4,
471
+ color: '#a0a0a0',
472
+ marginBottom: 10,
473
+ }}
474
+ >
475
+ Proposed · not wired yet
476
+ </div>
477
+ <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, lineHeight: 1.7, color: '#b0b0b0' }}>
478
+ <li>
479
+ <b>Ring</b> (outside the construct border): <b>solid</b> = error/warn (contradicts
480
+ source), <b>dashed</b> = info/gap (couldn&apos;t confirm). Color = severity.
481
+ </li>
482
+ <li>
483
+ <b>Corner chip</b> = the <i>earliest failing rung</i> by verification order (file →
484
+ symbol → declaration → type → signature), with a count when a node has more than one.
485
+ </li>
486
+ <li>
487
+ First cell is the clean node for contrast — no ring, no chip. Compose with selection,
488
+ file-open spotlight, and dimming without touching the border.
489
+ </li>
490
+ </ul>
491
+ </div>
492
+ <div
493
+ style={{
494
+ display: 'flex',
495
+ flexWrap: 'wrap',
496
+ gap: '40px 28px',
497
+ alignItems: 'flex-start',
498
+ marginTop: 28,
499
+ }}
500
+ >
501
+ {ISSUE_EXAMPLES.map((ex) => (
502
+ <IssueCell key={ex.caption} {...ex} />
503
+ ))}
504
+ </div>
505
+ </div>
506
+ );
507
+ },
508
+ };
@@ -511,69 +511,91 @@ export function ComponentDeclaration({
511
511
 
512
512
  if (showFile && component.file) {
513
513
  const fileInteractive = !!onOpenFile;
514
+ const segBorder =
515
+ fileHovered && fileInteractive
516
+ ? theme.colors.accent ?? color
517
+ : theme.colors.border;
518
+ const openAt = fileInteractive
519
+ ? (e: { stopPropagation: () => void }) => {
520
+ e.stopPropagation();
521
+ openDeclarationFile();
522
+ }
523
+ : undefined;
514
524
  lines.push(
515
525
  line(
516
526
  <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
517
527
  <span
518
- role={fileInteractive ? 'button' : undefined}
519
- tabIndex={fileInteractive ? 0 : undefined}
520
- title={fileInteractive ? `Open ${component.file}` : component.file}
521
528
  onMouseEnter={() => setFileHovered(true)}
522
529
  onMouseLeave={() => setFileHovered(false)}
523
- onClick={
524
- fileInteractive
525
- ? (e) => {
526
- e.stopPropagation();
527
- openDeclarationFile();
528
- }
529
- : undefined
530
- }
531
530
  style={{
532
531
  display: 'inline-flex',
533
- alignItems: 'center',
534
- padding: '1px 8px',
532
+ alignItems: 'stretch',
535
533
  borderRadius: 6,
536
- border: `1px solid ${
537
- fileHovered && fileInteractive
538
- ? theme.colors.accent ?? color
539
- : theme.colors.border
540
- }`,
534
+ overflow: 'hidden',
535
+ border: `1px solid ${segBorder}`,
541
536
  background: theme.colors.backgroundSecondary ?? 'transparent',
542
- color:
543
- fileHovered && fileInteractive
544
- ? theme.colors.text
545
- : theme.colors.textSecondary ?? muted,
546
- fontFamily: theme.fonts.monospace,
547
- fontSize: theme.fontSizes[0],
548
537
  whiteSpace: 'nowrap',
549
- cursor: fileInteractive ? 'pointer' : 'default',
550
538
  }}
551
539
  >
552
- {component.file}
553
- </span>
554
- {fileOpen && (
555
540
  <span
556
- title="This file is open below"
541
+ role={fileInteractive ? 'button' : undefined}
542
+ tabIndex={fileInteractive ? 0 : undefined}
543
+ title={fileInteractive ? `Open ${component.file}` : component.file}
544
+ onClick={openAt}
557
545
  style={{
558
546
  display: 'inline-flex',
559
547
  alignItems: 'center',
560
- gap: 4,
561
- height: 16,
562
- padding: '0 5px',
563
- border: `1px solid ${theme.colors.accent ?? color}`,
564
- borderRadius: 999,
565
- color: theme.colors.accent ?? color,
548
+ padding: '1px 8px',
549
+ color:
550
+ fileHovered && fileInteractive
551
+ ? theme.colors.text
552
+ : theme.colors.textSecondary ?? muted,
566
553
  fontFamily: theme.fonts.monospace,
567
- fontSize: theme.fontSizes[0] * 0.8,
568
- letterSpacing: 0.4,
569
- textTransform: 'uppercase',
570
- whiteSpace: 'nowrap',
554
+ fontSize: theme.fontSizes[0],
555
+ cursor: fileInteractive ? 'pointer' : 'default',
571
556
  }}
572
557
  >
573
- open
558
+ {component.file}
574
559
  </span>
575
- )}
576
- {lineLocationLabel}
560
+ {declarationStartLine != null && (
561
+ <span
562
+ role={fileInteractive ? 'button' : undefined}
563
+ tabIndex={fileInteractive ? 0 : undefined}
564
+ title="Open at declaration line"
565
+ onClick={openAt}
566
+ style={{
567
+ display: 'inline-flex',
568
+ alignItems: 'center',
569
+ padding: '1px 6px',
570
+ borderLeft: `1px solid ${segBorder}`,
571
+ color: theme.colors.accent ?? color,
572
+ fontFamily: theme.fonts.monospace,
573
+ fontSize: theme.fontSizes[0],
574
+ cursor: fileInteractive ? 'pointer' : 'default',
575
+ }}
576
+ >
577
+ L{declarationStartLine}
578
+ </span>
579
+ )}
580
+ {fileOpen && (
581
+ <span
582
+ title="This file is open below"
583
+ style={{
584
+ display: 'inline-flex',
585
+ alignItems: 'center',
586
+ padding: '1px 6px',
587
+ borderLeft: `1px solid ${segBorder}`,
588
+ color: theme.colors.accent ?? color,
589
+ fontFamily: theme.fonts.monospace,
590
+ fontSize: theme.fontSizes[0] * 0.85,
591
+ letterSpacing: 0.4,
592
+ textTransform: 'uppercase',
593
+ }}
594
+ >
595
+ open
596
+ </span>
597
+ )}
598
+ </span>
577
599
  </span>,
578
600
  'file',
579
601
  ),