@principal-ai/subsystems-react 0.53.2 → 0.53.3

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.
@@ -12,6 +12,7 @@
12
12
  * wants it: technology on the top row, description under the label.
13
13
  */
14
14
 
15
+ import { useLayoutEffect, useRef } from 'react';
15
16
  import { useTheme } from '@principal-ade/industry-theme';
16
17
  import { AppWindow, Component, Database, Maximize2 } from 'lucide-react';
17
18
  import type { LucideIcon } from 'lucide-react';
@@ -27,20 +28,45 @@ export const NODE_W = 250;
27
28
  export const NODE_H = 150;
28
29
 
29
30
  /**
30
- * A component is a proper square — equal sides, and visibly smaller than the
31
- * container it sits inside. Square-vs-rectangle is the clearest signal that
32
- * these are different C4 levels, and it survives grayscale and colour-blind
33
- * reading where a border-weight difference alone does not.
31
+ * A component has to *read* as a square, and equal sides do not manage that.
32
+ *
33
+ * A literally square box is perceived as tall: the eye measures it against the
34
+ * upright frame and the surrounding boxes, not against an abstract 1:1, so it
35
+ * lands looking narrower than it is. The correction is to make the real thing
36
+ * wider than it is tall, which is what this is for.
37
+ *
38
+ * 8:7 is further than the textbook 5%, and deliberately: the card's height is a
39
+ * `minHeight`, so a long description grows the drawn box past the height
40
+ * reserved here (see the probe below). 160 buys the square reading against a
41
+ * *rendered* height that runs past 140, not just against the nominal one.
42
+ *
43
+ * The level signal survives it: 8:7 is still unmistakably the square member of
44
+ * the set against the container's 5:3, and still beats a border-weight
45
+ * difference alone under grayscale and colour-blind reading.
46
+ */
47
+ export const COMPONENT_ASPECT = 8 / 7;
48
+
49
+ /** Short side of a component card — the height, and a hard ceiling on the drawn box. */
50
+ export const COMPONENT_H = 140;
51
+
52
+ /** Long side — the optically corrected one. 160, derived so the ratio is the constant. */
53
+ export const COMPONENT_W = Math.round(COMPONENT_H * COMPONENT_ASPECT);
54
+
55
+ /**
56
+ * Nominal edge of a component card, for callers that sized a box off a single
57
+ * number. It is the *short* side; the width the graph lays out against is
58
+ * `COMPONENT_W`, not this.
34
59
  */
35
- export const COMPONENT_SIZE = 140;
60
+ export const COMPONENT_SIZE = COMPONENT_H;
36
61
 
37
62
  /**
38
- * Per-level box size. The graph lays out against these, so a square component
39
- * and a rectangular container do not collide at a shared nominal width.
63
+ * Per-level box size. The graph lays out against these, so a near-square
64
+ * component and a rectangular container do not collide at a shared nominal
65
+ * width.
40
66
  */
41
67
  export function nodeSize(node: C4Element): { width: number; height: number } {
42
68
  return node.kind === 'component'
43
- ? { width: COMPONENT_SIZE, height: COMPONENT_SIZE }
69
+ ? { width: COMPONENT_W, height: COMPONENT_H }
44
70
  : { width: NODE_W, height: NODE_H };
45
71
  }
46
72
 
@@ -186,6 +212,13 @@ export interface NodeShape {
186
212
  kind: C4ShapeKind;
187
213
  /** Corner radius, for the shapes that use one. */
188
214
  radius: number;
215
+ /**
216
+ * Horizontal text alignment. This is a level cue, not a typographic
217
+ * preference: a column of left-aligned containers beside a set of centred
218
+ * ones reads as two kinds without comparing box sizes, and unlike the aspect
219
+ * ratio it survives a crop or a narrow viewport.
220
+ */
221
+ align: 'left' | 'center';
189
222
  }
190
223
 
191
224
  /**
@@ -195,24 +228,30 @@ export interface NodeShape {
195
228
  * The convention:
196
229
  *
197
230
  * container wide rectangle, slight radius — a runtime boundary
198
- * component square — a part inside one (see `nodeSize`)
199
- * external system wide rectangle, no radius — someone else's system
200
- * person pill — not a box at all
231
+ * component square, text centred — a part inside one
232
+ * external system wide rectangle, no radius — someone else's system
233
+ * person pill, text centred — not a box at all
234
+ *
235
+ * `square` here is a corner treatment, not a claim about the dimensions: a
236
+ * component's box is optically 8:7 (see `COMPONENT_ASPECT`) and still reads as
237
+ * this shape.
201
238
  *
202
239
  * A container keeps a slight radius; an external system is drawn with none, so
203
240
  * the two are distinguishable by corner treatment as well as by the kind icon
204
- * and frame position. The border dash stays spent on proposed-vs-accepted.
241
+ * and frame position. Alignment is the third, cheapest cue — it needs no
242
+ * comparison against anything. The border dash stays spent on
243
+ * proposed-vs-accepted.
205
244
  */
