@ggui-ai/iframe-runtime 0.16.0 → 0.18.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
@@ -127,6 +129,15 @@ export interface ReactRootMount {
127
129
  unmount(): void;
128
130
  /** Current component code (useful for tests + audit). */
129
131
  readonly currentCode: string | null;
132
+ /**
133
+ * Whether this mount HOLDS a component — i.e. whether the tree it renders
134
+ * is anything but its own stylesheet (ggui#1103). `applyRender` succeeding
135
+ * is not the same fact: a payload with no code, or a module that threw,
136
+ * leaves the scope element carrying only its `<style>`, and a caller that
137
+ * treats "a render was applied" as "something painted" retires the host's
138
+ * waiting indicator over a blank card.
139
+ */
140
+ readonly painted: boolean;
130
141
  }
131
142
  /**
132
143
  * Mount a React root inside `container` and render `render`'s
@@ -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,16 +435,17 @@ 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();
454
445
  return {
446
+ get painted() {
447
+ return currentComponent !== null;
448
+ },
455
449
  get currentCode() {
456
450
  return currentCode;
457
451
  },
@@ -468,6 +462,7 @@ export async function mountReactRoot(container, opts) {
468
462
  currentComponent = null;
469
463
  currentCode = null;
470
464
  renderTree(currentOpts);
465
+ reportNoComponent('update', 'no-code', new Error('update carried no component code'), next.onError);
471
466
  return;
472
467
  }
473
468
  try {
@@ -480,7 +475,7 @@ export async function mountReactRoot(container, opts) {
480
475
  currentComponent = null;
481
476
  currentCode = null;
482
477
  renderTree(currentOpts);
483
- next.onError?.(e);
478
+ reportNoComponent('update', 'eval-failed', e, next.onError);
484
479
  }
485
480
  },
486
481
  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
@@ -100,6 +106,14 @@ export interface RenderItemHandle {
100
106
  unmount(): void;
101
107
  /** Current render kind. Test-only — not load-bearing. */
102
108
  readonly kind: MountedKind;
109
+ /**
110
+ * Whether the active mount PAINTS something (ggui#1103). A provisional,
111
+ * system or MCP-app mount always does; a React mount does only while it
112
+ * holds a component. The runtime reads this to decide whether the served
113
+ * shell's waiting indicator has become a lie — "a render was applied" is
114
+ * not that fact.
115
+ */
116
+ readonly painted: boolean;
103
117
  }
104
118
  export declare function mountRender(container: HTMLElement, opts: RenderItemOptions): Promise<RenderItemHandle>;
105
119
  export {};
@@ -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
  });
