@principal-ai/subsystems-react 0.52.0 → 0.53.1

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 (36) hide show
  1. package/dist/subsystem/C4Graph.d.ts +8 -1
  2. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  3. package/dist/subsystem/C4Graph.js +470 -155
  4. package/dist/subsystem/C4Graph.js.map +1 -1
  5. package/dist/subsystem/C4NodeCard.d.ts +42 -1
  6. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  7. package/dist/subsystem/C4NodeCard.js +75 -20
  8. package/dist/subsystem/C4NodeCard.js.map +1 -1
  9. package/dist/subsystem/IssueList.d.ts.map +1 -1
  10. package/dist/subsystem/IssueList.js +13 -4
  11. package/dist/subsystem/IssueList.js.map +1 -1
  12. package/dist/subsystem/c4.d.ts +27 -3
  13. package/dist/subsystem/c4.d.ts.map +1 -1
  14. package/dist/subsystem/c4.js +48 -0
  15. package/dist/subsystem/c4.js.map +1 -1
  16. package/dist/subsystem/model.d.ts +35 -245
  17. package/dist/subsystem/model.d.ts.map +1 -1
  18. package/dist/subsystem/model.js +11 -29
  19. package/dist/subsystem/model.js.map +1 -1
  20. package/dist/utils/elkLayout.d.ts +26 -2
  21. package/dist/utils/elkLayout.d.ts.map +1 -1
  22. package/dist/utils/elkLayout.js +40 -8
  23. package/dist/utils/elkLayout.js.map +1 -1
  24. package/package.json +4 -3
  25. package/src/stories/Subsystem/C4Graph/C4CardGrow.stories.tsx +125 -0
  26. package/src/stories/Subsystem/C4Graph/C4Component.stories.tsx +21 -11
  27. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -9
  28. package/src/stories/Subsystem/C4Graph/C4Transition.stories.tsx +131 -0
  29. package/src/subsystem/C4Graph.tsx +578 -198
  30. package/src/subsystem/C4NodeCard.test.ts +6 -3
  31. package/src/subsystem/C4NodeCard.tsx +158 -49
  32. package/src/subsystem/IssueList.category.test.ts +39 -0
  33. package/src/subsystem/IssueList.tsx +14 -3
  34. package/src/subsystem/c4.ts +74 -3
  35. package/src/subsystem/model.ts +62 -305
  36. package/src/utils/elkLayout.ts +60 -10
@@ -150,18 +150,21 @@ describe('nodeTopLabel — the technology, or the kind when none is stated', ()
150
150
  expect(
151
151
  nodeTopLabel(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
152
152
  ).toBe('Bun + Electrobun');
153
- expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
154
153
  });
155
154
 
