@ggui-ai/iframe-runtime 0.10.0 → 0.12.0

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/dist/runtime.js CHANGED
@@ -28,10 +28,12 @@
28
28
  // schema-defining dependency initializes (see zod-jitless.ts).
29
29
  import './zod-jitless.js';
30
30
  import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
31
+ import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
31
32
  import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
32
33
  import { projectHostContext } from '@ggui-ai/protocol';
33
34
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
34
35
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
36
+ import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
35
37
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
36
38
  import { createChannelTransportRouter, } from './channel-transport.js';
37
39
  import { RelayIncapableError } from './relay-incapability.js';
@@ -47,7 +49,7 @@ import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
47
49
  import { mountRender, } from './render-item.js';
48
50
  import { fromBootstrapFailure, } from './protocol-error.js';
49
51
  import { postObservabilityToParent, } from './observability.js';
50
- import { mountUiFeedbackChrome } from './ui-feedback-chrome.js';
52
+ import { ACTION_TOAST_Z_INDEX, mountUiFeedbackChrome } from './ui-feedback-chrome.js';
51
53
  import { hostCanReceiveMessages, hostCanRelayToolCalls, hostCapabilitiesCaptured, setHostCapabilities, } from './host-capabilities.js';
52
54
  import { makeLifecycleEvent, postLifecycleToParent, } from './lifecycle.js';
53
55
  import { contextSlotLastValues, createContextStateHost, installContextRegistry, reemitLastContextValues, } from './context-observer.js';
