@ggui-ai/iframe-runtime 0.9.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.
package/dist/runtime.js CHANGED
@@ -1,8 +1,38 @@
1
- import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, } from '@ggui-ai/protocol/integrations/mcp-apps';
2
- import { parseMetaFromGlobal, parseMetaFromToolResult, } from './meta-parse.js';
1
+ /**
2
+ * `@ggui-ai/iframe-runtime` iframe runtime entry.
3
+ *
4
+ * This is the file esbuild bundles into `dist/iframe-runtime.js`. The
5
+ * thin-shell HTML loads it via `<script type="module" src=".../iframe-runtime.js">`;
6
+ * on import the side-effects below take over: build a status DOM,
7
+ * postMessage `ui/initialize` to the parent, parse the bootstrap, open
8
+ * the WebSocket, run the version handshake, and mount the render
9
+ * placeholder.
10
+ *
11
+ * Boot sequence:
12
+ * - Boot from the `_meta["ai.ggui/render"]` slice received via
13
+ * `ui/initialize`.
14
+ * - Open the WebSocket, run the subscribe handshake.
15
+ * - Mount the render — either a structural placeholder or, when
16
+ * the renderer hooks are wired, a React mount of `componentCode`.
17
+ *
18
+ * The runtime advertises its build version via a post-`ui/initialize`
19
+ * notification (`ggui:renderer-ready`).
20
+ *
21
+ * Failure surfacing: every parse / handshake failure goes to two
22
+ * places — (1) the in-iframe status line (operator-visible) and
23
+ * (2) a parent-bound `postMessage({type:'ggui:bootstrap-failed', ...})`.
24
+ * The post-message envelope is consumed by the `<McpAppIframe>` host
25
+ * wrapper, which routes it to the host's `onError` callback.
26
+ */
27
+ // FIRST import on purpose — declares zod jitless before any
28
+ // schema-defining dependency initializes (see zod-jitless.ts).
29
+ import './zod-jitless.js';
30
+ import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
31
+ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
3
32
  import { projectHostContext } from '@ggui-ai/protocol';
4
33
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
5
34
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
35
+ import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
6
36
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
7
37
  import { createChannelTransportRouter, } from './channel-transport.js';
8
38
  import { RelayIncapableError } from './relay-incapability.js';
