@ggui-ai/iframe-runtime 0.10.0 → 0.11.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.
@@ -62,17 +62,37 @@ export interface ReactRootMountOptions {
62
62
  */
63
63
  readonly themeMode?: 'light' | 'dark';
64
64
  /**
65
- * Per-app theme overlay applied at the iframe's `:root` AFTER the base
66
- * token block, so the operator-supplied `--ggui-*` values win the
67
- * cascade (a partial set — unset vars keep their token defaults). A
68
- * structural subset of protocol's `AppTheme` (`name` is display-only,
69
- * not needed at render). `mode` drives `color-scheme` so native
70
- * controls / scrollbars / canvas pick up dark vs light.
65
+ * Per-app theme overlay — a partial set of `--ggui-*` values; unset
66
+ * vars keep their token defaults. Merged INTO the scoped in-body
67
+ * token block after the base block (and after `hostPalette`), which
68
+ * is the only placement that can recolor tree content: a `:root`-only
69
+ * append is cascade-dead for components — the scoped block sits later
70
+ * in document order and re-specifies every token (browser-verified,
71
+ * see the scoped-merge comment in `renderTree`). The `:root` append
72
+ * still happens, but for body chrome only. A structural subset of
73
+ * protocol's `AppTheme` (`name` is display-only, not needed at
74
+ * render). `mode` drives `color-scheme` so native controls /
75
+ * scrollbars / canvas pick up dark vs light.
76
+ *
77
+ * Precedence (ggui#573 ruling): base tokens < `hostPalette` <
78
+ * `appTheme` < `cssOverrides` — the slice's stamped theme always
79
+ * wins; the host palette is the fallback layer beneath it.
71
80
  */
72
81
  readonly appTheme?: {
73
82
  readonly mode: 'light' | 'dark';
74
83
  readonly cssVariables: Record<string, string>;
75
84
  };
85
+ /**
86
+ * Host-announced palette, already translated onto `--ggui-*` tokens
87
+ * (see `host-palette-bridge.ts` — the spec `--color-*` vocabulary
88
+ * never reaches this layer). Merged into the scoped token block
89
+ * after the base block and BEFORE `appTheme`, so it repaints only
90
+ * the slots no operator theme re-specifies (ggui#572; precedence
91
+ * per the ggui#573 ruling above). Values are sanitized by the
92
+ * bridge before they get here — host input never reaches this
93
+ * string-join raw.
94
+ */
95
+ readonly hostPalette?: Readonly<Record<string, string>>;
76
96
  readonly cssOverrides?: string;
77
97
  readonly onError?: (error: Error) => void;
78
98
  /**
@@ -59,6 +59,18 @@ function makeScopeClass() {
59
59
  scopeCounter += 1;
60
60
  return `ggui-rcr-${scopeCounter}`;
61
61
  }
62
+ /**
63
+ * Join a custom-property record into CSS declarations. Values were
64
+ * validated injection-safe upstream (appTheme: write-side
65
+ * `appThemeSchema` + the wire parser; hostPalette: the host-palette
66
+ * bridge's sanitizer), so the string-join is safe — do NOT
67
+ * re-sanitize here.
68
+ */
69
+ function toCssDecls(vars) {
70
+ return Object.entries(vars)
71
+ .map(([k, v]) => `${k}: ${v};`)
72
+ .join('');
73
+ }
62
74
  // =============================================================================
63
75
  // Error boundary — port of RCR's internal ErrorBoundary (L68–203).
64
76
  // =============================================================================
@@ -305,10 +317,16 @@ export async function mountReactRoot(container, opts) {
305
317
  let themeCss = opts.themeId
306
318
  ? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
307
319
  : getScopedCssTokens(scopeClass, opts.themeMode);
320
+ if (opts.hostPalette) {
321
+ // Host palette = the fallback layer (ggui#572, #573 ruling):
322
+ // after the base scoped block so it can repaint at all (the base
323
+ // re-specifies every token), BEFORE the appTheme overlay so the
324
+ // slice's stamped theme wins by document order. Keys + values
325
+ // arrive pre-sanitized from the host-palette bridge.
326
+ themeCss += `.${scopeClass}{${toCssDecls(opts.hostPalette)}}`;
327
+ }
308
328
  if (opts.appTheme) {
309
- const decls = Object.entries(opts.appTheme.cssVariables)
310
- .map(([k, v]) => `${k}: ${v};`)
311
- .join('');
329
+ const decls = toCssDecls(opts.appTheme.cssVariables);
312
330
  // The overlay must be merged HERE, into the scoped style that
313
331
  // mounts INSIDE the scope div — the `:root` append below is
314
332
  // cascade-dead for tree content: the in-scope block sits later
@@ -316,9 +334,11 @@ export async function mountReactRoot(container, opts) {
316
334
  // specificity the base scoped tokens always win over a head-level
317
335
  // override (browser-verified in rnd/gen-ui/beauty/experiments/001,
318
336
  // the probe sequence that found this). Appended AFTER the base
319
- // block and BEFORE `cssOverrides`, so: base < operator overlay <
320
- // explicit overrides. Values validated injection-safe upstream
321
- // (same guarantee the `:root` append cites).
337
+ // block + host palette and BEFORE `cssOverrides`, so: base <
338
+ // host palette < operator overlay < explicit overrides
339
+ // (ggui#573: slice wins, host fallback). Values validated
340
+ // injection-safe upstream (same guarantee the `:root` append
341
+ // cites).
322
342
  themeCss += `.${scopeClass}{${decls}}`;
323
343
  }
324
344
  // Also inject theme CSS at `:root` on `document.head`. The scoped
@@ -334,10 +354,15 @@ export async function mountReactRoot(container, opts) {
334
354
  let rootCss = opts.themeId
335
355
  ? getThemeCss(opts.themeId, opts.themeMode)
336
356
  : getCssTokens(opts.themeMode);
357
+ if (opts.hostPalette) {
358
+ // BODY-CHROME ONLY, same as the appTheme `:root` append below —
359
+ // and beneath it, mirroring the scoped-block order (#573:
360
+ // slice wins, host fallback). Keeps the embedding shell's
361
+ // body font/color/background coherent with the tree.
362
+ rootCss += `:root{${toCssDecls(opts.hostPalette)}}`;
363
+ }
337
364
  if (opts.appTheme) {
338
- const decls = Object.entries(opts.appTheme.cssVariables)
339
- .map(([k, v]) => `${k}: ${v};`)
340
- .join('');
365
+ const decls = toCssDecls(opts.appTheme.cssVariables);
341
366
  // BODY-CHROME ONLY. This `:root` append themes the embedding
342
367
  // shell's body styles (font/color/background outside the scope
343
368
  // div). It does NOT reach tree content — the scoped in-body
@@ -56,6 +56,14 @@ export interface RenderItemOptions {
56
56
  readonly mode: 'light' | 'dark';
57
57
  readonly cssVariables: Record<string, string>;
58
58
  };
59
+ /**
60
+ * Host-announced palette, pre-mapped onto `--ggui-*` tokens by the
61
+ * host-palette bridge. Forwarded to
62
+ * {@link ReactRootMountOptions.hostPalette}: the fallback layer
63
+ * beneath `appTheme` in the scoped token block (ggui#572, merge
64
+ * order per the ggui#573 ruling — slice wins, host fallback).
65
+ */
66
+ readonly hostPalette?: Readonly<Record<string, string>>;
59
67
  readonly cssOverrides?: string;
60
68
  readonly onError?: (err: Error) => void;
61
69
  /**
@@ -177,6 +177,9 @@ export async function mountRender(container, opts) {
177
177
  ...(currentOpts.themeId !== undefined ? { themeId: currentOpts.themeId } : {}),
178
178
  ...(currentOpts.themeMode !== undefined ? { themeMode: currentOpts.themeMode } : {}),
179
179
  ...(currentOpts.appTheme !== undefined ? { appTheme: currentOpts.appTheme } : {}),
180
+ ...(currentOpts.hostPalette !== undefined
181
+ ? { hostPalette: currentOpts.hostPalette }
182
+ : {}),
180
183
  ...(currentOpts.cssOverrides !== undefined
181
184
  ? { cssOverrides: currentOpts.cssOverrides }
182
185
  : {}),
@@ -244,6 +247,7 @@ export async function mountRender(container, opts) {
244
247
  ...(next.themeId !== undefined ? { themeId: next.themeId } : {}),
245
248
  ...(next.themeMode !== undefined ? { themeMode: next.themeMode } : {}),
246
249
  ...(next.appTheme !== undefined ? { appTheme: next.appTheme } : {}),
250
+ ...(next.hostPalette !== undefined ? { hostPalette: next.hostPalette } : {}),
247
251
  ...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
248
252
  ...(next.onError !== undefined ? { onError: next.onError } : {}),
249
253
  renderWrapper: wrapInScopedProvider,
package/dist/runtime.d.ts CHANGED
@@ -97,6 +97,87 @@ export declare function setCurrentApp(app: App): void;
97
97
  * App); production caller is `buildOpts` inside `bootSequence`.
98
98
  */
99
99
  export declare function hostAnnouncedThemeMode(): 'light' | 'dark' | undefined;
100
+ /**
101
+ * Resolve the mount's base-stylesheet color mode from the slice, with
102
+ * the host as the final fallback (ggui#589, completing the ggui#551
103
+ * precedence ruling — "slice wins, host is the fallback, absent ≠
104
+ * light"):
105
+ *
106
+ * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
107
+ * host-announced theme > undefined
108
+ *
109
+ * The middle leg is the ggui#589 fix: a render envelope carrying a
110
+ * per-app theme (`theme: {mode, cssVariables}`) but no top-level
111
+ * `themeMode` is still a slice-stamped mode opinion — before this,
112
+ * the base token ladder ignored it and painted the LIGHT token set
113
+ * under a dark overlay whenever the host (correctly, per the
114
+ * adapter-boundary doctrine in the native host helpers) announced no
115
+ * `hostContext.theme`: a light skeleton in a dark skin. The theme
116
+ * object's own `mode` already drove `color-scheme`; now it also
117
+ * selects the ladder.
118
+ *
119
+ * `undefined` still means "no opinion anywhere" — the renderer's
120
+ * default applies; never coerced to `'light'`.
121
+ *
122
+ * @internal — exported for unit tests; production caller is
123
+ * `buildOpts` inside `bootSequence`.
124
+ */
125
+ export declare function resolveMountThemeMode(meta: {
126
+ readonly themeMode?: 'light' | 'dark';
127
+ readonly theme?: {
128
+ readonly mode: 'light' | 'dark';
129
+ readonly cssVariables?: Record<string, string>;
130
+ };
131
+ }): 'light' | 'dark' | undefined;
132
+ /**
133
+ * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
134
+ *
135
+ * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
136
+ * > undefined (renderer default ladder)
137
+ *
138
+ * The name leg makes a slice theme whose `name` matches a REGISTERED
139
+ * theme id select that theme as the BASE token ladder — the overlay's
140
+ * cssVariables still apply above it, so the base carries the full
141
+ * brand ramp coverage a sparse overlay cannot (the "only the mapped
142
+ * vars carry brand" store-frame class). An unregistered name is
143
+ * harmless by construction: `getScopedThemeCss` falls back to the
144
+ * default theme, which is byte-identical to the no-themeId path
145
+ * (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
146
+ * naming its theme "My Custom" renders exactly as before.
147
+ *
148
+ * @internal — exported for unit tests; production caller is
149
+ * `buildOpts` inside `bootSequence`.
150
+ */
151
+ export declare function resolveMountThemeId(meta: {
152
+ readonly themeId?: string;
153
+ readonly theme?: {
154
+ readonly name?: string;
155
+ readonly mode?: 'light' | 'dark';
156
+ readonly cssVariables?: Record<string, string>;
157
+ };
158
+ }): string | undefined;
159
+ /**
160
+ * The host's announced palette, read from the connected App's
161
+ * pre-merged hostContext and translated onto `--ggui-*` tokens by the
162
+ * host-palette bridge. `undefined` when no App is connected, the host
163
+ * sent no `styles.variables`, or nothing in it maps/survives
164
+ * sanitization — callers spread-skip the option, exactly like
165
+ * {@link hostAnnouncedThemeMode}.
166
+ *
167
+ * This is the palette input leg of host-theme adaptation (ggui#572,
168
+ * the color analog of #551's mode leg above): the spec `--color-*`
169
+ * variables land as inline custom properties on `<html>` via the
170
+ * canonical ext-apps helper, but nothing ggui renders consumes that
171
+ * vocabulary, and the scoped token block shadows root inheritance
172
+ * anyway — so without this mapping a host palette repaints nothing.
173
+ * `buildOpts` threads it onto the mount options; the renderer merges
174
+ * it as the fallback layer BENEATH the slice's own theme (ggui#573
175
+ * ruling: slice wins, host fallback).
176
+ *
177
+ * @internal — exported for unit tests (`setCurrentApp` injects the
178
+ * App); production caller is `buildOpts` inside `bootSequence`.
179
+ */
180
+ export declare function hostAnnouncedPalette(): Readonly<Record<string, string>> | undefined;
100
181
  /**
101
182
  * @internal — exported for unit tests to reset module state between
102
183
  * scenarios.
@@ -733,7 +814,7 @@ export declare function requestDisplayModeInParent(args: {
733
814
  * or honor an upstream `defaultPrevented` from a higher-priority
734
815
  * handler.
735
816
  *
736
- * Idempotent: re-mounts (via `installPostMountListener`) call this
817
+ * Idempotent: re-mounts (via `installPersistentToolResultListener`) call this
737
818
  * helper again; the {@link anchorClickInterceptInstalled} guard makes
738
819
  * subsequent calls a no-op.
739
820
  */
package/dist/runtime.js CHANGED
@@ -32,6 +32,7 @@ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolRes
32
32
  import { projectHostContext } from '@ggui-ai/protocol';
33
33
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
34
34
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
35
+ import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
35
36
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
36
37
  import { createChannelTransportRouter, } from './channel-transport.js';
37
38
  import { RelayIncapableError } from './relay-incapability.js';
@@ -87,7 +88,7 @@ function dispatchDrainAck(payload) {
87
88
  // App-class plumbing (Phase 1.19b.3). The renderer used to roll its own
88
89
  // JSON-RPC pump (a closure-scoped `makeJsonRpcCaller` for the single
89
90
  // `ui/initialize` request + a module-level `ensurePostRpcToParentListener`
90
- // for `tools/call` and a `installPostMountListener` for inbound
91
+ // for `tools/call` and a `installPersistentToolResultListener` for inbound
91
92
  // `ui/notifications/tool-result`). Every one of those was a hand-rolled
92
93
  // reimplementation of a primitive `@modelcontextprotocol/ext-apps`'s
93
94
  // `App` class already ships — drift across them produced the Reading-B
@@ -186,6 +187,80 @@ export function hostAnnouncedThemeMode() {
186
187
  const theme = currentApp?.getHostContext()?.theme;
187
188
  return theme === 'light' || theme === 'dark' ? theme : undefined;
188
189
  }
190
+ /**
191
+ * Resolve the mount's base-stylesheet color mode from the slice, with
192
+ * the host as the final fallback (ggui#589, completing the ggui#551
193
+ * precedence ruling — "slice wins, host is the fallback, absent ≠
194
+ * light"):
195
+ *
196
+ * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
197
+ * host-announced theme > undefined
198
+ *
199
+ * The middle leg is the ggui#589 fix: a render envelope carrying a
200
+ * per-app theme (`theme: {mode, cssVariables}`) but no top-level
201
+ * `themeMode` is still a slice-stamped mode opinion — before this,
202
+ * the base token ladder ignored it and painted the LIGHT token set
203
+ * under a dark overlay whenever the host (correctly, per the
204
+ * adapter-boundary doctrine in the native host helpers) announced no
205
+ * `hostContext.theme`: a light skeleton in a dark skin. The theme
206
+ * object's own `mode` already drove `color-scheme`; now it also
207
+ * selects the ladder.
208
+ *
209
+ * `undefined` still means "no opinion anywhere" — the renderer's
210
+ * default applies; never coerced to `'light'`.
211
+ *
212
+ * @internal — exported for unit tests; production caller is
213
+ * `buildOpts` inside `bootSequence`.
214
+ */
215
+ export function resolveMountThemeMode(meta) {
216
+ return meta.themeMode ?? meta.theme?.mode ?? hostAnnouncedThemeMode();
217
+ }
218
+ /**
219
+ * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
220
+ *
221
+ * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
222
+ * > undefined (renderer default ladder)
223
+ *
224
+ * The name leg makes a slice theme whose `name` matches a REGISTERED
225
+ * theme id select that theme as the BASE token ladder — the overlay's
226
+ * cssVariables still apply above it, so the base carries the full
227
+ * brand ramp coverage a sparse overlay cannot (the "only the mapped
228
+ * vars carry brand" store-frame class). An unregistered name is
229
+ * harmless by construction: `getScopedThemeCss` falls back to the
230
+ * default theme, which is byte-identical to the no-themeId path
231
+ * (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
232
+ * naming its theme "My Custom" renders exactly as before.
233
+ *
234
+ * @internal — exported for unit tests; production caller is
235
+ * `buildOpts` inside `bootSequence`.
236
+ */
237
+ export function resolveMountThemeId(meta) {
238
+ return meta.themeId ?? meta.theme?.name;
239
+ }
240
+ /**
241
+ * The host's announced palette, read from the connected App's
242
+ * pre-merged hostContext and translated onto `--ggui-*` tokens by the
243
+ * host-palette bridge. `undefined` when no App is connected, the host
244
+ * sent no `styles.variables`, or nothing in it maps/survives
245
+ * sanitization — callers spread-skip the option, exactly like
246
+ * {@link hostAnnouncedThemeMode}.
247
+ *
248
+ * This is the palette input leg of host-theme adaptation (ggui#572,
249
+ * the color analog of #551's mode leg above): the spec `--color-*`
250
+ * variables land as inline custom properties on `<html>` via the
251
+ * canonical ext-apps helper, but nothing ggui renders consumes that
252
+ * vocabulary, and the scoped token block shadows root inheritance
253
+ * anyway — so without this mapping a host palette repaints nothing.
254
+ * `buildOpts` threads it onto the mount options; the renderer merges
255
+ * it as the fallback layer BENEATH the slice's own theme (ggui#573
256
+ * ruling: slice wins, host fallback).
257
+ *
258
+ * @internal — exported for unit tests (`setCurrentApp` injects the
259
+ * App); production caller is `buildOpts` inside `bootSequence`.
260
+ */
261
+ export function hostAnnouncedPalette() {
262
+ return mapHostPaletteToGguiVars(currentApp?.getHostContext()?.styles?.variables);
263
+ }
189
264
  /**
190
265
  * @internal — exported for unit tests to reset module state between
191
266
  * scenarios.
@@ -314,6 +389,14 @@ export async function bootSequence(opts) {
314
389
  const toolResultPromise = opts.preResolvedMeta !== undefined
315
390
  ? Promise.resolve(null)
316
391
  : awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
392
+ // Persistent re-mount listener — ALSO pre-connect (ggui#586). On a
393
+ // Tier-1/preResolved boot the one-shot listener above is skipped,
394
+ // which made the post-mount installation the FIRST toolresult
395
+ // registration — after the handshake, tripping the ext-apps timing
396
+ // notice and leaning on SDK replay for correctness. Registering here
397
+ // covers every boot path; arrivals before a renderer publishes
398
+ // `applyRender` no-op inside the listener.
399
+ installPersistentToolResultListener(app);
317
400
  const initResult = await connectApp(app, transport);
318
401
  if (!initResult.ok) {
319
402
  setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
@@ -1259,17 +1342,19 @@ function awaitToolResultMeta(timeoutMs) {
1259
1342
  });
1260
1343
  }
1261
1344
  /**
1262
- * Module-level guard for the App-mediated post-mount toolresult
1263
- * re-listener. Ensures exactly one persistent
1264
- * `app.addEventListener('toolresult', …)` registration even across
1265
- * re-mounts (e.g. an agent fires a second `ggui_render` and the
1266
- * listener re-applies through the published `applyRender`).
1345
+ * Per-App guard for the persistent toolresult re-listener. Ensures
1346
+ * exactly one `app.addEventListener('toolresult', …)` registration per
1347
+ * App instance even across re-mounts (e.g. an agent fires a second
1348
+ * `ggui_render` and the listener re-applies through the published
1349
+ * `applyRender`). Keyed by App — not a module boolean — so a fresh App
1350
+ * (a re-booted document in tests; production is single-tenant per
1351
+ * iframe) always gets its own registration, never a stale latch.
1267
1352
  */
1268
- let postMountListenerInstalled = false;
1353
+ const persistentToolResultListenerApps = new WeakSet();
1269
1354
  /**
1270
1355
  * Module-level handle to the active renderer's `applyRender`, published
1271
1356
  * by `bootProduction`'s `setup()` once the renderer is built. The
1272
- * module-level {@link installPostMountListener} resolves this to re-mount
1357
+ * module-level {@link installPersistentToolResultListener} resolves this to re-mount
1273
1358
  * on a host-re-emitted tool-result WITHOUT a fresh boot — no second mount,
1274
1359
  * no second WS. This is the no-WS live-re-render channel for
1275
1360
  * spec-compliant MCP-Apps hosts (claude.ai / ChatGPT / Claude Desktop)
@@ -1281,8 +1366,8 @@ let postMountListenerInstalled = false;
1281
1366
  let activeApplyRender = null;
1282
1367
  /**
1283
1368
  * Module-level guard for {@link installAnchorClickInterceptor}. Same
1284
- * rationale as {@link postMountListenerInstalled}: re-mounts are
1285
- * triggered by `installPostMountListener`, and stacking capture-phase
1369
+ * rationale as {@link persistentToolResultListenerApps}: re-mounts are
1370
+ * triggered by `installPersistentToolResultListener`, and stacking capture-phase
1286
1371
  * click listeners across re-mounts would multiply the audit envelopes
1287
1372
  * that fire on a single click.
1288
1373
  */
@@ -2813,7 +2898,7 @@ export function requestDisplayModeInParent(args) {
2813
2898
  * or honor an upstream `defaultPrevented` from a higher-priority
2814
2899
  * handler.
2815
2900
  *
2816
- * Idempotent: re-mounts (via `installPostMountListener`) call this
2901
+ * Idempotent: re-mounts (via `installPersistentToolResultListener`) call this
2817
2902
  * helper again; the {@link anchorClickInterceptInstalled} guard makes
2818
2903
  * subsequent calls a no-op.
2819
2904
  */
@@ -2954,15 +3039,26 @@ export function __resetInterceptorsForTest() {
2954
3039
  fullscreenInterceptInstalled = false;
2955
3040
  }
2956
3041
  /**
2957
- * Install a persistent `app.addEventListener('toolresult', …)` listener
2958
- * that catches `ui/notifications/tool-result` notifications arriving
2959
- * AFTER the initial mount. Each new tool-result that carries a
2960
- * different bootstrap (different sessionId / codeUrl / kind) triggers
2961
- * a re-mount through the published `applyRender`. This closes the boot-only-
3042
+ * Install the persistent `app.addEventListener('toolresult', …)`
3043
+ * listener that catches `ui/notifications/tool-result` notifications
3044
+ * arriving at ANY point in the document's life. Each tool-result that
3045
+ * carries a different bootstrap (different sessionId / codeUrl / kind)
3046
+ * triggers a re-mount through the published `applyRender`; arrivals
3047
+ * before a renderer publishes `applyRender` no-op safely (and do NOT
3048
+ * latch the dedupe key — see below). This closes the boot-only-
2962
3049
  * listener gap that prevented live re-render when an agent issued
2963
3050
  * a second `ggui_render` to the same render-resource.
2964
3051
  *
2965
- * Idempotent: subsequent calls no-op via {@link postMountListenerInstalled}.
3052
+ * REGISTRATION TIMING (ggui#586): called by `bootSequence` BEFORE
3053
+ * `app.connect(transport)` — on a Tier-1/preResolved boot the one-shot
3054
+ * Tier-2 listener never registers, so this used to be the FIRST
3055
+ * `toolresult` registration and landed post-handshake (the ext-apps
3056
+ * `_assertHandlerTiming` notice a guuey production render surfaced).
3057
+ * The SDK's replay made that harmless; pre-registering removes the
3058
+ * dependence on replay entirely.
3059
+ *
3060
+ * Idempotent per App: repeat calls with the same App no-op via
3061
+ * {@link persistentToolResultListenerApps}.
2966
3062
  *
2967
3063
  * Why module-level vs scoped to a single mount: re-mounts re-apply
2968
3064
  * through `applyRender` while the listener stays live, and the
@@ -2972,17 +3068,13 @@ export function __resetInterceptorsForTest() {
2972
3068
  * Spec-canonical (post-Phase-1.19b.3): the previous hand-rolled
2973
3069
  * `window.addEventListener('message', …)` is gone; App handles every
2974
3070
  * inbound `ui/notifications/tool-result` envelope and dispatches via
2975
- * its event system. Per-iframe single-tenancy means the App handle
2976
- * comes from `getCurrentApp()` — set by `bootSequence`'s connect
2977
- * path before this helper runs.
3071
+ * its event system. The App handle is passed by the boot path
3072
+ * directly (pre-connect, so `getCurrentApp()` is not yet set).
2978
3073
  */
2979
- function installPostMountListener() {
2980
- if (postMountListenerInstalled)
3074
+ function installPersistentToolResultListener(app) {
3075
+ if (persistentToolResultListenerApps.has(app))
2981
3076
  return;
2982
- const app = getCurrentApp();
2983
- if (app === null)
2984
- return; // pre-connect; caller bug, swallow safely
2985
- postMountListenerInstalled = true;
3077
+ persistentToolResultListenerApps.add(app);
2986
3078
  let lastMetaKey = null;
2987
3079
  app.addEventListener('toolresult', (params) => {
2988
3080
  const meta = extractMetaFromToolResult(params);
@@ -3020,15 +3112,21 @@ function installPostMountListener() {
3020
3112
  ].join('|');
3021
3113
  if (key === lastMetaKey)
3022
3114
  return;
3023
- lastMetaKey = key;
3024
3115
  // Re-mount through the EXISTING renderer's `applyRender` (published
3025
3116
  // module-level by bootProduction's setup) — NOT a fresh boot. One
3026
3117
  // mount surface, one WS; the prior stale-closure / second-WS race
3027
3118
  // (the #290 root cause) is structurally impossible. Project the new
3028
3119
  // tool-result meta into a seed and re-apply it.
3120
+ //
3121
+ // The apply-null check runs BEFORE the dedupe key latches (ggui#586
3122
+ // ordering): now that this listener registers pre-connect, the
3123
+ // BOOT-path tool-result also flows through here while no renderer
3124
+ // is published yet — latching its key then would silently dedupe a
3125
+ // legitimate post-mount re-broadcast of the same envelope.
3029
3126
  const apply = activeApplyRender;
3030
3127
  if (apply === null)
3031
3128
  return; // no renderer published yet — no-op safely
3129
+ lastMetaKey = key;
3032
3130
  void buildGguiSessionSeedInput(meta).then((seed) => {
3033
3131
  if (seed === null)
3034
3132
  return;
@@ -3461,13 +3559,21 @@ async function bootProduction(opts) {
3461
3559
  !('createdAt' in render)
3462
3560
  ? { codeModuleUrl: meta.codeModuleUrl }
3463
3561
  : {}),
3464
- ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
3465
- // Mode: the slice's stamped `themeMode` (every operator layer
3466
- // resolves into it) wins; when absent, the HOST's announced
3467
- // theme fills the gap (ggui#551 — see `hostAnnouncedThemeMode`).
3468
- // Absent-in-slice means "no operator opinion", not "light".
3562
+ // Base-theme ladder (ggui#589 ask 3 — see `resolveMountThemeId`):
3563
+ // stamped themeId > the slice theme object's NAME (a
3564
+ // registered name binds the full brand base; unregistered
3565
+ // names fall back to the default ladder, byte-identical to
3566
+ // the no-themeId path).
3469
3567
  ...(() => {
3470
- const themeMode = meta.themeMode ?? hostAnnouncedThemeMode();
3568
+ const themeId = resolveMountThemeId(meta);
3569
+ return themeId !== undefined ? { themeId } : {};
3570
+ })(),
3571
+ // Mode ladder (ggui#551 + #589 — see `resolveMountThemeMode`):
3572
+ // stamped `themeMode` > the slice theme OBJECT's `mode` >
3573
+ // host-announced theme. Absent everywhere means "no opinion",
3574
+ // not "light".
3575
+ ...(() => {
3576
+ const themeMode = resolveMountThemeMode(meta);
3471
3577
  return themeMode !== undefined ? { themeMode } : {};
3472
3578
  })(),
3473
3579
  // Per-app theme overlay (St3 M2.2). Threaded straight from the
@@ -3477,6 +3583,15 @@ async function bootProduction(opts) {
3477
3583
  // `color-scheme` at `:root`. THEME IS COMPONENT-ONLY — system
3478
3584
  // cards theme via the SystemCardHost/ThemeProvider path.
3479
3585
  ...(meta.theme !== undefined ? { appTheme: meta.theme } : {}),
3586
+ // Host palette: always threaded when the host announced one —
3587
+ // unlike mode there is no either/or gate here, because the
3588
+ // precedence is CSS document order inside the scoped block
3589
+ // (base < hostPalette < appTheme, ggui#572/#573: slice wins,
3590
+ // host fallback), not a value-level ?? fallback.
3591
+ ...(() => {
3592
+ const hostPalette = hostAnnouncedPalette();
3593
+ return hostPalette !== undefined ? { hostPalette } : {};
3594
+ })(),
3480
3595
  ...(wrapOuter !== undefined ? { wrapOuter } : {}),
3481
3596
  };
3482
3597
  };
@@ -3500,17 +3615,17 @@ async function bootProduction(opts) {
3500
3615
  }
3501
3616
  await renderHandle.update(buildOpts(render));
3502
3617
  };
3503
- // Publish `applyRender` module-level + install the persistent
3504
- // post-mount tool-result listener. On a host-re-emitted tool-result
3505
- // (claude.ai re-broadcast; the Anthropic-SDK-strips-_meta refetch),
3506
- // the listener re-mounts through THIS `applyRender` — no fresh boot,
3507
- // no second WS. The no-WS live-re-render channel; for WS hosts the
3508
- // render-frame handler already covers re-render, so it's a
3509
- // redundant-safe fallback (guarded by the sessionId pin + liveTrio
3510
- // dedupe). Runs only on the production renderer path (tests that
3511
- // drive bootSequence without a renderer never reach setup()).
3618
+ // Publish `applyRender` module-level. The persistent tool-result
3619
+ // listener is ALREADY registered — `bootSequence` installs it
3620
+ // pre-connect (ggui#586) — and reads this slot dynamically, so
3621
+ // publishing here is what arms it. On a host-re-emitted
3622
+ // tool-result (claude.ai re-broadcast; the
3623
+ // Anthropic-SDK-strips-_meta refetch), the listener re-mounts
3624
+ // through THIS `applyRender` — no fresh boot, no second WS. The
3625
+ // no-WS live-re-render channel; for WS hosts the render-frame
3626
+ // handler already covers re-render, so it's a redundant-safe
3627
+ // fallback (guarded by the sessionId pin + liveTrio dedupe).
3512
3628
  activeApplyRender = applyRender;
3513
- installPostMountListener();
3514
3629
  // Validator context — A2UI default for `_ggui:preview`; no
3515
3630
  // bootstrap-supplied overrides today (the
3516
3631
  // `extraReservedValidators` injection slot is reserved for a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "The iframe-local ggui runtime: a self-contained ESM bundle that boots inside an MCP Apps host iframe, runs the protocol-version handshake, opens the live-channel WebSocket, and mounts the rendered UI. Bundles React, ReactDOM, and the ggui design system so generated component code has a single seam to read from.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -36,12 +36,12 @@
36
36
  "react": "^19.0.0",
37
37
  "react-dom": "^19.0.0",
38
38
  "zod": "^4.3.6",
39
- "@ggui-ai/design": "0.10.0",
40
- "@ggui-ai/gadgets": "0.10.0",
41
- "@ggui-ai/live-channel": "0.10.0",
42
- "@ggui-ai/protocol": "0.10.0",
43
- "@ggui-ai/preview-a2ui": "0.10.0",
44
- "@ggui-ai/wire": "0.10.0"
39
+ "@ggui-ai/preview-a2ui": "0.11.0",
40
+ "@ggui-ai/live-channel": "0.11.0",
41
+ "@ggui-ai/gadgets": "0.11.0",
42
+ "@ggui-ai/design": "0.11.0",
43
+ "@ggui-ai/protocol": "0.11.0",
44
+ "@ggui-ai/wire": "0.11.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.27.0"