@@ -87,7 +89,7 @@ function dispatchDrainAck(payload) {
87
89
  // App-class plumbing (Phase 1.19b.3). The renderer used to roll its own
88
90
  // JSON-RPC pump (a closure-scoped `makeJsonRpcCaller` for the single
89
91
  // `ui/initialize` request + a module-level `ensurePostRpcToParentListener`
90
- // for `tools/call` and a `installPostMountListener` for inbound
92
+ // for `tools/call` and a `installPersistentToolResultListener` for inbound
91
93
  // `ui/notifications/tool-result`). Every one of those was a hand-rolled
92
94
  // reimplementation of a primitive `@modelcontextprotocol/ext-apps`'s
93
95
  // `App` class already ships — drift across them produced the Reading-B
@@ -186,6 +188,94 @@ export function hostAnnouncedThemeMode() {
186
188
  const theme = currentApp?.getHostContext()?.theme;
187
189
  return theme === 'light' || theme === 'dark' ? theme : undefined;
188
190
  }
191
+ /**
192
+ * Resolve the mount's base-stylesheet color mode from the slice, with
193
+ * the host as the final fallback (ggui#589, completing the ggui#551
194
+ * precedence ruling — "slice wins, host is the fallback, absent ≠
195
+ * light"):
196
+ *
197
+ * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
198
+ * host-announced theme > undefined
199
+ *
200
+ * The middle leg is the ggui#589 fix: a render envelope carrying a
201
+ * per-app theme (`theme: {mode, cssVariables}`) but no top-level
202
+ * `themeMode` is still a slice-stamped mode opinion — before this,
203
+ * the base token ladder ignored it and painted the LIGHT token set
204
+ * under a dark overlay whenever the host (correctly, per the
205
+ * adapter-boundary doctrine in the native host helpers) announced no
206
+ * `hostContext.theme`: a light skeleton in a dark skin. The theme
207
+ * object's own `mode` already drove `color-scheme`; now it also
208
+ * selects the ladder.
209
+ *
210
+ * `undefined` still means "no opinion anywhere" — the renderer's
211
+ * default applies; never coerced to `'light'`.
212
+ *
213
+ * @internal — exported for unit tests; production caller is
214
+ * `buildOpts` inside `bootSequence`.
215
+ */
216
+ export function resolveMountThemeMode(meta) {
217
+ // The CLIENT projection of the protocol's normative theme-binding
218
+ // total order (@ggui-ai/protocol/integrations/theme-binding,
219
+ // ggui#598 leg 4) — the server stamps via the sibling projection and
220
+ // the protocol's composition-law test pins that the two compose
221
+ // without rank drift.
222
+ return effectiveThemeMode({
223
+ stamped: meta.themeMode,
224
+ sessionSidecar: meta.theme?.mode,
225
+ hostAnnounced: hostAnnouncedThemeMode(),
226
+ });
227
+ }
228
+ /**
229
+ * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
230
+ *
231
+ * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
232
+ * > undefined (renderer default ladder)
233
+ *
234
+ * The name leg makes a slice theme whose `name` matches a REGISTERED
235
+ * theme id select that theme as the BASE token ladder — the overlay's
236
+ * cssVariables still apply above it, so the base carries the full
237
+ * brand ramp coverage a sparse overlay cannot (the "only the mapped
238
+ * vars carry brand" store-frame class). An unregistered name is
239
+ * harmless by construction: `getScopedThemeCss` falls back to the
240
+ * default theme, which is byte-identical to the no-themeId path
241
+ * (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
242
+ * naming its theme "My Custom" renders exactly as before.
243
+ *
244
+ * @internal — exported for unit tests; production caller is
245
+ * `buildOpts` inside `bootSequence`.
246
+ */
247
+ export function resolveMountThemeId(meta) {
248
+ // Client projection of the normative theme-binding order — see
249
+ // resolveMountThemeMode above.
250
+ return effectiveThemeId({
251
+ stamped: meta.themeId,
252
+ sidecarName: meta.theme?.name,
253
+ });
254
+ }
255
+ /**
256
+ * The host's announced palette, read from the connected App's
257
+ * pre-merged hostContext and translated onto `--ggui-*` tokens by the
258
+ * host-palette bridge. `undefined` when no App is connected, the host
259
+ * sent no `styles.variables`, or nothing in it maps/survives
260
+ * sanitization — callers spread-skip the option, exactly like
261
+ * {@link hostAnnouncedThemeMode}.
262
+ *
263
+ * This is the palette input leg of host-theme adaptation (ggui#572,
264
+ * the color analog of #551's mode leg above): the spec `--color-*`
265
+ * variables land as inline custom properties on `<html>` via the
266
+ * canonical ext-apps helper, but nothing ggui renders consumes that
267
+ * vocabulary, and the scoped token block shadows root inheritance
268
+ * anyway — so without this mapping a host palette repaints nothing.
269
+ * `buildOpts` threads it onto the mount options; the renderer merges
270
+ * it as the fallback layer BENEATH the slice's own theme (ggui#573
271
+ * ruling: slice wins, host fallback).
272
+ *
273
+ * @internal — exported for unit tests (`setCurrentApp` injects the
274
+ * App); production caller is `buildOpts` inside `bootSequence`.
275
+ */
276
+ export function hostAnnouncedPalette() {
277
+ return mapHostPaletteToGguiVars(currentApp?.getHostContext()?.styles?.variables);
278
+ }
189
279
  /**
190
280
  * @internal — exported for unit tests to reset module state between
191
281
  * scenarios.
@@ -314,6 +404,14 @@ export async function bootSequence(opts) {
314
404
  const toolResultPromise = opts.preResolvedMeta !== undefined
315
405
  ? Promise.resolve(null)
316
406
  : awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
407
+ // Persistent re-mount listener — ALSO pre-connect (ggui#586). On a
408
+ // Tier-1/preResolved boot the one-shot listener above is skipped,
409
+ // which made the post-mount installation the FIRST toolresult
410
+ // registration — after the handshake, tripping the ext-apps timing
411
+ // notice and leaning on SDK replay for correctness. Registering here
412
+ // covers every boot path; arrivals before a renderer publishes
413
+ // `applyRender` no-op inside the listener.
414
+ installPersistentToolResultListener(app);
317
415
  const initResult = await connectApp(app, transport);
318
416
  if (!initResult.ok) {
319
417
  setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
@@ -1259,17 +1357,19 @@ function awaitToolResultMeta(timeoutMs) {
1259
1357
  });
1260
1358
  }
1261
1359
  /**
1262
- * Module-level guard for the App-mediated post-mount toolresult
1263
- * re-listener. Ensures exactly one persistent
1264
- * `app.addEventListener('toolresult', …)` registration even across
1265
- * re-mounts (e.g. an agent fires a second `ggui_render` and the
1266
- * listener re-applies through the published `applyRender`).
1360
+ * Per-App guard for the persistent toolresult re-listener. Ensures
1361
+ * exactly one `app.addEventListener('toolresult', …)` registration per
1362
+ * App instance even across re-mounts (e.g. an agent fires a second
1363
+ * `ggui_render` and the listener re-applies through the published
1364
+ * `applyRender`). Keyed by App — not a module boolean — so a fresh App
1365
+ * (a re-booted document in tests; production is single-tenant per
1366
+ * iframe) always gets its own registration, never a stale latch.
1267
1367
  */
1268
- let postMountListenerInstalled = false;
1368
+ const persistentToolResultListenerApps = new WeakSet();
1269
1369
  /**
1270
1370
  * Module-level handle to the active renderer's `applyRender`, published
1271
1371
  * by `bootProduction`'s `setup()` once the renderer is built. The
1272
- * module-level {@link installPostMountListener} resolves this to re-mount
1372
+ * module-level {@link installPersistentToolResultListener} resolves this to re-mount
1273
1373
  * on a host-re-emitted tool-result WITHOUT a fresh boot — no second mount,
1274
1374
  * no second WS. This is the no-WS live-re-render channel for
1275
1375
  * spec-compliant MCP-Apps hosts (claude.ai / ChatGPT / Claude Desktop)
@@ -1281,8 +1381,8 @@ let postMountListenerInstalled = false;
1281
1381
  let activeApplyRender = null;
1282
1382
  /**
1283
1383
  * Module-level guard for {@link installAnchorClickInterceptor}. Same
1284
- * rationale as {@link postMountListenerInstalled}: re-mounts are
1285
- * triggered by `installPostMountListener`, and stacking capture-phase
1384
+ * rationale as {@link persistentToolResultListenerApps}: re-mounts are
1385
+ * triggered by `installPersistentToolResultListener`, and stacking capture-phase
1286
1386
  * click listeners across re-mounts would multiply the audit envelopes
1287
1387
  * that fire on a single click.
1288
1388
  */
@@ -1545,9 +1645,11 @@ export function emitAudit(args) {
1545
1645
  * Returns `'success'` when the handler said `ok:true`. Returns
1546
1646
  * `'fallback'` for any other outcome — pipe missing
1547
1647
  * (`PIPE_NOT_FOUND`), envelope rejected (`INVALID_ACTION_KIND`),
1548
- * relay error, no host relay wired, or any unexpected shape. The
1549
- * iframe-runtime then falls through to `ui/message` so the gesture
1550
- * still reaches the agent on its next turn.
1648
+ * relay error, no host relay wired, or any unexpected shape. On
1649
+ * `'fallback'` the gesture was NOT enqueued and is dropped with local
1650
+ * feedback only (toast / #440 latch) — no `ui/message` fires (the
1651
+ * doorbell is success-path-only: there is nothing to point it at), and
1652
+ * no alternate transport is attempted today (ggui#599's open leg).
1551
1653
  */
1552
1654
  /**
1553
1655
  * Unwrap the payload record of a relayed submit-action tool result.
@@ -1613,6 +1715,16 @@ function isRelayShapedFailure(resp) {
1613
1715
  * consumer" — the doorbell rings. Shares {@link submitActionPayload}
1614
1716
  * with {@link classifySubmitActionResponse} so both reads agree on
1615
1717
  * which envelope tier carries the payload.
1718
+ *
1719
+ * RELAY CONTRACT (ggui#603): this read only works when the host's
1720
+ * `tools/call` relay threads the server's tool result VERBATIM — the
1721
+ * `consumerPresent` marker rides `structuredContent` on the real
1722
+ * result. A relay that wraps or synthesizes the response silently
1723
+ * strips the marker, and every healthy gesture then misclassifies as
1724
+ * "agent not listening" (a persistent `action_required` toast per
1725
+ * gesture). Verbatim threading is the same obligation that keeps
1726
+ * `_meta` alive on `resources/read` relays; a conformance vector for
1727
+ * it is tracked on ggui#603.
1616
1728
  */
1617
1729
  function extractConsumerPresent(resp) {
1618
1730
  const inner = submitActionPayload(resp);
@@ -1823,7 +1935,7 @@ function showActionToast(text, kind, onDismiss) {
1823
1935
  'bottom:16px',
1824
1936
  'left:50%',
1825
1937
  'transform:translateX(-50%)',
1826
- 'z-index:2147483647',
1938
+ `z-index:${ACTION_TOAST_Z_INDEX}`,
1827
1939
  'pointer-events:auto',
1828
1940
  'max-width:90%',
1829
1941
  'padding:8px 14px',
@@ -2813,7 +2925,7 @@ export function requestDisplayModeInParent(args) {
2813
2925
  * or honor an upstream `defaultPrevented` from a higher-priority
2814
2926
  * handler.
2815
2927
  *
2816
- * Idempotent: re-mounts (via `installPostMountListener`) call this
2928
+ * Idempotent: re-mounts (via `installPersistentToolResultListener`) call this
2817
2929
  * helper again; the {@link anchorClickInterceptInstalled} guard makes
2818
2930
  * subsequent calls a no-op.
2819
2931
  */
@@ -2954,15 +3066,26 @@ export function __resetInterceptorsForTest() {
2954
3066
  fullscreenInterceptInstalled = false;
2955
3067
  }
2956
3068
  /**
2957
- * Install a persistent `app.addEventListener('toolresult', …)` listener
2958
- * that catches `ui/notifications/tool-result` notifications arriving
2959
- * AFTER the initial mount. Each new tool-result that carries a
2960
- * different bootstrap (different sessionId / codeUrl / kind) triggers
2961
- * a re-mount through the published `applyRender`. This closes the boot-only-
3069
+ * Install the persistent `app.addEventListener('toolresult', …)`
3070
+ * listener that catches `ui/notifications/tool-result` notifications
3071
+ * arriving at ANY point in the document's life. Each tool-result that
3072
+ * carries a different bootstrap (different sessionId / codeUrl / kind)
3073
+ * triggers a re-mount through the published `applyRender`; arrivals
3074
+ * before a renderer publishes `applyRender` no-op safely (and do NOT
3075
+ * latch the dedupe key — see below). This closes the boot-only-
2962
3076
  * listener gap that prevented live re-render when an agent issued
2963
3077
  * a second `ggui_render` to the same render-resource.
2964
3078
  *
2965
- * Idempotent: subsequent calls no-op via {@link postMountListenerInstalled}.
3079
+ * REGISTRATION TIMING (ggui#586): called by `bootSequence` BEFORE
3080
+ * `app.connect(transport)` — on a Tier-1/preResolved boot the one-shot
3081
+ * Tier-2 listener never registers, so this used to be the FIRST
3082
+ * `toolresult` registration and landed post-handshake (the ext-apps
3083
+ * `_assertHandlerTiming` notice a guuey production render surfaced).
3084
+ * The SDK's replay made that harmless; pre-registering removes the
3085
+ * dependence on replay entirely.
3086
+ *
3087
+ * Idempotent per App: repeat calls with the same App no-op via
3088
+ * {@link persistentToolResultListenerApps}.
2966
3089
  *
2967
3090
  * Why module-level vs scoped to a single mount: re-mounts re-apply
2968
3091
  * through `applyRender` while the listener stays live, and the
@@ -2972,17 +3095,13 @@ export function __resetInterceptorsForTest() {
2972
3095
  * Spec-canonical (post-Phase-1.19b.3): the previous hand-rolled
2973
3096
  * `window.addEventListener('message', …)` is gone; App handles every
2974
3097
  * inbound `ui/notifications/tool-result` envelope and dispatches via
2975
- * its event system. Per-iframe single-tenancy means the App handle
2976
- * comes from `getCurrentApp()` — set by `bootSequence`'s connect
2977
- * path before this helper runs.
3098
+ * its event system. The App handle is passed by the boot path
3099
+ * directly (pre-connect, so `getCurrentApp()` is not yet set).
2978
3100
  */
2979
- function installPostMountListener() {
2980
- if (postMountListenerInstalled)
3101
+ function installPersistentToolResultListener(app) {
3102
+ if (persistentToolResultListenerApps.has(app))
2981
3103
  return;
2982
- const app = getCurrentApp();
2983
- if (app === null)
2984
- return; // pre-connect; caller bug, swallow safely
2985
- postMountListenerInstalled = true;
3104
+ persistentToolResultListenerApps.add(app);
2986
3105
  let lastMetaKey = null;
2987
3106
  app.addEventListener('toolresult', (params) => {
2988
3107
  const meta = extractMetaFromToolResult(params);
@@ -3020,15 +3139,21 @@ function installPostMountListener() {
3020
3139
  ].join('|');
3021
3140
  if (key === lastMetaKey)
3022
3141
  return;
3023
- lastMetaKey = key;
3024
3142
  // Re-mount through the EXISTING renderer's `applyRender` (published
3025
3143
  // module-level by bootProduction's setup) — NOT a fresh boot. One
3026
3144
  // mount surface, one WS; the prior stale-closure / second-WS race
3027
3145
  // (the #290 root cause) is structurally impossible. Project the new
3028
3146
  // tool-result meta into a seed and re-apply it.
3147
+ //
3148
+ // The apply-null check runs BEFORE the dedupe key latches (ggui#586
3149
+ // ordering): now that this listener registers pre-connect, the
3150
+ // BOOT-path tool-result also flows through here while no renderer
3151
+ // is published yet — latching its key then would silently dedupe a
3152
+ // legitimate post-mount re-broadcast of the same envelope.
3029
3153
  const apply = activeApplyRender;
3030
3154
  if (apply === null)
3031
3155
  return; // no renderer published yet — no-op safely
3156
+ lastMetaKey = key;
3032
3157
  void buildGguiSessionSeedInput(meta).then((seed) => {
3033
3158
  if (seed === null)
3034
3159
  return;
@@ -3306,20 +3431,30 @@ async function bootProduction(opts) {
3306
3431
  }
3307
3432
  },
3308
3433
  };
3309
- // Spec-canonical outbound dispatch. The WS pipe is for streamSpec
3310
- // subscriptions ONLY (inbound `ggui_emit` fanout + `props_update` +
3311
- // `render` + `data` + `drain_ack` + `channel_payload`).
3312
- // Outbound user actions go through the MCP-Apps host relay per
3313
- // spec §401: postMessage `tools/call:ggui_runtime_submit_action`
3314
- // to the parent → `AppRenderer.onCallTool` → sample agent's
3315
- // `/relay/tools-call` → ggui MCP server →
3316
- // `createGguiSubmitActionHandler.append` → `pendingEventConsumer`
3317
- // → `ggui_consume` wakes the agent. The server's WS
3318
- // `handleInboundAction` writes to the render ledger only — no
3319
- // downstream consumer — so the WS action path silently drops
3320
- // clicks. `routeDispatch` is the shared named-export helper
3321
- // (production + tests exercise the same code path); threading it
3322
- // here keeps LIVE-mode on the canonical dispatch pipeline.
3434
+ // Spec-canonical outbound dispatch: user actions go through the
3435
+ // MCP-Apps host relay (SPEC §4.7): postMessage
3436
+ // `tools/call:ggui_runtime_submit_action` to the parent →
3437
+ // `AppRenderer.onCallTool` → host's MCP client → ggui MCP server
3438
+ // → `createGguiSubmitActionHandler.append` → `pendingEventConsumer`
3439
+ // → `ggui_consume` wakes the agent. NOTE (truth, ggui#596 +
3440
+ // adversarial-cycle correction): the server's WS ingress
3441
+ // dual-writes accepted `data:submit` frames onto the consume pipe
3442
+ // ONLY when the live channel is composed with a
3443
+ // `pendingEventConsumer` — which `createGguiServer` threads for
3444
+ // DEFAULT-handlers compositions only (a custom `handlers` list
3445
+ // drains its own pipe; bridging the channel onto an instance
3446
+ // nobody drains would buffer gestures into the void — see the
3447
+ // hoist note in server.ts). The hosted pod IS a custom-handlers
3448
+ // caller: on it, a WS gesture lands on the retained ledger only
3449
+ // and never reaches the agent. Any WS action fallback (ggui#599's
3450
+ // open design leg) therefore requires the server/pod bridge
3451
+ // FIRST — it is a capability gap on that composition today. The WS pipe otherwise carries
3452
+ // streamSpec subscriptions (inbound `ggui_emit` fanout +
3453
+ // `props_update` + `render` + `data` + `drain_ack` +
3454
+ // `channel_payload`). `routeDispatch` is the shared named-export
3455
+ // helper (production + tests exercise the same code path);
3456
+ // threading it here keeps LIVE-mode on the canonical dispatch
3457
+ // pipeline.
3323
3458
  // Single mounted render — populated by `applyRender` on the first
3324
3459
  // render frame. The wire config + data channel handler read it
3325
3460
  // through `currentRender`-returning thunks so they always see the
@@ -3461,13 +3596,21 @@ async function bootProduction(opts) {
3461
3596
  !('createdAt' in render)
3462
3597
  ? { codeModuleUrl: meta.codeModuleUrl }
3463
3598
  : {}),
3464
- ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
3465
- // Mode: the slice's stamped `themeMode` (every operator layer
3466
- // resolves into it) wins; when absent, the HOST's announced
3467
- // theme fills the gap (ggui#551 — see `hostAnnouncedThemeMode`).
3468
- // Absent-in-slice means "no operator opinion", not "light".
3599
+ // Base-theme ladder (ggui#589 ask 3 — see `resolveMountThemeId`):
3600
+ // stamped themeId > the slice theme object's NAME (a
3601
+ // registered name binds the full brand base; unregistered
3602
+ // names fall back to the default ladder, byte-identical to
3603
+ // the no-themeId path).
3469
3604
  ...(() => {
3470
- const themeMode = meta.themeMode ?? hostAnnouncedThemeMode();
3605
+ const themeId = resolveMountThemeId(meta);
3606
+ return themeId !== undefined ? { themeId } : {};
3607
+ })(),
3608
+ // Mode ladder (ggui#551 + #589 — see `resolveMountThemeMode`):
3609
+ // stamped `themeMode` > the slice theme OBJECT's `mode` >
3610
+ // host-announced theme. Absent everywhere means "no opinion",
3611
+ // not "light".
3612
+ ...(() => {
3613
+ const themeMode = resolveMountThemeMode(meta);
3471
3614
  return themeMode !== undefined ? { themeMode } : {};
3472
3615
  })(),
3473
3616
  // Per-app theme overlay (St3 M2.2). Threaded straight from the
@@ -3477,6 +3620,15 @@ async function bootProduction(opts) {
3477
3620
  // `color-scheme` at `:root`. THEME IS COMPONENT-ONLY — system
3478
3621
  // cards theme via the SystemCardHost/ThemeProvider path.
3479
3622
  ...(meta.theme !== undefined ? { appTheme: meta.theme } : {}),
3623
+ // Host palette: always threaded when the host announced one —
3624
+ // unlike mode there is no either/or gate here, because the
3625
+ // precedence is CSS document order inside the scoped block
3626
+ // (base < hostPalette < appTheme, ggui#572/#573: slice wins,
3627
+ // host fallback), not a value-level ?? fallback.
3628
+ ...(() => {
3629
+ const hostPalette = hostAnnouncedPalette();
3630
+ return hostPalette !== undefined ? { hostPalette } : {};
3631
+ })(),
3480
3632
  ...(wrapOuter !== undefined ? { wrapOuter } : {}),
3481
3633
  };
3482
3634
  };
@@ -3500,17 +3652,17 @@ async function bootProduction(opts) {
3500
3652
  }
3501
3653
  await renderHandle.update(buildOpts(render));
3502
3654
  };
3503
- // Publish `applyRender` module-level + install the persistent
3504
- // post-mount tool-result listener. On a host-re-emitted tool-result
3505
- // (claude.ai re-broadcast; the Anthropic-SDK-strips-_meta refetch),
3506
- // the listener re-mounts through THIS `applyRender` — no fresh boot,
3507
- // no second WS. The no-WS live-re-render channel; for WS hosts the
3508
- // render-frame handler already covers re-render, so it's a
3509
- // redundant-safe fallback (guarded by the sessionId pin + liveTrio
3510
- // dedupe). Runs only on the production renderer path (tests that
3511
- // drive bootSequence without a renderer never reach setup()).
3655
+ // Publish `applyRender` module-level. The persistent tool-result
3656
+ // listener is ALREADY registered — `bootSequence` installs it
3657
+ // pre-connect (ggui#586) — and reads this slot dynamically, so
3658
+ // publishing here is what arms it. On a host-re-emitted
3659
+ // tool-result (claude.ai re-broadcast; the
3660
+ // Anthropic-SDK-strips-_meta refetch), the listener re-mounts
3661
+ // through THIS `applyRender` — no fresh boot, no second WS. The
3662
+ // no-WS live-re-render channel; for WS hosts the render-frame
3663
+ // handler already covers re-render, so it's a redundant-safe
3664
+ // fallback (guarded by the sessionId pin + liveTrio dedupe).
3512
3665
  activeApplyRender = applyRender;
3513
- installPostMountListener();
3514
3666
  // Validator context — A2UI default for `_ggui:preview`; no
3515
3667
  // bootstrap-supplied overrides today (the
3516
3668
  // `extraReservedValidators` injection slot is reserved for a
@@ -1,4 +1,20 @@
1
1
  import type { ObservabilityEmitter } from './observability.js';
2
+ /**
3
+ * Stacking contract between the two body-appended runtime layers
4
+ * (ggui#603): the feedback chrome must sit strictly ABOVE the action
5
+ * toast. The persistent `action_required` toast parks
6
+ * `pointer-events:auto` at fixed bottom-center — the same region this
7
+ * chrome occupies — and is re-armed per gesture; without this order an
8
+ * armed toast eats every tap aimed at the feedback controls. The toast
9
+ * remains dismissable on its own uncovered area (it accepts the click
10
+ * anywhere on itself), so partial occlusion costs it nothing.
11
+ *
12
+ * Single source: `runtime.ts`'s toast reads {@link ACTION_TOAST_Z_INDEX};
13
+ * this module applies {@link UI_FEEDBACK_CHROME_Z_INDEX} to the chrome
14
+ * container. The test suite pins the relationship, not the numbers.
15
+ */
16
+ export declare const UI_FEEDBACK_CHROME_Z_INDEX = 2147483647;
17
+ export declare const ACTION_TOAST_Z_INDEX = 2147483646;
2
18
  export interface UiFeedbackChromeOptions {
3
19
  /**
4
20
  * Observability sink the card emits `ui-feedback` events into.
@@ -36,6 +36,22 @@
36
36
  * gate passes.
37
37
  */
38
38
  import { createElement } from 'react';
39
+ /**
40
+ * Stacking contract between the two body-appended runtime layers
41
+ * (ggui#603): the feedback chrome must sit strictly ABOVE the action
42
+ * toast. The persistent `action_required` toast parks
43
+ * `pointer-events:auto` at fixed bottom-center — the same region this
44
+ * chrome occupies — and is re-armed per gesture; without this order an
45
+ * armed toast eats every tap aimed at the feedback controls. The toast
46
+ * remains dismissable on its own uncovered area (it accepts the click
47
+ * anywhere on itself), so partial occlusion costs it nothing.
48
+ *
49
+ * Single source: `runtime.ts`'s toast reads {@link ACTION_TOAST_Z_INDEX};
50
+ * this module applies {@link UI_FEEDBACK_CHROME_Z_INDEX} to the chrome
51
+ * container. The test suite pins the relationship, not the numbers.
52
+ */
53
+ export const UI_FEEDBACK_CHROME_Z_INDEX = 2147483647;
54
+ export const ACTION_TOAST_Z_INDEX = 2147483646;
39
55
  /**
40
56
  * Mount the in-iframe UI-feedback affordance, or do nothing.
41
57
  *
@@ -69,6 +85,10 @@ export async function mountUiFeedbackChrome(doc, opts) {
69
85
  doc.querySelector('[data-ggui-ui-feedback-chrome]')?.remove();
70
86
  const container = doc.createElement('div');
71
87
  container.setAttribute('data-ggui-ui-feedback-chrome', '');
88
+ // Win the stacking race against the action toast (ggui#603 — see
89
+ // the constants' docblock above).
90
+ container.style.position = 'relative';
91
+ container.style.zIndex = String(UI_FEEDBACK_CHROME_Z_INDEX);
72
92
  doc.body.appendChild(container);
73
93
  const root = reactDomClient.createRoot(container);
74
94
  root.render(createElement(cardMod.UiFeedbackCard, {
@@ -95,9 +95,13 @@ export interface BuildRootWireConfigOptions {
95
95
  *
96
96
  * The default WS-frame send reaches the agent only on servers whose
97
97
  * live channel bridges WS `data:submit` actions onto the same
98
- * pending-events pipe (first-party `createGguiServer` does — see
99
- * `GguiSessionChannelOptions.pendingEventConsumer`); it remains the
100
- * seam tests and direct-WS callers exercise.
98
+ * pending-events pipe. First-party `createGguiServer` bridges it
99
+ * ONLY when composed with DEFAULT handlers (`opts.handlers`
100
+ * undefined); a custom `handlers` list drains its own pipe, so the
101
+ * channel deliberately does not bridge — the hosted pod is such a
102
+ * caller, and on it a WS gesture lands on the retained ledger only.
103
+ * See `GguiSessionChannelOptions.pendingEventConsumer`. The default
104
+ * send remains the seam tests and direct-WS callers exercise.
101
105
  *
102
106
  * Called AFTER outbound validation passes. Receives the validated
103
107
  * {@link ActionEnvelope}; caller extracts payload.action +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "The iframe-local ggui runtime: a self-contained ESM bundle that boots inside an MCP Apps host iframe, runs the protocol-version handshake, opens the live-channel WebSocket, and mounts the rendered UI. Bundles React, ReactDOM, and the ggui design system so generated component code has a single seam to read from.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -36,12 +36,12 @@
36
36
  "react": "^19.0.0",
37
37
  "react-dom": "^19.0.0",
38
38
  "zod": "^4.3.6",
39
- "@ggui-ai/design": "0.10.0",
40
- "@ggui-ai/gadgets": "0.10.0",
41
- "@ggui-ai/live-channel": "0.10.0",
42
- "@ggui-ai/protocol": "0.10.0",
43
- "@ggui-ai/preview-a2ui": "0.10.0",
44
- "@ggui-ai/wire": "0.10.0"
39
+ "@ggui-ai/design": "0.12.0",
40
+ "@ggui-ai/live-channel": "0.12.0",
41
+ "@ggui-ai/gadgets": "0.12.0",
42
+ "@ggui-ai/preview-a2ui": "0.12.0",
43
+ "@ggui-ai/protocol": "0.12.0",
44
+ "@ggui-ai/wire": "0.12.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.27.0"