@@ -58,7 +88,7 @@ function dispatchDrainAck(payload) {
58
88
  // App-class plumbing (Phase 1.19b.3). The renderer used to roll its own
59
89
  // JSON-RPC pump (a closure-scoped `makeJsonRpcCaller` for the single
60
90
  // `ui/initialize` request + a module-level `ensurePostRpcToParentListener`
61
- // for `tools/call` and a `installPostMountListener` for inbound
91
+ // for `tools/call` and a `installPersistentToolResultListener` for inbound
62
92
  // `ui/notifications/tool-result`). Every one of those was a hand-rolled
63
93
  // reimplementation of a primitive `@modelcontextprotocol/ext-apps`'s
64
94
  // `App` class already ships — drift across them produced the Reading-B
@@ -131,6 +161,106 @@ export function setCurrentApp(app) {
131
161
  function getCurrentApp() {
132
162
  return currentApp;
133
163
  }
164
+ /**
165
+ * The host's announced color mode, read from the connected App's
166
+ * pre-merged hostContext (`ui/initialize` result, kept current by the
167
+ * App across `host-context-changed`). `undefined` when no App is
168
+ * connected, the host sent no `theme`, or it sent something outside
169
+ * the spec's `'light' | 'dark'` literals (dropped, never guessed —
170
+ * the same tolerant posture as `projectHostContext`).
171
+ *
172
+ * This is the INPUT leg of host-theme adaptation
173
+ * (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
174
+ * `hostContext.theme` alone reaches only `data-theme` +
175
+ * `color-scheme` via `applyDocumentTheme` — nothing in the token
176
+ * pipeline keys off it, so a dark host painted the light ladder
177
+ * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
178
+ * `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
179
+ * every operator layer (live provider > per-render override > app
180
+ * default > server default) still wins by being stamped; the host
181
+ * fills the gap where no operator had an opinion.
182
+ *
183
+ * @internal — exported for unit tests (`setCurrentApp` injects the
184
+ * App); production caller is `buildOpts` inside `bootSequence`.
185
+ */
186
+ export function hostAnnouncedThemeMode() {
187
+ const theme = currentApp?.getHostContext()?.theme;
188
+ return theme === 'light' || theme === 'dark' ? theme : undefined;
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
+ }
134
264
  /**
135
265
  * @internal — exported for unit tests to reset module state between
136
266
  * scenarios.
@@ -259,6 +389,14 @@ export async function bootSequence(opts) {
259
389
  const toolResultPromise = opts.preResolvedMeta !== undefined
260
390
  ? Promise.resolve(null)
261
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);
262
400
  const initResult = await connectApp(app, transport);
263
401
  if (!initResult.ok) {
264
402
  setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
@@ -314,7 +452,16 @@ export async function bootSequence(opts) {
314
452
  parsed = inline;
315
453
  }
316
454
  else {
317
- const fromToolResult = await toolResultPromise;
455
+ // Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
456
+ // result named the view but carried no material; now that the
457
+ // App is connected, resolve the locator through the host's
458
+ // `resources/read` proxy. One round trip, ahead of the Tier 2
459
+ // wait; a miss falls through to Tier 2 unchanged (its listener
460
+ // is already armed and resolves locator-only arrivals too).
461
+ const viaDoor = opts.preResolvedLocator !== undefined
462
+ ? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
463
+ : null;
464
+ const fromToolResult = viaDoor ?? (await toolResultPromise);
318
465
  if (fromToolResult !== null) {
319
466
  parsed = { ok: true, meta: fromToolResult };
320
467
  }
@@ -1052,6 +1199,26 @@ export function readPendingToolResults() {
1052
1199
  }
1053
1200
  return null;
1054
1201
  }
1202
+ /**
1203
+ * Sibling of {@link readPendingToolResults} for the read-plane-only
1204
+ * posture (ggui#537): the NEWEST buffered tool result that carries a
1205
+ * `ui://ggui/render/…` locator but no bootstrap material. Consulted only
1206
+ * when the buffer yielded no inline slice; the autostart threads it to
1207
+ * `bootSequence` as `preResolvedLocator`, resolved after the handshake.
1208
+ */
1209
+ export function readPendingToolResultLocator() {
1210
+ if (typeof window === 'undefined')
1211
+ return null;
1212
+ const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
1213
+ if (!Array.isArray(raw) || raw.length === 0)
1214
+ return null;
1215
+ for (let i = raw.length - 1; i >= 0; i--) {
1216
+ const locator = extractLocatorFromToolResult(raw[i]);
1217
+ if (locator !== null)
1218
+ return locator;
1219
+ }
1220
+ return null;
1221
+ }
1055
1222
  /**
1056
1223
  * Listen for a `ui/notifications/tool-result` notification via the
1057
1224
  * App's spec-canonical event surface and resolve to the extracted
@@ -1067,36 +1234,75 @@ export function readPendingToolResults() {
1067
1234
  function awaitToolResultMetaFromApp(app, timeoutMs) {
1068
1235
  return new Promise((resolve) => {
1069
1236
  let settled = false;
1070
- const handler = (params) => {
1237
+ const settle = (value) => {
1071
1238
  if (settled)
1072
1239
  return;
1073
- const meta = extractMetaFromToolResult(params);
1074
- if (meta === null)
1075
- return;
1076
1240
  settled = true;
1077
1241
  app.removeEventListener('toolresult', handler);
1078
1242
  clearTimeout(timer);
1079
- resolve(meta);
1243
+ resolve(value);
1080
1244
  };
1081
- app.addEventListener('toolresult', handler);
1082
- const timer = setTimeout(() => {
1245
+ const handler = (params) => {
1083
1246
  if (settled)
1084
1247
  return;
1085
- settled = true;
1086
- app.removeEventListener('toolresult', handler);
1087
- resolve(null);
1088
- }, timeoutMs);
1248
+ const meta = extractMetaFromToolResult(params);
1249
+ if (meta !== null) {
1250
+ settle(meta);
1251
+ return;
1252
+ }
1253
+ // Read-plane door (ggui#537): identity-only result → resolve the
1254
+ // locator through the host's `resources/read` proxy. Async, and
1255
+ // it never settles `null` — a later result carrying the slice
1256
+ // inline (or the timeout) still decides.
1257
+ const locator = extractLocatorFromToolResult(params);
1258
+ if (locator === null)
1259
+ return;
1260
+ void resolveMetaViaReadDoor(app, locator).then((resolved) => {
1261
+ if (resolved !== null)
1262
+ settle(resolved);
1263
+ });
1264
+ };
1265
+ app.addEventListener('toolresult', handler);
1266
+ const timer = setTimeout(() => settle(null), timeoutMs);
1089
1267
  });
1090
1268
  }
1091
1269
  /**
1092
- * Pre-handshake variant — listens to raw `window.message` for the
1093
- * autostart layer. Used BEFORE the App is constructed, so this can't
1094
- * route through `app.addEventListener`. Pairs with the minimal-shell
1095
- * pattern: the shell buffers any tool-results that arrived BEFORE
1096
- * runtime load (read via {@link readPendingToolResults}); this
1097
- * listener catches the ones that arrive AFTER the bundle parses but
1098
- * BEFORE bootSequence runs.
1099
- */
1270
+ * The read-plane door (ggui#537). A server running the read-plane-only
1271
+ * posture publishes only the view's identity on the tool result — the
1272
+ * `ui://ggui/render/…` locator — and no bootstrap material; the
1273
+ * per-render resource that locator names is the self-contained shell,
1274
+ * whose document inlines the very envelope this runtime boots from.
1275
+ * Ask the HOST to read it (`app.readServerResource` → `resources/read`,
1276
+ * proxied by the host's own MCP client — no fetch from this sandbox, so
1277
+ * a host CSP without `connect-src` to the server is not in the way),
1278
+ * recover the envelope with the protocol's writer/reader pair
1279
+ * ({@link readGguiShellEnvelope}), and validate it exactly as an inline
1280
+ * slice would be. `null` on any miss (host cannot proxy reads, resource
1281
+ * gone, no envelope) — the caller's other tiers still decide.
1282
+ */
1283
+ export async function resolveMetaViaReadDoor(app, locator) {
1284
+ let text;
1285
+ try {
1286
+ const res = await app.readServerResource({ uri: locator });
1287
+ const first = res.contents[0];
1288
+ text = first !== undefined && 'text' in first && typeof first.text === 'string' ? first.text : undefined;
1289
+ }
1290
+ catch (err) {
1291
+ // eslint-disable-next-line no-console -- operator-visible: the door is a boot tier, its miss must be legible
1292
+ console.warn(`[ggui-runtime] read-plane door: host could not read ${locator} — ${err instanceof Error ? err.message : String(err)}`);
1293
+ return null;
1294
+ }
1295
+ if (text === undefined)
1296
+ return null;
1297
+ const envelope = readGguiShellEnvelope(text);
1298
+ if (envelope === undefined)
1299
+ return null;
1300
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope);
1301
+ if (!parsed.ok || parsed.meta === undefined)
1302
+ return null;
1303
+ const validated = validateMeta(parsed.meta);
1304
+ return validated.ok ? validated.meta : null;
1305
+ }
1100
1306
  function awaitToolResultMeta(timeoutMs) {
1101
1307
  if (typeof window === 'undefined')
1102
1308
  return Promise.resolve(null);
@@ -1119,25 +1325,36 @@ function awaitToolResultMeta(timeoutMs) {
1119
1325
  return;
1120
1326
  }
1121
1327
  const meta = extractMetaFromToolResult(m.params);
1122
- if (meta !== null)
1123
- settle(meta);
1328
+ if (meta !== null) {
1329
+ settle({ kind: 'meta', meta });
1330
+ return;
1331
+ }
1332
+ // Identity-only result (ggui#537): settle NOW with the locator so
1333
+ // boot starts immediately and the door runs after the handshake —
1334
+ // waiting the full timeout for a slice this posture never sends
1335
+ // would cost every spec-host view 30 s for nothing.
1336
+ const locator = extractLocatorFromToolResult(m.params);
1337
+ if (locator !== null)
1338
+ settle({ kind: 'locator', locator });
1124
1339
  };
1125
1340
  window.addEventListener('message', onMessage);
1126
1341
  const timer = setTimeout(() => settle(null), timeoutMs);
1127
1342
  });