@@ -218,6 +222,11 @@ export async function mountRender(container, opts) {
218
222
  get kind() {
219
223
  return currentKind;
220
224
  },
225
+ get painted() {
226
+ if (currentKind === 'react')
227
+ return reactMount?.painted === true;
228
+ return currentKind !== 'none';
229
+ },
221
230
  async update(next) {
222
231
  const prevKind = currentKind;
223
232
  currentOpts = next;
@@ -249,6 +258,7 @@ export async function mountRender(container, opts) {
249
258
  ...(next.appTheme !== undefined ? { appTheme: next.appTheme } : {}),
250
259
  ...(next.hostPalette !== undefined ? { hostPalette: next.hostPalette } : {}),
251
260
  ...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
261
+ ...(next.fit !== undefined ? { fit: next.fit } : {}),
252
262
  ...(next.onError !== undefined ? { onError: next.onError } : {}),
253
263
  renderWrapper: wrapInScopedProvider,
254
264
  });
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):
@@ -412,6 +421,14 @@ export interface RendererHandle {
412
421
  * `propsSpec` / `streamSpec`.
413
422
  */
414
423
  getCurrentGguiSession(): GguiSession | GguiSessionSeedInput | null;
424
+ /**
425
+ * Whether the mounted render PAINTS something (ggui#1103) — false while no
426
+ * render has mounted, and false for a mount holding no component (a payload
427
+ * with no code, or a module that threw). Read by the boot to decide whether
428
+ * the served shell's waiting indicator has become a lie; "a render was
429
+ * applied" is a different fact and was the one being used.
430
+ */
431
+ painted(): boolean;
415
432
  readonly validatorCtx: RendererValidatorContext;
416
433
  /**
417
434
  * Send surface for outbound frames. Wired by `setup()` to the WS
package/dist/runtime.js CHANGED
@@ -30,8 +30,9 @@ 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
+ import { installDismissIntentListener } from './dismiss-intent.js';
35
36
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
36
37
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, subscribeThemeChange as subscribeHostThemeChange, } from './host-context-emitter.js';
37
38
  import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
@@ -203,6 +204,16 @@ export function hostAnnouncedThemeMode() {
203
204
  const theme = currentApp?.getHostContext()?.theme;
204
205
  return theme === 'light' || theme === 'dark' ? theme : undefined;
205
206
  }
207
+ /**
208
+ * The host's announced display mode, from the same pre-merged host context the
209
+ * theme mode reads (ggui#1041). `fullscreen` means the host shows this page as
210
+ * the whole of a canvas — the runtime then composes the tree with `fit: 'fill'`
211
+ * so the mounted root drops its own silhouette and fills the page; anything
212
+ * else (or an absent context) keeps the inline card.
213
+ */
214
+ export function hostAnnouncedDisplayMode() {
215
+ return currentApp?.getHostContext()?.displayMode;
216
+ }
206
217
  /**
207
218
  * Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
208
219
  * runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
@@ -363,6 +374,13 @@ export async function bootSequence(opts) {
363
374
  onLifecycle?.(makeLifecycleEvent('error', { error: { code: reason, message } }));
364
375
  };
365
376
  const refs = ensureStatusDom(doc);
377
+ // ggui#1109: a key pressed inside the card's document never reaches the page
378
+ // around it, so a dismiss gesture died here — which is the moment a user who
379
+ // just clicked the card presses Escape. The card forwards the INTENT and
380
+ // never acts on it; the host owns what dismissal means, and ignoring is
381
+ // conformant. Installed before any mount, because the gesture can precede
382
+ // the first frame.
383
+ installDismissIntentListener(doc);
366
384
  // Live regions up BEFORE anything can announce through them
367
385
  // (ggui#447). A region created in the same tick as its first message
368
386
  // is a region no screen reader was watching yet, and that first
@@ -642,6 +660,7 @@ export async function bootSequence(opts) {
642
660
  mountedRender = target;
643
661
  if (renderer !== null) {
644
662
  await renderer.applyRender(target);
663
+ retireStandbyIfPainted();
645
664
  if (target.type !== 'mcpApps' && target.type !== 'system') {
646
665
  renderer.channelTransport.applyRender({
647
666
  sessionId: target.id,
@@ -661,11 +680,42 @@ export async function bootSequence(opts) {
661
680
  if (codeReadyEmitted)
662
681
  return;
663
682
  codeReadyEmitted = true;
664
- // First real paint — the served shell's working-state mark (#667)
665
- // becomes a lie the moment content lands.
666
- retireShellLoadingIndicator(doc);
683
+ // The served shell's working-state mark (#667) is retired by PAINT, not by
684
+ // this event (ggui#1103): `code-ready` fires after a render was APPLIED,
685
+ // and applying succeeds when the mount holds no component — so retiring
686
+ // here left a blank box with no indicator, indistinguishable from a card
687
+ // still negotiating. `code-ready` itself keeps firing exactly where it did:
688
+ // hosts pin selectors on it (E2E specs, accessibility scanners).
689
+ retireStandbyIfPainted();
667
690
  onLifecycle?.(makeLifecycleEvent('code-ready', { sessionId: meta.sessionId }));
668
691
  };
692
+ /**
693
+ * Retire the shell's standby ONLY when the mount paints something, and say
694
+ * so when a boot reaches its end painting nothing (ggui#1103). Three states
695
+ * that looked alike — negotiating, empty, painted — now read apart: the
696
+ * standby means "still coming", its absence with content means the card,
697
+ * its absence with a said status means "nothing to show".
698
+ */
699
+ let saidNothingToShow = false;
700
+ function retireStandbyIfPainted() {
701
+ if (renderer !== null && renderer.painted()) {
702
+ retireShellLoadingIndicator(doc);
703
+ // A frame that lands after we said there was nothing withdraws the
704
+ // sentence — the card is the answer now.
705
+ if (saidNothingToShow) {
706
+ saidNothingToShow = false;
707
+ setConnectedStatus(refs);
708
+ }
709
+ return;
710
+ }
711
+ if (!codeReadyEmitted)
712
+ return; // still coming — the standby is honest
713
+ retireShellLoadingIndicator(doc);
714
+ if (!saidNothingToShow) {
715
+ saidNothingToShow = true;
716
+ setStatus(refs, 'Nothing to show for this card.', 'idle');
717
+ }
718
+ }
669
719
  // Mode discriminators. The mount surface is DECOUPLED from the live
670
720
  // channel: static content (codeUrl/codeB64/kind) paints immediately
671
721
  // with no WS; the live trio (wsUrl+wsToken) is an OPTIONAL
@@ -735,6 +785,7 @@ export async function bootSequence(opts) {
735
785
  if (seed !== null) {
736
786
  await renderer.applyRender(seed);
737
787
  mountedRender = seed;
788
+ retireStandbyIfPainted();
738
789
  emitCodeReadyOnce();
739
790
  }
740
791
  }
@@ -1347,7 +1398,10 @@ export async function resolveMetaViaReadDoor(app, locator) {
1347
1398
  const envelope = readGguiShellEnvelope(text);
1348
1399
  if (envelope === undefined)
1349
1400
  return null;
1350
- const parsed = parseMcpAppAiGguiRenderMeta(envelope, { onInvalidTheme: reportInvalidAppTheme });
1401
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope, {
1402
+ onInvalidTheme: reportInvalidAppTheme,
1403
+ onStrippedThemeMembers: reportStrippedThemeMembers,
1404
+ });
1351
1405
  if (!parsed.ok || parsed.meta === undefined)
1352
1406
  return null;
1353
1407
  const validated = validateMeta(parsed.meta);
@@ -3874,6 +3928,10 @@ async function bootProduction(opts) {
3874
3928
  const themeMode = resolveMountThemeMode(meta);
3875
3929
  return themeMode !== undefined ? { themeMode } : {};
3876
3930
  })(),
