graphlin 0.1.3 → 0.2.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 (106) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +12 -3
  4. package/docs/decision-service.md +393 -0
  5. package/docs/extension-authoring.md +553 -0
  6. package/docs/model-api.md +293 -0
  7. package/docs/usage.md +472 -0
  8. package/docs/visualizer-views.md +240 -0
  9. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  10. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  11. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  12. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  13. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  14. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  15. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  16. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  17. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  18. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  19. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  20. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  21. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  22. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  23. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  24. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  25. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  26. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  27. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  28. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  29. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  30. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  31. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  32. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  33. package/package.json +78 -9
  34. package/plugin.json +4 -2
  35. package/runtime/architecture/analysis.mjs +344 -0
  36. package/runtime/architecture/controller.mjs +209 -0
  37. package/runtime/architecture/evidence.mjs +108 -0
  38. package/runtime/architecture/profile.mjs +56 -0
  39. package/runtime/core/evidence.mjs +43 -9
  40. package/runtime/core/graph.mjs +11 -6
  41. package/runtime/core/privacy.mjs +1 -0
  42. package/runtime/daemon/auth.mjs +7 -3
  43. package/runtime/daemon/diagnostics.mjs +1 -1
  44. package/runtime/daemon/extension-api.mjs +203 -0
  45. package/runtime/daemon/lineage.mjs +70 -0
  46. package/runtime/daemon/manager.mjs +9 -6
  47. package/runtime/daemon/model-api.mjs +728 -0
  48. package/runtime/daemon/model-persistence.mjs +220 -0
  49. package/runtime/daemon/server.mjs +81 -14
  50. package/runtime/daemon/settings.mjs +11 -3
  51. package/runtime/decisions/broker.mjs +349 -0
  52. package/runtime/decisions/contracts.mjs +179 -0
  53. package/runtime/decisions/evaluation.mjs +305 -0
  54. package/runtime/decisions/faults.mjs +32 -0
  55. package/runtime/decisions/index.mjs +818 -0
  56. package/runtime/decisions/profiles.mjs +93 -0
  57. package/runtime/decisions/questions.mjs +268 -0
  58. package/runtime/discovery/index.mjs +2 -0
  59. package/runtime/discovery/inventory.mjs +160 -0
  60. package/runtime/discovery/parser.mjs +40 -0
  61. package/runtime/discovery/structure.mjs +232 -0
  62. package/runtime/extensions/contracts.mjs +59 -0
  63. package/runtime/extensions/frame.mjs +64 -0
  64. package/runtime/extensions/index.mjs +9 -0
  65. package/runtime/extensions/manifest.mjs +95 -0
  66. package/runtime/extensions/packages.mjs +222 -0
  67. package/runtime/extensions/profiles.mjs +36 -0
  68. package/runtime/extensions/projection.mjs +130 -0
  69. package/runtime/extensions/registry.mjs +285 -0
  70. package/runtime/extensions/scene.mjs +105 -0
  71. package/runtime/extensions/sdk.d.ts +205 -0
  72. package/runtime/extensions/sdk.mjs +88 -0
  73. package/runtime/jev/index.mjs +13 -777
  74. package/runtime/jev/provider.mjs +101 -0
  75. package/runtime/jev/questions.mjs +16 -258
  76. package/runtime/jev/wire.mjs +17 -25
  77. package/runtime/model/changes.mjs +42 -0
  78. package/runtime/model/history.mjs +124 -0
  79. package/runtime/model/index.mjs +2 -0
  80. package/runtime/model/project-model.mjs +1020 -0
  81. package/runtime/model/records.mjs +240 -0
  82. package/runtime/pipeline.mjs +267 -55
  83. package/runtime/platform.mjs +254 -0
  84. package/runtime/visualizers/blocks.mjs +5 -0
  85. package/runtime/visualizers/c4.mjs +154 -0
  86. package/runtime/visualizers/changes.mjs +24 -0
  87. package/runtime/visualizers/code.mjs +5 -0
  88. package/runtime/visualizers/index.mjs +23 -0
  89. package/runtime/visualizers/structure.mjs +120 -0
  90. package/runtime/visualizers/timeline.mjs +66 -0
  91. package/runtime/web/app.js +225 -63
  92. package/runtime/web/extension-frame.js +128 -0
  93. package/runtime/web/index.html +38 -1
  94. package/runtime/web/model-client.js +162 -0
  95. package/runtime/web/platform.js +445 -0
  96. package/runtime/web/scene.js +111 -0
  97. package/runtime/web/style.css +51 -0
  98. package/schemas/graph.schema.json +4 -1
  99. package/scripts/arguments.mjs +5 -1
  100. package/scripts/build-packages.mjs +6 -2
  101. package/scripts/control.mjs +1 -1
  102. package/scripts/daemon.mjs +2 -1
  103. package/scripts/extensions.mjs +44 -0
  104. package/scripts/graphlin.mjs +23 -3
  105. package/scripts/onboarding.mjs +10 -3
  106. package/scripts/validate-packages.mjs +54 -8
@@ -1,11 +1,14 @@
1
1
  import { layoutGraph, LAYOUT_ALGORITHMS } from './layout.js';
2
2
  import { sketchOutline, sketchDetails, sketchConnection } from './sketch.js';
3
3
  import { createLiveSidebar } from './sidebar.js';
4
+ import { createViewPlatform } from './platform.js';
5
+ import { layoutScene, sceneGraph, representedSelection } from './scene.js';
4
6
 
5
7
  const SVG_NS = 'http://www.w3.org/2000/svg';
6
8
  const MAX_JSON_BYTES = 8 * 1024 * 1024;
7
9
  const LIMITS = Object.freeze({ nodes: 500, edges: 1500, activity: 200, hookEvents: 200, history: 100, refs: 32, sessions: 100 });
