@ggui-ai/iframe-runtime 0.15.0 → 0.16.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
@@ -27,13 +27,13 @@
27
27
  // FIRST import on purpose — declares zod jitless before any
28
28
  // schema-defining dependency initializes (see zod-jitless.ts).
29
29
  import './zod-jitless.js';
30
- import { connectionStore } from './connection.js';
30
+ import { runtimeConnectionWriter } from './connection.js';
31
31
  import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
32
32
  import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
33
- import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
33
+ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, reportInvalidAppTheme, } from './meta-parse.js';
34
34
  import { projectHostContext } from '@ggui-ai/protocol/wire';
35
35
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
36
- import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
36
+ import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, subscribeThemeChange as subscribeHostThemeChange, } from './host-context-emitter.js';
37
37
  import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
38
38
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
39
39
  import { createChannelTransportRouter, } from './channel-transport.js';
@@ -171,6 +171,11 @@ export function setCurrentApp(app) {
171
171
  function getCurrentApp() {
172
172
  return currentApp;
173
173
  }
174
+ /**
175
+ * The live theme re-injection's subscription (ggui#987 §4), held so a
176
+ * re-entered boot replaces it instead of stacking a second subscriber.
177
+ */
178
+ let activeThemeUnsubscribe = null;
174
179
  /**
175
180
  * The host's announced color mode, read from the connected App's
176
181
  * pre-merged hostContext (`ui/initialize` result, kept current by the
@@ -185,10 +190,11 @@ function getCurrentApp() {
185
190
  * `color-scheme` via `applyDocumentTheme` — nothing in the token
186
191
  * pipeline keys off it, so a dark host painted the light ladder
187
192
  * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
188
- * `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
189
- * every operator layer (live provider > per-render override > app
190
- * default > server default) still wins by being stamped; the host
191
- * fills the gap where no operator had an opinion.
193
+ * Under ggui#987 §4 the embedding host OWNS runtime mode: when it
194
+ * announces one, `buildOpts` paints it over every stamped opinion; the
195
+ * stamped layers decide only when the host is silent. Read live from
196
+ * the App's pre-merged hostContext, so a `hostcontextchanged` flip
197
+ * re-resolves on the next `buildOpts`.
192
198
  *
193
199
  * @internal — exported for unit tests (`setCurrentApp` injects the
194
200
  * App); production caller is `buildOpts` inside `bootSequence`.
@@ -198,36 +204,30 @@ export function hostAnnouncedThemeMode() {
198
204
  return theme === 'light' || theme === 'dark' ? theme : undefined;
199
205
  }
200
206
  /**
201
- * Resolve the mount's base-stylesheet color mode from the slice, with
202
- * the host as the final fallback (ggui#589, completing the ggui#551
203
- * precedence ruling — "slice wins, host is the fallback, absent ≠
204
- * light"):
207
+ * Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
208
+ * runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
205
209
  *
206
- * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
207
- * host-announced theme > undefined
210
+ * host-announced theme > stamped `themeMode` > `theme.mode`
211
+ * (the app theme's default) > undefined
208
212
  *
209
- * The middle leg is the ggui#589 fix: a render envelope carrying a
210
- * per-app theme (`theme: {mode, cssVariables}`) but no top-level
211
- * `themeMode` is still a slice-stamped mode opinion — before this,
212
- * the base token ladder ignored it and painted the LIGHT token set
213
- * under a dark overlay whenever the host (correctly, per the
214
- * adapter-boundary doctrine in the native host helpers) announced no
215
- * `hostContext.theme`: a light skeleton in a dark skin. The theme
216
- * object's own `mode` already drove `color-scheme`; now it also
217
- * selects the ladder.
213
+ * A card that ignored the host's announced mode painted a light
214
+ * skeleton inside a dark chat — the host's word is what the user
215
+ * actually sees around the card. The stamped layers (live provider >
216
+ * per-render override > app default > server default) decide only when
217
+ * the host is silent, which is the adapter-boundary case (a host
218
+ * helper that announces no `hostContext.theme`).
218
219
  *
219
220
  * `undefined` still means "no opinion anywhere" — the renderer's
220
- * default applies; never coerced to `'light'`.
221
+ * default applies; never coerced to `'light'` here (ggui#551).
221
222
  *
222
223
  * @internal — exported for unit tests; production caller is
223
224
  * `buildOpts` inside `bootSequence`.
224
225
  */
225
226
  export function resolveMountThemeMode(meta) {
226
227
  // The CLIENT projection of the protocol's normative theme-binding
227
- // total order (@ggui-ai/protocol/integrations/theme-binding,
228
- // ggui#598 leg 4) — the server stamps via the sibling projection and
229
- // the protocol's composition-law test pins that the two compose
230
- // without rank drift.
228
+ // total order (@ggui-ai/protocol/integrations/theme-binding) — the
229
+ // server stamps via the sibling projection and the protocol's
230
+ // composition-law test pins that the two compose without rank drift.
231
231
  return effectiveThemeMode({
232
232
  stamped: meta.themeMode,
233
233
  sessionSidecar: meta.theme?.mode,
@@ -235,20 +235,14 @@ export function resolveMountThemeMode(meta) {
235
235
  });
236
236
  }
237
237
  /**
238
- * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
238
+ * Resolve the mount's compiled-ladder id from the slice (ggui#987 D2):
239
239
  *
240
- * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
241
- * > undefined (renderer default ladder)
240
+ * stamped `themeId` > undefined (renderer default ladder)
242
241
  *
243
- * The name leg makes a slice theme whose `name` matches a REGISTERED
244
- * theme id select that theme as the BASE token ladder — the overlay's
245
- * cssVariables still apply above it, so the base carries the full
246
- * brand ramp coverage a sparse overlay cannot (the "only the mapped
247
- * vars carry brand" store-frame class). An unregistered name is
248
- * harmless by construction: `getScopedThemeCss` falls back to the
249
- * default theme, which is byte-identical to the no-themeId path
250
- * (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
251
- * naming its theme "My Custom" renders exactly as before.
242
+ * The app theme's `name` is a LABEL the author's tooling reads back —
243
+ * it never selects a ladder. An app theme carries its own full
244
+ * projection (`overlays`, exactly the manifest minus the floor), so
245
+ * there is no coverage gap for a compiled theme to fill beneath it.
252
246
  *
253
247
  * @internal — exported for unit tests; production caller is
254
248
  * `buildOpts` inside `bootSequence`.
@@ -256,10 +250,7 @@ export function resolveMountThemeMode(meta) {
256
250
  export function resolveMountThemeId(meta) {
257
251
  // Client projection of the normative theme-binding order — see
258
252
  // resolveMountThemeMode above.
259
- return effectiveThemeId({
260
- stamped: meta.themeId,
261
- sidecarName: meta.theme?.name,
262
- });
253
+ return effectiveThemeId({ stamped: meta.themeId });
263
254
  }
264
255
  /**
265
256
  * The host's announced palette, read from the connected App's
@@ -461,7 +452,7 @@ export async function bootSequence(opts) {
461
452
  // `tools/call` (dispatchSubmitAction, channel-transport router)
462
453
  // routes through `app.callServerTool` instead of the legacy raw
463
454
  // postMessage pump. Idempotent: re-boot with the same App is a
464
- // no-op; a different App throws (the iframe is single-tenant).
455
+ // no-op; a different App throws (the iframe is single-app).
465
456
  setCurrentApp(app);
466
457
  // Slice-meta resolution — spec-canonical primary, no in-house
467
458
  // Reading-B (retired Phase 1.19b.3 with the App-class swap; the
@@ -1356,7 +1347,7 @@ export async function resolveMetaViaReadDoor(app, locator) {
1356
1347
  const envelope = readGguiShellEnvelope(text);
1357
1348
  if (envelope === undefined)
1358
1349
  return null;
1359
- const parsed = parseMcpAppAiGguiRenderMeta(envelope);
1350
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope, { onInvalidTheme: reportInvalidAppTheme });
1360
1351
  if (!parsed.ok || parsed.meta === undefined)
1361
1352
  return null;
1362
1353
  const validated = validateMeta(parsed.meta);
@@ -1406,7 +1397,7 @@ function awaitToolResultMeta(timeoutMs) {
1406
1397
  * App instance even across re-mounts (e.g. an agent fires a second
1407
1398
  * `ggui_render` and the listener re-applies through the published
1408
1399
  * `applyRender`). Keyed by App — not a module boolean — so a fresh App
1409
- * (a re-booted document in tests; production is single-tenant per
1400
+ * (a re-booted document in tests; production is single-app per
1410
1401
  * iframe) always gets its own registration, never a stale latch.
1411
1402
  */
1412
1403
  const persistentToolResultListenerApps = new WeakSet();
@@ -1883,7 +1874,7 @@ function applyFreezeCue(root) {
1883
1874
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
1884
1875
  fontSize: '11px',
1885
1876
  fontWeight: '600',
1886
- color: 'var(--ggui-color-onSurface, inherit)',
1877
+ color: 'var(--ggui-color-onContainer, inherit)',
1887
1878
  background: 'rgba(128,128,128,0.16)',
1888
1879
  border: '1px solid currentColor',
1889
1880
  opacity: '0.85',
@@ -2320,7 +2311,7 @@ let relayLatchTrigger;
2320
2311
  let relayLatchScope = {};
2321
2312
  function transitionRelayLatch(next, trigger, scope = {}) {
2322
2313
  relayIncapabilityAnnounced = next;
2323
- connectionStore.set(!next);
2314
+ runtimeConnectionWriter().set(!next);
2324
2315
  if (next && trigger !== undefined) {
2325
2316
  relayLatchedAt = Date.now();
2326
2317
  relayDeadTaps = 0;
@@ -2435,6 +2426,10 @@ function isRelayNoticeVisible() {
2435
2426
  * @internal — exported for unit tests.
2436
2427
  */
2437
2428
  export function resetRelayLatchForBoot() {
2429
+ // The runtime's claim on the document's connection writer is taken HERE,
2430
+ // eagerly, before any transition (ggui#843): a second runtime in this
2431
+ // document fails at boot naming itself, never continuing unclaimed.
2432
+ runtimeConnectionWriter();
2438
2433
  if (typeof document !== 'undefined') {
2439
2434
  const el = document.getElementById(ACTION_TOAST_ID);
2440
2435
  if (el !== null && el.hasAttribute(RELAY_NOTICE_ATTR))
@@ -2445,14 +2440,14 @@ export function resetRelayLatchForBoot() {
2445
2440
  transitionRelayLatch(false);
2446
2441
  }
2447
2442
  else {
2448
- connectionStore.set(true);
2443
+ runtimeConnectionWriter().set(true);
2449
2444
  }
2450
2445
  }
2451
2446
  /**
2452
2447
  * Module-level handle to the CURRENT boot's telemetry sink so
2453
2448
  * module-scope paths (the doorbell emitter below) can report without
2454
2449
  * threading the sink through every call chain. Per-iframe
2455
- * single-tenancy makes a single slot correct — same justification as
2450
+ * single-app scope makes a single slot correct — same justification as
2456
2451
  * `getCurrentApp()`.
2457
2452
  */
2458
2453
  let currentTelemetrySink = null;
@@ -2540,7 +2535,7 @@ function resetRelayCueThrottles() {
2540
2535
  /** @internal — exported for unit tests to reset module state. */
2541
2536
  export function __resetRelayNoticeForTest() {
2542
2537
  relayIncapabilityAnnounced = false;
2543
- connectionStore.set(true);
2538
+ runtimeConnectionWriter().set(true);
2544
2539
  relayLatchedAt = undefined;
2545
2540
  relayDeadTaps = 0;
2546
2541
  relayLatchTrigger = undefined;
@@ -3712,10 +3707,10 @@ async function bootProduction(opts) {
3712
3707
  // DEFAULT-handlers compositions only (a custom `handlers` list
3713
3708
  // drains its own pipe; bridging the channel onto an instance
3714
3709
  // nobody drains would buffer gestures into the void — see the
3715
- // hoist note in server.ts). The hosted pod IS a custom-handlers
3716
- // caller: on it, a WS gesture lands on the retained ledger only
3710
+ // hoist note in server.ts). A deployment composing its own `handlers`
3711
+ // IS such a caller: on it, a WS gesture lands on the retained ledger only
3717
3712
  // and never reaches the agent. Any WS action fallback (ggui#599's
3718
- // open design leg) therefore requires the server/pod bridge
3713
+ // open design leg) therefore requires the server-side bridge
3719
3714
  // FIRST — it is a capability gap on that composition today. The WS pipe otherwise carries
3720
3715
  // streamSpec subscriptions (inbound `ggui_emit` fanout +
3721
3716
  // `props_update` + `render` + `data` + `drain_ack` +
@@ -3864,29 +3859,27 @@ async function bootProduction(opts) {
3864
3859
  !('createdAt' in render)
3865
3860
  ? { codeModuleUrl: meta.codeModuleUrl }
3866
3861
  : {}),
3867
- // Base-theme ladder (ggui#589 ask 3 — see `resolveMountThemeId`):
3868
- // stamped themeId > the slice theme object's NAME (a
3869
- // registered name binds the full brand base; unregistered
3870
- // names fall back to the default ladder, byte-identical to
3871
- // the no-themeId path).
3862
+ // Compiled ladder (see `resolveMountThemeId`): the stamped
3863
+ // themeId alone; absent = the renderer's default ladder.
3872
3864
  ...(() => {
3873
3865
  const themeId = resolveMountThemeId(meta);
3874
3866
  return themeId !== undefined ? { themeId } : {};
3875
3867
  })(),
3876
- // Mode ladder (ggui#551 + #589 — see `resolveMountThemeMode`):
3877
- // stamped `themeMode` > the slice theme OBJECT's `mode` >
3878
- // host-announced theme. Absent everywhere means "no opinion",
3879
- // not "light".
3868
+ // Mode (ggui#987 §4 — see `resolveMountThemeMode`): host-
3869
+ // announced theme > stamped `themeMode` > the app theme's
3870
+ // default `mode`. Absent everywhere means "no opinion", not
3871
+ // "light". Re-resolved on every buildOpts, so the live
3872
+ // hostcontextchanged re-injection below picks up a flip.
3880
3873
  ...(() => {
3881
3874
  const themeMode = resolveMountThemeMode(meta);
3882
3875
  return themeMode !== undefined ? { themeMode } : {};
3883
3876
  })(),
3884
- // Per-app theme overlay (St3 M2.2). Threaded straight from the
3877
+ // Per-app theme (ggui#987 v2). Threaded straight from the
3885
3878
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3886
3879
  // already injection-validated by the wire parser) onto the mount
3887
- // options so the renderer applies the `--ggui-*` overrides +
3888
- // `color-scheme` at `:root`. THEME IS COMPONENT-ONLY — system
3889
- // cards theme via the SystemCardHost/ThemeProvider path.
3880
+ // options; the renderer injects the effective mode's overlay
3881
+ // into the scoped block + `:root`. THEME IS COMPONENT-ONLY —
3882
+ // system cards theme via the SystemCardHost/ThemeProvider path.
3890
3883
  ...(meta.theme !== undefined ? { appTheme: meta.theme } : {}),
3891
3884
  // Host palette: always threaded when the host announced one —
3892
3885
  // unlike mode there is no either/or gate here, because the
@@ -3912,14 +3905,52 @@ async function bootProduction(opts) {
3912
3905
  * layer just calls `applyRender(render)` without owning
3913
3906
  * lifecycle state.
3914
3907
  */
3908
+ // Every in-place update of the mounted slot goes through ONE
3909
+ // chain: the WS render-frame / props_update path and the live
3910
+ // theme re-injection below are independent callers, and the
3911
+ // handle's `update` has no lock of its own — serializing them
3912
+ // here is what keeps a host flip landing mid-render from
3913
+ // interleaving two updates.
3914
+ let updateChain = Promise.resolve();
3915
+ const queueUpdate = (opts) => {
3916
+ const run = updateChain.then(async () => {
3917
+ if (renderHandle === null)
3918
+ return;
3919
+ await renderHandle.update(opts());
3920
+ });
3921
+ updateChain = run.catch(() => undefined);
3922
+ return run;
3923
+ };
3915
3924
  const applyRender = async (render) => {
3916
3925
  currentRender = render;
3917
3926
  if (renderHandle === null) {
3918
3927
  renderHandle = await mountRender(renderInto, buildOpts(render));
3919
3928
  return;
3920
3929
  }
3921
- await renderHandle.update(buildOpts(render));
3930
+ await queueUpdate(() => buildOpts(render));
3922
3931
  };
3932
+ // Live re-injection (ggui#987 §4): the host owns runtime mode and
3933
+ // may flip it mid-session. The trigger is the emitter's THEME
3934
+ // subscription — the raw `theme` / `styles` payload — because the
3935
+ // host-context projection excludes both by design and its
3936
+ // equality gate would never fire for a theme-only flip. The App
3937
+ // pre-merges the new hostContext before the emitter fans out, so
3938
+ // re-running `buildOpts` on the CURRENT render re-resolves mode +
3939
+ // palette and the renderer repaints from the retained other
3940
+ // overlay — no fetch, no remount. A rejection is reported to the
3941
+ // embedding host, never dropped on the floor.
3942
+ activeThemeUnsubscribe?.();
3943
+ activeThemeUnsubscribe = subscribeHostThemeChange(() => {
3944
+ if (renderHandle === null || currentRender === null)
3945
+ return;
3946
+ const render = currentRender;
3947
+ queueUpdate(() => buildOpts(render)).catch((err) => {
3948
+ postObservabilityToParent({
3949
+ kind: 'theme-reinject-failed',
3950
+ message: err instanceof Error ? err.message : String(err),
3951
+ });
3952
+ });
3953
+ });
3923
3954
  // Publish `applyRender` module-level. The persistent tool-result
3924
3955
  // listener is ALREADY registered — `bootSequence` installs it
3925
3956
  // pre-connect (ggui#586) — and reads this slot dynamically, so
@@ -36,7 +36,7 @@ function GguiWordmark() {
36
36
  // off-white in dark). `gray-300` flips too — the gray scale in
37
37
  // `darkTheme` is intentionally inverted, so the wordmark's
38
38
  // contrasting "chrome" tone reads correctly under either theme.
39
- const ink = 'var(--ggui-color-onSurface, #292929)';
39
+ const ink = 'var(--ggui-color-onContainer, #292929)';
40
40
  const chrome = 'var(--ggui-color-neutral-300, #d1d5db)';
41
41
  return (_jsxs("svg", { viewBox: "0 0 224 50", width: "72", height: "16", role: "img", "aria-label": "ggui", style: { display: 'block', flexShrink: 0 }, children: [_jsx("path", { d: "M 0 0 H 50 V 25 H 25 V 50 H 0 Z", fill: chrome }), _jsx("rect", { x: "33", y: "33", width: "17", height: "17", fill: ink }), _jsx("path", { d: "M 58 0 H 108 V 25 H 83 V 50 H 58 Z", fill: ink }), _jsx("rect", { x: "91", y: "33", width: "17", height: "17", fill: chrome }), _jsx("path", { d: "M 141 50 C 154.807 50 166 38.8071 166 25 V 0 H 116 V 25 C 116 38.8071 127.193 50 141 50 Z", fill: ink }), _jsx("rect", { x: "174", y: "0", width: "50", height: "50", fill: chrome })] }));
42
42
  }
@@ -87,10 +87,10 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
87
87
  // INVERTED in darkTheme so `gray-100` is light-on-dark when in
88
88
  // dark mode and dark-on-light when in light mode).
89
89
  //
90
- // - `--ggui-color-surface` — opaque card surface (white in light,
90
+ // - `--ggui-color-container` — opaque card surface (white in light,
91
91
  // slate-800 in dark). Used for the URL block + QR backdrop.
92
- // - `--ggui-color-onSurface` — high-contrast body text.
93
- // - `--ggui-color-onSurfaceVariant` — muted captions/headings.
92
+ // - `--ggui-color-onContainer` — high-contrast body text.
93
+ // - `--ggui-color-onSunken` — muted captions/headings.
94
94
  // - `--ggui-color-neutral-100` — subtle tinted background that
95
95
  // auto-flips. Used for the inner "details" panel — slightly
96
96
  // differentiated from outer transparent canvas without being
@@ -103,7 +103,7 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
103
103
  // background creates a card-on-card look. Padding stays so content
104
104
  // doesn't crowd the iframe edges. Outside Claude (preview iframe,
105
105
  // console inspector), the ThemeProvider's body background is the
106
- // theme's `--ggui-color-surface`, so a transparent card just
106
+ // theme's `--ggui-color-container`, so a transparent card just
107
107
  // shows the theme's canvas behind it — also fine.
108
108
  _jsx(Card, { padding: "lg", border: false, shadow: "none", style: { backgroundColor: 'transparent' }, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "md", children: [_jsxs("div", { style: {
109
109
  display: 'flex',
@@ -118,11 +118,11 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
118
118
  }, children: [_jsx(Heading, { level: 3, children: "Connect an LLM provider" }), _jsx("code", { style: {
119
119
  fontFamily: 'var(--ggui-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
120
120
  fontSize: 'var(--ggui-font-size-xs, 11px)',
121
- color: 'var(--ggui-color-onSurfaceVariant, #71717a)',
121
+ color: 'var(--ggui-color-onSunken, #71717a)',
122
122
  opacity: 0.7,
123
123
  userSelect: 'all',
124
124
  }, title: "iframe-runtime build identifier \u2014 useful for diagnosing stale caches", children: BUILD_ID })] }), _jsx(Text, { style: {
125
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
125
+ color: 'var(--ggui-color-onSunken, #52525b)',
126
126
  lineHeight: '1.55',
127
127
  }, children: "ggui needs an LLM key to render UIs. Open this server\u2019s settings page to paste an Anthropic, OpenAI, Google, or OpenRouter API key." })] }), _jsx("div", { style: {
128
128
  display: 'flex',
@@ -148,7 +148,7 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
148
148
  borderRadius: 'var(--ggui-shape-radius-sm, 6px)',
149
149
  lineHeight: 0,
150
150
  }, children: _jsx(QrCode, { value: settingsUrl, size: 120, foreground: "#000000", background: "#ffffff" }) }), _jsx(Text, { size: "xs", style: {
151
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
151
+ color: 'var(--ggui-color-onSunken, #52525b)',
152
152
  fontSize: 'var(--ggui-font-size-xs, 11px)',
153
153
  textAlign: 'center',
154
154
  }, children: "Scan with your phone" })] }), _jsxs("div", { style: {
@@ -158,7 +158,7 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
158
158
  flex: '1 1 240px',
159
159
  minWidth: '200px',
160
160
  }, children: [_jsx(Text, { size: "xs", style: {
161
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
161
+ color: 'var(--ggui-color-onSunken, #52525b)',
162
162
  fontWeight: 'var(--ggui-font-weight-semibold, 600)',
163
163
  textTransform: 'uppercase',
164
164
  letterSpacing: '0.06em',
@@ -166,11 +166,11 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
166
166
  }, children: "Or open this URL in your browser" }), _jsx("div", { style: {
167
167
  fontFamily: 'var(--ggui-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
168
168
  fontSize: 'var(--ggui-font-size-sm, 13px)',
169
- color: 'var(--ggui-color-onSurface, #111)',
169
+ color: 'var(--ggui-color-onContainer, #111)',
170
170
  wordBreak: 'break-all',
171
171
  userSelect: 'all',
172
172
  padding: 'var(--ggui-spacing-3, 12px)',
173
- background: 'var(--ggui-color-surface, #fff)',
173
+ background: 'var(--ggui-color-container, #fff)',
174
174
  border: '1px solid var(--ggui-color-neutral-200, #e4e4e7)',
175
175
  borderRadius: 'var(--ggui-shape-radius-sm, 6px)',
176
176
  lineHeight: '1.45',
@@ -180,10 +180,10 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
180
180
  gap: 'var(--ggui-spacing-3, 12px)',
181
181
  flexWrap: 'wrap',
182
182
  }, children: [_jsx(Button, { variant: "primary", size: "md", onClick: onPrimary, disabled: ctaDisabled, children: ctaLabel }), ctaState === 'idle' || ctaState === 'working' ? (_jsx(Text, { size: "xs", style: {
183
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
183
+ color: 'var(--ggui-color-onSunken, #52525b)',
184
184
  opacity: 0.85,
185
185
  }, children: "or tap-and-hold the URL above to copy" })) : null] })] })] }) }), intent ? (_jsxs(Text, { size: "xs", style: {
186
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
186
+ color: 'var(--ggui-color-onSunken, #52525b)',
187
187
  opacity: 0.7,
188
188
  }, children: ["Pending prompt: ", intent] })) : null] }) }));
