@ggui-ai/iframe-runtime 0.1.0-rc.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 (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +33 -0
  3. package/dist/bootstrap.d.ts +72 -0
  4. package/dist/bootstrap.js +500 -0
  5. package/dist/canvas/animator/animator-host.d.ts +63 -0
  6. package/dist/canvas/animator/animator-host.js +105 -0
  7. package/dist/canvas/animator/animator-pill.d.ts +42 -0
  8. package/dist/canvas/animator/animator-pill.js +119 -0
  9. package/dist/canvas/animator/state-machine.d.ts +106 -0
  10. package/dist/canvas/animator/state-machine.js +150 -0
  11. package/dist/canvas/canvas-shell.d.ts +77 -0
  12. package/dist/canvas/canvas-shell.js +117 -0
  13. package/dist/canvas/display-mode-policy.d.ts +104 -0
  14. package/dist/canvas/display-mode-policy.js +98 -0
  15. package/dist/canvas/mount.d.ts +150 -0
  16. package/dist/canvas/mount.js +202 -0
  17. package/dist/canvas/nav-stack.d.ts +133 -0
  18. package/dist/canvas/nav-stack.js +172 -0
  19. package/dist/channel-transport.d.ts +182 -0
  20. package/dist/channel-transport.js +344 -0
  21. package/dist/channels/channel-payload.d.ts +20 -0
  22. package/dist/channels/channel-payload.js +34 -0
  23. package/dist/channels/data.d.ts +48 -0
  24. package/dist/channels/data.js +103 -0
  25. package/dist/channels/drain-ack.d.ts +13 -0
  26. package/dist/channels/drain-ack.js +15 -0
  27. package/dist/channels/feedback.d.ts +14 -0
  28. package/dist/channels/feedback.js +19 -0
  29. package/dist/channels/index.d.ts +7 -0
  30. package/dist/channels/index.js +7 -0
  31. package/dist/channels/props-update.d.ts +53 -0
  32. package/dist/channels/props-update.js +130 -0
  33. package/dist/channels/push.d.ts +47 -0
  34. package/dist/channels/push.js +54 -0
  35. package/dist/channels/system.d.ts +26 -0
  36. package/dist/channels/system.js +44 -0
  37. package/dist/compiled-validators.d.ts +40 -0
  38. package/dist/compiled-validators.js +74 -0
  39. package/dist/context-observer.d.ts +205 -0
  40. package/dist/context-observer.js +312 -0
  41. package/dist/gadget-loader.d.ts +118 -0
  42. package/dist/gadget-loader.js +136 -0
  43. package/dist/globals.d.ts +234 -0
  44. package/dist/globals.js +101 -0
  45. package/dist/host-context-emitter.d.ts +91 -0
  46. package/dist/host-context-emitter.js +213 -0
  47. package/dist/iframe-runtime.js +261 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +4 -0
  50. package/dist/lifecycle.d.ts +66 -0
  51. package/dist/lifecycle.js +49 -0
  52. package/dist/mcp-app-iframe-host.d.ts +69 -0
  53. package/dist/mcp-app-iframe-host.js +214 -0
  54. package/dist/observability.d.ts +225 -0
  55. package/dist/observability.js +28 -0
  56. package/dist/protocol-error.d.ts +199 -0
  57. package/dist/protocol-error.js +116 -0
  58. package/dist/provisional-renderer.d.ts +62 -0
  59. package/dist/provisional-renderer.js +323 -0
  60. package/dist/react-renderer.d.ts +95 -0
  61. package/dist/react-renderer.js +343 -0
  62. package/dist/registry-subscribe.d.ts +102 -0
  63. package/dist/registry-subscribe.js +319 -0
  64. package/dist/runtime.d.ts +531 -0
  65. package/dist/runtime.js +2623 -0
  66. package/dist/server.d.ts +36 -0
  67. package/dist/server.js +72 -0
  68. package/dist/stack-item-renderer.d.ts +103 -0
  69. package/dist/stack-item-renderer.js +270 -0
  70. package/dist/stack.d.ts +90 -0
  71. package/dist/stack.js +118 -0
  72. package/dist/status-dom.d.ts +72 -0
  73. package/dist/status-dom.js +51 -0
  74. package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
  75. package/dist/system-cards/NoCredentialsCard.js +189 -0
  76. package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
  77. package/dist/system-cards/ProtocolProbeCard.js +463 -0
  78. package/dist/system-cards/QrCode.d.ts +46 -0
  79. package/dist/system-cards/QrCode.js +55 -0
  80. package/dist/system-cards/host-detect.d.ts +31 -0
  81. package/dist/system-cards/host-detect.js +108 -0
  82. package/dist/system-cards/host-intents.d.ts +85 -0
  83. package/dist/system-cards/host-intents.js +170 -0
  84. package/dist/system-cards/index.d.ts +70 -0
  85. package/dist/system-cards/index.js +134 -0
  86. package/dist/types.d.ts +69 -0
  87. package/dist/types.js +3 -0
  88. package/dist/validation.d.ts +83 -0
  89. package/dist/validation.js +106 -0
  90. package/dist/wire-config.d.ts +214 -0
  91. package/dist/wire-config.js +290 -0
  92. package/package.json +81 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Animator pill.
3
+ *
4
+ * Visual component that renders the current `AnimatorState`. Two
5
+ * layouts:
6
+ *
7
+ * - centered (when canvas navStack is empty): pill at ~40% top
8
+ * - navbar (when canvas has content): pill compressed into top chrome
9
+ *
10
+ * V1 ships PLACEHOLDER visuals per the design doc. Behavioral spec is
11
+ * locked; the designer pass refines exact motion + sizing. Engineering
12
+ * baseline: pill shape, brand accent dot, label text, pulse animation
13
+ * via CSS keyframes.
14
+ *
15
+ * Boundary discipline:
16
+ * - Pure view component. Takes `state` + `layout` props, renders DOM.
17
+ * - No state, no effects, no subscriptions. Parent (animator-host)
18
+ * owns those.
19
+ * - Accessibility: `aria-live="polite"` for routine transitions;
20
+ * `assertive` for error. Reduced-motion honored via CSS.
21
+ */
22
+ import { type ReactNode } from 'react';
23
+ import type { AnimatorState } from './state-machine.js';
24
+ export interface AnimatorPillProps {
25
+ readonly state: AnimatorState;
26
+ /**
27
+ * Where to draw the pill. The shell decides based on navStack
28
+ * emptiness:
29
+ * - `'centered'` — canvas empty; pill fills (vertically offset
30
+ * above center).
31
+ * - `'navbar'` — canvas has content; pill compressed into the
32
+ * top chrome.
33
+ */
34
+ readonly layout: 'centered' | 'navbar';
35
+ /**
36
+ * Brand label shown when state is `ready` (otherwise the active
37
+ * label is derived from state). Falls back to `'ggui'` per
38
+ * decisions.md.
39
+ */
40
+ readonly readyLabel?: string;
41
+ }
42
+ export declare function AnimatorPill({ state, layout, readyLabel, }: AnimatorPillProps): ReactNode;
@@ -0,0 +1,119 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Derive the user-facing label from the state. Centralized so the
4
+ * announcement text + visible text stay in lockstep.
5
+ */
6
+ function labelForState(state, readyLabel) {
7
+ switch (state.kind) {
8
+ case 'ready':
9
+ return readyLabel;
10
+ case 'handshake':
11
+ return `Negotiating: ${state.intent}`;
12
+ case 'constructing':
13
+ return `Building: ${state.intent}`;
14
+ case 'listening':
15
+ return 'Listening…';
16
+ case 'content':
17
+ switch (state.substate.kind) {
18
+ case 'idle':
19
+ return state.activeItemId;
20
+ case 'handshake':
21
+ return `Negotiating: ${state.substate.intent}`;
22
+ case 'constructing':
23
+ return `Building: ${state.substate.intent}`;
24
+ case 'listening':
25
+ return 'Listening…';
26
+ }
27
+ // eslint-disable-next-line no-fallthrough
28
+ case 'error':
29
+ return `Error: ${state.message}`;
30
+ case 'offline':
31
+ return 'Offline — reconnecting…';
32
+ }
33
+ }
34
+ /**
35
+ * The dot animation depends on outer state OR the active substate
36
+ * when on `content`.
37
+ */
38
+ function indicatorClassFor(state) {
39
+ switch (state.kind) {
40
+ case 'ready':
41
+ return 'ggui-animator-dot--ready';
42
+ case 'handshake':
43
+ return 'ggui-animator-dot--handshake';
44
+ case 'constructing':
45
+ return 'ggui-animator-dot--constructing';
46
+ case 'listening':
47
+ return 'ggui-animator-dot--listening';
48
+ case 'content':
49
+ switch (state.substate.kind) {
50
+ case 'idle':
51
+ return 'ggui-animator-dot--content-idle';
52
+ case 'handshake':
53
+ return 'ggui-animator-dot--handshake';
54
+ case 'constructing':
55
+ return 'ggui-animator-dot--constructing';
56
+ case 'listening':
57
+ return 'ggui-animator-dot--listening';
58
+ }
59
+ // eslint-disable-next-line no-fallthrough
60
+ case 'error':
61
+ return 'ggui-animator-dot--error';
62
+ case 'offline':
63
+ return 'ggui-animator-dot--offline';
64
+ }
65
+ }
66
+ /**
67
+ * Inline styles for the placeholder visual. Replaced by the designer
68
+ * pass with a real CSS module. Keeping them inline avoids the
69
+ * CSS-loader plumbing question for v1.
70
+ */
71
+ const pillStyles = {
72
+ centered: {
73
+ position: 'absolute',
74
+ top: '40%',
75
+ left: '50%',
76
+ transform: 'translate(-50%, -50%)',
77
+ display: 'inline-flex',
78
+ alignItems: 'center',
79
+ gap: 8,
80
+ padding: '8px 16px',
81
+ borderRadius: 9999,
82
+ background: 'var(--ggui-color-surface, rgba(245, 245, 245, 0.95))',
83
+ border: '1px solid var(--ggui-color-outlineVariant, rgba(148, 163, 184, 0.3))',
84
+ fontSize: 13,
85
+ color: 'var(--ggui-color-onSurface, #1a1a1a)',
86
+ fontFamily: 'inherit',
87
+ },
88
+ navbar: {
89
+ display: 'inline-flex',
90
+ alignItems: 'center',
91
+ gap: 6,
92
+ padding: '4px 12px',
93
+ borderRadius: 9999,
94
+ background: 'var(--ggui-color-surface, rgba(245, 245, 245, 0.95))',
95
+ border: '1px solid var(--ggui-color-outlineVariant, rgba(148, 163, 184, 0.3))',
96
+ fontSize: 12,
97
+ color: 'var(--ggui-color-onSurface, #1a1a1a)',
98
+ fontFamily: 'inherit',
99
+ },
100
+ dot: {
101
+ width: 8,
102
+ height: 8,
103
+ borderRadius: '50%',
104
+ background: 'var(--ggui-color-primary, #2563eb)',
105
+ flexShrink: 0,
106
+ },
107
+ errorDot: {
108
+ background: 'var(--ggui-color-error, #b91c1c)',
109
+ },
110
+ };
111
+ export function AnimatorPill({ state, layout, readyLabel = 'ggui', }) {
112
+ const label = labelForState(state, readyLabel);
113
+ const dotClass = indicatorClassFor(state);
114
+ const dotStyle = {
115
+ ...pillStyles.dot,
116
+ ...(state.kind === 'error' ? pillStyles.errorDot : {}),
117
+ };
118
+ return (_jsxs("div", { role: "status", "aria-live": state.kind === 'error' ? 'assertive' : 'polite', "aria-label": label, style: layout === 'centered' ? pillStyles.centered : pillStyles.navbar, "data-ggui-animator-state": state.kind, "data-ggui-animator-layout": layout, children: [_jsx("span", { className: dotClass, style: dotStyle, "aria-hidden": "true" }), _jsx("span", { children: label })] }));
119
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Animator state machine.
3
+ *
4
+ * Pure-function reducer driving the ggui-animator pill's state. Inputs:
5
+ * - live-channel `_ggui:lifecycle` envelopes (handshake_started,
6
+ * handshake_completed, push_started, consume_polling)
7
+ * - the existing `push` envelope (stack item appended) → content
8
+ * - the existing `drain_ack` envelope (consume drained) → exit listening
9
+ * - canvas-local events (navStack changes, mount, error overlay)
10
+ *
11
+ * Output: the next `AnimatorState`. No DOM, no timers, no side effects.
12
+ * Visual presentation (pill / navbar) lives in `animator-pill.tsx`.
13
+ * Subscription wiring lives in `animator-host.tsx`. Timer-based
14
+ * flicker suppression lives in the host with a small queue +
15
+ * clearTimeout helper.
16
+ *
17
+ * The machine has five primary states plus a `content` state with
18
+ * substates and error/offline overlays.
19
+ */
20
+ import type { ConsumePollingPayload, HandshakeCompletedPayload, HandshakeStartedPayload, PushStartedPayload } from '@ggui-ai/protocol';
21
+ export type AnimatorContentSubstate = {
22
+ readonly kind: 'idle';
23
+ } | {
24
+ readonly kind: 'handshake';
25
+ readonly intent: string;
26
+ } | {
27
+ readonly kind: 'constructing';
28
+ readonly intent: string;
29
+ readonly stackItemId: string;
30
+ } | {
31
+ readonly kind: 'listening';
32
+ readonly stackItemId: string;
33
+ };
34
+ export type AnimatorState = {
35
+ readonly kind: 'ready';
36
+ } | {
37
+ readonly kind: 'handshake';
38
+ readonly handshakeId: string;
39
+ readonly intent: string;
40
+ } | {
41
+ readonly kind: 'constructing';
42
+ readonly stackItemId: string;
43
+ readonly intent: string;
44
+ } | {
45
+ readonly kind: 'listening';
46
+ readonly stackItemId: string;
47
+ } | {
48
+ readonly kind: 'content';
49
+ readonly activeItemId: string;
50
+ readonly substate: AnimatorContentSubstate;
51
+ } | {
52
+ readonly kind: 'error';
53
+ readonly message: string;
54
+ readonly recoveryTo: AnimatorState;
55
+ } | {
56
+ readonly kind: 'offline';
57
+ };
58
+ export declare const INITIAL_STATE: AnimatorState;
59
+ /**
60
+ * Discriminated union of every event the reducer accepts. The host
61
+ * fans these in from live-channel + canvas-local sources. Wire types
62
+ * (HandshakeStartedPayload etc.) are reused verbatim so the reducer
63
+ * doesn't introduce a parallel type vocabulary.
64
+ */
65
+ export type AnimatorEvent = {
66
+ readonly kind: 'handshake_started';
67
+ readonly payload: HandshakeStartedPayload;
68
+ } | {
69
+ readonly kind: 'handshake_completed';
70
+ readonly payload: HandshakeCompletedPayload;
71
+ } | {
72
+ readonly kind: 'push_started';
73
+ readonly payload: PushStartedPayload;
74
+ } | {
75
+ readonly kind: 'consume_polling';
76
+ readonly payload: ConsumePollingPayload;
77
+ } | {
78
+ readonly kind: 'stack_item_appended';
79
+ readonly stackItemId: string;
80
+ } | {
81
+ readonly kind: 'action_drained';
82
+ readonly stackItemId: string;
83
+ } | {
84
+ readonly kind: 'stack_emptied';
85
+ } | {
86
+ readonly kind: 'contract_error';
87
+ readonly message: string;
88
+ } | {
89
+ readonly kind: 'error_dismissed';
90
+ } | {
91
+ readonly kind: 'transport_offline';
92
+ } | {
93
+ readonly kind: 'transport_online';
94
+ };
95
+ /**
96
+ * Compute the next animator state for an event. Pure function.
97
+ *
98
+ * Decision-log refs (from canvas-mode-decisions.md):
99
+ *
100
+ * - Substate priority on `content`: constructing > handshake > listening > idle.
101
+ * - drain_ack is the close signal for listening (no separate
102
+ * `consume_polling: closed` envelope today).
103
+ * - Listening during agent reasoning: stay in `ready` post-drain_ack.
104
+ * Honest signal; don't fake activity the server can't observe.
105
+ */
106
+ export declare function transition(state: AnimatorState, event: AnimatorEvent): AnimatorState;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Animator state machine.
3
+ *
4
+ * Pure-function reducer driving the ggui-animator pill's state. Inputs:
5
+ * - live-channel `_ggui:lifecycle` envelopes (handshake_started,
6
+ * handshake_completed, push_started, consume_polling)
7
+ * - the existing `push` envelope (stack item appended) → content
8
+ * - the existing `drain_ack` envelope (consume drained) → exit listening
9
+ * - canvas-local events (navStack changes, mount, error overlay)
10
+ *
11
+ * Output: the next `AnimatorState`. No DOM, no timers, no side effects.
12
+ * Visual presentation (pill / navbar) lives in `animator-pill.tsx`.
13
+ * Subscription wiring lives in `animator-host.tsx`. Timer-based
14
+ * flicker suppression lives in the host with a small queue +
15
+ * clearTimeout helper.
16
+ *
17
+ * The machine has five primary states plus a `content` state with
18
+ * substates and error/offline overlays.
19
+ */
20
+ export const INITIAL_STATE = { kind: 'ready' };
21
+ // =============================================================================
22
+ // Reducer
23
+ // =============================================================================
24
+ /**
25
+ * Compute the next animator state for an event. Pure function.
26
+ *
27
+ * Decision-log refs (from canvas-mode-decisions.md):
28
+ *
29
+ * - Substate priority on `content`: constructing > handshake > listening > idle.
30
+ * - drain_ack is the close signal for listening (no separate
31
+ * `consume_polling: closed` envelope today).
32
+ * - Listening during agent reasoning: stay in `ready` post-drain_ack.
33
+ * Honest signal; don't fake activity the server can't observe.
34
+ */
35
+ export function transition(state, event) {
36
+ // Error + offline overlays — apply first, regardless of current state.
37
+ if (event.kind === 'contract_error') {
38
+ return {
39
+ kind: 'error',
40
+ message: event.message,
41
+ recoveryTo: state.kind === 'error' ? state.recoveryTo : state,
42
+ };
43
+ }
44
+ if (event.kind === 'error_dismissed') {
45
+ if (state.kind === 'error')
46
+ return state.recoveryTo;
47
+ return state;
48
+ }
49
+ if (event.kind === 'transport_offline') {
50
+ if (state.kind === 'offline')
51
+ return state;
52
+ return { kind: 'offline' };
53
+ }
54
+ if (event.kind === 'transport_online') {
55
+ if (state.kind === 'offline')
56
+ return INITIAL_STATE;
57
+ return state;
58
+ }
59
+ // From `content`: substate transitions instead of replacing the outer state.
60
+ if (state.kind === 'content') {
61
+ switch (event.kind) {
62
+ case 'handshake_started':
63
+ return {
64
+ ...state,
65
+ substate: { kind: 'handshake', intent: event.payload.intent },
66
+ };
67
+ case 'handshake_completed':
68
+ // Drop to idle — `push_started` arrives next when gen is expected.
69
+ return { ...state, substate: { kind: 'idle' } };
70
+ case 'push_started':
71
+ return {
72
+ ...state,
73
+ substate: {
74
+ kind: 'constructing',
75
+ intent: event.payload.intent,
76
+ stackItemId: event.payload.stackItemId,
77
+ },
78
+ };
79
+ case 'consume_polling':
80
+ return {
81
+ ...state,
82
+ substate: {
83
+ kind: 'listening',
84
+ stackItemId: event.payload.stackItemId,
85
+ },
86
+ };
87
+ case 'stack_item_appended':
88
+ // New item arrived (e.g. push completed). activeItem moves;
89
+ // substate drops to idle.
90
+ return {
91
+ kind: 'content',
92
+ activeItemId: event.stackItemId,
93
+ substate: { kind: 'idle' },
94
+ };
95
+ case 'action_drained':
96
+ // Listening close signal. Drop substate to idle if it was
97
+ // listening for THIS item; otherwise no-op (out-of-order drain
98
+ // for a popped item).
99
+ if (state.substate.kind === 'listening' && state.substate.stackItemId === event.stackItemId) {
100
+ return { ...state, substate: { kind: 'idle' } };
101
+ }
102
+ return state;
103
+ case 'stack_emptied':
104
+ return INITIAL_STATE;
105
+ }
106
+ }
107
+ // From any non-content state: outer state transitions.
108
+ switch (event.kind) {
109
+ case 'handshake_started':
110
+ return {
111
+ kind: 'handshake',
112
+ handshakeId: event.payload.handshakeId,
113
+ intent: event.payload.intent,
114
+ };
115
+ case 'handshake_completed':
116
+ // Only honor completion that matches the current handshake's id —
117
+ // a stale completion (from a race) doesn't displace newer state.
118
+ if (state.kind !== 'handshake')
119
+ return state;
120
+ if (state.handshakeId !== event.payload.handshakeId)
121
+ return state;
122
+ // genExpected pre-warms `constructing`? The `push_started`
123
+ // envelope carries the authoritative stackItemId + intent —
124
+ // wait for it.
125
+ return INITIAL_STATE;
126
+ case 'push_started':
127
+ return {
128
+ kind: 'constructing',
129
+ stackItemId: event.payload.stackItemId,
130
+ intent: event.payload.intent,
131
+ };
132
+ case 'consume_polling':
133
+ return { kind: 'listening', stackItemId: event.payload.stackItemId };
134
+ case 'stack_item_appended':
135
+ return {
136
+ kind: 'content',
137
+ activeItemId: event.stackItemId,
138
+ substate: { kind: 'idle' },
139
+ };
140
+ case 'action_drained':
141
+ // Drained while in listening for THIS item → drop to ready.
142
+ // Otherwise no-op.
143
+ if (state.kind === 'listening' && state.stackItemId === event.stackItemId) {
144
+ return INITIAL_STATE;
145
+ }
146
+ return state;
147
+ case 'stack_emptied':
148
+ return INITIAL_STATE;
149
+ }
150
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * CanvasShell.
3
+ *
4
+ * Top-level component for canvas-mode iframes. Owns:
5
+ *
6
+ * 1. Layout: animator chrome (centered or navbar) + content area
7
+ * 2. Navigation state via {@link NavStackModel}
8
+ * 3. Active stack-item mount (one at a time — the top of navStack)
9
+ * 4. Display-mode escalation requests (escalation policy applied at
10
+ * navStack transitions + on host-context-changed)
11
+ *
12
+ * Subscriptions/transport are wired by the parent boot path
13
+ * (`bootProduction` branching on `bootstrap.canvasMode`) — the shell
14
+ * receives a pre-built navStack model, an AnimatorEventStream
15
+ * (subscribed to live-channel lifecycle envelopes), and callbacks for
16
+ * the side-effecting concerns (display-mode requests, live-channel
17
+ * `canvas_navigated` envelope emission).
18
+ *
19
+ * Active-item rendering reuses the existing stack-item dispatcher
20
+ * via a render-prop slot.
21
+ *
22
+ * Boundary discipline:
23
+ *
24
+ * - The shell renders the animator pill via `<AnimatorHost>` — but
25
+ * doesn't subscribe to the live channel itself. Caller passes the
26
+ * event stream + nav model in.
27
+ * - Display-mode requests fire through a caller-supplied callback,
28
+ * not directly via `requestDisplayModeInParent` from `runtime.ts`.
29
+ * Keeps the shell testable without DOM/postMessage.
30
+ * - Back-arrow click → caller-supplied `onBackGesture()` (also lets
31
+ * the caller emit the `canvas_navigated` envelope).
32
+ */
33
+ import { type ReactNode } from 'react';
34
+ import type { McpUiDisplayMode, SessionStackEntry } from '@ggui-ai/protocol';
35
+ import { type AnimatorEventStream } from './animator/animator-host.js';
36
+ import { NavStackModel } from './nav-stack.js';
37
+ export interface CanvasShellProps {
38
+ readonly navStack: NavStackModel;
39
+ readonly events: AnimatorEventStream;
40
+ /**
41
+ * Host's `availableDisplayModes` from `HostContext`. Drives the
42
+ * display-mode escalation policy. Undefined when the host doesn't
43
+ * speak the spec — escalation collapses to a no-op.
44
+ */
45
+ readonly availableDisplayModes?: readonly McpUiDisplayMode[];
46
+ /** Host's current display mode. Tracks updates from `host-context-changed`. */
47
+ readonly currentDisplayMode?: McpUiDisplayMode;
48
+ /**
49
+ * Fire a `ui/request-display-mode` postMessage to the host. The
50
+ * shell calls this when {@link reconcileDisplayMode} returns a
51
+ * `request` decision. Tests inject a recorder; production wires it
52
+ * to `requestDisplayModeInParent` from `runtime.ts`.
53
+ */
54
+ readonly onRequestDisplayMode: (mode: McpUiDisplayMode) => void;
55
+ /**
56
+ * Called when the user back-navigates. The shell handles the local
57
+ * `navStack.pop()`; this callback lets the caller emit the
58
+ * `canvas_navigated` envelope on the live channel + any other side effects.
59
+ */
60
+ readonly onBackGesture: (args: {
61
+ readonly previousActiveItemId: string;
62
+ readonly activeItemId: string | null;
63
+ }) => void;
64
+ /**
65
+ * Render-prop for the active stack item. Called with the current
66
+ * top of navStack (or null when empty); production wires this to
67
+ * `mountStackItem` from `stack-item-renderer.ts`. Tests inject a
68
+ * simple component.
69
+ *
70
+ * Why a render-prop: keeps the shell testable without the full
71
+ * `mountStackItem` dependency graph (WireConfig, StreamBus, theme
72
+ * resolution, ContextStateHost) — those are bootProduction's
73
+ * concern, not the shell's.
74
+ */
75
+ readonly renderActiveItem: (item: SessionStackEntry | null) => ReactNode;
76
+ }
77
+ export declare function CanvasShell({ navStack, events, onBackGesture, renderActiveItem, }: CanvasShellProps): ReactNode;
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * CanvasShell.
4
+ *
5
+ * Top-level component for canvas-mode iframes. Owns:
6
+ *
7
+ * 1. Layout: animator chrome (centered or navbar) + content area
8
+ * 2. Navigation state via {@link NavStackModel}
9
+ * 3. Active stack-item mount (one at a time — the top of navStack)
10
+ * 4. Display-mode escalation requests (escalation policy applied at
11
+ * navStack transitions + on host-context-changed)
12
+ *
13
+ * Subscriptions/transport are wired by the parent boot path
14
+ * (`bootProduction` branching on `bootstrap.canvasMode`) — the shell
15
+ * receives a pre-built navStack model, an AnimatorEventStream
16
+ * (subscribed to live-channel lifecycle envelopes), and callbacks for
17
+ * the side-effecting concerns (display-mode requests, live-channel
18
+ * `canvas_navigated` envelope emission).
19
+ *
20
+ * Active-item rendering reuses the existing stack-item dispatcher
21
+ * via a render-prop slot.
22
+ *
23
+ * Boundary discipline:
24
+ *
25
+ * - The shell renders the animator pill via `<AnimatorHost>` — but
26
+ * doesn't subscribe to the live channel itself. Caller passes the
27
+ * event stream + nav model in.
28
+ * - Display-mode requests fire through a caller-supplied callback,
29
+ * not directly via `requestDisplayModeInParent` from `runtime.ts`.
30
+ * Keeps the shell testable without DOM/postMessage.
31
+ * - Back-arrow click → caller-supplied `onBackGesture()` (also lets
32
+ * the caller emit the `canvas_navigated` envelope).
33
+ */
34
+ import { useEffect, useState } from 'react';
35
+ import { AnimatorHost, } from './animator/animator-host.js';
36
+ export function CanvasShell({ navStack, events, onBackGesture, renderActiveItem, }) {
37
+ // Subscribe to navStack's version counter so React re-renders when
38
+ // it mutates. Mirrors useSyncExternalStore pattern with a manual
39
+ // useState bridge (avoids a React 18+ requirement for the
40
+ // dependency that's not necessarily satisfied in older callers).
41
+ //
42
+ // Uses `onMutation` (not `onNavChange`) so in-place replaces from
43
+ // `ggui_update` props patches re-render too — otherwise the active
44
+ // item shows stale props after the first turn. The version value
45
+ // itself is unread — only the setVersion side effect matters.
46
+ const [, setVersion] = useState(navStack.version());
47
+ useEffect(() => {
48
+ const unsubscribe = navStack.onMutation(() => {
49
+ setVersion(navStack.version());
50
+ });
51
+ return unsubscribe;
52
+ }, [navStack]);
53
+ // Layout selector — centered animator pill (empty) vs. navbar
54
+ // (content present). The version-tracked `useState` above ensures
55
+ // this re-evaluates on every navStack mutation.
56
+ const contentState = navStack.size() === 0 ? 'empty' : 'has-content';
57
+ // Display-mode escalation is policy-driven by `reconcileDisplayMode`,
58
+ // but auto-firing it on every navStack transition is too aggressive:
59
+ // a canvas with one item would silently request `fullscreen`, taking
60
+ // over the host's viewport without user intent. The policy stays in
61
+ // the codebase + remains tested for the eventual user-driven trigger
62
+ // (e.g. a fullscreen-toggle control in the animator chrome), but the
63
+ // shell does NOT auto-fire it. `onRequestDisplayMode` is wired on
64
+ // the props; the call site moves from here to whatever UI affordance
65
+ // gives the user explicit consent. Until then: display mode only
66
+ // changes when the user requests it manually.
67
+ const activeItem = navStack.peek();
68
+ const handleBack = () => {
69
+ const previous = navStack.peek();
70
+ if (!previous)
71
+ return;
72
+ const newActive = navStack.pop();
73
+ onBackGesture({
74
+ previousActiveItemId: previous.id,
75
+ activeItemId: newActive?.id ?? null,
76
+ });
77
+ };
78
+ // Inline layout styles for the placeholder shell. Designer pass
79
+ // refines exact dimensions / transitions.
80
+ const containerStyle = {
81
+ position: 'relative',
82
+ width: '100%',
83
+ height: '100%',
84
+ display: 'flex',
85
+ flexDirection: 'column',
86
+ overflow: 'hidden',
87
+ };
88
+ const navbarStyle = {
89
+ flexShrink: 0,
90
+ display: 'flex',
91
+ alignItems: 'center',
92
+ gap: 8,
93
+ padding: '8px 12px',
94
+ borderBottom: '1px solid var(--ggui-color-outlineVariant, rgba(148, 163, 184, 0.2))',
95
+ background: 'var(--ggui-color-surface, rgba(255, 255, 255, 0.98))',
96
+ };
97
+ const contentStyle = {
98
+ flex: 1,
99
+ overflow: 'auto',
100
+ position: 'relative',
101
+ };
102
+ const emptyStateStyle = {
103
+ flex: 1,
104
+ position: 'relative',
105
+ };
106
+ if (contentState === 'empty') {
107
+ return (_jsx("div", { style: containerStyle, "data-ggui-canvas-shell": "empty", children: _jsx("div", { style: emptyStateStyle, children: _jsx(AnimatorHost, { events: events, layout: "centered" }) }) }));
108
+ }
109
+ return (_jsxs("div", { style: containerStyle, "data-ggui-canvas-shell": "content", children: [_jsxs("div", { style: navbarStyle, "data-ggui-canvas-navbar": "true", children: [navStack.size() > 1 ? (_jsx("button", { type: "button", onClick: handleBack, "aria-label": "Go back", "data-ggui-canvas-back": "true", style: {
110
+ background: 'transparent',
111
+ border: 'none',
112
+ cursor: 'pointer',
113
+ padding: '4px 8px',
114
+ fontSize: 16,
115
+ color: 'var(--ggui-color-onSurface, #1a1a1a)',
116
+ }, children: "\u2190" })) : null, _jsx(AnimatorHost, { events: events, layout: "navbar" })] }), _jsx("div", { style: contentStyle, "data-ggui-canvas-content": "true", children: renderActiveItem(activeItem) })] }));
117
+ }