@ggui-ai/iframe-runtime 0.16.0 → 0.17.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/index.d.ts CHANGED
@@ -37,7 +37,7 @@ export { parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './m
37
37
  export type { RendererBootFailureReason, RendererBootFailedMessage, } from './runtime.js';
38
38
  export type { ProtocolError, ProtocolErrorEmitter, BootstrapFailureReason, } from './protocol-error.js';
39
39
  export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
40
- export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, SchemaVersionMismatchEvent, FontFaceBlockedEvent, AppThemeInvalidEvent, ThemeReinjectFailedEvent, SubscribeFailedEvent, ChannelTransportPickedEvent, ChannelTransportFallbackEvent, ChannelTransportResubscribedEvent, ChannelPollDegradedEvent, ChannelPollRecoveredEvent, UiFeedbackEvent, RelayDeadTapEvent, RelayIncapabilityEvent, RelayLatchTrigger, UnknownObservabilityEvent, } from './observability.js';
40
+ export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, SchemaVersionMismatchEvent, FontFaceBlockedEvent, AppThemeInvalidEvent, AppThemeMemberStrippedEvent, ThemeReinjectFailedEvent, SubscribeFailedEvent, ChannelTransportPickedEvent, ChannelTransportFallbackEvent, ChannelTransportResubscribedEvent, ChannelPollDegradedEvent, ChannelPollRecoveredEvent, UiFeedbackEvent, RelayDeadTapEvent, RelayIncapabilityEvent, RelayLatchTrigger, UnknownObservabilityEvent, } from './observability.js';
41
41
  export { isObservabilityMessage, isRelayDeadTapEvent, isRelayIncapabilityEvent, postObservabilityToParent, } from './observability.js';
42
42
  export type { LifecycleEmitter } from './lifecycle.js';
43
43
  export { postLifecycleToParent, makeLifecycleEvent } from './lifecycle.js';
@@ -25,6 +25,14 @@ export declare function validateMeta(meta: McpAppAiGguiRenderMeta): McpAppAiGgui
25
25
  * package.
26
26
  */
27
27
  export declare function reportInvalidAppTheme(issues: readonly string[]): void;
28
+ /**
29
+ * The read door KEPT the slice's theme but stripped top-level members
30
+ * this release does not name (ggui#1093 belt, VERSION-POLICY §3.6):
31
+ * report the member names to the embedding host so a newer writer's
32
+ * member is visible, not swallowed. Passed as `onStrippedThemeMembers`
33
+ * on every `parseMcpAppAiGguiRenderMeta` call in this package.
34
+ */
35
+ export declare function reportStrippedThemeMembers(keys: readonly string[]): void;
28
36
  /**
29
37
  * Parse the render slice from `globalThis.__GGUI_META__` — the
30
38
  * synchronous self-contained shell delivery channel.
@@ -335,6 +335,16 @@ export function validateMeta(meta) {
335
335
  export function reportInvalidAppTheme(issues) {
336
336
  postObservabilityToParent({ kind: 'app-theme-invalid', issues: [...issues] });
337
337
  }
338
+ /**
339
+ * The read door KEPT the slice's theme but stripped top-level members
340
+ * this release does not name (ggui#1093 belt, VERSION-POLICY §3.6):
341
+ * report the member names to the embedding host so a newer writer's
342
+ * member is visible, not swallowed. Passed as `onStrippedThemeMembers`
343
+ * on every `parseMcpAppAiGguiRenderMeta` call in this package.
344
+ */
345
+ export function reportStrippedThemeMembers(keys) {
346
+ postObservabilityToParent({ kind: 'app-theme-member-stripped', keys: [...keys] });
347
+ }
338
348
  /**
339
349
  * Parse the render slice from `globalThis.__GGUI_META__` — the
340
350
  * synchronous self-contained shell delivery channel.
@@ -357,7 +367,10 @@ export function parseMetaFromGlobal() {
357
367
  if (!isPlainObject(raw)) {
358
368
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
359
369
  }
360
- const parsed = parseMcpAppAiGguiRenderMeta(raw, { onInvalidTheme: reportInvalidAppTheme });
370
+ const parsed = parseMcpAppAiGguiRenderMeta(raw, {
371
+ onInvalidTheme: reportInvalidAppTheme,
372
+ onStrippedThemeMembers: reportStrippedThemeMembers,
373
+ });
361
374
  if (!parsed.ok) {
362
375
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
363
376
  }
@@ -408,7 +421,10 @@ export function parseMetaFromToolResult(params) {
408
421
  return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
409
422
  }
410
423
  const meta = topMeta;
411
- const parsed = parseMcpAppAiGguiRenderMeta(meta, { onInvalidTheme: reportInvalidAppTheme });
424
+ const parsed = parseMcpAppAiGguiRenderMeta(meta, {
425
+ onInvalidTheme: reportInvalidAppTheme,
426
+ onStrippedThemeMembers: reportStrippedThemeMembers,
427
+ });
412
428
  if (!parsed.ok) {
413
429
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
414
430
  }
@@ -33,7 +33,7 @@ import { MCP_APP_OBSERVE_TYPE } from '@ggui-ai/protocol/integrations/mcp-apps';
33
33
  *
34
34
  * @public
35
35
  */
