@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/dismiss-intent.d.ts +28 -0
- package/dist/dismiss-intent.js +65 -0
- package/dist/events-polling.js +12 -0
- package/dist/iframe-runtime.js +67 -65
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/meta-parse.d.ts +8 -0
- package/dist/meta-parse.js +18 -2
- package/dist/observability.d.ts +33 -1
- package/dist/react-renderer.d.ts +11 -0
- package/dist/react-renderer.js +71 -76
- package/dist/render-item.d.ts +14 -0
- package/dist/render-item.js +10 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +64 -5
- package/dist/status-dom.js +7 -0
- package/package.json +7 -7
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';
|
package/dist/meta-parse.d.ts
CHANGED
|
@@ -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.
|
package/dist/meta-parse.js
CHANGED
|
@@ -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, {
|
|
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, {
|
|
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
|
}
|
package/dist/observability.d.ts
CHANGED
|
@@ -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 /
|
package/dist/react-renderer.d.ts
CHANGED
|
@@ -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
|
package/dist/react-renderer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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,
|
|
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
|
|
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
|
|
478
|
+
reportNoComponent('update', 'eval-failed', e, next.onError);
|
|
484
479
|
}
|
|
485
480
|
},
|
|
486
481
|
unmount() {
|
package/dist/render-item.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/render-item.js
CHANGED
|
@@ -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
|
-
//
|
|
665
|
-
//
|
|
666
|
-
|
|
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, {
|
|
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,
|
package/dist/status-dom.js
CHANGED
|
@@ -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.
|
|
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/
|
|
45
|
-
"@ggui-ai/
|
|
46
|
-
"@ggui-ai/
|
|
47
|
-
"@ggui-ai/
|
|
48
|
-
"@ggui-ai/protocol": "0.
|
|
49
|
-
"@ggui-ai/wire": "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"
|