206
245
  export function nodeShape(node: C4Element): NodeShape {
207
246
  switch (node.kind) {
208
247
  case 'person':
209
- return { kind: 'pill', radius: NODE_H / 2 };
248
+ return { kind: 'pill', radius: NODE_H / 2, align: 'center' };
210
249
  case 'external-system':
211
- return { kind: 'rect', radius: 0 };
250
+ return { kind: 'rect', radius: 0, align: 'left' };
212
251
  case 'component':
213
- return { kind: 'square', radius: 6 };
252
+ return { kind: 'square', radius: 6, align: 'center' };
214
253
  case 'container':
215
- return { kind: 'rect', radius: 4 };
254
+ return { kind: 'rect', radius: 4, align: 'left' };
216
255
  }
217
256
  }
218
257
 
@@ -280,12 +319,37 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
280
319
  const subtitle = nodeSubtitle(node);
281
320
 
282
321
  const size = nodeSize(node);
283
- // A pill (a person) has no straight left edge, so left-aligned text reads as
284
- // spilling out of the oval. Centre the text and inset it from the curve.
285
- const centered = shape.kind === 'pill';
322
+ const centered = shape.align === 'center';
323
+ // The pill's inset is a separate concern from alignment, and a centred
324
+ // component must not inherit it: 30px of each side would leave a 160-wide box
325
+ // 100px of text, which wraps the description into exactly the extra lines
326
+ // that grow the box past the height the layout reserved.
327
+ const pillInset = shape.kind === 'pill';
328
+
329
+ // TEMPORARY diagnostic — remove. The card's height is `minHeight`, so the box
330
+ // the browser lays out can exceed the nominal size the layout reserved, and
331
+ // nothing in the type system shows that: `nodeSize` and the drawn box are two
332
+ // different numbers. `offsetWidth/Height` are the border box, so this reports
333
+ // what was actually drawn. Logs on change only, so a reflow that does not
334
+ // resize the card stays quiet.
335
+ const probeRef = useRef<HTMLDivElement | null>(null);
336
+ const probeSeen = useRef('');
337
+ useLayoutEffect(() => {
338
+ const el = probeRef.current;
339
+ if (!el || typeof ResizeObserver === 'undefined') return;
340
+ const ro = new ResizeObserver(() => {
341
+ const line = `${el.offsetWidth}x${el.offsetHeight} (nominal ${size.width}x${size.height})`;
342
+ if (line === probeSeen.current) return;
343
+ probeSeen.current = line;
344
+ console.log(`[c4-card] ${node.kind} ${node.id} ${line}`);
345
+ });
346
+ ro.observe(el);
347
+ return () => ro.disconnect();
348
+ }, [node.kind, node.id, size.width, size.height]);
286
349
 
287
350
  return (
288
351
  <div
352
+ ref={probeRef}
289
353
  onClick={onClick}
290
354
  style={{
291
355
  position: 'relative',
@@ -300,7 +364,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick, component
300
364
  // height on every card. In a graph grid that makes the row unscannable.
301
365
  justifyContent: 'flex-start',
302
366
  gap: 3,
303
- padding: centered ? '10px 30px' : '10px 12px',
367
+ padding: pillInset ? '10px 30px' : '10px 12px',
304
368
  borderRadius: shape.radius,
305
369
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
306
370
  border: `${style.width}px ${style.dash} ${style.color}`,
@@ -1140,15 +1140,18 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1140
1140
  [trails],
1141
1141
  );
1142
1142
 
1143
- // Per-node diagnostics badge: fold each component-targeted finding that maps
1144
- // to a verification rung into one badge per node — worst severity wins, the
1145
- // chip shows the earliest failing rung, and the count tallies the findings.
1143
+ // Per-node diagnostics badge: fold each component-targeted finding into one
1144
+ // badge per node — worst severity wins, the chip shows the earliest failing
1145
+ // rung, and the count tallies the findings. A finding with no construct rung
1146
+ // still badges the node when it carries a dedicated kind icon (e.g. a C4
1147
+ // process gap): the chip wears the kind's icon instead of a rung's, so the
1148
+ // node says "diagnostics here" for every finding the sidebar lists.
1146
1149
  const issueBadgeByAlias = useMemo(() => {
1147
1150
  const map = new Map<string, SubsystemNodeIssue>();
1148
1151
  if (!issues?.length) return map;
1149
1152
  for (const issue of issues) {
1150
1153
  const rung = issueRung(issue.kind);
1151
- if (!rung) continue;
1154
+ if (!rung && !ISSUE_KIND_ICON[issue.kind]) continue;
1152
1155
  const comp = issueComponent(issue);
1153
1156
  if (!comp) continue;
1154
1157
  const prev = map.get(comp.alias);
@@ -1158,12 +1161,27 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1158
1161
  ? 'error'
1159
1162
  : 'info',
1160
1163
  rung:
1161
- prev != null && ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
1162
- ? prev.rung
1163
- : rung,
1164
+ rung == null
1165
+ ? prev?.rung
1166
+ : prev?.rung != null &&
1167
+ ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
1168
+ ? prev.rung
1169
+ : rung,
1164
1170
  count: (prev?.count ?? 0) + 1,
1165
1171
  });
1166
1172
  }
1173
+ // A rung finding wins the chip; only rung-less nodes wear a kind icon —
1174
+ // the first kind-iconed finding the sidebar lists for that node.
1175
+ for (const [alias, badge] of map) {
1176
+ if (badge.rung != null) continue;
1177
+ const kinded = issues.find(
1178
+ (i) =>
1179
+ !issueRung(i.kind) &&
1180
+ ISSUE_KIND_ICON[i.kind] &&
1181
+ issueComponent(i)?.alias === alias,
1182
+ );
1183
+ if (kinded) map.set(alias, { ...badge, kind: kinded.kind });
1184
+ }
1167
1185
  return map;
1168
1186
  }, [issues, issueComponent]);