1128
1343
  }
1129
1344
  /**
1130
- * Module-level guard for the App-mediated post-mount toolresult
1131
- * re-listener. Ensures exactly one persistent
1132
- * `app.addEventListener('toolresult', …)` registration even across
1133
- * re-mounts (e.g. an agent fires a second `ggui_render` and the
1134
- * 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.
1135
1352
  */
1136
- let postMountListenerInstalled = false;
1353
+ const persistentToolResultListenerApps = new WeakSet();
1137
1354
  /**
1138
1355
  * Module-level handle to the active renderer's `applyRender`, published
1139
1356
  * by `bootProduction`'s `setup()` once the renderer is built. The
1140
- * module-level {@link installPostMountListener} resolves this to re-mount
1357
+ * module-level {@link installPersistentToolResultListener} resolves this to re-mount
1141
1358
  * on a host-re-emitted tool-result WITHOUT a fresh boot — no second mount,
1142
1359
  * no second WS. This is the no-WS live-re-render channel for
1143
1360
  * spec-compliant MCP-Apps hosts (claude.ai / ChatGPT / Claude Desktop)
@@ -1149,8 +1366,8 @@ let postMountListenerInstalled = false;
1149
1366
  let activeApplyRender = null;
1150
1367
  /**
1151
1368
  * Module-level guard for {@link installAnchorClickInterceptor}. Same
1152
- * rationale as {@link postMountListenerInstalled}: re-mounts are
1153
- * triggered by `installPostMountListener`, and stacking capture-phase
1369
+ * rationale as {@link persistentToolResultListenerApps}: re-mounts are
1370
+ * triggered by `installPersistentToolResultListener`, and stacking capture-phase
1154
1371
  * click listeners across re-mounts would multiply the audit envelopes
1155
1372
  * that fire on a single click.
1156
1373
  */