156
155
  test('a container with no technology falls back to its kind', () => {
157
156
  expect(nodeTopLabel(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
158
157
  });
159
158
 
160
- test('the other kinds fall back to their C4 kind', () => {
161
- expect(nodeTopLabel(node({ kind: 'component' }))).toBe('component');
159
+ test('an external system / person falls back to its C4 kind', () => {
162
160
  expect(nodeTopLabel(node({ kind: 'external-system' }))).toBe('external-system');
163
161
  expect(nodeTopLabel(node({ kind: 'person' }))).toBe('person');
164
162
  });
163
+
164
+ test('a component has no top label — its technology is the container’s', () => {
165
+ expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('');
166
+ expect(nodeTopLabel(node({ kind: 'component' }))).toBe('');
167
+ });
165
168
  });
166
169
 
167
170
  describe('nodeShape — kind without relying on colour or dash', () => {
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { useTheme } from '@principal-ade/industry-theme';
16
- import { AppWindow, Database } from 'lucide-react';
16
+ import { AppWindow, Component, Database, Maximize2 } from 'lucide-react';
17
17
  import type { LucideIcon } from 'lucide-react';
18
18
  import { TechMark, technologyBrand } from './techIcons';
19
19
  import type { C4Element } from './c4';
@@ -50,6 +50,17 @@ export function nodeSize(node: C4Element): { width: number; height: number } {
50
50
  */
51
51
  const LABEL_SLOT_H = 40;
52
52
 
53
+ /**
54
+ * Space a *grown* container reserves above its nested components: the card's own
55
+ * top padding + the label slot + the row gap + the technology row, plus a little
56
+ * air. The description is hidden while grown, so it is not part of this.
57
+ *
58
+ * The graph hands this to ELK as the compound parent's top padding rather than
59
+ * nudging the children down itself — so the parent's size and the children's
60
+ * placement are still one ELK fit, not our arithmetic layered on top of it.
61
+ */
62
+ export const OPEN_HEADER_PAD = 84;
63
+
53
64
  type Theme = ReturnType<typeof useTheme>['theme'];
54
65
 
55
66
  export interface NodeStyle {
@@ -151,14 +162,17 @@ export function nodeKindIcon(node: C4Element): LucideIcon | undefined {
151
162
  * The text on the top row: the technology the box is built with (Bun, React,
152
163
  * Postgres…). Falls back to the container kind / C4 kind when no technology is
153
164
  * stated, so the row is never blank.
165
+ *
166
+ * A component returns `''`: its technology is almost always the container's, so
167
+ * restating it is noise. The container frame already carries it.
154
168
  */
155
169
  export function nodeTopLabel(node: C4Element): string {
170
+ if (node.kind === 'component') return '';
156
171
  const technology = nodeTechnology(node);
157
172
  if (technology) return technology;
158
173
  switch (node.kind) {
159
174
  case 'container':
160
175
  return node.containerKind;
161
- case 'component':
162
176
  case 'external-system':
163
177
  case 'person':
164
178
  return node.kind;
@@ -225,9 +239,37 @@ export interface C4NodeCardProps {
225
239
  /** Draw React Flow's edge handles. Off when rendering a bare gallery. */
226
240
  handles?: React.ReactNode;
227
241
  onClick?: () => void;
242
+ /**
243
+ * Number of `C4Component`s authored inside this element. The card can't see
244
+ * the model, so the graph passes it in; shown right-aligned on the technology
245
+ * row for containers. Omitted/0 → no count.
246
+ */
247
+ componentCount?: number;
248
+ /**
249
+ * When set, the card draws an expand affordance (bottom-right) that calls
250
+ * this — the drill-down trigger. Only containers with components pass it.
251
+ */
252
+ onExpand?: () => void;
253
+ /**
254
+ * Explicit box size, overriding the kind's default. Lets a host animate the
255
+ * card (the drill-down grow) without the card owning that state.
256
+ */
257
+ width?: number;
258
+ height?: number;
259
+ /**
260
+ * Show the description line under the label. Off when the card is grown to
261
+ * host components — the internals occupy that space.
262
+ * @default true
263
+ */
264
+ showDescription?: boolean;
265
+ /**
266
+ * Content drawn below the header (label / technology / description). Used by
267
+ * the grown card to host its component cards.
268
+ */
269
+ children?: React.ReactNode;
228
270
  }
229
271
 
230
- export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeCardProps) {
272
+ export function C4NodeCard({ node, selected = false, handles, onClick, componentCount = 0, onExpand, width, height, showDescription = true, children }: C4NodeCardProps) {
231
273
  const { theme } = useTheme();
232
274
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
233
275
  const style = nodeStyle(node, theme, selected);
@@ -246,8 +288,10 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
246
288
  <div
247
289
  onClick={onClick}
248
290
  style={{
249
- width: size.width,
250
- minHeight: size.height,
291
+ position: 'relative',
292
+ width: width ?? size.width,
293
+ minHeight: height ?? size.height,
294
+ transition: 'width 420ms ease-out, min-height 420ms ease-out',
251
295
  boxSizing: 'border-box',
252
296
  display: 'flex',
253
297
  flexDirection: 'column',
@@ -265,71 +309,104 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
265
309
  fontFamily: theme.fonts.body,
266
310
  }}
267
311
  >
268
- {/* Left: the brand mark when we hold the official one, then the
269
- technology. Right: a container's C4 sort, application vs data-store.
270
- The kind itself stays on the shape, confirmation on the border (see
271
- `nodeShape`, `nodeStyle`). */}
312
+ {/* The label row: the kind mark, then the name. Two lines rather than an
313
+ ellipsis — a truncated label hides the very thing that distinguishes
314
+ two containers from each other. The fixed height reserves both lines
315
+ even for a one-line label, so the subtitle below sits at the same y on
316
+ every card. */}
272
317
  <span
273
318
  style={{
274
319
  display: 'flex',
275
- alignItems: 'center',
320
+ alignItems: 'flex-start',
276
321
  justifyContent: centered ? 'center' : 'space-between',
277
322
  gap: 6,
278
- fontFamily: theme.fonts.monospace,
279
- fontSize: theme.fontSizes[0],
280
- color: muted,
281
- letterSpacing: 0.5,
282
- textTransform: 'uppercase',
323
+ height: LABEL_SLOT_H,
283
324
  minWidth: 0,
284
325
  }}
285
326
  >
286
- <span style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
287
- {brand && <TechMark brand={brand} />}
288
- <span
289
- style={{
290
- fontWeight: 600,
291
- whiteSpace: 'nowrap',
292
- overflow: 'hidden',
293
- textOverflow: 'ellipsis',
294
- minWidth: 0,
295
- }}
296
- >
297
- {topLabel}
298
- </span>
327
+ <span
328
+ style={{
329
+ fontFamily: theme.fonts.body,
330
+ fontWeight: 600,
331
+ fontSize: theme.fontSizes[2],
332
+ color: theme.colors.text,
333
+ lineHeight: 1.25,
334
+ maxHeight: LABEL_SLOT_H,
335
+ display: '-webkit-box',
336
+ WebkitLineClamp: 2,
337
+ WebkitBoxOrient: 'vertical',
338
+ overflow: 'hidden',
339
+ textAlign: centered ? 'center' : 'left',
340
+ minWidth: 0,
341
+ }}
342
+ >
343
+ {node.label}
299
344
  </span>
300
345
  {Icon && (
301
346
  <span
302
- title={node.kind === 'container' ? node.containerKind : node.kind}
303
347
  aria-label={node.kind === 'container' ? node.containerKind : node.kind}
304
- style={{ display: 'inline-flex', flexShrink: 0, color: style.color }}
348
+ style={{
349
+ display: 'inline-flex',
350
+ flexShrink: 0,
351
+ color: style.color,
352
+ // Nudge down so the glyph optically centres on the first text line.
353
+ marginTop: 3,
354
+ }}
305
355
  >
306
- <Icon size={13} strokeWidth={2.25} />
356
+ <Icon size={16} strokeWidth={2.25} />
307
357
  </span>
308
358
  )}
309
359
  </span>
310
- {/* Two lines rather than an ellipsis: a truncated label hides the very
311
- thing that distinguishes two containers from each other. The fixed
312
- height reserves both lines even for a one-line label, so the subtitle
313
- below sits at the same y on every card. */}
360
+ {/* The technology row: the brand mark when we hold the official one, then
361
+ the technology. For a container, right-aligned, the number of members
362
+ it groups. The kind itself stays on the shape, confirmation on the
363
+ border (see `nodeShape`, `nodeStyle`). */}
314
364
  <span
315
365
  style={{
316
- fontFamily: theme.fonts.body,
317
- fontWeight: 600,
318
- fontSize: theme.fontSizes[2],
319
- color: theme.colors.text,
320
- lineHeight: 1.25,
321
- height: LABEL_SLOT_H,
322
- display: '-webkit-box',
323
- WebkitLineClamp: 2,
324
- WebkitBoxOrient: 'vertical',
325
- overflow: 'hidden',
326
- textAlign: centered ? 'center' : 'left',
366
+ display: 'flex',
367
+ alignItems: 'center',
368
+ justifyContent: centered ? 'center' : 'space-between',
369
+ gap: 6,
370
+ fontFamily: theme.fonts.monospace,
371
+ fontSize: theme.fontSizes[0],
372
+ color: muted,
373
+ letterSpacing: 0.5,
374
+ textTransform: 'uppercase',
375
+ minWidth: 0,
327
376
  }}
328
- title={node.label}
329
377
  >
330
- {node.label}
378
+ {topLabel && (
379
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
380
+ {brand && <TechMark brand={brand} />}
381
+ <span
382
+ style={{
383
+ fontWeight: 600,
384
+ whiteSpace: 'nowrap',
385
+ overflow: 'hidden',
386
+ textOverflow: 'ellipsis',
387
+ minWidth: 0,
388
+ }}
389
+ >
390
+ {topLabel}
391
+ </span>
392
+ </span>
393
+ )}
394
+ {node.kind === 'container' && (
395
+ <span
396
+ style={{
397
+ flexShrink: 0,
398
+ letterSpacing: 0,
399
+ display: 'inline-flex',
400
+ alignItems: 'center',
401
+ gap: 4,
402
+ }}
403
+ >
404
+ {componentCount}
405
+ <Component size={12} strokeWidth={2.25} />
406
+ </span>
407
+ )}
331
408
  </span>
332
- {subtitle && (
409
+ {showDescription && subtitle && (
333
410
  <span
334
411
  style={{
335
412
  fontFamily: theme.fonts.monospace,
@@ -344,6 +421,38 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
344
421
  {subtitle}
345
422
  </span>
346
423
  )}
424
+ {children}
425
+ {/* Drill-down affordance: bottom-right, its own hit target so the card's
426
+ own click (select) is unaffected. */}
427
+ {onExpand && (
428
+ <button
429
+ type="button"
430
+ onClick={(e) => {
431
+ e.stopPropagation();
432
+ onExpand();
433
+ }}
434
+ aria-label="Show components"
435
+ style={{
436
+ position: 'absolute',
437
+ right: 6,
438
+ bottom: 6,
439
+ display: 'inline-flex',
440
+ alignItems: 'center',
441
+ justifyContent: 'center',
442
+ width: 22,
443
+ height: 22,
444
+ padding: 0,
445
+ borderRadius: 4,
446
+ border: `1px solid ${style.color}`,
447
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
448
+ color: style.color,
449
+ cursor: 'pointer',
450
+ lineHeight: 1,
451
+ }}
452
+ >
453
+ <Maximize2 size={13} strokeWidth={2.25} />
454
+ </button>
455
+ )}
347
456
  {handles}
348
457
  </div>
349
458
  );
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { issueCategory, issueKindLabel } from './IssueList';
3
+
4
+ /**
5
+ * Every C4 process-verification kind must land in the dynamic-topology
6
+ * category. These kinds are emitted by new audit passes over time, and an
7
+ * unmapped kind silently falls through to `construct` — the list then files a
8
+ * process finding under Constructs while the maintenance lane tally counts it
9
+ * as dynamic-topology, so the construct lane reads verified (green) beside a
10
+ * process issue. The regressions are real: `boundary_process_missing` shipped
11
+ * unmapped long before the container-first kinds joined it.
12
+ */
13
+ describe('issueCategory — C4 process kinds', () => {
14
+ const processKinds = [
15
+ 'boundary_process_missing',
16
+ 'boundary_process_nest_disagree',
17
+ 'boundary_process_unassigned',
18
+ 'boundary_process_proposed',
19
+ 'boundary_process_rejected',
20
+ 'boundary_process_unbacked',
21
+ ];
22
+
23
+ for (const kind of processKinds) {
24
+ test(`${kind} categorizes as dynamic-topology`, () => {
25
+ expect(issueCategory({ id: 'x', severity: 'info', kind, message: '' })).toBe(
26
+ 'dynamic-topology',
27
+ );
28
+ });
29
+ }
30
+
31
+ test('mapped kinds get human labels, not the humanized fallback', () => {
32
+ expect(issueKindLabel({ id: 'x', severity: 'info', kind: 'boundary_process_missing', message: '' })).toBe(
33
+ 'Process claim missing',
34
+ );
35
+ expect(issueKindLabel({ id: 'x', severity: 'info', kind: 'boundary_process_unbacked', message: '' })).toBe(
36
+ 'Process claim unbacked',
37
+ );
38
+ });
39
+ });
@@ -20,12 +20,14 @@ import {
20
20
  CircleDashed,
21
21
  CircleEllipsis,
22
22
  CircleHelp,
23
+ CircleSlash,
23
24
  CircleX,
24
25
  Component,
25
26
  FileX,
26
27
  FolderGit2,
27
28
  Footprints,
28
29
  History,
30
+ Link2Off,
29
31
  MapPin,
30
32
  Network,
31
33
  Route,
@@ -112,9 +114,11 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
112
114
  // C4 process verification (verifyProcessBoundaries): a boundary whose
113
115
  // container claim is not an accepted one. `verified` reports nothing —
114
116
  // the audit reports absence only.
117
+ boundary_process_missing: 'dynamic-topology',
115
118
  boundary_process_unassigned: 'dynamic-topology',
116
119
  boundary_process_proposed: 'dynamic-topology',
117
120
  boundary_process_rejected: 'dynamic-topology',
121
+ boundary_process_unbacked: 'dynamic-topology',
118
122
  trail: 'trail',
119
123
  step_unconfirmed: 'trail',
120
124
  step_stale: 'trail',
@@ -180,9 +184,12 @@ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
180
184
  boundary_process_nest_disagree: Split,
181
185
  // C4 process verification: unclaimed = no container at all, proposed = an
182
186
  // agent scaffold awaits a decision, rejected = a container was declined.
187
+ boundary_process_missing: CircleSlash,
183
188
  boundary_process_unassigned: CircleEllipsis,
184
189
  boundary_process_proposed: CircleDashed,
185
190
  boundary_process_rejected: CircleX,
191
+ // Container-first gate: a claim cites a key no accepted container owns.
192
+ boundary_process_unbacked: Link2Off,
186
193
  // Trail step verification: unconfirmed = no augmentation yet, stale = code changed
187
194
  step_unconfirmed: CircleHelp,
188
195
  step_stale: History,
@@ -267,9 +274,11 @@ const KIND_LABEL: Record<string, string> = {
267
274
  boundary_process_nest_disagree: 'Module spans multiple process contexts',
268
275
  // C4 process verification (labels read as boundary states, since the layer
269
276
  // scopes them to process frames).
277
+ boundary_process_missing: 'Process claim missing',
270
278
  boundary_process_unassigned: 'Process boundary unclaimed',
271
279
  boundary_process_proposed: 'Container awaiting decision',
272
280
  boundary_process_rejected: 'Container rejected',
281
+ boundary_process_unbacked: 'Process claim unbacked',
273
282
  trail: 'Step issue',
274
283
  step_unconfirmed: 'Call site not verified',
275
284
  step_stale: 'Call site changed',
@@ -304,9 +313,11 @@ const KIND_ORDER: Record<string, number> = {
304
313
  boundary_module_file_mismatch: 23,
305
314
  // Runtime topology — process containment
306
315
  boundary_process_nest_disagree: 30,
307
- boundary_process_unassigned: 31,
308
- boundary_process_proposed: 32,
309
- boundary_process_rejected: 33,
316
+ boundary_process_missing: 31,
317
+ boundary_process_unassigned: 32,
318
+ boundary_process_unbacked: 33,
319
+ boundary_process_proposed: 34,
320
+ boundary_process_rejected: 35,
310
321
  // Trail — mechanical issues first, then step verification
311
322
  trail: 40,
312
323
  step_unconfirmed: 41,
@@ -153,7 +153,11 @@ export interface C4ElementSet {
153
153
  /** Which level to draw. Determines which frames are drawn. */
154
154
  export type C4View = 'container' | 'component';
155
155
 
156
- /** A compound frame: the system, or a container holding its components. */
156
+ /**
157
+ * A compound frame the renderer draws: the system, or a container holding its
158
+ * components. Never authored — `deriveC4Groups` produces these from the element
159
+ * `parentId` chains and the current scope.
160
+ */
157
161
  export interface C4Group {
158
162
  id: string;
159
163
  kind: 'system' | 'container';
@@ -162,6 +166,63 @@ export interface C4Group {
162
166
  memberIds: string[];
163
167
  }
164
168
 
169
+ /**
170
+ * Derive the frames to draw from the elements and the current scope.
171
+ *
172
+ * A container becomes a frame whenever its components are on screen — in the
173
+ * component view, or when it is the opened container. The system frame wraps
174
+ * whichever containers are drawn. No frame is authored; this is the view of the
175
+ * element set, so a container's identity (id, label, technology, colour) lives
176
+ * only on the element.
177
+ */
178
+ export function deriveC4Groups(
179
+ model: Pick<C4Model, 'view' | 'system' | 'nodes' | 'openContainerId'>,
180
+ ): C4Group[] {
181
+ const groups: C4Group[] = [];
182
+ const containers = model.nodes.filter((n): n is C4Container => n.kind === 'container');
183
+ const components = model.nodes.filter((n): n is C4Component => n.kind === 'component');
184
+
185
+ const opened = model.openContainerId ?? null;
186
+ // Component view shows every container's components; container view shows a
187
+ // container's components only when it is opened.
188
+ const framing = (containerId: string): boolean =>
189
+ model.view === 'component' || opened === containerId;
190
+
191
+ const framedContainers: string[] = [];
192
+ for (const c of containers) {
193
+ const members = components
194
+ .filter((comp) => (comp.container ?? comp.parentId) === c.id)
195
+ .map((comp) => comp.id);
196
+ if (members.length > 0 && framing(c.id)) {
197
+ groups.push({
198
+ id: c.id,
199
+ kind: 'container',
200
+ label: c.label,
201
+ parentId: model.system.id,
202
+ memberIds: members,
203
+ });
204
+ framedContainers.push(c.id);
205
+ }
206
+ }
207
+
208
+ // The system frame wraps the framed containers (component view) or the whole
209
+ // set of containers (container view) — the boundary is the system, so it must
210
+ // NOT shrink to the opened container. Opening one adds a nested container
211
+ // frame inside it; the system frame still spans everything.
212
+ const systemMembers =
213
+ model.view === 'component' ? framedContainers : containers.map((c) => c.id);
214
+ if (systemMembers.length > 0) {
215
+ groups.push({
216
+ id: model.system.id,
217
+ kind: 'system',
218
+ label: model.system.label,
219
+ memberIds: systemMembers,
220
+ });
221
+ }
222
+
223
+ return groups;
224
+ }
225
+
165
226
  /**
166
227
  * A line between two elements. Authored, like the boxes.
167
228
  *
@@ -229,13 +290,23 @@ export function protocolColor(protocol: string | undefined): string {
229
290
  return best ? PROTOCOL_COLOR[best]! : PROTOCOL_COLOR_FALLBACK;
230
291
  }
231
292
 
232
- /** A drawable C4 projection. Authored, not derived. */
293
+ /**
294
+ * A drawable C4 projection. Authored: the boxes and lines, nothing drawn for
295
+ * you. Frames are derived by the renderer from the elements' `parentId` chains
296
+ * — a container is a frame when its components are being shown (component view,
297
+ * or opened via `openContainerId`), never a separately-authored thing.
298
+ */
233
299
  export interface C4Model {
234
300
  view: C4View;
235
301
  system: { id: string; label: string; repoKey?: string };
236
302
  nodes: C4Element[];
237
- groups: C4Group[];
238
303
  edges: C4Edge[];
304
+ /**
305
+ * Drill-down scope: the id of a container whose components should be shown
306
+ * inside it as a frame. Absent/null → the container is drawn as a box and its
307
+ * components are hidden. The renderer derives the frames either way.
308
+ */
309
+ openContainerId?: string | null;
239
310
  }
240
311
 
241
312
  /**