1169
1187
 
@@ -1135,8 +1135,19 @@ export type SubsystemIssueRung =
1135
1135
  export interface SubsystemNodeIssue {
1136
1136
  /** Worst severity on the node — `error` (contradicts source) beats `info`. */
1137
1137
  severity: 'error' | 'info';
1138
- /** Earliest failing rung across the node's findings. */
1139
- rung: SubsystemIssueRung;
1138
+ /**
1139
+ * Earliest failing rung across the node's findings. Absent when none of the
1140
+ * node's findings sit on the construct ladder — a rung-less finding (e.g. a
1141
+ * process gap) carries its audit `kind` instead.
1142
+ */
1143
+ rung?: SubsystemIssueRung;
1144
+ /**
1145
+ * The audit kind of the node's finding when that finding has no construct
1146
+ * rung: the chip wears the kind's icon (`ISSUE_KIND_ICON`) rather than a
1147
+ * rung's. Set only when `rung` is absent — a rung finding is more specific
1148
+ * about where the construct fails, so it wins the chip.
1149
+ */
1150
+ kind?: string;
1140
1151
  /** Total findings on this node. */
1141
1152
  count: number;
1142
1153
  }
@@ -46,6 +46,7 @@ import {
46
46
  import { componentColor } from '../pierre/constructColors';
47
47
  import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
48
48
  import { ISSUE_KIND_ICON, ISSUE_RUNG_ICON } from './IssueList';
49
+ import { Search } from 'lucide-react';
49
50
  import type { LucideIcon } from 'lucide-react';
50
51
 
51
52
  export const CONSTRUCT_LABEL: Record<string, string> = {
@@ -122,11 +123,13 @@ function useSubsystemCallbacks(): SubsystemGraphCallbacks {
122
123
 
123
124
  /**
124
125
  * The diagnostics overlay for a component node: the node's own border turns
125
- * dotted (see the node's `borderStyle`) and an earliest-failing-rung corner
126
- * chip (file → symbol → declaration → type → signature) carries the severity
127
- * color, with a count when the node has more than one finding. The chip is
128
- * purely additive and non-interactive; the border stays construct-colored, so
129
- * severity never has to compete with the construct palette.
126
+ * dotted (see the node's `borderStyle`) and a severity-colored corner chip
127
+ * carries the count. The chip's icon is the earliest-failing construct rung
128
+ * (file → symbol → declaration → type → signature); a finding with no rung
129
+ * (e.g. a C4 process gap) wears its kind icon instead — see
130
+ * `SubsystemNodeIssue.kind`. The chip is purely additive and non-interactive;
131
+ * the border stays construct-colored, so severity never has to compete with
132
+ * the construct palette.
130
133
  */
131
134
  function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
132
135
  const { theme } = useTheme();
@@ -134,10 +137,15 @@ function NodeIssueOverlay({ issue }: { issue: SubsystemNodeIssue }) {
134
137
  const color = isError
135
138
  ? (theme.colors.error ?? '#e5534b')
136
139
  : (theme.colors.warning ?? '#d4a017');
140
+ const Icon = issue.rung
141
+ ? ISSUE_RUNG_ICON[issue.rung]
142
+ : issue.kind
143
+ ? (ISSUE_KIND_ICON[issue.kind] ?? Search)
144
+ : Search;
137
145
  return (
138
146
  <IssueChip
139
147
  color={color}
140
- Icon={ISSUE_RUNG_ICON[issue.rung]}
148
+ Icon={Icon}
141
149
  count={issue.count}
142
150
  anchor={{ right: -9, bottom: -8 }}
143
151
  />