36
- export type ObservabilityEvent = SchemaVersionMismatchEvent | FontFaceBlockedEvent | AppThemeInvalidEvent | ThemeReinjectFailedEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | UnknownObservabilityEvent;
36
+ export type ObservabilityEvent = SchemaVersionMismatchEvent | FontFaceBlockedEvent | AppThemeInvalidEvent | AppThemeMemberStrippedEvent | ThemeReinjectFailedEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | ComponentEmptyEvent | UnknownObservabilityEvent;
37
37
  /**
38
38
  * Fired when the protocol-version handshake rejects the connection.
39
39
  * Parallel to `UpgradeRequiredError` — the host can choose to render
@@ -295,6 +295,25 @@ export interface RelayDeadTapEvent {
295
295
  readonly sessionId?: string;
296
296
  readonly appId?: string;
297
297
  }
298
+ /**
299
+ * The mount painted NOTHING (ggui#1103): the renderer held no component,
300
+ * so the scope element carries only its stylesheet — a blank card, with no
301
+ * error boundary to catch anything (the boundary wraps a null child). The
302
+ * host is told which mount moment it was and why, because a card that
303
+ * renders nothing is invisible on every other channel: `reason: 'no-code'`
304
+ * is a payload that carried no component code, `'eval-failed'` a module
305
+ * that threw while evaluating. Names the moment only — never the code, the
306
+ * props or the error text, which stay in the operator-visible console line.
307
+ *
308
+ * @public
309
+ */
310
+ export interface ComponentEmptyEvent {
311
+ readonly kind: 'component-empty';
312
+ /** Which lifecycle moment painted nothing. */
313
+ readonly where: 'mount' | 'update';
314
+ /** Why the renderer held no component. */
315
+ readonly reason: 'no-code' | 'eval-failed';
316
+ }
298
317
  /**
299
318
  * Catch-all branch for event kinds the host's typings don't recognize.
300
319
  * Lets the union stay extensible without forcing protocol-version
@@ -332,6 +351,19 @@ export interface AppThemeInvalidEvent {
332
351
  readonly kind: 'app-theme-invalid';
333
352
  readonly issues: readonly string[];
334
353
  }
354
+ /**
355
+ * The render slice's `theme` carried top-level members THIS release does
356
+ * not name (ggui#1093 belt, VERSION-POLICY §3.6): the read door kept the
357
+ * theme and stripped them, and the card says which — a newer writer's
358
+ * member is never silently swallowed. `keys` are the member names only,
359
+ * never their content.
360
+ *
361
+ * @public
362
+ */
363
+ export interface AppThemeMemberStrippedEvent {
364
+ readonly kind: 'app-theme-member-stripped';
365
+ readonly keys: readonly string[];
366
+ }
335
367
  /**
336
368
  * The live re-injection on a host theme flip (ggui#987 §4) failed: the
337
369
  * renderer's update rejected, so the card kept its previous overlay /
@@ -95,6 +95,8 @@ export interface ReactRootMountOptions {
95
95
  */