8
- const ROLES = ['client', 'service', 'datastore', 'queue', 'external', 'module', 'function', 'class', 'interface', 'event', 'configuration', 'package'];
10
+ const ROLES = ['client', 'service', 'datastore', 'queue', 'external', 'module', 'function', 'class', 'interface', 'event', 'configuration', 'package',
11
+ 'method', 'namespace', 'enum', 'type_alias', 'variable', 'file', 'directory', 'project', 'unknown', 'group'];
9
12
  export const SHAPE_NAMES = Object.freeze({
10
13
  rounded_rect: 'Rounded rectangle', rect: 'Rectangle', cylinder: 'Cylinder', cloud: 'Cloud',
11
14
  diamond: 'Diamond', group: 'Group', browser: 'Browser', component: 'Component',
@@ -22,6 +25,7 @@ const RELATIONS = ['calls', 'reads', 'writes', 'publishes', 'consumes', 'depends
22
25
  const CLASSIFICATIONS = ['pending', 'accepted', 'tentative', 'abstained', 'stale'];
23
26
  const EVIDENCE = ['proposed', 'observed', 'verified', 'removed'];
24
27
  const VALIDITY = ['current', 'stale', 'retracted'];
28
+ const INTERPRETATION_BASES = ['jev_interpretation', 'decision_interpretation'];
25
29
  const ACTIVITY = ['idle', 'pending', 'running', 'failed', 'interrupted', 'unknown'];
26
30
  const EVENT_STATES = ['pending', 'succeeded', 'failed', 'interrupted', 'unresolved', 'observed'];
27
31
  const CLASSIFIERS = ['ready', 'metadata_only', 'missing_key', 'paused', 'unavailable', 'timeout', 'demo'];
@@ -29,12 +33,15 @@ const ROLE_SHAPES = {
29
33
  client: 'browser', service: 'component', datastore: 'cylinder', queue: 'queue', external: 'cloud',
30
34
  module: 'rect', function: 'hexagon', class: 'class_box', interface: 'interface_box',
31
35
  event: 'document', configuration: 'parallelogram', package: 'folder',
36
+ method: 'hexagon', namespace: 'folder', enum: 'class_box', type_alias: 'interface_box',
37
+ variable: 'rect', file: 'document', directory: 'folder', project: 'folder', unknown: 'rect', group: 'group',
32
38
  };
33
39
  const PROBABILITIES = ['supportProbability', 'roleProbability', 'roleConfidence', 'missingContextProbability'];
34
40
  const NODE_WIDTH = 190;
35
41
  const NODE_HEIGHT = 104;
36
42
  const EDGE_LANE_GAP = 36;
37
- const EDGE_RELATION_ORDER = ['calls', 'writes', 'depends_on', 'reads', 'publishes', 'consumes'];
43
+ const EDGE_RELATION_ORDER = ['calls', 'writes', 'depends_on', 'reads', 'publishes', 'consumes',
44
+ 'imports', 'references', 'member_of', 'hosted_by', 'contains', 'unknown'];
38
45
  const LAYOUT_NAMES = {
39
46
  hierarchy: 'Hierarchy top-down', dependency: 'Dependency left-right', grouped: 'Group by type',
40
47
  circular: 'Circular', grid: 'Grid', original: 'Original', force: 'Force-directed',
@@ -229,7 +236,7 @@ function normalizeRefs(value, includeExcerpts = true) {
229
236
  startLine: count(ref.startLine),
230
237
  endLine: count(ref.endLine),
231
238
  sourceClass: token(ref.sourceClass, ['source', 'public_intent'], 'unknown'),
232
- basis: ref.basis === 'jev_interpretation' ? 'jev_interpretation' : 'unknown',
239
+ basis: token(ref.basis, INTERPRETATION_BASES, 'unknown'),
233
240
  };
234
241
  if (record(ref.sourceRef) && ref.sourceRef.type === 'artifact') {
235
242
  result.sourceRef = {
@@ -420,16 +427,27 @@ export function claimSummary(claim) {
420
427
  if (claim.validity === 'stale' || claim.classification === 'stale') {
421
428
  return { tone: 'stale', label: 'Evidence stale', explanation: 'The backing evidence is no longer current. This interpretation needs reconciliation with the current artifact version.' };
422
429
  }
430
+ if (claim.basis === 'parsed' && refs.length && !refs.some(ref => ref.sourceClass === 'public_intent')) {
431
+ return { tone: 'observed', label: 'Parsed source', explanation: 'A local source parser identified this structure. Source structure does not establish execution or runtime connectivity.' };
432
+ }
433
+ if (claim.basis === 'metadata') {
434
+ return { tone: 'proposed', label: 'Filesystem scope', explanation: 'An observed filesystem scope. Its responsibility and runtime role are unknown.' };
435
+ }
436
+ if (claim.basis === 'decision' && claim.classification === 'accepted' && refs.length) {
437
+ return { tone: 'observed', label: 'Supported interpretation', explanation: 'The model records a supported interpretation of the referenced evidence. This does not establish runtime hosting or execution.' };
438
+ }
423
439
  if (claim.evidenceState === 'proposed' || (refs.length && refs.every(ref => ref.sourceClass === 'public_intent'))) {
424
440
  return { tone: 'proposed', label: 'Proposed', explanation: 'This is a proposal or stated intent. It does not establish that a component exists or that a change completed.' };
425
441
  }
426
442
  if (claim.classification !== 'accepted') {
427
443
  return { tone: 'proposed', label: upperFirst(claim.classification), explanation: 'The code interpretation is uncertain or incomplete. Inspect the evidence before relying on this claim.' };
428
444
  }
429
- if (!refs.length || refs.some(ref => ref.basis !== 'jev_interpretation' || ref.sourceClass === 'unknown')) {
445
+ if (!refs.length || refs.some(ref => !INTERPRETATION_BASES.includes(ref.basis) || ref.sourceClass === 'unknown')) {
430
446
  return { tone: 'proposed', label: 'Provenance incomplete', explanation: 'This snapshot does not provide enough provenance to establish the basis of this claim.' };
431
447
  }
432
- return { tone: 'observed', label: 'Code evidence', explanation: 'Jev interpreted approved source evidence as supporting this claim. Code or configuration can describe a dependency without proving that it runs or connects successfully.' };
448
+ const generic = refs.some(ref => ref.basis === 'decision_interpretation');
449
+ return { tone: 'observed', label: generic ? 'Decision interpretation' : 'Code evidence',
450
+ explanation: `${generic ? 'A decision provider' : 'Jev'} interpreted approved source evidence as supporting this claim. Code or configuration can describe a dependency without proving that it runs or connects successfully.` };
433
451
  }
434
452
 
435
453
  export function edgeLanes(edges) {
@@ -519,8 +537,8 @@ export function graphBounds(graph, routes = graphEdgeRoutes(graph)) {
519
537
  if (!graph.nodes.length) return { x: 0, y: 0, width: 920, height: 510 };
520
538
  let minX = Math.min(...graph.nodes.map(node => node.x));
521
539
  let minY = Math.min(...graph.nodes.map(node => node.y));
522
- let maxX = Math.max(...graph.nodes.map(node => node.x + NODE_WIDTH));
523
- let maxY = Math.max(...graph.nodes.map(node => node.y + NODE_HEIGHT));
540
+ let maxX = Math.max(...graph.nodes.map(node => node.x + (node.width || NODE_WIDTH)));
541
+ let maxY = Math.max(...graph.nodes.map(node => node.y + (node.height || NODE_HEIGHT)));
524
542
  for (const edge of graph.edges) {
525
543
  const route = routes.get(edge.id);
526
544
  if (!route) continue;
@@ -604,16 +622,16 @@ function curveRoute(points, normal) {
604
622
  };
605
623
  }
606
624
 
607
- function nodePort(center, direction, normal, offset, shapeName) {
625
+ function nodePort(center, direction, normal, offset, shapeName, width = NODE_WIDTH, height = NODE_HEIGHT) {
608
626
  const reach = Math.min(
609
- direction.x === 0 ? Infinity : NODE_WIDTH / 2 / Math.abs(direction.x),
610
- direction.y === 0 ? Infinity : NODE_HEIGHT / 2 / Math.abs(direction.y),
627
+ direction.x === 0 ? Infinity : width / 2 / Math.abs(direction.x),
628
+ direction.y === 0 ? Infinity : height / 2 / Math.abs(direction.y),
611
629
  );
612
630
  const x = direction.x * reach + normal.x * offset;
613
631
  const y = direction.y * reach + normal.y * offset;
614
632
  const scale = Math.min(
615
- x === 0 ? Infinity : NODE_WIDTH / 2 / Math.abs(x),
616
- y === 0 ? Infinity : NODE_HEIGHT / 2 / Math.abs(y),
633
+ x === 0 ? Infinity : width / 2 / Math.abs(x),
634
+ y === 0 ? Infinity : height / 2 / Math.abs(y),
617
635
  );
618
636
  const polygons = {
619
637
  diamond: [[95, -12], [204, 52], [95, 116], [-14, 52]],
@@ -663,15 +681,15 @@ export function routeEdge(source, target, lane = 0) {
663
681
  boundary({ x: x - 80, y: source.y + (side > 0 ? 0 : NODE_HEIGHT) }),
664
682
  ], { x: 0, y: side });
665
683
  }
666
- const a = { x: source.x + NODE_WIDTH / 2, y: source.y + NODE_HEIGHT / 2 };
667
- const b = { x: target.x + NODE_WIDTH / 2, y: target.y + NODE_HEIGHT / 2 };
684
+ const a = { x: source.x + (source.width || NODE_WIDTH) / 2, y: source.y + (source.height || NODE_HEIGHT) / 2 };
685
+ const b = { x: target.x + (target.width || NODE_WIDTH) / 2, y: target.y + (target.height || NODE_HEIGHT) / 2 };
668
686
  const distance = Math.hypot(b.x - a.x, b.y - a.y);
669
687
  const direction = distance ? { x: (b.x - a.x) / distance, y: (b.y - a.y) / distance } : { x: 1, y: 0 };
670
688
  const canonicalDirection = source.id <= target.id ? 1 : -1;
671
689
  const normal = { x: -direction.y * canonicalDirection, y: direction.x * canonicalDirection };
672
690
  const portOffset = Math.max(-36, Math.min(36, slot * 6));
673
- const start = nodePort(a, direction, normal, portOffset, source.shape);
674
- const end = nodePort(b, { x: -direction.x, y: -direction.y }, normal, portOffset, target.shape);
691
+ const start = nodePort(a, direction, normal, portOffset, source.shape, source.width, source.height);
692
+ const end = nodePort(b, { x: -direction.x, y: -direction.y }, normal, portOffset, target.shape, target.width, target.height);
675
693
  const arc = slot * EDGE_LANE_GAP * 4 / 3;
676
694
  const control = fraction => ({
677
695
  x: start.x + (end.x - start.x) * fraction + normal.x * arc,
@@ -1565,6 +1583,7 @@ export function startViewer() {
1565
1583
  views: new Map(), viewKey: null, view: null, displayGraph: null, searchQuery: '',
1566
1584
  nodeTypes: null, nodeTypeButtons: new Map(),
1567
1585
  effects: new Map(), liveReady: false, motionReady: false, movement: null, closed: false, projectName: '',
1586
+ model: null, scene: null, projectedGraph: null, platformActive: false, custom: false, follow: true, viewName: 'Code',
1568
1587
  };
1569
1588
  const motionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
1570
1589
  const sketches = createSketchCache();
@@ -1597,6 +1616,72 @@ export function startViewer() {
1597
1616
  let pointer = null;
1598
1617
  let canvasSize = '';
1599
1618
  let projectController = null;
1619
+ const platform = createViewPlatform({
1620
+ document, request,
1621
+ onFollow(value) { state.follow = value; if (!value) { clearMotion(); state.followFit = false; } },
1622
+ onSelect({ entityId, relationId, activityId }) {
1623
+ if (entityId) {
1624
+ platform.selected(entityId);
1625
+ select('node', state.scene ? representedSelection(entityId, state.scene, state.model) || entityId : entityId);
1626
+ revealInspector();
1627
+ } else if (relationId) {
1628
+ const edge = state.scene?.edges.find(edge => edge.relationIds?.includes(relationId));
1629
+ if (edge) select('edge', edge.id);
1630
+ } else if (activityId) {
1631
+ const event = state.model?.activity.find(event => event.id === activityId);
1632
+ if (event) {
1633
+ $('inspector-body').replaceChildren(html('h3', readable(event.kind || 'Activity')),
1634
+ html('p', `Outcome: ${event.outcome || 'unresolved'}. Attribution: ${event.attribution || 'unknown'}.`),
1635
+ html('p', 'This observation has no linked source entity.'));
1636
+ revealInspector();
1637
+ }
1638
+ }
1639
+ },
1640
+ onView(result) {
1641
+ if (state.closed) return;
1642
+ if (result.clear) {
1643
+ state.scene = null; state.projectedGraph = null; state.custom = false;
1644
+ state.displayGraph = null; state.selection = null; state.inspectorSignature = '';
1645
+ clearMotion();
1646
+ for (const layer of ['group-layer', 'node-layer', 'edge-layer', 'edge-label-layer']) $(layer)?.replaceChildren();
1647
+ state.nodeElements.clear(); state.edgeElements.clear();
1648
+ $('architecture').hidden = true; $('architecture').setAttribute('aria-hidden', 'true');
1649
+ $('custom-view').hidden = true; $('empty-canvas').hidden = true;
1650
+ $('inspector-body').replaceChildren(html('p', 'Select an item in the active view to inspect its evidence.'));
1651
+ updateControls();
1652
+ return;
1653
+ }
1654
+ const previous = state.projectedGraph, previousModel = state.model;
1655
+ const switched = !state.platformActive || state.viewName !== result.name || previousModel?.projectId !== result.model.projectId;
1656
+ state.platformActive = true; state.model = result.model; state.scene = result.scene || null;
1657
+ state.custom = result.kind === 'custom'; state.customCount = result.itemCount; state.viewName = result.name;
1658
+ state.projectedGraph = result.scene ? sceneGraph(result.scene, result.model) : null;
1659
+ $('architecture').hidden = state.custom; $('custom-view').hidden = !state.custom;
1660
+ $('architecture').setAttribute('aria-hidden', String(state.custom));
1661
+ if (switched) resetMotionBaseline();
1662
+ const selectionId = state.scene && representedSelection(result.selection, state.scene, state.model);
1663
+ if (selectionId) state.selection = { type: 'node', id: selectionId };
1664
+ const live = result.streamed && !switched && state.follow;
1665
+ const arrivals = liveNodeChanges(previous, state.projectedGraph, live);
1666
+ const focusNodeId = live && state.scene && result.focusEntityId
1667
+ ? representedSelection(result.focusEntityId, state.scene, state.model) : arrivals.added.at(-1);
1668
+ const before = state.displayGraph;
1669
+ render({ forceFit: result.force || switched, focusNodeId });
1670
+ if (!state.custom && state.scene && !state.scene.groups.length) animateChanges(arrivals, before, focusNodeId);
1671
+ const coverage = result.scene?.coverage;
1672
+ $('view-coverage').hidden = false;
1673
+ const counts = state.model.coverage?.counts || state.model.coverage || {};
1674
+ const progress = ['inventoried', 'inspected', 'deferred', 'unsupported', 'unavailable']
1675
+ .filter(key => Number.isSafeInteger(counts[key])).map(key => `${counts[key]} ${key}`).join(' · ');
1676
+ $('view-coverage').textContent = [coverage?.label || 'Observable activity; outcomes may be unresolved',
1677
+ coverage?.truncated ? `${coverage.shown} shown of ${coverage.total}; open a scope for more` : '',
1678
+ state.model.coverage?.client?.truncated
1679
+ ? `Partial scope: ${Object.entries(state.model.coverage.client.totals)
1680
+ .filter(([kind, total]) => total > state.model.coverage.client.retained[kind])
1681
+ .map(([kind, total]) => `${state.model.coverage.client.retained[kind]} of ${total} ${kind}`).join(', ')}. Open a source scope for more.` : '',
1682
+ progress].filter(Boolean).join(' · ');
1683
+ },
1684
+ });
1600
1685
 
1601
1686
  function announce(message) {
1602
1687
  clearTimeout(announcementTimer);
@@ -1640,14 +1725,15 @@ export function startViewer() {
1640
1725
  if ($('orientation-prompt').textContent !== ORIENTATION_PROMPT) $('orientation-prompt').textContent = ORIENTATION_PROMPT;
1641
1726
  $('orientation').hidden = Boolean(state.searchQuery || state.nodeTypes !== null || state.replayFrame || state.snapshot?.mode === 'demo' || state.snapshot?.mode === 'replay');
1642
1727
  }
1643
- function currentGraph() { return state.replayFrame?.graph || state.snapshot?.graph; }
1728
+ function currentGraph() { return state.platformActive ? state.projectedGraph || { revision: state.model?.revision || 0, nodes: [], edges: [] }
1729
+ : state.replayFrame?.graph || state.snapshot?.graph; }
1644
1730
  function applyTheme() {
1645
1731
  const theme = token(state.view?.theme, THEMES, 'sketchbook');
1646
1732
  if ($('drawing').dataset.theme !== theme) $('drawing').dataset.theme = theme;
1647
1733
  $('theme').value = theme;
1648
1734
  }
1649
1735
  function presentation() {
1650
- const key = presentationKey(state.snapshot, state.replayFrame);
1736
+ const key = presentationKey(state.snapshot, state.replayFrame) + (state.platformActive ? `:${platform.active}:${JSON.stringify(platform.selection)}` : '');
1651
1737
  if (state.viewKey !== key) {
1652
1738
  finishPan();
1653
1739
  clearMotion();
@@ -1774,6 +1860,8 @@ export function startViewer() {
1774
1860
  $('theme').disabled = !state.snapshot;
1775
1861
  $('arrange').disabled = !nodes;
1776
1862
  $('auto-arrange').disabled = !state.snapshot;
1863
+ if (state.custom) for (const id of ['fit', 'zoom-in', 'zoom-out', 'arrange', 'layout', 'auto-arrange']) $(id).disabled = true;
1864
+ if (state.platformActive) { $('replay').disabled = true; $('history').disabled = true; }
1777
1865
  }
1778
1866
  function resetView() {
1779
1867
  resetMotionBaseline();
@@ -1790,12 +1878,12 @@ export function startViewer() {
1790
1878
  const snapshot = normalizeSnapshot(raw);
1791
1879
  if (!streamed) resetMotionBaseline();
1792
1880
  const switched = state.snapshot && (snapshot.sessionId !== state.snapshot.sessionId || snapshot.projectId !== state.snapshot.projectId);
1793
- const eligible = streamed && state.motionReady && !switched && !state.replayFrame &&
1881
+ const eligible = !state.platformActive && state.follow && streamed && state.motionReady && !switched && !state.replayFrame &&
1794
1882
  snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay' && motionAllowed();
1795
1883
  const changes = liveNodeChanges(state.snapshot?.graph, snapshot.graph, eligible);
1796
1884
  // A live baseline is independent of animation preferences. Reconnects and
1797
1885
  // initial/session snapshots establish it without focusing an old arrival.
1798
- const live = streamed && state.liveReady && !switched && !state.replayFrame &&
1886
+ const live = !state.platformActive && state.follow && streamed && state.liveReady && !switched && !state.replayFrame &&
1799
1887
  snapshot.mode !== 'replay' && state.snapshot?.mode !== 'replay';
1800
1888
  const visible = new Set(filterDiagram(snapshot.graph, state.searchQuery, state.nodeTypes).nodes.map(node => node.id));
1801
1889
  const focusNodeId = liveNodeChanges(state.snapshot?.graph, snapshot.graph, live).added.filter(id => visible.has(id)).at(-1);
@@ -1848,7 +1936,7 @@ export function startViewer() {
1848
1936
  $('session').replaceChildren(...options);
1849
1937
  $('session').dataset.signature = signature;
1850
1938
  }
1851
- $('session').value = snapshot.sessionId || '';
1939
+ $('session').value = state.platformActive ? platform.selection.session || snapshot.sessionId || '' : snapshot.sessionId || '';
1852
1940
  }
1853
1941
  function shape(node) {
1854
1942
  const details = detailSketches.paths(node.shape, node.id);
@@ -1928,6 +2016,10 @@ export function startViewer() {
1928
2016
  }
1929
2017
  function select(type, id) {
1930
2018
  state.selection = { type, id };
2019
+ if (type === 'node' && state.platformActive) {
2020
+ const node = currentGraph()?.nodes.find(node => node.id === id);
2021
+ if (node?.entityId) platform.selected(node.entityId);
2022
+ }
1931
2023
  updateSelection();
1932
2024
  renderInspector();
1933
2025
  renderActivity();
@@ -2006,8 +2098,8 @@ export function startViewer() {
2006
2098
  const width = state.viewport.width * scale;
2007
2099
  const height = state.viewport.height * scale;
2008
2100
  state.viewport = {
2009
- x: node.x + NODE_WIDTH / 2 - width / 2,
2010
- y: node.y + NODE_HEIGHT / 2 - height / 2,
2101
+ x: node.x + (node.width || NODE_WIDTH) / 2 - width / 2,
2102
+ y: node.y + (node.height || NODE_HEIGHT) / 2 - height / 2,
2011
2103
  width, height,
2012
2104
  };
2013
2105
  state.zoom = zoom;
@@ -2062,10 +2154,18 @@ export function startViewer() {
2062
2154
  if (!canonical) return;
2063
2155
  const view = presentation();
2064
2156
  applyTheme();
2065
- renderNodeTypeFilters(canonical);
2157
+ if (state.platformActive && !state.scene) {
2158
+ $('canvas-title').textContent = state.viewName;
2159
+ $('graph-count').textContent = state.custom && Number.isSafeInteger(state.customCount) ? `${state.customCount} items` : '';
2160
+ $('revision').textContent = `Revision ${state.model.revision}`;
2161
+ $('empty-canvas').hidden = true;
2162
+ return;
2163
+ }
2164
+ renderNodeTypeFilters(state.platformActive ? { nodes: state.model.entities } : canonical);
2066
2165
  // Lay out only visible nodes so hidden components leave no empty slots.
2067
2166
  // Evidence, exports and live arrival detection retain the canonical graph.
2068
- const graph = projectPresentation(filterDiagram(canonical, state.searchQuery, state.nodeTypes), view, { arrange });
2167
+ const graph = state.scene?.groups.length ? sceneGraph(layoutScene(state.scene), state.model)
2168
+ : projectPresentation(state.scene ? canonical : filterDiagram(canonical, state.searchQuery, state.nodeTypes), view, { arrange });
2069
2169
  state.displayGraph = graph;
2070
2170
  const routes = graphEdgeRoutes(graph);
2071
2171
  const bounds = graphBounds(graph, routes);
@@ -2074,7 +2174,7 @@ export function startViewer() {
2074
2174
  // diagram changes retain fit-all; metadata-only updates keep the camera.
2075
2175
  const newest = graph.nodes.find(node => node.id === focusNodeId);
2076
2176
  if (newest && state.viewport && !forceFit) focusNode(newest, bounds);
2077
- else if (forceFit || !state.viewport || signature !== state.lastGraphSignature) fitCamera(bounds);
2177
+ else if (forceFit || !state.viewport || (state.follow && signature !== state.lastGraphSignature)) fitCamera(bounds);
2078
2178
  state.lastGraphSignature = signature;
2079
2179
  $('layout').value = view.algorithm;
2080
2180
  $('auto-arrange').checked = view.auto;
@@ -2129,6 +2229,35 @@ export function startViewer() {
2129
2229
  }
2130
2230
  for (const node of graph.nodes) {
2131
2231
  let group = state.nodeElements.get(node.id);
2232
+ if (group && Boolean(group.isSceneGroup) !== Boolean(node.isGroup)) { group.remove(); state.nodeElements.delete(node.id); group = null; }
2233
+ if (node.isGroup) {
2234
+ if (!group) {
2235
+ group = interactiveGroup('node', node.id);
2236
+ group.isSceneGroup = true;
2237
+ group.setAttribute('class', 'diagram-group');
2238
+ state.nodeElements.set(node.id, group);
2239
+ $('group-layer').append(group);
2240
+ }
2241
+ group.setAttribute('transform', `translate(${node.x} ${node.y})`);
2242
+ group.setAttribute('aria-label', `${node.label}. ${node.memberCount} members, ${node.activityCount} with activity. ${node.collapsed ? 'Collapsed' : 'Expanded'}. Inspect evidence.`);
2243
+ group.dataset.change = node.style || 'default';
2244
+ const toggle = svgElement('g', { class: 'group-toggle', role: 'button', tabindex: 0,
2245
+ 'aria-label': `${node.collapsed ? 'Expand' : 'Collapse'} ${node.label}`,
2246
+ 'aria-expanded': String(!node.collapsed), transform: `translate(${node.width - 33} 10)` });
2247
+ toggle.append(svgElement('rect', { width: 24, height: 24, rx: 4 }),
2248
+ svgElement('text', { x: 12, y: 18, 'text-anchor': 'middle' }, node.collapsed ? '+' : '−'));
2249
+ const toggleGroup = event => { event.stopPropagation(); platform.toggle(node.id, node.collapsed); };
2250
+ toggle.addEventListener('click', toggleGroup);
2251
+ toggle.addEventListener('keydown', event => {
2252
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleGroup(event); }
2253
+ });
2254
+ const restoreToggleFocus = group.contains(document.activeElement) && document.activeElement !== group;
2255
+ group.replaceChildren(svgElement('rect', { class: 'group-frame', width: node.width, height: node.height, rx: 5 }),
2256
+ svgElement('text', { class: 'group-heading', x: 14, y: 27 }, clip(node.label, Math.max(15, Math.floor((node.width - 70) / 8)))),
2257
+ svgElement('text', { class: 'group-summary', x: 14, y: 47 }, `${node.memberCount} members · ${node.activityCount} with activity`), toggle);
2258
+ if (restoreToggleFocus) toggle.focus({ preventScroll: true });
2259
+ continue;
2260
+ }
2132
2261
  if (!group) {
2133
2262
  group = interactiveGroup('node', node.id);
2134
2263
  const center = svgElement('g', { transform: `translate(${NODE_WIDTH / 2} ${NODE_HEIGHT / 2})` });
@@ -2151,6 +2280,7 @@ export function startViewer() {
2151
2280
  group.setAttribute('transform', `translate(${node.x} ${node.y})`);
2152
2281
  group.dataset.shape = node.shape;
2153
2282
  group.dataset.kind = node.kind;
2283
+ group.dataset.change = node.style || 'default';
2154
2284
  group.setAttribute('aria-label', `${node.label}. ${upperFirst(node.kind)}. ${summary.label}. Activity ${node.activityState}. Inspect evidence.`);
2155
2285
  const nodeSignature = JSON.stringify([node.label, node.shape, node.kind, node.activityState, summary]);
2156
2286
  if (group.renderSignature === nodeSignature) continue;
@@ -2182,7 +2312,7 @@ export function startViewer() {
2182
2312
  }
2183
2313
  updateSelection();
2184
2314
  $('revision').textContent = `Revision ${graph.revision}`;
2185
- $('canvas-title').textContent = state.replayFrame ? 'Architecture replay' : 'Live architecture';
2315
+ $('canvas-title').textContent = state.platformActive ? state.viewName : state.replayFrame ? 'Architecture replay' : 'Live architecture';
2186
2316
  $('diagram-title').textContent = `${state.replayFrame ? 'Historical' : 'Live'} architecture, revision ${graph.revision}`;
2187
2317
  $('diagram-desc').textContent = `${graph.nodes.length} components and ${graph.edges.length} relationships. Code interpretation does not establish runtime connectivity. Use Tab and Enter to inspect a component or relationship. With the diagram focused, use plus and minus to zoom, arrow keys to pan, and 0 to fit.`;
2188
2318
  $('graph-count').textContent = `${graph.nodes.length} components · ${graph.edges.length} relationships`;
@@ -2286,7 +2416,11 @@ export function startViewer() {
2286
2416
  const body = $('inspector-body');
2287
2417
  const graph = currentGraph();
2288
2418
  const selected = state.selection;
2289
- const claim = selected && graph ? (selected.type === 'node' ? graph.nodes : graph.edges).find(item => item.id === selected.id) : null;
2419
+ let claim = selected && graph ? (selected.type === 'node' ? graph.nodes : graph.edges).find(item => item.id === selected.id) : null;
2420
+ if (!claim && state.platformActive && selected?.type === 'node') {
2421
+ const entity = state.model.entities.find(entity => entity.id === selected.id);
2422
+ if (entity) claim = sceneGraph({ groups: [], edges: [], nodes: [{ id: entity.id, entityId: entity.id, label: entity.label, kind: entity.kind }] }, state.model).nodes[0];
2423
+ }
2290
2424
  const linkedIds = new Set(claim?.sourceRefs.map(ref => ref.eventId) || []);
2291
2425
  const related = state.snapshot?.activity.filter(event => linkedIds.has(event.id)).slice(-5).reverse() || [];
2292
2426
  const override = selected?.type === 'node' ? state.view?.shapes.get(selected.id) : undefined;
@@ -2315,7 +2449,9 @@ export function startViewer() {
2315
2449
  fact(facts, 'Classification', upperFirst(claim.classification));
2316
2450
  fact(facts, 'Validity', upperFirst(claim.validity));
2317
2451
  fact(facts, 'Evidence state', claim.evidenceState === 'verified' ? 'Verification reported; scope unavailable' : upperFirst(claim.evidenceState));
2318
- fact(facts, 'Basis', claim.sourceRefs.length && claim.sourceRefs.every(ref => ref.basis === 'jev_interpretation') ? 'Jev code interpretation' : 'Provenance incomplete');
2452
+ const interpreted = claim.sourceRefs.length && claim.sourceRefs.every(ref => INTERPRETATION_BASES.includes(ref.basis));
2453
+ const interpretationLabel = claim.sourceRefs.some(ref => ref.basis === 'decision_interpretation') ? 'Decision interpretation' : 'Jev code interpretation';
2454
+ fact(facts, 'Basis', claim.basis ? upperFirst(claim.basis) : interpreted ? interpretationLabel : 'Provenance incomplete');
2319
2455
  fact(facts, 'Runtime', 'Not established by this snapshot');
2320
2456
  if (selected.type === 'node') fact(facts, 'Activity', upperFirst(claim.activityState));
2321
2457
  else {
@@ -2323,6 +2459,14 @@ export function startViewer() {
2323
2459
  fact(facts, 'To', graph.nodes.find(node => node.id === claim.target)?.label || 'Unknown component');
2324
2460
  }
2325
2461
  body.replaceChildren(type, html('h3', claim.label), badges, html('p', summary.explanation), facts);
2462
+ if (state.platformActive && selected.type === 'node') {
2463
+ fact(facts, 'Change', readable(claim.style || 'No comparison'));
2464
+ if (claim.memberCount) fact(facts, 'Members', String(claim.memberCount));
2465
+ const openScope = html('button', 'Open source scope');
2466
+ openScope.setAttribute('type', 'button');
2467
+ openScope.addEventListener('click', () => platform.scope(claim.entityId || claim.id));
2468
+ body.append(openScope);
2469
+ } else if (claim.relationIds?.length) fact(facts, 'Supporting relations', claim.relationIds.join(', '));
2326
2470
  if (selected.type === 'node') {
2327
2471
  const label = html('label', 'Shape · visual only', 'shape-picker');
2328
2472
  label.setAttribute('for', 'display-shape');
@@ -2356,36 +2500,38 @@ export function startViewer() {
2356
2500
  announce(`Display shape changed. ${claim.label} remains classified as ${claim.kind}.`);
2357
2501
  });
2358
2502
  }
2359
- const confidence = normalizeConfidence(claim.confidence);
2360
- body.append(html('h4', 'Classifier confidence'));
2361
- const confidenceList = html('ul', undefined, 'confidence-list');
2362
- const confidenceLabels = {
2363
- supportProbability: 'Evidence support probability',
2364
- roleProbability: 'Selected role probability',
2365
- roleConfidence: 'Role distribution confidence',
2366
- missingContextProbability: 'Missing-context probability',
2367
- reportedConfidence: 'Reported classifier confidence',
2368
- };
2369
- for (const key of Object.keys(confidenceLabels)) {
2370
- if (!probability(confidence[key])) continue;
2371
- const item = html('li');
2372
- item.append(html('span', confidenceLabels[key]), html('strong', `${(confidence[key] * 100).toFixed(1)}%`));
2373
- confidenceList.append(item);
2374
- }
2375
- if (confidenceList.childElementCount) body.append(confidenceList);
2376
- else body.append(html('p', 'Confidence was not supplied for this claim.', 'fine-print'));
2377
- body.append(html('p', 'These values describe the classifier’s interpretation. They are not measured accuracy or the probability that a runtime connection succeeds.', 'fine-print'));
2378
- if (record(confidence.roleProbabilities)) {
2379
- const details = html('details');
2380
- details.append(html('summary', 'Role probabilities'));
2381
- const list = html('ul', undefined, 'confidence-list');
2382
- for (const [role, value] of Object.entries(confidence.roleProbabilities)) {
2383
- const row = html('li');
2384
- row.append(html('span', upperFirst(role)), html('strong', `${(value * 100).toFixed(1)}%`));
2385
- list.append(row);
2503
+ if (!['parsed', 'metadata', 'lexical'].includes(claim.basis)) {
2504
+ const confidence = normalizeConfidence(claim.confidence);
2505
+ body.append(html('h4', 'Classifier confidence'));
2506
+ const confidenceList = html('ul', undefined, 'confidence-list');
2507
+ const confidenceLabels = {
2508
+ supportProbability: 'Evidence support probability',
2509
+ roleProbability: 'Selected role probability',
2510
+ roleConfidence: 'Role distribution confidence',
2511
+ missingContextProbability: 'Missing-context probability',
2512
+ reportedConfidence: 'Reported classifier confidence',
2513
+ };
2514
+ for (const key of Object.keys(confidenceLabels)) {
2515
+ if (!probability(confidence[key])) continue;
2516
+ const item = html('li');
2517
+ item.append(html('span', confidenceLabels[key]), html('strong', `${(confidence[key] * 100).toFixed(1)}%`));
2518
+ confidenceList.append(item);
2519
+ }
2520
+ if (confidenceList.childElementCount) body.append(confidenceList);
2521
+ else body.append(html('p', 'Confidence was not supplied for this claim.', 'fine-print'));
2522
+ body.append(html('p', 'These values describe the classifier’s interpretation. They are not measured accuracy or the probability that a runtime connection succeeds.', 'fine-print'));
2523
+ if (record(confidence.roleProbabilities)) {
2524
+ const details = html('details');
2525
+ details.append(html('summary', 'Role probabilities'));
2526
+ const list = html('ul', undefined, 'confidence-list');
2527
+ for (const [role, value] of Object.entries(confidence.roleProbabilities)) {
2528
+ const row = html('li');
2529
+ row.append(html('span', upperFirst(role)), html('strong', `${(value * 100).toFixed(1)}%`));
2530
+ list.append(row);
2531
+ }
2532
+ details.append(list);
2533
+ body.append(details);
2386
2534
  }
2387
- details.append(list);
2388
- body.append(details);
2389
2535
  }
2390
2536
  body.append(html('h4', `Source references (${claim.sourceRefs.length})`));
2391
2537
  if (!claim.sourceRefs.length) body.append(html('p', 'No source references were supplied. This claim’s provenance cannot be inspected.', 'fine-print'));
@@ -2393,8 +2539,9 @@ export function startViewer() {
2393
2539
  for (const ref of claim.sourceRefs) {
2394
2540
  const item = html('li', undefined, 'source-reference');
2395
2541
  item.append(
2396
- html('strong', ref.sourceClass === 'public_intent' ? 'Public intent' : ref.sourceClass === 'source' ? 'Source artifact' : 'Unknown source class'),
2397
- html('span', ref.basis === 'jev_interpretation' ? 'Basis: Jev interpretation' : 'Basis not supplied'),
2542
+ html('strong', ref.sourceClass === 'public_intent' ? 'Public intent' : ref.sourceClass === 'source' || (claim.basis === 'parsed' && ref.artifactId) ? 'Source artifact' : 'Unknown source class'),
2543
+ html('span', claim.basis ? `Basis: ${upperFirst(claim.basis)}` : ref.basis === 'decision_interpretation'
2544
+ ? 'Basis: Decision interpretation' : ref.basis === 'jev_interpretation' ? 'Basis: Jev interpretation' : 'Basis not supplied'),
2398
2545
  html('span', `${ref.sourceClass === 'public_intent' ? 'Message' : 'Artifact'}: ${ref.sourceRef?.messageId || ref.artifactId || 'not supplied'}`),
2399
2546
  html('span', `Version: ${ref.hash || 'not supplied'} · ${ref.sourceClass === 'public_intent' ? 'content version' : 'generation'} ${ref.sourceRef?.contentVersion ?? ref.generation}`),
2400
2547
  );
@@ -2427,6 +2574,16 @@ export function startViewer() {
2427
2574
  }
2428
2575
  }
2429
2576
  function renderHistory() {
2577
+ if (state.platformActive) {
2578
+ const replay = Boolean(platform.selection.checkpoint);
2579
+ $('live').setAttribute('aria-pressed', String(!replay));
2580
+ $('replay').setAttribute('aria-pressed', String(replay));
2581
+ $('history-position').textContent = `Rev. ${state.model.revision}`;
2582
+ $('replay-note').textContent = replay ? 'Recorded checkpoint. Choose Live to return to current observations.'
2583
+ : 'Use Position to inspect a retained model checkpoint.';
2584
+ $('activity-note').textContent = 'This panel shows live capture. The timeline follows the selected model position.';
2585
+ return;
2586
+ }
2430
2587
  const replay = Boolean(state.replayFrame);
2431
2588
  const frameIndex = replay ? state.frames.findIndex(frame => frame.revision === state.replayFrame.revision) : state.frames.length - 1;
2432
2589
  $('live').setAttribute('aria-pressed', String(!replay));
@@ -2572,6 +2729,7 @@ export function startViewer() {
2572
2729
  connection('reconnecting');
2573
2730
  error('The live connection was lost. Displaying the last received snapshot while the viewer reconnects.');
2574
2731
  });
2732
+ void platform.start();
2575
2733
  void dashboardInfo.refresh();
2576
2734
  // Optional authenticated metadata must not hold up the event stream or
2577
2735
  // turn an older server's missing endpoint into a connection failure.
@@ -2681,6 +2839,7 @@ export function startViewer() {
2681
2839
  $('history-toggle').addEventListener('click', onToggleHistory);
2682
2840
  $('activity-toggle').addEventListener('click', onToggleActivity);
2683
2841
  const applyFilters = () => {
2842
+ if (state.platformActive) { platform.filter(state.searchQuery, state.nodeTypes); return; }
2684
2843
  // Filtering is not a source change: cancel existing decoration/movement
2685
2844
  // and render directly, without changing the snapshot arrival baseline.
2686
2845
  finishPan();
@@ -2691,6 +2850,7 @@ export function startViewer() {
2691
2850
  const onSearchInput = () => {
2692
2851
  if (state.closed || state.searchQuery === $('diagram-search').value) return;
2693
2852
  state.searchQuery = $('diagram-search').value;
2853
+ if (state.platformActive) { platform.filter(state.searchQuery, state.nodeTypes); return; }
2694
2854
  applyFilters();
2695
2855
  };
2696
2856
  const onNodeTypeClick = event => {
@@ -2702,7 +2862,7 @@ export function startViewer() {
2702
2862
  if (!kind || !state.nodeTypeButtons.has(kind)) return;
2703
2863
  // null means all, including future kinds. Explicit selections retain their
2704
2864
  // choices when a kind disappears and returns; new kinds stay unselected.
2705
- if (state.nodeTypes === null) state.nodeTypes = new Set(currentGraph().nodes.map(node => node.kind));
2865
+ if (state.nodeTypes === null) state.nodeTypes = new Set((state.platformActive ? state.model.entities : currentGraph().nodes).map(node => node.kind));
2706
2866
  if (state.nodeTypes.has(kind)) state.nodeTypes.delete(kind);
2707
2867
  else state.nodeTypes.add(kind);
2708
2868
  applyFilters();
@@ -2742,9 +2902,10 @@ export function startViewer() {
2742
2902
  $('node-types-none')?.addEventListener('click', onNoNodeTypes);
2743
2903
  window.addEventListener('keydown', onSearchKeyDown);
2744
2904
  $('pause').addEventListener('click', () => control(state.snapshot?.paused ? 'resume' : 'pause'));
2745
- $('session').addEventListener('change', () => control('session', $('session').value));
2905
+ $('session').addEventListener('change', () => state.platformActive ? platform.session($('session').value) : control('session', $('session').value));
2746
2906
  $('export').addEventListener('click', exportJSON);
2747
2907
  $('live').addEventListener('click', () => {
2908
+ if (state.platformActive) { platform.live(); return; }
2748
2909
  resetMotionBaseline();
2749
2910
  state.replayFrame = null;
2750
2911
  if (state.snapshot) render();
@@ -2850,7 +3011,7 @@ export function startViewer() {
2850
3011
  $('architecture').addEventListener('pointerup', finishPan);
2851
3012
  $('architecture').addEventListener('pointercancel', finishPan);
2852
3013
  $('architecture').addEventListener('lostpointercapture', finishPan);
2853
- const onPageHide = () => { connectionDialog.close(); diagnosticsDialog.close(); resetMotionBaseline(); state.stream?.close(); state.stream = null; };
3014
+ const onPageHide = () => { connectionDialog.close(); diagnosticsDialog.close(); platform.suspend(); resetMotionBaseline(); state.stream?.close(); state.stream = null; };
2854
3015
  const onPageShow = event => { if (!state.closed && event.persisted) connect(); };
2855
3016
  const onOnline = () => { if (!state.closed && state.connection !== 'connected') connect(); };
2856
3017
  const onVisibility = () => resetMotionBaseline();
@@ -2879,6 +3040,7 @@ export function startViewer() {
2879
3040
  ready: connect(),
2880
3041
  close() {
2881
3042
  state.closed = true;
3043
+ platform.close();
2882
3044
  projectController?.abort();
2883
3045
  projectController = null;
2884
3046
  dashboardInfo.close();