symbiote-ui 0.3.0-alpha.52 → 0.3.0-alpha.53

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.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  All notable changes to `symbiote-ui` will be documented in this file.
4
4
 
5
- ## [0.3.0-alpha.52] - 2026-07-10
5
+ ## Unreleased
6
6
 
7
7
  ### Added
8
8
 
9
+ - Added public `CanvasGraph.getViewport()` and `setViewport()` camera contracts
10
+ for clamped absolute live and deterministic viewport projection.
11
+ - Added ordered v3 interaction-cue planning through
12
+ `createWebMcpTourTurnActionPlans`; presentation tools now consume cue bindings
13
+ directly and preserve strict tool input schemas.
9
14
  - Added bounded, cancellation-aware Media Studio frame-sequence playback,
10
15
  caption overlays, and external-clock timeline controls for synchronized
11
16
  audio, seek, pause, and resume workflows.
@@ -35,6 +40,9 @@ All notable changes to `symbiote-ui` will be documented in this file.
35
40
  `{ steps: [{ target, holdMs?, gesture?, label? }] }` scenarios with a host
36
41
  `resolveTarget`, per-step `onStep`, configurable `defaultHoldMs`, and
37
42
  `AbortSignal` support.
43
+ - Added synchronous, seed-driven `presentAnnotationFrame` projection to the
44
+ presenter cursor so offline render workers can reproduce all marker and symbol
45
+ gestures without wall-clock or prior-frame state.
38
46
  - Added `actions` and `embed` custom-content message parts to `ChatMessageItem`
39
47
  and the message model: an `actions` part
40
48
  (`{ type: 'actions', actions: [{ id, label, icon, variant }] }`) renders inline
@@ -46,6 +54,8 @@ All notable changes to `symbiote-ui` will be documented in this file.
46
54
 
47
55
  ### Fixed
48
56
 
57
+ - `registerWebMcpTool()` now forwards native registration options, including
58
+ `AbortSignal` and origin exposure, to `document.modelContext.registerTool()`.
49
59
  - Fixed cascade theme widget/editor slider rows staying stale or failing to
50
60
  apply range input after switching theme variants, and tuned the `classic`
51
61
  preset to sharp, no-outline chrome while keeping its lightness and chroma