3931
+ // The host's canvas is the chrome (ggui#1041): a `fullscreen` display mode
3932
+ // composes the tree with `fit: 'fill'`; the hostcontextchanged re-injection
3933
+ // below picks up a flip exactly as it does the theme mode.
3934
+ ...(hostAnnouncedDisplayMode() === 'fullscreen' ? { fit: 'fill' } : {}),
3877
3935
  // Per-app theme (ggui#987 v2). Threaded straight from the
3878
3936
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3879
3937
  // already injection-validated by the wire parser) onto the mount
@@ -4067,6 +4125,7 @@ async function bootProduction(opts) {
4067
4125
  getChannelTransport: () => channelTransport,
4068
4126
  }));
4069
4127
  return {
4128
+ painted: () => renderHandle?.painted === true,
4070
4129
  rootWireConfig: rootConfig,
4071
4130
  streamBus,
4072
4131
  applyRender,
@@ -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.18.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/gadgets": "0.18.0",
45
+ "@ggui-ai/design": "0.18.0",
46
+ "@ggui-ai/live-channel": "0.18.0",
47
+ "@ggui-ai/preview-a2ui": "0.18.0",
48
+ "@ggui-ai/protocol": "0.18.0",
49
+ "@ggui-ai/wire": "0.18.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.27.0"