189
189
  }
@@ -372,7 +372,7 @@ function ProbeButtonRow({ method, description, call, }) {
372
372
  padding: 'var(--ggui-spacing-3, 12px)',
373
373
  borderRadius: 'var(--ggui-shape-radius-md, 10px)',
374
374
  border: '1px solid var(--ggui-color-neutral-200, #e4e4e7)',
375
- background: 'var(--ggui-color-surface, #fff)',
375
+ background: 'var(--ggui-color-container, #fff)',
376
376
  }, children: [_jsxs("div", { style: {
377
377
  display: 'flex',
378
378
  alignItems: 'center',
@@ -382,10 +382,10 @@ function ProbeButtonRow({ method, description, call, }) {
382
382
  }, children: [_jsx("code", { style: {
383
383
  fontFamily: 'var(--ggui-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
384
384
  fontSize: 'var(--ggui-font-size-sm, 13px)',
385
- color: 'var(--ggui-color-onSurface, #111)',
385
+ color: 'var(--ggui-color-onContainer, #111)',
386
386
  fontWeight: 'var(--ggui-font-weight-semibold, 600)',
387
387
  }, children: method }), statusBadge] }), _jsx(Text, { size: "xs", style: {
388
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
388
+ color: 'var(--ggui-color-onSunken, #52525b)',
389
389
  fontSize: 'var(--ggui-font-size-xs, 11px)',
390
390
  }, children: description }), _jsx("div", { style: { display: 'flex', gap: 'var(--ggui-spacing-2, 8px)' }, children: _jsx(Button, { variant: "primary", size: "sm", onClick: onClick, disabled: outcome.state === 'pending', children: buttonLabel }) }), outcome.state === 'success' || outcome.state === 'error' ? (_jsx("pre", { style: {
391
391
  margin: 0,
@@ -393,7 +393,7 @@ function ProbeButtonRow({ method, description, call, }) {
393
393
  fontSize: 'var(--ggui-font-size-xs, 11px)',
394
394
  color: outcome.state === 'error'
395
395
  ? 'var(--ggui-color-warning-500, #b45309)'
396
- : 'var(--ggui-color-onSurfaceVariant, #52525b)',
396
+ : 'var(--ggui-color-onSunken, #52525b)',
397
397
  background: 'var(--ggui-color-neutral-100, #f4f4f5)',
398
398
  padding: 'var(--ggui-spacing-2, 8px)',
399
399
  borderRadius: 'var(--ggui-shape-radius-sm, 6px)',
@@ -414,7 +414,7 @@ export function ProtocolProbeCard({ intent, }) {
414
414
  gap: 'var(--ggui-spacing-3, 12px)',
415
415
  flexWrap: 'wrap',
416
416
  }, children: [_jsx(Heading, { level: 3, children: "MCP Apps protocol probe" }), _jsx(Badge, { variant: "info", children: "diagnostic" })] }), _jsxs(Text, { style: {
417
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
417
+ color: 'var(--ggui-color-onSunken, #52525b)',
418
418
  lineHeight: '1.55',
419
419
  }, children: ["One button per primary host effect emitted by the iframe runtime's gesture surfaces (submit-action bridge / native- idiom interceptors). Click each to verify which methods this host honors. The", _jsx("code", { children: "tools/call" }), " row chains the full submit-action bridge (audit + context + consent), reproducing the production ", _jsx("code", { children: "dispatchSubmitAction" }), " envelope shape verbatim. The receive log below captures every notification the host emits while this card is mounted."] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(ProbeButtonRow, { method: "ui/message", description: "Submit-action bridge step 3 \u2014 consent-gated user authorization. Host pre-fills + waits for user confirm before the LLM acts on the paired pending-action context.", call: call }), _jsx(ProbeButtonRow, { method: "ui/update-model-context", description: "Submit-action bridge step 2 \u2014 silent structured `[ggui:pending-action]` context drop. LLM honors on next turn. No immediate UI.", call: call }), _jsx(ProbeButtonRow, { method: "ui/open-link", description: "Native-idiom anchor click interceptor primary effect. Opens ggui.ai in the host's browser / new tab. Real iframe code triggers via `<a href target='_blank'>` or cross-origin href.", call: call }), _jsx(ProbeButtonRow, { method: "ui/request-display-mode", description: "Native-idiom fullscreen interceptor primary effect. Request fullscreen \u2014 real iframe code calls `el.requestFullscreen()`; the runtime overrides the prototype to route through here.", call: call }), _jsx(ProbeButtonRow, { method: "tools/call", description: "Full submit-action bridge: `tools/call ggui_runtime_submit_action` (audit, kind:'dispatch') + `ui/update-model-context` + `ui/message`. Spec \u00A7401 requires `_meta.ui.visibility:['app']` on the receiving tool.", call: call })] }), _jsxs(Stack, { gap: "sm", children: [_jsxs("div", { style: {
420
420
  display: 'flex',
@@ -422,10 +422,10 @@ export function ProtocolProbeCard({ intent, }) {
422
422
  justifyContent: 'space-between',
423
423
  gap: 'var(--ggui-spacing-3, 12px)',
424
424
  }, children: [_jsx(Heading, { level: 4, children: "Receive log" }), _jsxs(Text, { size: "xs", style: {
425
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
425
+ color: 'var(--ggui-color-onSunken, #52525b)',
426
426
  fontSize: 'var(--ggui-font-size-xs, 11px)',
427
427
  }, children: [log.length, " ", log.length === 1 ? 'event' : 'events'] })] }), log.length === 0 ? (_jsx(Text, { size: "xs", style: {
428
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
428
+ color: 'var(--ggui-color-onSunken, #52525b)',
429
429
  fontStyle: 'italic',
430
430
  }, children: "No host notifications observed yet. Click a probe button or wait for the host to emit one." })) : (_jsx("div", { style: {
431
431
  display: 'flex',
@@ -443,17 +443,17 @@ export function ProtocolProbeCard({ intent, }) {
443
443
  }, children: [_jsxs("summary", { style: {
444
444
  cursor: 'pointer',
445
445
  padding: 'var(--ggui-spacing-1, 4px) 0',
446
- color: 'var(--ggui-color-onSurface, #111)',
446
+ color: 'var(--ggui-color-onContainer, #111)',
447
447
  }, children: [_jsx("span", { style: {
448
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
448
+ color: 'var(--ggui-color-onSunken, #52525b)',
449
449
  marginRight: '8px',
450
450
  }, children: entry.tsIso.slice(11, 19) }), _jsx("span", { style: { fontWeight: 600 }, children: entry.method })] }), _jsx("pre", { style: {
451
451
  margin: '4px 0 0 16px',
452
452
  whiteSpace: 'pre-wrap',
453
453
  wordBreak: 'break-word',
454
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
454
+ color: 'var(--ggui-color-onSunken, #52525b)',
455
455
  }, children: JSON.stringify(entry.params, null, 2) })] }, entry.id))) }))] }), intent ? (_jsxs(Text, { size: "xs", style: {
456
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
456
+ color: 'var(--ggui-color-onSunken, #52525b)',
457
457
  opacity: 0.7,
458
458
  }, children: ["Triggered by: ", intent] })) : null] }) }));