@@ -69,9 +69,6 @@ const DEFAULT_MENU_ITEMS = Object.freeze([
69
69
 
70
70
  const INCREMENTAL_LAYOUT_INITIAL_ALPHA = 0.045;
71
71
  const SEEDED_LAYOUT_INITIAL_ALPHA = 0.22;
72
- const VISUAL_FOCUS_LAYOUT_INITIAL_ALPHA = 0.18;
73
- const CRYSTAL_FOCUS_LAYOUT_INITIAL_ALPHA = 0.07;
74
- const VISUAL_FOCUS_LAYOUT_PADDING = 8;
75
72
  const NODE_APPEARANCE_START_SCALE = 0.2;
76
73
  const ENTERING_LAYOUT_SIZE_SCALE = 0.18;
77
74
  const ENTERING_LAYOUT_SIZE_WARMUP_TICKS = 72;
@@ -674,7 +671,6 @@ export class CanvasGraph extends Symbiote {
674
671
  attributeChangedCallback(name, oldValue, newValue) {
675
672
  if (oldValue === newValue) return;
676
673
  if (name === 'active-node-scale' || name === 'info-panel-scale') {
677
- this._restartWorkerForVisualFocus();
678
674
  this.needsDraw = true;
679
675
  this._wakeLoop?.();
680
676
  return;
@@ -1208,6 +1204,37 @@ export class CanvasGraph extends Symbiote {
1208
1204
  return true;
1209
1205
  }
1210
1206
 
1207
+ getViewport() {
1208
+ return {
1209
+ zoom: this.zoom,
1210
+ panX: this.panX,
1211
+ panY: this.panY,
1212
+ };
1213
+ }
1214
+
1215
+ setViewport(viewport = {}) {
1216
+ if (!Number.isFinite(viewport.zoom)
1217
+ || !Number.isFinite(viewport.panX)
1218
+ || !Number.isFinite(viewport.panY)) {
1219
+ throw new TypeError('canvas-graph viewport requires finite zoom, panX, and panY');
1220
+ }
1221
+ let rect = this.canvas?.getBoundingClientRect?.();
1222
+ let target = {
1223
+ zoom: this._clampZoom(viewport.zoom, rect),
1224
+ panX: viewport.panX,
1225
+ panY: viewport.panY,
1226
+ animate: viewport.animate === true,
1227
+ viewportEase: viewport.viewportEase,
1228
+ };
1229
+ let applied = target.animate
1230
+ ? this._applyViewportTarget(target)
1231
+ : this._setViewportImmediate(target);
1232
+ if (!applied) return false;
1233
+ this.needsDraw = true;
1234
+ this._wakeLoop();
1235
+ return this.getViewport();
1236
+ }
1237
+
1211
1238
  _captureViewportState() {
1212
1239
  return {
1213
1240
  zoom: this.zoom,
@@ -1366,7 +1393,10 @@ export class CanvasGraph extends Symbiote {
1366
1393
  this._queueTransitionMarker(previousNode.id, node.id, options);
1367
1394
  }
1368
1395
  this.updateInteractionDepths();
1369
- if (isNewActivation) this._restartWorkerForVisualFocus();
1396
+ if (isNewActivation) {
1397
+ this.needsDraw = true;
1398
+ this._wakeLoop();
1399
+ }
1370
1400
  return true;
1371
1401
  }
1372
1402
 
@@ -2075,7 +2105,6 @@ export class CanvasGraph extends Symbiote {
2075
2105
  max: 4,
2076
2106
  });
2077
2107
  }
2078
- this._restartWorkerForVisualFocus();
2079
2108
  this.needsDraw = true;
2080
2109
  this._wakeLoop();
2081
2110
  }
@@ -2085,43 +2114,13 @@ export class CanvasGraph extends Symbiote {
2085
2114
  let height = node?.h;
2086
2115
  if (this.renderMode === 'dots') {
2087
2116
  const conns = this.adjMap.get(node.id)?.size || 0;
2088
- const isActiveLayoutNode = this.activeNode?.id === node.id;
2089
- const scale = isActiveLayoutNode ? this._resolveActiveNodeScale() : 1;
2090
- const radius = getNodeRadius(node, conns, { scale });
2091
- const layoutRadius = radius + (isActiveLayoutNode ? VISUAL_FOCUS_LAYOUT_PADDING : 0);
2092
- width = layoutRadius * 2;
2093
- height = layoutRadius * 2;
2117
+ const radius = getNodeRadius(node, conns, { scale: 1 });
2118
+ width = radius * 2;
2119
+ height = radius * 2;
2094
2120
  }
2095
2121
  return { width, height };
2096
2122
  }
2097
2123
 
2098
- _getWorkerRestartOptions(options = {}) {
2099
- const baseOptions = this._lastWorkerOptions && typeof this._lastWorkerOptions === 'object'
2100
- ? { ...this._lastWorkerOptions }
2101
- : {};
2102
- return {
2103
- ...baseOptions,
2104
- activeGroupId: this.currentGroupId,
2105
- boundaryRadius: this.currentGroupId ? this.graphDB.nodes.get(this.currentGroupId)?.w / 2 : null,
2106
- attractors: baseOptions.attractors ?? null,
2107
- ...options,
2108
- };
2109
- }
2110
-
2111
- _restartWorkerForVisualFocus() {
2112
- if (this._layoutSuspended || !this.worker || !this.nodes?.length || this.renderMode !== 'dots') return;
2113
- const baseAlpha = Number.isFinite(this._lastWorkerOptions?.initialAlpha)
2114
- ? this._lastWorkerOptions.initialAlpha
2115
- : 0;
2116
- const focusAlpha = this._usesCrystalForceLayout()
2117
- ? CRYSTAL_FOCUS_LAYOUT_INITIAL_ALPHA
2118
- : VISUAL_FOCUS_LAYOUT_INITIAL_ALPHA;
2119
- this.startWorker(this._getWorkerRestartOptions({
2120
- initialAlpha: Math.max(baseAlpha, focusAlpha),
2121
- crystalReseed: this._usesCrystalForceLayout(),
2122
- }));
2123
- }
2124
-
2125
2124
  _queueNodeAppearances(nodeIds, options = {}) {
2126
2125
  if (!Array.isArray(nodeIds) || nodeIds.length === 0) return;
2127
2126
  let now = renderNow();
@@ -2451,7 +2450,7 @@ export class CanvasGraph extends Symbiote {
2451
2450
  nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
2452
2451
  mode: 'continuous',
2453
2452
  positionOrigin: this.renderMode === 'dots' ? 'center' : 'top-left',
2454
- activeVisualNodeId: this.renderMode === 'dots' ? this.activeNode?.id || null : null,
2453
+ activeVisualNodeId: null,
2455
2454
  ...autoOptions,
2456
2455
  ...this._forceLayoutOverrides,
2457
2456
  ...(customOptions || {}),
@@ -1,5 +1,16 @@
1
1
  export const GRAPH_PATH_STYLES = ['pcb', 'bezier', 'orthogonal', 'straight'];
2
2
  export const GRAPH_VIEW_MODES = ['structured', 'flat'];
3
+ export const GRAPH_PATH_STYLE_MENU_GROUP = Object.freeze({
4
+ group: 'path',
5
+ groupLabel: 'Connections',
6
+ groupOrder: 20,
7
+ });
8
+ export const GRAPH_PATH_STYLE_MENU_ITEMS = Object.freeze({
9
+ pcb: Object.freeze({ label: 'Routed', icon: 'route' }),
10
+ orthogonal: Object.freeze({ label: 'Right angles', icon: 'account_tree' }),
11
+ bezier: Object.freeze({ label: 'Curved', icon: 'gesture' }),
12
+ straight: Object.freeze({ label: 'Straight', icon: 'trending_flat' }),
13
+ });
3
14
 
4
15
  export const GRAPH_DIRECTORY_FRAME_COLORS = [
5
16
  'var(--sn-graph-cluster-0)',
@@ -22,6 +33,43 @@ export function normalizeGraphExplorerViewMode(mode) {
22
33
 
23
34
  export const normalizeGraphViewMode = normalizeGraphExplorerViewMode;
24
35
 
36
+ function supportsGraphPathStyleMenuActions(mode) {
37
+ return mode === 'structured';
38
+ }
39
+
40
+ export function resolveGraphPathStyleAction(actionId) {
41
+ const id = String(actionId || '');
42
+ if (!id.startsWith('path:')) return '';
43
+ const style = id.slice('path:'.length);
44
+ return GRAPH_PATH_STYLES.includes(style) ? style : '';
45
+ }
46
+
47
+ export function createGraphPathStyleMenuActions({
48
+ mode = 'structured',
49
+ pathStyle = 'pcb',
50
+ labels = {},
51
+ titles = {},
52
+ group = GRAPH_PATH_STYLE_MENU_GROUP.group,
53
+ groupLabel = GRAPH_PATH_STYLE_MENU_GROUP.groupLabel,
54
+ groupOrder = GRAPH_PATH_STYLE_MENU_GROUP.groupOrder,
55
+ } = {}) {
56
+ if (!supportsGraphPathStyleMenuActions(mode)) return [];
57
+ return GRAPH_PATH_STYLES.map((style) => {
58
+ const item = GRAPH_PATH_STYLE_MENU_ITEMS[style] || {};
59
+ const label = labels[style] || item.label || style;
60
+ return {
61
+ id: `path:${style}`,
62
+ label,
63
+ icon: item.icon || 'route',
64
+ title: titles[style] || `Use ${label} connection paths`,
65
+ group,
66
+ groupLabel,
67
+ groupOrder,
68
+ active: style === pathStyle,
69
+ };
70
+ });
71
+ }
72
+
25
73
  function scheduleGraphExplorerFrame(callback) {
26
74
  if (typeof requestAnimationFrame === 'function') {
27
75
  requestAnimationFrame(callback);
@@ -346,6 +394,21 @@ export function createGraphExplorerViewController({
346
394
  return api;
347
395
  },
348
396
 
397
+ getPathStyleMenuActions(options = {}) {
398
+ return createGraphPathStyleMenuActions({
399
+ ...options,
400
+ mode: state.mode,
401
+ pathStyle: state.pathStyle,
402
+ });
403
+ },
404
+
405
+ runPathStyleMenuAction(actionId) {
406
+ const style = resolveGraphPathStyleAction(actionId);
407
+ if (!style || state.mode !== 'structured') return false;
408
+ api.setPathStyle(style);
409
+ return true;
410
+ },
411
+
349
412
  fitView({ structuredArgs = [], flatArgs = [] } = {}) {
350
413
  if (state.mode === 'flat') {
351
414
  state.flatGraph?.fitView?.(...flatArgs);
package/canvas/index.js CHANGED
@@ -36,11 +36,14 @@ export {
36
36
  } from './CanvasGraph/CanvasGraphViewport.js';
37
37
  export {
38
38
  GRAPH_DIRECTORY_FRAME_COLORS,
39
+ GRAPH_PATH_STYLE_MENU_GROUP,
40
+ GRAPH_PATH_STYLE_MENU_ITEMS,
39
41
  GRAPH_PATH_STYLES,
40
42
  GRAPH_VIEW_MODES,
41
43
  addGraphDirectoryFrames,
42
44
  applyGraphExplorerViewMode,
43
45
  buildFlatPathHash,
46
+ createGraphPathStyleMenuActions,
44
47
  createGraphExplorerViewController,
45
48
  createGraphViewModeController,
46
49
  getFileSelectionNodeId,
@@ -52,6 +55,7 @@ export {
52
55
  normalizeGraphViewMode,
53
56
  renderGraphPathStyleButton,
54
57
  renderGraphViewModeButton,
58
+ resolveGraphPathStyleAction,
55
59
  resolveFlatHashChange,
56
60
  resolveInitialGraphViewMode,
57
61
  selectGraphLabelMode,
@@ -17,6 +17,9 @@
17
17
  * same deterministic move counter as the travel arc, so runs differ yet stay
18
18
  * reproducible without `Math.random`. A faint, themed ink trail traces under the
19
19
  * cursor so the flourish reads, then fades.
20
+ * `presentAnnotationFrame` projects those same paths synchronously from explicit
21
+ * progress and seed values. It uses no animation frame, timer, or prior cursor
22
+ * position, so offline render workers can reproduce any frame independently.
20
23
  *
21
24
  * `clickElement(el)` is the native-control gesture: the cursor travels to a real
22
25
  * button/tab/control, pulses a click target ring, and then calls the element's
@@ -148,6 +151,7 @@ const TRAVEL_PX_PER_MS = 0.75; // longer hops take a little more time
148
151
  const GESTURE_MS = 720; // base duration of a single gesture pass
149
152
  const GESTURE_JITTER_PX = 2.4; // peak per-frame hand-tremor amplitude
150
153
  const INK_FADE_MS = 520; // how long the ink trail lingers before it clears
154
+ const DETERMINISTIC_GESTURE_STEPS = 96;
151
155
 
152
156
  const DEFAULT_HOLD_MS = 1200;
153
157
 
@@ -887,6 +891,9 @@ function inertCursor() {
887
891
  } catch (_) {}
888
892
  }
889
893
  },
894
+ presentAnnotationFrame() {
895
+ return { presented: false, reason: 'unsupported' };
896
+ },
890
897
  clear() {},
891
898
  dispose() {},
892
899
  isSupported() {
@@ -903,7 +910,7 @@ function inertCursor() {
903
910
  * env this returns inert no-ops and `isSupported()` is false.
904
911
  *
905
912
  * @param {Document} [doc] - document to render into (defaults to the global one).
906
- * @returns {{ moveTo: (el: Element, opts?: object) => void, markElement: (el: Element, opts?: object) => void, annotateElement: (el: Element, opts?: object) => void, clickElement: (el: Element, opts?: object) => void, clear: () => void, dispose: () => void, isSupported: () => boolean }}
913
+ * @returns {{ moveTo: (el: Element, opts?: object) => void, markElement: (el: Element, opts?: object) => void, annotateElement: (el: Element, opts?: object) => void, presentAnnotationFrame: (el: Element, annotation: object, frame: object) => object, clickElement: (el: Element, opts?: object) => void, clear: () => void, dispose: () => void, isSupported: () => boolean }}
907
914
  */
908
915
  export function createPresenterCursor(doc = typeof document !== 'undefined' ? document : null) {
909
916
  if (!hasDocument(doc)) return inertCursor();
@@ -1456,6 +1463,93 @@ export function createPresenterCursor(doc = typeof document !== 'undefined' ? do
1456
1463
  }
1457
1464
  }
1458
1465
 
1466
+ function presentAnnotationFrame(el, value, frame = {}) {
1467
+ if (disposed) return { presented: false, reason: 'disposed' };
1468
+ let annotation = normalizePresenterAnnotation(value);
1469
+ if (!annotation) {
1470
+ clear();
1471
+ return { presented: false, reason: 'invalid-annotation' };
1472
+ }
1473
+ if (!el || typeof el.getBoundingClientRect !== 'function') {
1474
+ clear();
1475
+ return { presented: false, reason: 'invalid-target' };
1476
+ }
1477
+ let viewport = {
1478
+ width: win?.innerWidth || doc.documentElement?.clientWidth || 0,
1479
+ height: win?.innerHeight || doc.documentElement?.clientHeight || 0,
1480
+ };
1481
+ let rect = resolvePresenterVisibleRect(el, viewport);
1482
+ if (!rect) {
1483
+ clear();
1484
+ return { presented: false, reason: 'hidden-target' };
1485
+ }
1486
+ let progress = Math.max(0, Math.min(1, Number(frame.progress) || 0));
1487
+ let seed = Number(frame.seed);
1488
+ if (!Number.isFinite(seed)) seed = 0;
1489
+ let drawRect = annotationRectFor(rect, viewport, annotation);
1490
+ let factory = annotation.kind === 'symbol'
1491
+ ? SYMBOLS[annotation.symbol]
1492
+ : GESTURES[annotation.marker];
1493
+ let options = annotation.kind === 'symbol' ? { placement: annotation.placement } : {};
1494
+ let plan = factory?.(drawRect, seed, options);
1495
+ if (!plan || typeof plan.point !== 'function') {
1496
+ clear();
1497
+ return { presented: false, reason: 'unsupported-annotation' };
1498
+ }
1499
+
1500
+ cancelTravel();
1501
+ cancelDrag();
1502
+ cancelGesture();
1503
+ cancelClick();
1504
+ overlay.classList.remove('is-paused');
1505
+ overlay.classList.add('is-visible');
1506
+ hideMarqueeFrame();
1507
+
1508
+ let pointCount = progress > 0
1509
+ ? Math.max(2, Math.floor(progress * DETERMINISTIC_GESTURE_STEPS) + 1)
1510
+ : 0;
1511
+ let points = [];
1512
+ let amp = GESTURE_JITTER_PX * (0.85 + (variation(seed, 31) * 0.5 + 0.5) * 0.4);
1513
+ for (let index = 0; index < pointCount; index += 1) {
1514
+ let t = Math.min(1, index / DETERMINISTIC_GESTURE_STEPS);
1515
+ let eased = easeInOutCubic(t);
1516
+ let ideal = plan.point(eased);
1517
+ let fade = 1 - eased * eased;
1518
+ points.push({
1519
+ x: ideal.x + jitter(seed, eased, amp, 0) * fade,
1520
+ y: ideal.y + jitter(seed, eased, amp, 1) * fade,
1521
+ });
1522
+ }
1523
+ let path = points.length >= 2
1524
+ ? points.reduce((d, point, index) => `${d}${index ? 'L' : 'M'}${point.x.toFixed(1)} ${point.y.toFixed(1)}`, '')
1525
+ : '';
1526
+ inkPath.setAttribute('d', path);
1527
+ ink.classList.toggle('is-inking', Boolean(path));
1528
+ let startIdeal = plan.point(0);
1529
+ let startPoint = {
1530
+ x: startIdeal.x + jitter(seed, 0, amp, 0),
1531
+ y: startIdeal.y + jitter(seed, 0, amp, 1),
1532
+ };
1533
+ let cursorPoint = progress >= 1 ? (plan.rest || plan.point(1)) : (points.at(-1) || startPoint);
1534
+ setCursor(cursorPoint.x, cursorPoint.y);
1535
+ let pathDigest = 2166136261;
1536
+ for (let index = 0; index < path.length; index += 1) {
1537
+ pathDigest ^= path.charCodeAt(index);
1538
+ pathDigest = Math.imul(pathDigest, 16777619);
1539
+ }
1540
+ return {
1541
+ presented: true,
1542
+ kind: annotation.kind,
1543
+ name: annotation.marker || annotation.symbol,
1544
+ progress,
1545
+ seed,
1546
+ rect: drawRect,
1547
+ cursor: cursorPoint,
1548
+ pathPoints: points.length,
1549
+ pathDigest: (pathDigest >>> 0).toString(16).padStart(8, '0'),
1550
+ };
1551
+ }
1552
+
1459
1553
  function clear() {
1460
1554
  if (disposed) return;
1461
1555
  cancelTravel();
@@ -1483,6 +1577,7 @@ export function createPresenterCursor(doc = typeof document !== 'undefined' ? do
1483
1577
  moveTo,
1484
1578
  markElement,
1485
1579
  annotateElement,
1580
+ presentAnnotationFrame,
1486
1581
  clickElement,
1487
1582
  clear,
1488
1583
  dispose,
@@ -234,7 +234,7 @@
234
234
  "name": "CanvasGraph",
235
235
  "tagName": "canvas-graph",
236
236
  "description": "Generic hierarchical graph overview/read renderer with force layout, semantic navigation, selection events, and optional device-orientation parallax.",
237
- "componentDescription": "Generic hierarchical graph overview/read renderer with force layout, semantic navigation, selection events, and optional device-orientation parallax. Use this interactive visual graph surface when composing canvas UI. Capabilities: hierarchical-graph, overview-read-renderer, force-layout, semantic-clusters, focus-selection, layout-snapshot, device-orientation-parallax. Data ownership: host-owned model; component renders and emits intent events. WebMCP tools: canvas_graph_set_model, canvas_graph_focus_node, canvas_graph_focus_nodes, canvas_graph_set_path.",
237
+ "componentDescription": "Generic hierarchical graph overview/read renderer with force layout, semantic navigation, selection events, and optional device-orientation parallax. Use this interactive visual graph surface when composing canvas UI. Capabilities: hierarchical-graph, overview-read-renderer, force-layout, semantic-clusters, focus-selection, layout-snapshot, viewport-control, device-orientation-parallax. Data ownership: host-owned model; component renders and emits intent events. WebMCP tools: canvas_graph_set_model, canvas_graph_focus_node, canvas_graph_focus_nodes, canvas_graph_set_path.",
238
238
  "attributes": [
239
239
  {
240
240
  "name": "active-node-scale",
@@ -376,6 +376,16 @@
376
376
  "name": "fitView",
377
377
  "description": "Fits the visible graph into the canvas viewport; accepts optional viewportEase for live replay tracking."
378
378
  },
379
+ {
380
+ "kind": "method",
381
+ "name": "getViewport",
382
+ "description": "Returns the current zoom and pan viewport as a portable snapshot."
383
+ },
384
+ {
385
+ "kind": "method",
386
+ "name": "setViewport",
387
+ "description": "Applies an absolute, clamped zoom and pan viewport immediately or through the graph camera easing loop."
388
+ },
379
389
  {
380
390
  "kind": "method",
381
391
  "name": "setVisualOptions",
@@ -1049,7 +1059,7 @@
1049
1059
  }
1050
1060
  },
1051
1061
  "agent": {
1052
- "componentDescription": "Generic hierarchical graph overview/read renderer with force layout, semantic navigation, selection events, and optional device-orientation parallax. Use this interactive visual graph surface when composing canvas UI. Capabilities: hierarchical-graph, overview-read-renderer, force-layout, semantic-clusters, focus-selection, layout-snapshot, device-orientation-parallax. Data ownership: host-owned model; component renders and emits intent events. WebMCP tools: canvas_graph_set_model, canvas_graph_focus_node, canvas_graph_focus_nodes, canvas_graph_set_path.",
1062
+ "componentDescription": "Generic hierarchical graph overview/read renderer with force layout, semantic navigation, selection events, and optional device-orientation parallax. Use this interactive visual graph surface when composing canvas UI. Capabilities: hierarchical-graph, overview-read-renderer, force-layout, semantic-clusters, focus-selection, layout-snapshot, viewport-control, device-orientation-parallax. Data ownership: host-owned model; component renders and emits intent events. WebMCP tools: canvas_graph_set_model, canvas_graph_focus_node, canvas_graph_focus_nodes, canvas_graph_set_path.",
1053
1063
  "semanticRole": "interactive visual graph surface",
1054
1064
  "usage": "Render or compose <canvas-graph> when the host needs generic hierarchical graph overview/read renderer with force layout, semantic navigation, selection events, and optional device-orientation parallax.",
1055
1065
  "dataOwnership": "host-owned model; component renders and emits intent events",
@@ -29623,7 +29633,7 @@
29623
29633
  "name": "CellBg",
29624
29634
  "tagName": "cell-bg",
29625
29635
  "description": "Animated cellular automaton background effect.",
29626
- "componentDescription": "Animated cellular automaton background effect. Use this effects UI component when composing effects UI. Capabilities: animated-background, canvas-effect, activity-trigger, smooth-start, smooth-stop, pulse, resize-aware, reduced-motion-friendly, layout-lifecycle. Data ownership: host-owned props and attributes; component owns presentation state. WebMCP tools: cell_bg_trigger, cell_bg_start, cell_bg_stop.",
29636
+ "componentDescription": "Animated cellular automaton background effect. Use this effects UI component when composing effects UI. Capabilities: animated-background, canvas-effect, activity-trigger, smooth-start, smooth-stop, pulse, resize-aware, reduced-motion-friendly, layout-lifecycle, deterministic-render. Data ownership: host-owned props and attributes; component owns presentation state. WebMCP tools: cell_bg_trigger, cell_bg_start, cell_bg_stop.",
29627
29637
  "attributes": [
29628
29638
  {
29629
29639
  "name": "active",
@@ -29645,6 +29655,13 @@
29645
29655
  "text": "number"
29646
29656
  },
29647
29657
  "description": "Default timed pulse duration in milliseconds."
29658
+ },
29659
+ {
29660
+ "name": "seed",
29661
+ "type": {
29662
+ "text": "string"
29663
+ },
29664
+ "description": "Stable seed used by deterministic external-frame rendering."
29648
29665
  }
29649
29666
  ],
29650
29667
  "members": [
@@ -29716,6 +29733,21 @@
29716
29733
  "kind": "method",
29717
29734
  "name": "resumeLayout",
29718
29735
  "description": "Restores persistent animation only when it was active before layout suspension."
29736
+ },
29737
+ {
29738
+ "kind": "method",
29739
+ "name": "setFrameDriver",
29740
+ "description": "Selects self-driven live animation or externally driven deterministic rendering."
29741
+ },
29742
+ {
29743
+ "kind": "method",
29744
+ "name": "presentFrame",
29745
+ "description": "Synchronously paints the current shared render-clock time."
29746
+ },
29747
+ {
29748
+ "kind": "method",
29749
+ "name": "getFramePresentation",
29750
+ "description": "Returns deterministic frame metrics and the current grid hash."
29719
29751
  }
29720
29752
  ],
29721
29753
  "events": [
@@ -30084,7 +30116,7 @@
30084
30116
  }
30085
30117
  },
30086
30118
  "agent": {
30087
- "componentDescription": "Animated cellular automaton background effect. Use this effects UI component when composing effects UI. Capabilities: animated-background, canvas-effect, activity-trigger, smooth-start, smooth-stop, pulse, resize-aware, reduced-motion-friendly, layout-lifecycle. Data ownership: host-owned props and attributes; component owns presentation state. WebMCP tools: cell_bg_trigger, cell_bg_start, cell_bg_stop.",
30119
+ "componentDescription": "Animated cellular automaton background effect. Use this effects UI component when composing effects UI. Capabilities: animated-background, canvas-effect, activity-trigger, smooth-start, smooth-stop, pulse, resize-aware, reduced-motion-friendly, layout-lifecycle, deterministic-render. Data ownership: host-owned props and attributes; component owns presentation state. WebMCP tools: cell_bg_trigger, cell_bg_start, cell_bg_stop.",
30088
30120
  "semanticRole": "effects UI component",
30089
30121
  "usage": "Render or compose <cell-bg> when the host needs animated cellular automaton background effect.",
30090
30122
  "dataOwnership": "host-owned props and attributes; component owns presentation state",
@@ -136,6 +136,11 @@ changing the host-owned graph model.
136
136
  Use `fitNodes(ids, { viewportEase })` or `fitView({ viewportEase })` when a
137
137
  live replay should track a changing area of interest with a slower camera path
138
138
  than ordinary user-initiated focus.
139
+ Use `getViewport()` to capture the current `{ zoom, panX, panY }` camera and
140
+ `setViewport({ zoom, panX, panY, animate })` to restore or project an absolute
141
+ camera state. `setViewport()` clamps zoom through the graph's normal viewport
142
+ rules and cancels stale immediate-camera targets, so deterministic replay must
143
+ prefer it over writing graph fields directly.
139
144
  During incremental model growth, `canvas-graph` seeds entering node positions
140
145
  near linked visible nodes, starts them with a small effective collision size,
141
146
  and restarts the force layout with low initial heat, so live process replay can
@@ -2,6 +2,13 @@ import Symbiote from '@symbiotejs/symbiote';
2
2
  import template from './CellBg.tpl.js';
3
3
  import css from './CellBg.css.js';
4
4
  import { CELL_BG_DEFAULTS, readCellBgTheme } from './cell-bg-theme.js';
5
+ import { renderNow } from '../../core/render-clock.js';
6
+ import {
7
+ cellBgRenderFrame,
8
+ cellBgRenderHash,
9
+ createCellBgRenderState,
10
+ seekCellBgRenderState,
11
+ } from './cell-bg-render-state.js';
5
12
 
6
13
  /**
7
14
  * Cellular Automaton Background Component
@@ -17,13 +24,21 @@ import { CELL_BG_DEFAULTS, readCellBgTheme } from './cell-bg-theme.js';
17
24
  const RULE_B = [3];
18
25
  const RULE_S = [2, 3];
19
26
  const PALETTE_SIZE = CELL_BG_DEFAULTS.paletteSize;
20
- const now = () => globalThis.performance?.now?.() ?? Date.now();
27
+ const now = () => renderNow();
21
28
  const requestFrame = (callback) => {
22
29
  if (typeof globalThis.requestAnimationFrame === 'function') {
23
30
  return globalThis.requestAnimationFrame(callback);
24
31
  }
25
32
  return setTimeout(() => callback(now()), 16);
26
33
  };
34
+ const cancelFrame = (handle) => {
35
+ if (handle === null || handle === undefined) return;
36
+ if (typeof globalThis.cancelAnimationFrame === 'function') {
37
+ globalThis.cancelAnimationFrame(handle);
38
+ } else {
39
+ clearTimeout(handle);
40
+ }
41
+ };
27
42
  const getReducedMotionQuery = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') || null;
28
43
 
29
44
  function normalizeCssColor(source, value) {
@@ -89,7 +104,7 @@ function readCssNumber(source, token, fallback) {
89
104
 
90
105
  export class CellBg extends Symbiote {
91
106
  static get observedAttributes() {
92
- return ['active', 'auto-trigger', 'pulse-duration'];
107
+ return ['active', 'auto-trigger', 'pulse-duration', 'seed'];
93
108
  }
94
109
 
95
110
  init$ = {
@@ -112,6 +127,10 @@ export class CellBg extends Symbiote {
112
127
  this._prefersReducedMotion = Boolean(this._motionQuery?.matches);
113
128
  this._motionChangeHandler = (event) => {
114
129
  this._prefersReducedMotion = Boolean(event.matches);
130
+ if (this._externalFrameDrive) {
131
+ this.presentFrame();
132
+ return;
133
+ }
115
134
  if (this._prefersReducedMotion) {
116
135
  if (this._pulseTimer) clearTimeout(this._pulseTimer);
117
136
  this._pulseTimer = null;
@@ -137,6 +156,11 @@ export class CellBg extends Symbiote {
137
156
  this.lastTime = now();
138
157
  this.isAnimating = false;
139
158
  this._stagnantCount = 0;
159
+ this._frameRequest = null;
160
+ this._externalFrameDrive = false;
161
+ this._externalRenderState = null;
162
+ this._externalRenderFrame = null;
163
+ this._externalRestoreState = null;
140
164
 
141
165
  // We only redraw on rAF if running, or if a single frame is needed after resize
142
166
  this.resize = this.resize.bind(this);
@@ -146,6 +170,7 @@ export class CellBg extends Symbiote {
146
170
  // Debounce: resize canvas immediately (prevents flash), pulse only after settle
147
171
  const onResize = () => {
148
172
  this.resize();
173
+ if (this._externalFrameDrive) return;
149
174
  if (this._resizeDebounce) clearTimeout(this._resizeDebounce);
150
175
  this._resizeDebounce = setTimeout(() => {
151
176
  this._resizeDebounce = null;
@@ -194,6 +219,9 @@ export class CellBg extends Symbiote {
194
219
  this.$.autoTrigger = this._readAutoTriggerAttribute();
195
220
  } else if (name === 'pulse-duration') {
196
221
  this.$.pulseDuration = this._readPulseDurationAttribute();
222
+ } else if (name === 'seed' && this._externalFrameDrive) {
223
+ this._externalRenderState = null;
224
+ this.presentFrame();
197
225
  }
198
226
  }
199
227
 
@@ -204,6 +232,10 @@ export class CellBg extends Symbiote {
204
232
  */
205
233
  toggle(state) {
206
234
  let next = !!state;
235
+ if (this._externalFrameDrive) {
236
+ this._recordExternalPersistent(next);
237
+ return;
238
+ }
207
239
  if (this.$.active !== next) {
208
240
  this.$.active = next;
209
241
  return;
@@ -211,7 +243,113 @@ export class CellBg extends Symbiote {
211
243
  this._setPersistent(next);
212
244
  }
213
245
 
246
+ setFrameDriver(mode = 'self') {
247
+ let nextMode = String(mode || 'self').trim().toLowerCase();
248
+ if (nextMode !== 'self' && nextMode !== 'external') {
249
+ throw new TypeError('CellBg frame driver must be self or external');
250
+ }
251
+ if (nextMode === 'external') {
252
+ if (!this._externalFrameDrive) {
253
+ this._externalRestoreState = {
254
+ toggled: Boolean(this._toggled),
255
+ active: Boolean(this.$?.active),
256
+ running: Boolean(this.running),
257
+ };
258
+ }
259
+ this._externalFrameDrive = true;
260
+ this.running = false;
261
+ this.isAnimating = false;
262
+ this.currentSpeed = 0;
263
+ if (this._frameRequest !== null) cancelFrame(this._frameRequest);
264
+ this._frameRequest = null;
265
+ if (this._pulseTimer) clearTimeout(this._pulseTimer);
266
+ if (this._resizeDebounce) clearTimeout(this._resizeDebounce);
267
+ this._pulseTimer = null;
268
+ this._resizeDebounce = null;
269
+ this._externalRenderState = null;
270
+ this.resize();
271
+ return 'external';
272
+ }
273
+
274
+ if (!this._externalFrameDrive) return 'self';
275
+ let restore = this._externalRestoreState || {};
276
+ if (this._externalRenderFrame) {
277
+ this.grid = new Uint8Array(this._externalRenderFrame.grid);
278
+ this.radii = new Float32Array(this._externalRenderFrame.radii);
279
+ }
280
+ this._externalFrameDrive = false;
281
+ this._externalRenderState = null;
282
+ this._externalRenderFrame = null;
283
+ this._externalRestoreState = null;
284
+ this._toggled = Boolean(restore.toggled);
285
+ this.$.active = Boolean(restore.active);
286
+ this.running = false;
287
+ this.isAnimating = false;
288
+ this.currentSpeed = 0;
289
+ this.lastTime = now();
290
+ if (restore.running || restore.toggled || restore.active) this._start('frame-driver-restore');
291
+ else this._draw();
292
+ return 'self';
293
+ }
294
+
295
+ presentFrame() {
296
+ if (!this._available) return false;
297
+ this.resize();
298
+ if (!this.canvas?._w || !this.canvas?._h) return false;
299
+ if (!this._externalFrameDrive) {
300
+ this._draw();
301
+ return true;
302
+ }
303
+ let options = this._externalRenderOptions();
304
+ this._externalRenderState = seekCellBgRenderState(this._externalRenderState, now(), options);
305
+ this._externalRenderFrame = cellBgRenderFrame(this._externalRenderState);
306
+ this._draw(this._externalRenderFrame);
307
+ return true;
308
+ }
309
+
310
+ getFramePresentation() {
311
+ if (!this._externalRenderState || !this._externalRenderFrame) return null;
312
+ return {
313
+ seed: this._externalRenderState.seed,
314
+ width: this.canvas?._w || 0,
315
+ height: this.canvas?._h || 0,
316
+ cols: this._externalRenderState.cols,
317
+ rows: this._externalRenderState.rows,
318
+ cellSize: this._cellSize,
319
+ stepMs: this._externalRenderState.stepMs,
320
+ stepIndex: this._externalRenderState.stepIndex,
321
+ timeMs: this._externalRenderState.timeMs,
322
+ gridHash: cellBgRenderHash(this._externalRenderState, this._externalRenderFrame),
323
+ activity: 'continuous',
324
+ reducedMotion: false,
325
+ };
326
+ }
327
+
328
+ _externalRenderOptions() {
329
+ return {
330
+ seed: this.getAttribute('seed') || 'symbiote-cell-bg',
331
+ cols: this.cols,
332
+ rows: this.rows,
333
+ stepMs: this._stepMs,
334
+ minRadius: this._minRadius,
335
+ maxRadius: this._maxRadius,
336
+ fadeRate: this._fadeRate,
337
+ };
338
+ }
339
+
340
+ _recordExternalPersistent(state) {
341
+ if (!this._externalRestoreState) return;
342
+ let active = Boolean(state);
343
+ this._externalRestoreState.toggled = active;
344
+ this._externalRestoreState.active = active;
345
+ this._externalRestoreState.running = active;
346
+ }
347
+
214
348
  _setPersistent(state) {
349
+ if (this._externalFrameDrive) {
350
+ this._recordExternalPersistent(state);
351
+ return;
352
+ }
215
353
  this._toggled = !!state;
216
354
  if (state && this._pulseTimer) {
217
355
  clearTimeout(this._pulseTimer);
@@ -240,6 +378,10 @@ export class CellBg extends Symbiote {
240
378
  * Stop persistent or timed animation with smooth deceleration.
241
379
  */
242
380
  stop() {
381
+ if (this._externalFrameDrive) {
382
+ this._recordExternalPersistent(false);
383
+ return;
384
+ }
243
385
  if (this._pulseTimer) clearTimeout(this._pulseTimer);
244
386
  this._pulseTimer = null;
245
387
  this._toggled = false;
@@ -252,6 +394,7 @@ export class CellBg extends Symbiote {
252
394
  }
253
395
 
254
396
  suspendLayout({ reason = 'layout-suspend' } = {}) {
397
+ if (this._externalFrameDrive) return;
255
398
  this._resumePersistentAfterSuspend = Boolean(this._toggled || this.$.active);
256
399
  if (this._pulseTimer) clearTimeout(this._pulseTimer);
257
400
  this._pulseTimer = null;
@@ -276,6 +419,7 @@ export class CellBg extends Symbiote {
276
419
  }
277
420
 
278
421
  resumeLayout() {
422
+ if (this._externalFrameDrive) return;
279
423
  if (this._resumePersistentAfterSuspend) this.start();
280
424
  this._resumePersistentAfterSuspend = false;
281
425
  }
@@ -287,6 +431,7 @@ export class CellBg extends Symbiote {
287
431
  * @param {number} [duration=10000]
288
432
  */
289
433
  pulse(duration = 10000) {
434
+ if (this._externalFrameDrive) return;
290
435
  if (this._toggled) return; // Already running persistently
291
436
  let safeDuration = this._normalizePulseDuration(duration);
292
437
  if (this._prefersReducedMotion) {
@@ -321,6 +466,8 @@ export class CellBg extends Symbiote {
321
466
  if (this._resizeDebounce) clearTimeout(this._resizeDebounce);
322
467
  this._pulseTimer = null;
323
468
  this._resizeDebounce = null;
469
+ if (this._frameRequest !== null) cancelFrame(this._frameRequest);
470
+ this._frameRequest = null;
324
471
  this._themeObserver?.disconnect();
325
472
  this.ownerDocument?.removeEventListener?.('cascade-theme-change', this._themeChangeHandler);
326
473
  if (typeof this._motionQuery?.removeEventListener === 'function') {
@@ -336,13 +483,18 @@ export class CellBg extends Symbiote {
336
483
  let previousCellSize = this._cellSize;
337
484
  let previousMinRadius = this._minRadius;
338
485
  let previousMaxRadius = this._maxRadius;
486
+ let previousStepMs = this._stepMs;
487
+ let previousFadeRate = this._fadeRate;
339
488
  this._buildPalette();
340
489
 
341
490
  let geometryChanged = previousCellSize !== this._cellSize
342
491
  || previousMinRadius !== this._minRadius
343
- || previousMaxRadius !== this._maxRadius;
492
+ || previousMaxRadius !== this._maxRadius
493
+ || previousStepMs !== this._stepMs
494
+ || previousFadeRate !== this._fadeRate;
344
495
  if (geometryChanged) {
345
- this.resize();
496
+ if (this._externalFrameDrive) this._externalRenderState = null;
497
+ this.resize({ force: true });
346
498
  } else {
347
499
  this._draw();
348
500
  }
@@ -350,6 +502,10 @@ export class CellBg extends Symbiote {
350
502
  }
351
503
 
352
504
  _scheduleThemeRefresh() {
505
+ if (this._externalFrameDrive) {
506
+ this.refreshTheme({ pulse: false });
507
+ return;
508
+ }
353
509
  if (this._themeRefreshQueued) return;
354
510
  this._themeRefreshQueued = true;
355
511
  requestFrame(() => {
@@ -381,6 +537,7 @@ export class CellBg extends Symbiote {
381
537
  }
382
538
 
383
539
  _triggerIfEnabled() {
540
+ if (this._externalFrameDrive) return;
384
541
  if (this.$.autoTrigger === false) return;
385
542
  this.trigger(this.$.pulseDuration);
386
543
  }
@@ -402,7 +559,7 @@ export class CellBg extends Symbiote {
402
559
  return theme;
403
560
  }
404
561
 
405
- resize() {
562
+ resize({ force = false } = {}) {
406
563
  if (!this._available || !this.canvas.parentElement) return;
407
564
  let dpr = globalThis.devicePixelRatio || 1;
408
565
  let w = this.canvas.parentElement.clientWidth;
@@ -410,6 +567,9 @@ export class CellBg extends Symbiote {
410
567
 
411
568
  if (w === 0 || h === 0) return; // Hidden or not attached
412
569
 
570
+ let sameSize = this.canvas._w === w && this.canvas._h === h;
571
+ if (sameSize && !force && (!this._externalFrameDrive || this._externalRenderState)) return;
572
+
413
573
  this.canvas.width = w * dpr;
414
574
  this.canvas.height = h * dpr;
415
575
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -428,6 +588,16 @@ export class CellBg extends Symbiote {
428
588
  this.cols = Math.ceil(w / this._cellSize) + 1;
429
589
  this.rows = Math.ceil(h / this._cellSize) + 1;
430
590
 
591
+ if (this._externalFrameDrive) {
592
+ this.grid = new Uint8Array(this.cols * this.rows);
593
+ this.radii = new Float32Array(this.cols * this.rows);
594
+ this.radii.fill(this._minRadius);
595
+ this._externalRenderState = createCellBgRenderState(this._externalRenderOptions());
596
+ this._externalRenderFrame = cellBgRenderFrame(this._externalRenderState);
597
+ this._draw(this._externalRenderFrame);
598
+ return;
599
+ }
600
+
431
601
  this.grid = new Uint8Array(this.cols * this.rows);
432
602
  this.radii = new Float32Array(this.cols * this.rows);
433
603
  this.radii.fill(this._minRadius);
@@ -460,6 +630,7 @@ export class CellBg extends Symbiote {
460
630
  }
461
631
 
462
632
  _start(reason = 'manual') {
633
+ if (this._externalFrameDrive) return;
463
634
  if (!this._available) return;
464
635
  if (this.running) return;
465
636
  this.running = true;
@@ -471,11 +642,12 @@ export class CellBg extends Symbiote {
471
642
  if (!this.isAnimating) {
472
643
  this.lastTime = now();
473
644
  this.isAnimating = true;
474
- requestFrame(this.renderLoop);
645
+ this._scheduleFrame();
475
646
  }
476
647
  }
477
648
 
478
649
  _stop(reason = 'manual') {
650
+ if (this._externalFrameDrive) return;
479
651
  if (!this.running) return;
480
652
  this.running = false;
481
653
  this.dispatchEvent(new CustomEvent('cell-bg-animation-stop', {
@@ -486,6 +658,11 @@ export class CellBg extends Symbiote {
486
658
  // Loop will smoothly decelerate and stop in renderLoop
487
659
  }
488
660
 
661
+ _scheduleFrame() {
662
+ if (this._externalFrameDrive || this._frameRequest !== null) return;
663
+ this._frameRequest = requestFrame(this.renderLoop);
664
+ }
665
+
489
666
  _step() {
490
667
  if (!this.cols || !this.rows) return;
491
668
  let len = this.cols * this.rows;
@@ -551,6 +728,8 @@ export class CellBg extends Symbiote {
551
728
  }
552
729
 
553
730
  renderLoop(ts) {
731
+ this._frameRequest = null;
732
+ if (this._externalFrameDrive) return;
554
733
  if (!this.isAnimating) return;
555
734
 
556
735
  let time = now();
@@ -559,7 +738,8 @@ export class CellBg extends Symbiote {
559
738
 
560
739
  // Smoothly accelerate/decelerate
561
740
  let targetSpeed = this.running ? 1.0 : 0.0;
562
- this.currentSpeed += (targetSpeed - this.currentSpeed) * 0.03; // Smooth transition factor
741
+ let speedBlend = 1 - Math.pow(1 - 0.03, dt / (1000 / 60));
742
+ this.currentSpeed += (targetSpeed - this.currentSpeed) * speedBlend;
563
743
 
564
744
  // If we're fully stopped and radii have faded (we just wait for speed to drop near 0)
565
745
  if (!this.running && this.currentSpeed < 0.005) {
@@ -582,17 +762,29 @@ export class CellBg extends Symbiote {
582
762
  maxSteps--;
583
763
  }
584
764
 
765
+ this._advanceLiveRadii(dt);
585
766
  this._draw();
586
767
 
587
768
  if (this.isAnimating) {
588
- requestFrame(this.renderLoop);
769
+ this._scheduleFrame();
770
+ }
771
+ }
772
+
773
+ _advanceLiveRadii(elapsedMs) {
774
+ let frames = Math.max(0, Number(elapsedMs) || 0) / (1000 / 60);
775
+ for (let index = 0; index < this.radii.length; index += 1) {
776
+ let alive = this.grid[index] === 1;
777
+ let target = alive ? this._maxRadius : this._minRadius;
778
+ let factor = alive ? 0.2 : this._fadeRate;
779
+ this.radii[index] = target + (this.radii[index] - target) * Math.pow(1 - factor, frames);
589
780
  }
590
781
  }
591
782
 
592
- _draw() {
783
+ _draw(frame = null) {
593
784
  if (!this._available || !this.canvas._w) return;
594
785
  let w = this.canvas._w;
595
786
  let h = this.canvas._h;
787
+ let radii = frame?.radii || this.radii;
596
788
 
597
789
  this.ctx.clearRect(0, 0, w, h);
598
790
  this.ctx.fillStyle = this._bgFill;
@@ -603,18 +795,7 @@ export class CellBg extends Symbiote {
603
795
  for (let y = 0; y < this.rows; y++) {
604
796
  for (let x = 0; x < this.cols; x++) {
605
797
  let idx = y * this.cols + x;
606
- let alive = this.grid[idx];
607
-
608
- let targetR = alive ? this._maxRadius : this._minRadius;
609
- let currentR = this.radii[idx];
610
-
611
- if (alive) {
612
- this.radii[idx] = currentR + (targetR - currentR) * 0.2;
613
- } else {
614
- this.radii[idx] = currentR + (targetR - currentR) * this._fadeRate;
615
- }
616
-
617
- let r = this.radii[idx];
798
+ let r = radii[idx];
618
799
  let cx = x * this._cellSize;
619
800
  let cy = y * this._cellSize;
620
801
 
@@ -0,0 +1,155 @@
1
+ const RULE_B = new Set([3]);
2
+ const RULE_S = new Set([2, 3]);
3
+ const LIVE_FRAME_MS = 1000 / 60;
4
+ const DEFAULT_SEED = 'symbiote-cell-bg';
5
+
6
+ function finitePositive(value, fallback) {
7
+ let number = Number(value);
8
+ return Number.isFinite(number) && number > 0 ? number : fallback;
9
+ }
10
+
11
+ function seedHash(value) {
12
+ let hash = 2166136261;
13
+ for (let char of String(value || DEFAULT_SEED)) {
14
+ hash ^= char.codePointAt(0);
15
+ hash = Math.imul(hash, 16777619);
16
+ }
17
+ return hash >>> 0 || 1;
18
+ }
19
+
20
+ function nextRandom(state) {
21
+ state.randomState = (state.randomState + 0x6D2B79F5) >>> 0;
22
+ let value = state.randomState;
23
+ value = Math.imul(value ^ (value >>> 15), value | 1);
24
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
25
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
26
+ }
27
+
28
+ function approach(value, target, factor, elapsedMs) {
29
+ let safeFactor = Math.min(0.999999, Math.max(0, Number(factor) || 0));
30
+ let frames = Math.max(0, Number(elapsedMs) || 0) / LIVE_FRAME_MS;
31
+ return target + (value - target) * Math.pow(1 - safeFactor, frames);
32
+ }
33
+
34
+ function advanceRadii(state, elapsedMs) {
35
+ for (let index = 0; index < state.radii.length; index += 1) {
36
+ let alive = state.grid[index] === 1;
37
+ let target = alive ? state.maxRadius : state.minRadius;
38
+ let factor = alive ? 0.2 : state.fadeRate;
39
+ state.radii[index] = approach(state.radii[index], target, factor, elapsedMs);
40
+ }
41
+ }
42
+
43
+ function injectNoise(state) {
44
+ let regionW = Math.max(4, Math.floor(state.cols * 0.3));
45
+ let regionH = Math.max(4, Math.floor(state.rows * 0.3));
46
+ let startX = Math.floor(nextRandom(state) * Math.max(1, state.cols - regionW));
47
+ let startY = Math.floor(nextRandom(state) * Math.max(1, state.rows - regionH));
48
+ for (let y = startY; y < Math.min(state.rows, startY + regionH); y += 1) {
49
+ for (let x = startX; x < Math.min(state.cols, startX + regionW); x += 1) {
50
+ if (nextRandom(state) >= 0.15) continue;
51
+ let index = y * state.cols + x;
52
+ state.grid[index] = 1;
53
+ state.radii[index] = state.minRadius;
54
+ }
55
+ }
56
+ }
57
+
58
+ function stepGrid(state) {
59
+ let next = new Uint8Array(state.grid.length);
60
+ let changed = 0;
61
+ for (let y = 0; y < state.rows; y += 1) {
62
+ for (let x = 0; x < state.cols; x += 1) {
63
+ let neighbors = 0;
64
+ for (let dy = -1; dy <= 1; dy += 1) {
65
+ for (let dx = -1; dx <= 1; dx += 1) {
66
+ if (dx === 0 && dy === 0) continue;
67
+ let nx = (x + dx + state.cols) % state.cols;
68
+ let ny = (y + dy + state.rows) % state.rows;
69
+ neighbors += state.grid[ny * state.cols + nx];
70
+ }
71
+ }
72
+ let index = y * state.cols + x;
73
+ let alive = state.grid[index] === 1;
74
+ next[index] = alive
75
+ ? (RULE_S.has(neighbors) ? 1 : 0)
76
+ : (RULE_B.has(neighbors) ? 1 : 0);
77
+ if (next[index] !== state.grid[index]) changed += 1;
78
+ }
79
+ }
80
+ state.grid = next;
81
+ if (changed < state.grid.length * 0.02) {
82
+ state.stagnantCount += 1;
83
+ if (state.stagnantCount >= 5) {
84
+ injectNoise(state);
85
+ state.stagnantCount = 0;
86
+ }
87
+ } else {
88
+ state.stagnantCount = 0;
89
+ }
90
+ }
91
+
92
+ export function createCellBgRenderState(options = {}) {
93
+ let cols = Math.max(1, Math.floor(finitePositive(options.cols, 1)));
94
+ let rows = Math.max(1, Math.floor(finitePositive(options.rows, 1)));
95
+ let minRadius = finitePositive(options.minRadius, 2);
96
+ let state = {
97
+ seed: String(options.seed || DEFAULT_SEED),
98
+ cols,
99
+ rows,
100
+ stepMs: finitePositive(options.stepMs, 75),
101
+ minRadius,
102
+ maxRadius: Math.max(minRadius, finitePositive(options.maxRadius, 4)),
103
+ fadeRate: Math.min(1, Math.max(0, Number(options.fadeRate) || 0.04)),
104
+ grid: new Uint8Array(cols * rows),
105
+ radii: new Float32Array(cols * rows),
106
+ randomState: seedHash(`${options.seed || DEFAULT_SEED}:${cols}x${rows}`),
107
+ stagnantCount: 0,
108
+ stepIndex: 0,
109
+ timeMs: 0,
110
+ };
111
+ state.radii.fill(minRadius);
112
+ for (let index = 0; index < state.grid.length; index += 1) {
113
+ state.grid[index] = nextRandom(state) < 0.15 ? 1 : 0;
114
+ }
115
+ return state;
116
+ }
117
+
118
+ export function seekCellBgRenderState(state, timeMs, options = state || {}) {
119
+ let targetTimeMs = Math.max(0, Number(timeMs) || 0);
120
+ let next = state;
121
+ if (!next || targetTimeMs < next.timeMs) {
122
+ next = createCellBgRenderState(options);
123
+ }
124
+ let targetStep = Math.floor(targetTimeMs / next.stepMs);
125
+ while (next.stepIndex < targetStep) {
126
+ advanceRadii(next, next.stepMs);
127
+ stepGrid(next);
128
+ next.stepIndex += 1;
129
+ }
130
+ next.timeMs = targetTimeMs;
131
+ return next;
132
+ }
133
+
134
+ export function cellBgRenderFrame(state) {
135
+ let elapsedMs = Math.max(0, state.timeMs - state.stepIndex * state.stepMs);
136
+ let radii = new Float32Array(state.radii.length);
137
+ for (let index = 0; index < radii.length; index += 1) {
138
+ let alive = state.grid[index] === 1;
139
+ let target = alive ? state.maxRadius : state.minRadius;
140
+ let factor = alive ? 0.2 : state.fadeRate;
141
+ radii[index] = approach(state.radii[index], target, factor, elapsedMs);
142
+ }
143
+ return { grid: state.grid, radii };
144
+ }
145
+
146
+ export function cellBgRenderHash(state, frame = cellBgRenderFrame(state)) {
147
+ let hash = 2166136261;
148
+ for (let index = 0; index < frame.grid.length; index += 1) {
149
+ hash ^= frame.grid[index];
150
+ hash = Math.imul(hash, 16777619);
151
+ hash ^= Math.round(frame.radii[index] * 1000);
152
+ hash = Math.imul(hash, 16777619);
153
+ }
154
+ return (hash >>> 0).toString(16).padStart(8, '0');
155
+ }
package/index.js CHANGED
@@ -136,11 +136,14 @@ export {
136
136
  } from './canvas/CanvasGraph/CanvasGraphViewport.js';
137
137
  export {
138
138
  GRAPH_DIRECTORY_FRAME_COLORS,
139
+ GRAPH_PATH_STYLE_MENU_GROUP,
140
+ GRAPH_PATH_STYLE_MENU_ITEMS,
139
141
  GRAPH_PATH_STYLES,
140
142
  GRAPH_VIEW_MODES,
141
143
  addGraphDirectoryFrames,
142
144
  applyGraphExplorerViewMode,
143
145
  buildFlatPathHash,
146
+ createGraphPathStyleMenuActions,
144
147
  createGraphExplorerViewController,
145
148
  createGraphViewModeController,
146
149
  getFileSelectionNodeId,
@@ -150,6 +153,7 @@ export {
150
153
  getNextGraphPathStyle,
151
154
  normalizeGraphExplorerViewMode,
152
155
  normalizeGraphViewMode,
156
+ resolveGraphPathStyleAction,
153
157
  resolveFlatHashChange,
154
158
  resolveInitialGraphViewMode,
155
159
  shouldClearFocusOnSelection,
@@ -3219,12 +3219,13 @@ export let COMPONENTS = [
3219
3219
  status: 'draft',
3220
3220
  schemaVersion: 'component-descriptor-v2',
3221
3221
  dataSchema: 'schemas/graph-model-v1.json',
3222
- capabilities: ['node-card', 'ports', 'controls', 'shape-variant', 'media-avatar', 'category-accent', 'type-accent', 'error-state'],
3222
+ capabilities: ['node-card', 'ports', 'controls', 'shape-variant', 'media-avatar', 'media-fit', 'category-accent', 'type-accent', 'error-state'],
3223
3223
  attributes: [
3224
3224
  { name: 'node-label', type: 'string', description: 'Rendered node label.' },
3225
3225
  { name: 'node-category', type: 'string', description: 'Semantic node category used for icon and accent selection.' },
3226
3226
  { name: 'node-type', type: 'string', description: 'Semantic node type used for type-specific accent selection when a matching theme token exists.' },
3227
3227
  { name: 'node-shape', type: 'string', description: 'Supported node shape: rect, pill, circle, disc, diamond, comment, or registered SVG preset such as hexagon.' },
3228
+ { name: 'data-media-fit', type: 'string', description: 'Resolved media object-fit strategy: contain for transparent/logo media, cover for cropped photo media.' },
3228
3229
  ],
3229
3230
  properties: [
3230
3231
  { name: 'mediaSrc', type: 'string', description: 'Media or avatar image URL derived from node params.' },
@@ -3453,7 +3454,7 @@ export let COMPONENTS = [
3453
3454
  status: 'draft',
3454
3455
  schemaVersion: 'component-descriptor-v2',
3455
3456
  dataSchema: 'schemas/graph-model-v1.json',
3456
- capabilities: ['hierarchical-graph', 'overview-read-renderer', 'force-layout', 'semantic-clusters', 'focus-selection', 'layout-snapshot', 'device-orientation-parallax'],
3457
+ capabilities: ['hierarchical-graph', 'overview-read-renderer', 'force-layout', 'semantic-clusters', 'focus-selection', 'layout-snapshot', 'viewport-control', 'device-orientation-parallax'],
3457
3458
  attributes: [
3458
3459
  { name: 'active-node-scale', type: 'number', description: 'Visual scale target for the selected node; defaults to 1.5.' },
3459
3460
  { name: 'info-panel-scale', type: 'number', description: 'Visual scale for the active node information panel; defaults to 1.' },
@@ -3480,6 +3481,8 @@ export let COMPONENTS = [
3480
3481
  { name: 'focusNodes', type: 'function', description: 'Agent-facing alias for fitting one or more graph node ids into the viewport.' },
3481
3482
  { name: 'queueTransitionMarkers', type: 'function', description: 'Queues animated route markers from one graph node to multiple target node ids without changing the host-owned graph model.' },
3482
3483
  { name: 'fitView', type: 'function', description: 'Fits the visible graph into the canvas viewport; accepts optional viewportEase for live replay tracking.' },
3484
+ { name: 'getViewport', type: 'function', description: 'Returns the current zoom and pan viewport as a portable snapshot.' },
3485
+ { name: 'setViewport', type: 'function', description: 'Applies an absolute, clamped zoom and pan viewport immediately or through the graph camera easing loop.' },
3483
3486
  { name: 'setVisualOptions', type: 'function', description: 'Applies visual tuning options such as activeNodeScale and infoPanelScale without changing the host-owned graph model.' },
3484
3487
  { name: 'setForceLayoutOptions', type: 'function', description: 'Applies force-layout tuning options, including layoutAlgorithm: organic, oil-cloud, crystal, or spring, without changing the host-owned graph model.' },
3485
3488
  { name: 'pulseNode', type: 'function', description: 'Projects a timed pulse around one node, optionally from an explicit render-clock start time.' },
@@ -4440,6 +4443,7 @@ export let COMPONENTS = [
4440
4443
  'resize-aware',
4441
4444
  'reduced-motion-friendly',
4442
4445
  'layout-lifecycle',
4446
+ 'deterministic-render',
4443
4447
  ],
4444
4448
  properties: [
4445
4449
  { name: 'active', type: 'boolean', description: 'Persistent animation state.' },
@@ -4450,6 +4454,7 @@ export let COMPONENTS = [
4450
4454
  { name: 'active', type: 'boolean', description: 'Starts persistent animation when present.' },
4451
4455
  { name: 'auto-trigger', type: 'boolean', description: 'Set to false/off to disable mount and resize pulses.' },
4452
4456
  { name: 'pulse-duration', type: 'number', description: 'Default timed pulse duration in milliseconds.' },
4457
+ { name: 'seed', type: 'string', description: 'Stable seed used by deterministic external-frame rendering.' },
4453
4458
  ],
4454
4459
  methods: [
4455
4460
  { name: 'start', type: 'function', description: 'Starts persistent animation with smooth acceleration.' },
@@ -4461,6 +4466,9 @@ export let COMPONENTS = [
4461
4466
  { name: 'resize', type: 'function', description: 'Resizes the canvas to its host bounds.' },
4462
4467
  { name: 'suspendLayout', type: 'function', description: 'Immediately stops animation work while the containing layout group is hidden.' },
4463
4468
  { name: 'resumeLayout', type: 'function', description: 'Restores persistent animation only when it was active before layout suspension.' },
4469
+ { name: 'setFrameDriver', type: 'function', description: 'Selects self-driven live animation or externally driven deterministic rendering.' },
4470
+ { name: 'presentFrame', type: 'function', description: 'Synchronously paints the current shared render-clock time.' },
4471
+ { name: 'getFramePresentation', type: 'function', description: 'Returns deterministic frame metrics and the current grid hash.' },
4464
4472
  ],
4465
4473
  events: [
4466
4474
  { name: 'cell-bg-animation-trigger', description: 'Emits when a timed activity pulse is requested.', detail: [{ name: 'duration', type: 'number' }] },
@@ -229,6 +229,14 @@ export let styles = css`
229
229
  -webkit-user-drag: none;
230
230
  }
231
231
 
232
+ &[data-media-fit='contain'] .sn-node-media-img {
233
+ object-fit: contain;
234
+ }
235
+
236
+ &[data-media-fit='cover'] .sn-node-media-img {
237
+ object-fit: cover;
238
+ }
239
+
232
240
  & .sn-node-content {
233
241
  padding: var(--sn-node-content-padding, 8px 12px 10px);
234
242
  min-width: 0;
@@ -43,6 +43,14 @@ function normalizeNodeTone(value) {
43
43
  return tone;
44
44
  }
45
45
 
46
+ function normalizeMediaFit(value) {
47
+ let fit = String(value || '').trim().toLowerCase();
48
+ if (fit === 'fit') return 'contain';
49
+ if (fit === 'crop') return 'cover';
50
+ if (fit === 'contain' || fit === 'cover') return fit;
51
+ return '';
52
+ }
53
+
46
54
  export class GraphNode extends Symbiote {
47
55
  destructionDelay = 200;
48
56
 
@@ -108,6 +116,7 @@ export class GraphNode extends Symbiote {
108
116
  this.toggleAttribute('data-header-hidden', Boolean(params.hideHeader || params.headerHidden));
109
117
  this.toggleAttribute('data-content-hidden', contentHidden);
110
118
  setOptionalAttribute(this, 'data-node-tone', normalizeNodeTone(params.tone || params.nodeTone));
119
+ setOptionalAttribute(this, 'data-media-fit', normalizeMediaFit(params.mediaFit || params.imageFit || params.avatarFit));
111
120
  this.set$({
112
121
  nodeIcon: node.icon || CATEGORY_ICONS[node.category] || CATEGORY_ICONS.default,
113
122
  mediaSrc: params.media || params.image || params.avatar || '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiote-ui",
3
- "version": "0.3.0-alpha.52",
3
+ "version": "0.3.0-alpha.53",
4
4
  "type": "module",
5
5
  "description": "Agent-ready Web Components, layouts, themes, manifests, and UI contracts for Symbiote systems.",
6
6
  "main": "index.js",
package/ui/index.js CHANGED
@@ -159,11 +159,14 @@ export {
159
159
  } from '../canvas/graph-layout.js';
160
160
  export {
161
161
  GRAPH_DIRECTORY_FRAME_COLORS,
162
+ GRAPH_PATH_STYLE_MENU_GROUP,
163
+ GRAPH_PATH_STYLE_MENU_ITEMS,
162
164
  GRAPH_PATH_STYLES,
163
165
  GRAPH_VIEW_MODES,
164
166
  addGraphDirectoryFrames,
165
167
  applyGraphExplorerViewMode,
166
168
  buildFlatPathHash,
169
+ createGraphPathStyleMenuActions,
167
170
  createGraphExplorerViewController,
168
171
  createGraphViewModeController,
169
172
  getFileSelectionNodeId,
@@ -175,6 +178,7 @@ export {
175
178
  normalizeGraphViewMode,
176
179
  renderGraphPathStyleButton,
177
180
  renderGraphViewModeButton,
181
+ resolveGraphPathStyleAction,
178
182
  resolveFlatHashChange,
179
183
  resolveInitialGraphViewMode,
180
184
  selectGraphLabelMode,
package/webmcp.js CHANGED
@@ -23,7 +23,6 @@ export {
23
23
  export function getModelContext(target = globalThis.document) {
24
24
  return target?.modelContext
25
25
  || (typeof target?.registerTool === 'function' ? target : null)
26
- || globalThis.navigator?.modelContext
27
26
  || null;
28
27
  }
29
28
 
@@ -65,7 +64,7 @@ export async function createNativeToolDescriptor(options) {
65
64
  return new ToolDescriptor(options);
66
65
  }
67
66
 
68
- export async function registerWebMcpTool(options, target = globalThis.document) {
67
+ export async function registerWebMcpTool(options, target = globalThis.document, registrationOptions = {}) {
69
68
  let context = getModelContext(target);
70
69
  if (!context || typeof context.registerTool !== 'function') {
71
70
  return { nativeActive: false, descriptor: createToolDescriptor(options), unregister: () => {} };
@@ -82,7 +81,7 @@ export async function registerWebMcpTool(options, target = globalThis.document)
82
81
  nativeActive = false;
83
82
  descriptor = createToolDescriptor(options);
84
83
  }
85
- let registration = context.registerTool(descriptor);
84
+ let registration = await context.registerTool(descriptor, registrationOptions);
86
85
  let unregister = registrationUnregister(registration);
87
86
 
88
87
  return { nativeActive, descriptor, unregister };
@@ -703,23 +702,42 @@ export function getWebMcpPresentationActionPhase(value, options = {}) {
703
702
  return WEBMCP_PRESENTATION_TOUR_PHASES.AFTER_FOCUS;
704
703
  }
705
704
 
706
- export function createWebMcpTourTurnActionPlan(turn = {}, options = {}) {
705
+ export function createWebMcpTourTurnActionPlans(turn = {}, options = {}) {
707
706
  let actions = webMcpPresentationActionsFromOptions(options);
708
- let source = isObject(turn?.webmcp) ? turn.webmcp : null;
709
- let action = webMcpPresentationActionFromValue(source, actions);
710
- let phase = source ? getWebMcpPresentationActionPhase(source, { ...options, actions }) : WEBMCP_PRESENTATION_TOUR_PHASES.NONE;
711
- let command = action && phase !== WEBMCP_PRESENTATION_TOUR_PHASES.NONE ? {
712
- tool: action.name || action.id,
713
- input: isObject(source.input) ? { ...source.input } : {},
714
- } : null;
715
- return {
716
- action,
717
- command,
718
- phase,
719
- runBeforeFocus: phase === WEBMCP_PRESENTATION_TOUR_PHASES.BEFORE_FOCUS,
720
- runAfterFocus: phase === WEBMCP_PRESENTATION_TOUR_PHASES.AFTER_FOCUS,
721
- runAsAnnotation: phase === WEBMCP_PRESENTATION_TOUR_PHASES.AFTER_FOCUS_ANNOTATION,
722
- runAsEffect: phase === WEBMCP_PRESENTATION_TOUR_PHASES.AFTER_FOCUS,
707
+ return (Array.isArray(turn?.cues) ? turn.cues : [])
708
+ .filter((cue) => cue?.kind === 'interaction' && isObject(cue.interaction?.binding))
709
+ .map((cue) => {
710
+ let binding = cue.interaction.binding;
711
+ let source = { tool: binding.tool, input: { ...(isObject(binding.input) ? binding.input : {}) } };
712
+ let action = webMcpPresentationActionFromValue(source, actions);
713
+ if (cue.targetId && action?.inputSchema?.properties?.targetId) source.input.targetId = cue.targetId;
714
+ let phase = getWebMcpPresentationActionPhase(source, { ...options, actions });
715
+ let command = action && phase !== WEBMCP_PRESENTATION_TOUR_PHASES.NONE ? {
716
+ tool: action.name || action.id,
717
+ input: source.input,
718
+ } : null;
719
+ return {
720
+ cue,
721
+ action,
722
+ command,
723
+ phase,
724
+ runBeforeFocus: phase === WEBMCP_PRESENTATION_TOUR_PHASES.BEFORE_FOCUS,
725
+ runAfterFocus: phase === WEBMCP_PRESENTATION_TOUR_PHASES.AFTER_FOCUS,
726
+ runAsAnnotation: phase === WEBMCP_PRESENTATION_TOUR_PHASES.AFTER_FOCUS_ANNOTATION,
727
+ runAsEffect: phase === WEBMCP_PRESENTATION_TOUR_PHASES.AFTER_FOCUS,
728
+ };
729
+ });
730
+ }
731
+
732
+ export function createWebMcpTourTurnActionPlan(turn = {}, options = {}) {
733
+ return createWebMcpTourTurnActionPlans(turn, options)[0] || {
734
+ action: null,
735
+ command: null,
736
+ phase: WEBMCP_PRESENTATION_TOUR_PHASES.NONE,
737
+ runBeforeFocus: false,
738
+ runAfterFocus: false,
739
+ runAsAnnotation: false,
740
+ runAsEffect: false,
723
741
  };
724
742
  }
725
743