96
96
  readonly hostPalette?: Readonly<Record<string, string>>;
97
97
  readonly cssOverrides?: string;
98
+ /** `fill` — the root is the whole canvas: no silhouette, fills the page (ggui#1041; from the host's `displayMode`). */
99
+ readonly fit?: 'fill';
98
100
  readonly onError?: (error: Error) => void;
99
101
  /**
100
102
  * Children injected BETWEEN the mount DOM (scope + CSS) and the
@@ -31,7 +31,8 @@
31
31
  */
32
32
  import React, { Component, createElement, Fragment, } from 'react';
33
33
  import { createRoot } from 'react-dom/client';
34
- import { stripMarkers, rewriteImports, getScopedThemeCss, getScopedCssTokens, framelessSuppressionRule, getThemeCss, getCssTokens, } from '@ggui-ai/design/rendering';
34
+ import { postObservabilityToParent } from './observability.js';
35
+ import { stripMarkers, rewriteImports, composeThemeCss, } from '@ggui-ai/design/rendering';
35
36
  import { hoistImports, loadModule, loadModuleInline, probeUrlModuleLoad, } from '@ggui-ai/design/module-loader';
36
37
  /**
37
38
  * Document-lifetime verdict: URL-scheme module loading (`blob:` main
@@ -66,11 +67,6 @@ function makeScopeClass() {
66
67
  * bridge's sanitizer), so the string-join is safe — do NOT
67
68
  * re-sanitize here.
68
69
  */
69
- function toCssDecls(vars) {
70
- return Object.entries(vars)
71
- .map(([k, v]) => `${k}: ${v};`)
72
- .join('');
73
- }
74
70
  // =============================================================================
75
71
  // Error boundary — port of RCR's internal ErrorBoundary (L68–203).
76
72
  // =============================================================================