@@ -2681,7 +2898,7 @@ export function requestDisplayModeInParent(args) {
2681
2898
  * or honor an upstream `defaultPrevented` from a higher-priority
2682
2899
  * handler.
2683
2900
  *
2684
- * Idempotent: re-mounts (via `installPostMountListener`) call this
2901
+ * Idempotent: re-mounts (via `installPersistentToolResultListener`) call this
2685
2902
  * helper again; the {@link anchorClickInterceptInstalled} guard makes
2686
2903
  * subsequent calls a no-op.
2687
2904
  */
@@ -2822,15 +3039,26 @@ export function __resetInterceptorsForTest() {
2822
3039
  fullscreenInterceptInstalled = false;
2823
3040
  }
2824
3041
  /**
2825
- * Install a persistent `app.addEventListener('toolresult', …)` listener
2826
- * that catches `ui/notifications/tool-result` notifications arriving
2827
- * AFTER the initial mount. Each new tool-result that carries a
2828
- * different bootstrap (different sessionId / codeUrl / kind) triggers
2829
- * 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-
2830
3049
  * listener gap that prevented live re-render when an agent issued
2831
3050
  * a second `ggui_render` to the same render-resource.
2832
3051
  *
2833
- * 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}.
2834
3062
  *
2835
3063
  * Why module-level vs scoped to a single mount: re-mounts re-apply
2836
3064
  * through `applyRender` while the listener stays live, and the
@@ -2840,17 +3068,13 @@ export function __resetInterceptorsForTest() {
2840
3068
  * Spec-canonical (post-Phase-1.19b.3): the previous hand-rolled
2841
3069
  * `window.addEventListener('message', …)` is gone; App handles every
2842
3070
  * inbound `ui/notifications/tool-result` envelope and dispatches via
2843
- * its event system. Per-iframe single-tenancy means the App handle
2844
- * comes from `getCurrentApp()` — set by `bootSequence`'s connect
2845
- * 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).
2846
3073
  */
2847
- function installPostMountListener() {
2848
- if (postMountListenerInstalled)
3074
+ function installPersistentToolResultListener(app) {
3075
+ if (persistentToolResultListenerApps.has(app))
2849
3076
  return;
2850
- const app = getCurrentApp();
2851
- if (app === null)
2852
- return; // pre-connect; caller bug, swallow safely
2853
- postMountListenerInstalled = true;
3077
+ persistentToolResultListenerApps.add(app);
2854
3078
  let lastMetaKey = null;
2855
3079
  app.addEventListener('toolresult', (params) => {
2856
3080
  const meta = extractMetaFromToolResult(params);
@@ -2888,15 +3112,21 @@ function installPostMountListener() {
2888
3112
  ].join('|');
2889
3113
  if (key === lastMetaKey)
2890
3114
  return;
2891
- lastMetaKey = key;
2892
3115
  // Re-mount through the EXISTING renderer's `applyRender` (published
2893
3116
  // module-level by bootProduction's setup) — NOT a fresh boot. One
2894
3117
  // mount surface, one WS; the prior stale-closure / second-WS race
2895
3118
  // (the #290 root cause) is structurally impossible. Project the new
2896
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.
2897
3126
  const apply = activeApplyRender;
2898
3127
  if (apply === null)
2899
3128
  return; // no renderer published yet — no-op safely
3129
+ lastMetaKey = key;
2900
3130
  void buildGguiSessionSeedInput(meta).then((seed) => {
2901
3131
  if (seed === null)
2902
3132
  return;
@@ -2969,7 +3199,7 @@ function readLiveBootstrapShape() {
2969
3199
  * re-await the same postMessage or re-parse the same global), saving
2970
3200
  * up to the 30s postMessage timeout for spec-strict hosts.
2971
3201
  */
2972
- function runBootProduction(preResolvedMeta) {
3202
+ function runBootProduction(preResolvedMeta, preResolvedLocator) {
2973
3203
  const { app, transport } = createDefaultApp();
2974
3204
  void bootProduction({
2975
3205
  doc: document,
@@ -2985,6 +3215,7 @@ function runBootProduction(preResolvedMeta) {
2985
3215
  onObserve: postObservabilityToParent,
2986
3216
  onLifecycle: postLifecycleToParent,
2987
3217
  ...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
3218
+ ...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
2988
3219
  });
2989
3220
  }
2990
3221
  if (shouldAutostart() && typeof window !== 'undefined') {
@@ -3015,15 +3246,26 @@ if (shouldAutostart() && typeof window !== 'undefined') {
3015
3246
  }
3016
3247
  else {
3017
3248
  const buffered = readPendingToolResults();
3249
+ const bufferedLocator = buffered === null ? readPendingToolResultLocator() : null;
3018
3250
  if (buffered !== null) {
3019
3251
  runBootProduction(buffered);
3020
3252
  }
3253
+ else if (bufferedLocator !== null) {
3254
+ // Read-plane-only posture (ggui#537): the buffered result named
3255
+ // the view; the door resolves it right after the handshake.
3256
+ runBootProduction(undefined, bufferedLocator);
3257
+ }
3021
3258
  else if (readLiveBootstrapShape()) {
3022
3259
  runBootProduction();
3023
3260
  }
3024
3261
  else {
3025
- void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((postMessageMeta) => {
3026
- runBootProduction(postMessageMeta ?? undefined);
3262
+ void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((pre) => {
3263
+ if (pre === null)
3264
+ runBootProduction();
3265
+ else if (pre.kind === 'meta')
3266
+ runBootProduction(pre.meta);
3267
+ else
3268
+ runBootProduction(undefined, pre.locator);
3027
3269
  });
3028
3270
  }
3029
3271
  }
@@ -3302,10 +3544,38 @@ async function bootProduction(opts) {
3302
3544
  ...(meta.gadgets !== undefined
3303
3545
  ? { gadgetPackages: meta.gadgets.map((g) => g.package) }
3304
3546
  : {}),
3305
- ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
3306
- ...(meta.themeMode !== undefined
3307
- ? { themeMode: meta.themeMode }
3547
+ // Strict-CSP module variant (ggui#522 slice 2) — attached to
3548
+ // the INLINE SEED mount only, detected by the absence of the
3549
+ // server-assigned ledger fields (`buildGguiSessionSeedInput`
3550
+ // omits them; every wire-delivered GguiSession carries them).
3551
+ // The variant URL names the SEEDED bytes: a WS-delivered
3552
+ // body may differ, and importing a stale variant would paint
3553
+ // the WRONG component rather than fail. A WS redelivery of
3554
+ // the SAME bytes loses nothing — same code ⇒ the renderer
3555
+ // skips re-evaluation entirely.
3556
+ ...(meta.codeModuleUrl !== undefined &&
3557
+ render.type !== 'mcpApps' &&
3558
+ render.type !== 'system' &&
3559
+ !('createdAt' in render)
3560
+ ? { codeModuleUrl: meta.codeModuleUrl }
3308
3561
  : {}),
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).
3567
+ ...(() => {
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);
3577
+ return themeMode !== undefined ? { themeMode } : {};
3578
+ })(),
3309
3579
  // Per-app theme overlay (St3 M2.2). Threaded straight from the
3310
3580
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3311
3581
  // already injection-validated by the wire parser) onto the mount
@@ -3313,6 +3583,15 @@ async function bootProduction(opts) {
3313
3583
  // `color-scheme` at `:root`. THEME IS COMPONENT-ONLY — system
3314
3584
  // cards theme via the SystemCardHost/ThemeProvider path.
3315
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
+ })(),
3316
3595
  ...(wrapOuter !== undefined ? { wrapOuter } : {}),
3317
3596
  };
3318
3597
  };
@@ -3336,17 +3615,17 @@ async function bootProduction(opts) {
3336
3615
  }
3337
3616
  await renderHandle.update(buildOpts(render));
3338
3617
  };
3339
- // Publish `applyRender` module-level + install the persistent
3340
- // post-mount tool-result listener. On a host-re-emitted tool-result
3341
- // (claude.ai re-broadcast; the Anthropic-SDK-strips-_meta refetch),
3342
- // the listener re-mounts through THIS `applyRender` — no fresh boot,
3343
- // no second WS. The no-WS live-re-render channel; for WS hosts the
3344
- // render-frame handler already covers re-render, so it's a
3345
- // redundant-safe fallback (guarded by the sessionId pin + liveTrio
3346
- // dedupe). Runs only on the production renderer path (tests that
3347
- // 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).
3348
3628
  activeApplyRender = applyRender;
3349
- installPostMountListener();
3350
3629
  // Validator context — A2UI default for `_ggui:preview`; no
3351
3630
  // bootstrap-supplied overrides today (the
3352
3631
  // `extraReservedValidators` injection slot is reserved for a
@@ -3493,5 +3772,8 @@ async function bootProduction(opts) {
3493
3772
  ...(opts.preResolvedMeta !== undefined
3494
3773
  ? { preResolvedMeta: opts.preResolvedMeta }
3495
3774
  : {}),
3775
+ ...(opts.preResolvedLocator !== undefined
3776
+ ? { preResolvedLocator: opts.preResolvedLocator }
3777
+ : {}),
3496
3778
  });
3497
3779
  }
package/dist/server.d.ts CHANGED
@@ -34,3 +34,22 @@ export declare const RUNTIME_DIST_DIR: string;
34
34
  * `bootstrap.runtimeUrl`, never hardcodes it.
35
35
  */
36
36
  export declare const RUNTIME_BUNDLE_URL_PATH: "/_ggui/iframe-runtime.js";
37
+ /**
38
+ * Directory of the static shim modules (`dist/shims/<name>.js`) the
39
+ * build emits alongside the bundle (ggui#522 slice 2 — `asset-url`
40
+ * delivery). The mcp-server captures these files at composition and
41
+ * serves them under `/_ggui/shims/<runtime-hash>/<name>.js` with the
42
+ * same immutable posture as the hashed bundle route: the shims ship in
43
+ * the same dist as the bundle, so the bundle's content hash versions
44
+ * both — a shim-affecting change in the design package also changes
45
+ * the bundle bytes.
46
+ */
47
+ export declare const RUNTIME_SHIMS_DIR: string;
48
+ /**
49
+ * The URL prefix under which the mcp-server publishes the shim
50
+ * directory family. The full shim directory for a given build is
51
+ * `<origin>${RUNTIME_SHIMS_URL_PREFIX}/<runtime-hash>` — the server-
52
+ * side `asset-url` rewrite composes shim URLs on it, and the shim
53
+ * route parses it back.
54
+ */
55
+ export declare const RUNTIME_SHIMS_URL_PREFIX: "/_ggui/shims";
package/dist/server.js CHANGED
@@ -70,3 +70,22 @@ export const RUNTIME_DIST_DIR = path.dirname(RUNTIME_BUNDLE_FILE);
70
70
  * `bootstrap.runtimeUrl`, never hardcodes it.
71
71
  */
72
72
  export const RUNTIME_BUNDLE_URL_PATH = '/_ggui/iframe-runtime.js';
73
+ /**
74
+ * Directory of the static shim modules (`dist/shims/<name>.js`) the
75
+ * build emits alongside the bundle (ggui#522 slice 2 — `asset-url`
76
+ * delivery). The mcp-server captures these files at composition and
77
+ * serves them under `/_ggui/shims/<runtime-hash>/<name>.js` with the
78
+ * same immutable posture as the hashed bundle route: the shims ship in
79
+ * the same dist as the bundle, so the bundle's content hash versions
80
+ * both — a shim-affecting change in the design package also changes
81
+ * the bundle bytes.
82
+ */
83
+ export const RUNTIME_SHIMS_DIR = path.resolve(here, 'shims');
84
+ /**
85
+ * The URL prefix under which the mcp-server publishes the shim
86
+ * directory family. The full shim directory for a given build is
87
+ * `<origin>${RUNTIME_SHIMS_URL_PREFIX}/<runtime-hash>` — the server-
88
+ * side `asset-url` rewrite composes shim URLs on it, and the shim
89
+ * route parses it back.
90
+ */
91
+ export const RUNTIME_SHIMS_URL_PREFIX = '/_ggui/shims';
@@ -0,0 +1 @@
1
+ const M = (globalThis.__ggui__&&globalThis.__ggui__["appComponents"]||window["__GGUI_APP_COMPONENTS"]) || {}; const handler = { get(_, key) { return M[key]; } }; export default new Proxy({}, handler);
@@ -0,0 +1 @@
1
+ const M = Object.assign({}, (globalThis.__ggui__&&globalThis.__ggui__["components"]||window["__GGUI_COMPONENTS"]||{}),(globalThis.__ggui__&&globalThis.__ggui__["primitives"]||window["__GGUI_PRIMITIVES"]||{}),(globalThis.__ggui__&&globalThis.__ggui__["compositions"]||window["__GGUI_COMPOSITIONS"]||{}),(globalThis.__ggui__&&globalThis.__ggui__["interact"]||window["__GGUI_INTERACT"]||{})); export const Container = M["Container"]; export const Card = M["Card"]; export const Stack = M["Stack"]; export const Row = M["Row"]; export const Grid = M["Grid"]; export const Box = M["Box"]; export const Divider = M["Divider"]; export const Spacer = M["Spacer"]; export const Text = M["Text"]; export const Heading = M["Heading"]; export const Button = M["Button"]; export const Input = M["Input"]; export const TextArea = M["TextArea"]; export const Select = M["Select"]; export const Checkbox = M["Checkbox"]; export const Toggle = M["Toggle"]; export const RadioGroup = M["RadioGroup"]; export const Slider = M["Slider"]; export const Badge = M["Badge"]; export const Spinner = M["Spinner"]; export const Skeleton = M["Skeleton"]; export const Avatar = M["Avatar"]; export const Alert = M["Alert"]; export const Progress = M["Progress"]; export const Image = M["Image"]; export const Icon = M["Icon"]; export const Link = M["Link"]; export const Tooltip = M["Tooltip"]; export const Table = M["Table"]; export const Tabs = M["Tabs"]; export const Toast = M["Toast"]; export const Accordion = M["Accordion"]; export const MotionKeyframes = M["MotionKeyframes"]; export const useMotion = M["useMotion"]; export const useAnimationKey = M["useAnimationKey"]; export const SearchField = M["SearchField"]; export const FormField = M["FormField"]; export const MenuItem = M["MenuItem"]; export const Tag = M["Tag"]; export const Dropdown = M["Dropdown"]; export const Autocomplete = M["Autocomplete"]; export const Breadcrumb = M["Breadcrumb"]; export const Pagination = M["Pagination"]; export const EmptyState = M["EmptyState"]; export const Stat = M["Stat"]; export const Stepper = M["Stepper"]; export const Markdown = M["Markdown"]; export const MarkdownInline = M["MarkdownInline"]; export const renderRichTextInlines = M["renderRichTextInlines"]; export const Header = M["Header"]; export const Sidebar = M["Sidebar"]; export const CardGrid = M["CardGrid"]; export const CommentThread = M["CommentThread"]; export const DataTable = M["DataTable"]; export const ChatWindow = M["ChatWindow"]; export const NavigationBar = M["NavigationBar"]; export const FileUploader = M["FileUploader"]; export const NotificationCenter = M["NotificationCenter"]; export const Modal = M["Modal"]; export const CommandPalette = M["CommandPalette"]; export const Footer = M["Footer"]; export const Hero = M["Hero"]; export const Clickable = M["Clickable"]; export const Hoverable = M["Hoverable"]; export const Pressable = M["Pressable"]; export default M;