459
459
  }
@@ -22,7 +22,7 @@ import qrcodeFactory from 'qrcode-generator';
22
22
  * fails (e.g., URL too long for any error-correction level) so the
23
23
  * caller can render a fallback.
24
24
  */
25
- export function QrCode({ value, size = 128, errorCorrection = 'M', foreground = 'var(--ggui-color-onSurface, #18181b)', background = 'var(--ggui-color-surface, #ffffff)', ariaLabel, }) {
25
+ export function QrCode({ value, size = 128, errorCorrection = 'M', foreground = 'var(--ggui-color-onContainer, #18181b)', background = 'var(--ggui-color-container, #ffffff)', ariaLabel, }) {
26
26
  const moduleMatrix = React.useMemo(() => {
27
27
  try {
28
28
  // typeNumber=0 lets the encoder pick the smallest fit. The
@@ -50,8 +50,8 @@ function UnknownSystemCard({ kind, props, }) {
50
50
  border: '1px solid var(--ggui-color-outlineVariant, #e4e4e7)',
51
51
  borderRadius: 'var(--ggui-shape-radius-md, 10px)',
52
52
  fontFamily: 'var(--ggui-font-family-sans, -apple-system, BlinkMacSystemFont, sans-serif)',
53
- background: 'var(--ggui-color-surfaceVariant, #f4f4f5)',
54
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
53
+ background: 'var(--ggui-color-sunken, #f4f4f5)',
54
+ color: 'var(--ggui-color-onSunken, #52525b)',
55
55
  },
56
56
  }, React.createElement('div', { style: { fontWeight: 600, marginBottom: 8 } }, `Unknown system card: ${kind}`), React.createElement('div', {
57
57
  style: {
@@ -98,8 +98,8 @@ export interface BuildRootWireConfigOptions {
98
98
  * pending-events pipe. First-party `createGguiServer` bridges it
99
99
  * ONLY when composed with DEFAULT handlers (`opts.handlers`
100
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.
101
+ * channel deliberately does not bridge — a deployment composing its own
102
+ * `handlers` is such a caller, and on it a WS gesture lands on the retained ledger only.
103
103
  * See `GguiSessionChannelOptions.pendingEventConsumer`. The default
104
104
  * send remains the seam tests and direct-WS callers exercise.
105
105
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
@@ -41,12 +41,12 @@
41
41
  "react": "^19.0.0",
42
42
  "react-dom": "^19.0.0",
43
43
  "zod": "^4.3.6",
44
- "@ggui-ai/design": "0.15.0",
45
- "@ggui-ai/gadgets": "0.15.0",
46
- "@ggui-ai/live-channel": "0.15.0",
47
- "@ggui-ai/preview-a2ui": "0.15.0",
48
- "@ggui-ai/protocol": "0.15.0",
49
- "@ggui-ai/wire": "0.15.0"
44
+ "@ggui-ai/design": "0.16.0",
45
+ "@ggui-ai/live-channel": "0.16.0",
46
+ "@ggui-ai/preview-a2ui": "0.16.0",
47
+ "@ggui-ai/gadgets": "0.16.0",
48
+ "@ggui-ai/protocol": "0.16.0",
49
+ "@ggui-ai/wire": "0.16.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.27.0"