@@ -320,46 +316,23 @@ export async function mountReactRoot(container, opts) {
320
316
  // below selects with the same `m`, so the ladder, the overlay, the
321
317
  // keyframes and `color-scheme` can never disagree.
322
318
  const m = opts.themeMode ?? 'light';
323
- let themeCss = opts.themeId
324
- ? getScopedThemeCss(opts.themeId, scopeClass, m)
325
- : getScopedCssTokens(scopeClass, m);
326
- if (opts.hostPalette) {
327
- // Host palette = the fallback layer (ggui#572, #573 ruling):
328
- // after the base scoped block so it can repaint at all (the base
329
- // re-specifies every token), BEFORE the appTheme overlay so the
330
- // slice's stamped theme wins by document order. Keys + values
331
- // arrive pre-sanitized from the host-palette bridge.
332
- themeCss += `.${scopeClass}{${toCssDecls(opts.hostPalette)}}`;
333
- }
334
- // The per-app theme's two variable layers, in this order: the
335
- // derived projection for the effective mode, then the mode-agnostic
336
- // `cssVariables` above it. Both must be merged HERE, into the
337
- // scoped style that mounts INSIDE the scope div — the `:root`
338
- // append below is cascade-dead for tree content: the in-scope block
339
- // sits later in document order than anything in `<head>`, so at
340
- // equal specificity the base scoped tokens always win over a head-
341
- // level override (browser-verified in rnd/gen-ui/beauty/
342
- // experiments/001, the probe sequence that found this). Appended
343
- // AFTER the base block + host palette and BEFORE `cssOverrides`:
344
- // compiled < host palette < overlays[m] < cssVariables < overrides
345
- // (ggui#573: slice wins, host fallback). Values validated
346
- // injection-safe upstream (same guarantee the `:root` append cites).
347
- const appVarBlocks = opts.appTheme
348
- ? [opts.appTheme.overlays[m], opts.appTheme.cssVariables]
349
- .filter((vars) => vars !== undefined)
350
- .map((vars) => `.${scopeClass}{${toCssDecls(vars)}}`)
351
- .join('')
352
- : '';
353
- themeCss += appVarBlocks;
354
- // Trailing rules (§3.3): the effective mode's `@keyframes` block and
355
- // the frameless silhouette suppression — the SAME rule text the
356
- // compiled path emits for `$metadata.frameless` — after
357
- // `cssOverrides`, so an override can never be shadowed by them.
358
- // Keyframes ride VERBATIM: their admission is the write door's
359
- // (`appThemeSchema.keyframes`), not re-checked here.
360
- const appTrailingCss = opts.appTheme
361
- ? `${opts.appTheme.keyframes?.[m] ?? ''}${opts.appTheme.frameless === true ? framelessSuppressionRule(scopeClass) : ''}`
362
- : '';
319
+ // ONE composition (`@ggui-ai/design/rendering` `composeThemeCss`): the
320
+ // scoped tree block — compiled ladder < host palette < overlays[m] <
321
+ // cssVariables < cssOverrides, then the mode's keyframes and the
322
+ // frameless rule. The `:root` chrome block below and an evaluator's
323
+ // page are the same function's other layers, so no two paints can
324
+ // disagree on a token (ggui#1020: a judge that composed its own
325
+ // scored an ink-on-ink hero readable).
326
+ const themeCss = composeThemeCss({
327
+ layer: 'tree',
328
+ scopeClass,
329
+ mode: m,
330
+ ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
331
+ ...(opts.hostPalette !== undefined ? { hostPalette: opts.hostPalette } : {}),
332
+ ...(opts.appTheme !== undefined ? { appTheme: opts.appTheme } : {}),
333
+ ...(opts.cssOverrides !== undefined ? { cssOverrides: opts.cssOverrides } : {}),
334
+ ...(opts.fit !== undefined ? { fit: opts.fit } : {}),
335
+ });
363
336
  // Also inject theme CSS at `:root` on `document.head`. The scoped
364
337
  // injection above isolates token resolution to the React tree, but
365
338
  // the embedding shell's body styles (font-family, color, background)
@@ -373,31 +346,17 @@ export async function mountReactRoot(container, opts) {
373
346
  // Same ladder, same mode as the scoped block above — the
374
347
  // standalone viewer's body chrome and its tree content resolve
375
348
  // the same values.
376
- let rootCss = opts.themeId ? getThemeCss(opts.themeId, m) : getCssTokens(m);
377
- if (opts.hostPalette) {
378
- // BODY-CHROME ONLY, same as the appTheme `:root` append below —
379
- // and beneath it, mirroring the scoped-block order (#573:
380
- // slice wins, host fallback). Keeps the embedding shell's
381
- // body font/color/background coherent with the tree.
382
- rootCss += `:root{${toCssDecls(opts.hostPalette)}}`;
383
- }
384
- // BODY-CHROME ONLY. `color-scheme` follows the effective mode so
385
- // native controls / scrollbars / canvas agree with the ladder
386
- // painted; the app theme's two variable layers ride the same
387
- // block, in the scoped order. None of this reaches tree content —
388
- // the scoped in-body token block wins by document order (see the
389
- // scoped merge above, and rnd/gen-ui/beauty/experiments/001 for
390
- // the probe that falsified the old "inherits into the tree"
391
- // claim). Values were validated injection-safe upstream (write-
392
- // side `appThemeSchema` + the wire parser re-validates), so the
393
- // string-join here is safe — do NOT re-sanitize.
394
- const rootAppDecls = opts.appTheme
395
- ? [opts.appTheme.overlays[m], opts.appTheme.cssVariables]
396
- .filter((vars) => vars !== undefined)
397
- .map(toCssDecls)
398
- .join('')
399
- : '';
400
- rootCss += `:root{color-scheme:${m};${rootAppDecls}}`;
349
+ // BODY-CHROME ONLY — the same composer's `chrome` layer: the ladder,
350
+ // the host palette, `color-scheme` + the app theme's variable layers
351
+ // (no keyframes, no overrides); tree content resolves the scoped block
352
+ // above, which wins by document order.
353
+ const rootCss = composeThemeCss({
354
+ layer: 'chrome',
355
+ mode: m,
356
+ ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
357
+ ...(opts.hostPalette !== undefined ? { hostPalette: opts.hostPalette } : {}),
358
+ ...(opts.appTheme !== undefined ? { appTheme: opts.appTheme } : {}),
359
+ });
401
360
  const styleId = 'ggui-theme-vars';
402
361
  let styleEl = document.getElementById(styleId);
403
362
  if (!styleEl) {
@@ -418,17 +377,51 @@ export async function mountReactRoot(container, opts) {
418
377
  // The scope `<div>` + inline `<style>` + error-boundary-wrapped
419
378
  // children match RCR L376–383. `key={currentCode?.length}` mirrors
420
379
  // RCR's "reset boundary when code changes" heuristic.
421
- root.render(createElement('div', { className: scopeClass }, createElement('style', null, `${themeCss}${opts.cssOverrides ?? ''}${appTrailingCss}`), createElement(RcrErrorBoundary, {
380
+ root.render(createElement('div', { className: scopeClass }, createElement('style', null, themeCss), createElement(RcrErrorBoundary, {
422
381
  key: currentCode?.length ?? 0,
423
382
  ...(opts.onError ? { onError: opts.onError } : {}),
424
383
  }, createElement(Fragment, null, wrapped))));
425
384
  }
385
+ /**
386
+ * The mount painted NOTHING (ggui#1103). `wrapped` is `null` whenever the
387
+ * renderer holds no component, so the scope element carries only its
388
+ * `<style>`: height 0, no text, and the error boundary never engages —
389
+ * it wraps a null child. Every such moment is reported on all three
390
+ * channels (an operator-visible console line, the caller's `onError`, the
391
+ * host's observability seam), because until now the two empty-code paths
392
+ * were silent on ALL of them and an empty card could not be told apart
393
+ * from a card whose module threw. Never carries the code or the props.
394
+ */
395
+ function reportNoComponent(where, reason, error, onError) {
396
+ if (reason === 'eval-failed') {
397
+ // eslint-disable-next-line no-console -- operator-visible failure signal
398
+ console.error(`[ggui] mountReactRoot: the ${where} painted nothing (eval-failed) —`, error);
399
+ }
400
+ else {
401
+ // A payload that carried no code is not an exception — nothing threw,
402
+ // and `onError` stays the caller's "the component failed" channel, so a
403
+ // host that paints an error panel does not paint one here. It is still
404
+ // operator-visible, and the host still hears it on the seam below.
405
+ // eslint-disable-next-line no-console -- operator-visible failure signal
406
+ console.warn(`[ggui] mountReactRoot: the ${where} painted nothing (no-code) — ${error.message}`);
407
+ }
408
+ try {
409
+ postObservabilityToParent({ kind: 'component-empty', where, reason });
410
+ }
411
+ catch {
412
+ // The host seam is best-effort: a blocked postMessage must not turn a
413
+ // blank card into a thrown mount.
414
+ }
415
+ if (reason === 'eval-failed')
416
+ onError?.(error);
417
+ }
426
418
  async function initialEvaluate() {
427
419
  const code = currentOpts.render.componentCode ?? '';
428
420
  if (code.trim().length === 0) {
429
421
  currentComponent = null;
430
422
  currentCode = null;
431
423
  renderTree(currentOpts);
424
+ reportNoComponent('mount', 'no-code', new Error('mount carried no component code'), currentOpts.onError);
432
425
  return;
433
426
  }
434
427
  try {
@@ -442,12 +435,10 @@ export async function mountReactRoot(container, opts) {
442
435
  // not enough on its own — not every mount path wires one, so
443
436
  // without this log a mount with a broken component renders a
444
437
  // blank iframe and emits nothing.
445
- // eslint-disable-next-line no-console -- operator-visible failure signal
446
- console.error('[ggui] mountReactRoot: component evaluation failed —', e);
447
438
  currentComponent = null;
448
439
  currentCode = null;
449
440
  renderTree(currentOpts);
450
- currentOpts.onError?.(e);
441
+ reportNoComponent('mount', 'eval-failed', e, currentOpts.onError);
451
442
  }
452
443
  }
453
444
  await initialEvaluate();
@@ -468,6 +459,7 @@ export async function mountReactRoot(container, opts) {
468
459
  currentComponent = null;
469
460
  currentCode = null;
470
461
  renderTree(currentOpts);
462
+ reportNoComponent('update', 'no-code', new Error('update carried no component code'), next.onError);
471
463
  return;
472
464
  }
473
465
  try {
@@ -480,7 +472,7 @@ export async function mountReactRoot(container, opts) {
480
472
  currentComponent = null;
481
473
  currentCode = null;
482
474
  renderTree(currentOpts);
483
- next.onError?.(e);
475
+ reportNoComponent('update', 'eval-failed', e, next.onError);
484
476
  }
485
477
  },
486
478
  unmount() {
@@ -46,6 +46,12 @@ export interface RenderItemOptions {
46
46
  * design registry's `getTheme(id, mode?)` default.
47
47
  */
48
48
  readonly themeMode?: 'light' | 'dark';
49
+ /**
50
+ * The mounted root fills its surface without a silhouette of its own (ggui#1041):
51
+ * set when the host's context says `displayMode: 'fullscreen'` — a canvas panel
52
+ * is the chrome. Absent for inline mounts.
53
+ */
54
+ readonly fit?: 'fill';
49
55
  /**
50
56
  * The per-app theme (protocol's `AppTheme`, ggui#987 v2) forwarded
51
57
  * verbatim to {@link ReactRootMountOptions.appTheme}: the renderer
@@ -183,6 +183,10 @@ export async function mountRender(container, opts) {
183
183
  ...(currentOpts.cssOverrides !== undefined
184
184
  ? { cssOverrides: currentOpts.cssOverrides }
185
185
  : {}),
186
+ // The host's canvas is the chrome (ggui#1041): `fit` MUST cross this
187
+ // boundary like every other compose option — ggui#1073 shipped
188
+ // without it, and every served hello composed as an inline card.
189
+ ...(currentOpts.fit !== undefined ? { fit: currentOpts.fit } : {}),
186
190
  ...(currentOpts.onError !== undefined ? { onError: currentOpts.onError } : {}),
187
191
  renderWrapper: wrapInScopedProvider,
188
192
  });
@@ -249,6 +253,7 @@ export async function mountRender(container, opts) {
249
253
  ...(next.appTheme !== undefined ? { appTheme: next.appTheme } : {}),
250
254
  ...(next.hostPalette !== undefined ? { hostPalette: next.hostPalette } : {}),
251
255
  ...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
256
+ ...(next.fit !== undefined ? { fit: next.fit } : {}),
252
257
  ...(next.onError !== undefined ? { onError: next.onError } : {}),
253
258
  renderWrapper: wrapInScopedProvider,
254
259
  });
package/dist/runtime.d.ts CHANGED
@@ -31,6 +31,7 @@ import { type McpAppAiGguiRenderMeta, type McpAppBootstrapFailedMessage, type Mc
31
31
  import type { GguiSessionSeedInput } from './types.js';
32
32
  import type { McpAppAiGguiMetaParseFailureReason } from './types.js';
33
33
  import { App } from '@modelcontextprotocol/ext-apps';
34
+ import type { McpUiDisplayMode } from '@modelcontextprotocol/ext-apps';
34
35
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
35
36
  import type { GadgetPackageRegistry } from './globals.js';
36
37
  import { StreamBus } from './wire-config.js';
@@ -107,6 +108,14 @@ export declare function setCurrentApp(app: App): void;
107
108
  * App); production caller is `buildOpts` inside `bootSequence`.
108
109
  */
109
110
  export declare function hostAnnouncedThemeMode(): 'light' | 'dark' | undefined;
111
+ /**
112
+ * The host's announced display mode, from the same pre-merged host context the
113
+ * theme mode reads (ggui#1041). `fullscreen` means the host shows this page as
114
+ * the whole of a canvas — the runtime then composes the tree with `fit: 'fill'`
115
+ * so the mounted root drops its own silhouette and fills the page; anything
116
+ * else (or an absent context) keeps the inline card.
117
+ */
118
+ export declare function hostAnnouncedDisplayMode(): McpUiDisplayMode | undefined;
110
119
  /**
111
120
  * Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
112
121
  * runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
package/dist/runtime.js CHANGED
@@ -30,7 +30,7 @@ import './zod-jitless.js';
30
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, reportInvalidAppTheme, } from './meta-parse.js';
33
+ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, reportInvalidAppTheme, reportStrippedThemeMembers, } from './meta-parse.js';
34
34
  import { projectHostContext } from '@ggui-ai/protocol/wire';
35
35
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
36
36
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, subscribeThemeChange as subscribeHostThemeChange, } from './host-context-emitter.js';
@@ -203,6 +203,16 @@ export function hostAnnouncedThemeMode() {
203
203
  const theme = currentApp?.getHostContext()?.theme;
204
204
  return theme === 'light' || theme === 'dark' ? theme : undefined;
205
205
  }
206
+ /**
207
+ * The host's announced display mode, from the same pre-merged host context the
208
+ * theme mode reads (ggui#1041). `fullscreen` means the host shows this page as
209
+ * the whole of a canvas — the runtime then composes the tree with `fit: 'fill'`
210
+ * so the mounted root drops its own silhouette and fills the page; anything
211
+ * else (or an absent context) keeps the inline card.
212
+ */
213
+ export function hostAnnouncedDisplayMode() {
214
+ return currentApp?.getHostContext()?.displayMode;
215
+ }
206
216
  /**
207
217
  * Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
208
218
  * runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
@@ -1347,7 +1357,10 @@ export async function resolveMetaViaReadDoor(app, locator) {
1347
1357
  const envelope = readGguiShellEnvelope(text);
1348
1358
  if (envelope === undefined)
1349
1359
  return null;
1350
- const parsed = parseMcpAppAiGguiRenderMeta(envelope, { onInvalidTheme: reportInvalidAppTheme });
1360
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope, {
1361
+ onInvalidTheme: reportInvalidAppTheme,
1362
+ onStrippedThemeMembers: reportStrippedThemeMembers,
1363
+ });
1351
1364
  if (!parsed.ok || parsed.meta === undefined)
1352
1365
  return null;
1353
1366
  const validated = validateMeta(parsed.meta);
@@ -3874,6 +3887,10 @@ async function bootProduction(opts) {
3874
3887
  const themeMode = resolveMountThemeMode(meta);
3875
3888
  return themeMode !== undefined ? { themeMode } : {};
3876
3889
  })(),
3890
+ // The host's canvas is the chrome (ggui#1041): a `fullscreen` display mode
3891
+ // composes the tree with `fit: 'fill'`; the hostcontextchanged re-injection
3892
+ // below picks up a flip exactly as it does the theme mode.
3893
+ ...(hostAnnouncedDisplayMode() === 'fullscreen' ? { fit: 'fill' } : {}),
3877
3894
  // Per-app theme (ggui#987 v2). Threaded straight from the
3878
3895
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3879
3896
  // already injection-validated by the wire parser) onto the mount
@@ -35,6 +35,13 @@ export function ensureStatusDom(doc) {
35
35
  (() => {
36
36
  const el = doc.createElement('ul');
37
37
  el.setAttribute('data-ggui-session-root', '');
38
+ // The list is the runtime's mount target, not a list: without this
39
+ // reset the browser's defaults (16 px margins, a 40 px indent, disc
40
+ // bullets) sat inside every compact card's iframe — the card 40 px
41
+ // right of the pane edge and 32 px taller than its box (ggui#1096).
42
+ el.style.margin = '0';
43
+ el.style.padding = '0';
44
+ el.style.listStyle = 'none';
38
45
  doc.body.appendChild(el);
39
46
  return el;
40
47
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.16.0",
3
+ "version": "0.17.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.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"
44
+ "@ggui-ai/design": "0.17.0",
45
+ "@ggui-ai/gadgets": "0.17.0",
46
+ "@ggui-ai/live-channel": "0.17.0",
47
+ "@ggui-ai/preview-a2ui": "0.17.0",
48
+ "@ggui-ai/protocol": "0.17.0",
49
+ "@ggui-ai/wire": "0.17.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.27.0"