@ggui-ai/iframe-runtime 0.14.0 → 0.16.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.
Files changed (42) hide show
  1. package/dist/channel-transport.d.ts +3 -3
  2. package/dist/channels/channel-payload.d.ts +1 -1
  3. package/dist/channels/data.d.ts +1 -1
  4. package/dist/channels/drain-ack.d.ts +1 -1
  5. package/dist/channels/props-update.d.ts +1 -1
  6. package/dist/channels/render-frame.d.ts +1 -1
  7. package/dist/compiled-validators.d.ts +1 -1
  8. package/dist/connection.d.ts +34 -12
  9. package/dist/connection.js +40 -12
  10. package/dist/context-observer.d.ts +1 -1
  11. package/dist/context-observer.js +1 -1
  12. package/dist/events-polling.d.ts +1 -1
  13. package/dist/host-context-emitter.d.ts +23 -2
  14. package/dist/host-context-emitter.js +38 -5
  15. package/dist/host-fonts.d.ts +10 -0
  16. package/dist/host-fonts.js +113 -0
  17. package/dist/host-palette-bridge.js +20 -15
  18. package/dist/iframe-runtime.js +97 -108
  19. package/dist/iframe-runtime.meta.json +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/mcp-app-iframe-host.d.ts +15 -0
  22. package/dist/mcp-app-iframe-host.js +5 -1
  23. package/dist/meta-parse.d.ts +9 -0
  24. package/dist/meta-parse.js +15 -3
  25. package/dist/observability.d.ts +73 -14
  26. package/dist/provisional-renderer.d.ts +1 -1
  27. package/dist/react-renderer.d.ts +19 -37
  28. package/dist/react-renderer.js +64 -72
  29. package/dist/registry-subscribe.d.ts +1 -1
  30. package/dist/render-item.d.ts +7 -14
  31. package/dist/runtime-telemetry.js +1 -1
  32. package/dist/runtime.d.ts +48 -56
  33. package/dist/runtime.js +213 -115
  34. package/dist/system-cards/NoCredentialsCard.js +13 -13
  35. package/dist/system-cards/ProtocolProbeCard.js +11 -11
  36. package/dist/system-cards/QrCode.js +1 -1
  37. package/dist/system-cards/index.js +2 -2
  38. package/dist/types.d.ts +1 -1
  39. package/dist/validation.d.ts +1 -1
  40. package/dist/validation.js +1 -1
  41. package/dist/wire-config.d.ts +3 -3
  42. package/package.json +7 -7
package/dist/runtime.js CHANGED
@@ -27,13 +27,13 @@
27
27
  // FIRST import on purpose — declares zod jitless before any
28
28
  // schema-defining dependency initializes (see zod-jitless.ts).
29
29
  import './zod-jitless.js';
30
- import { connectionStore } from './connection.js';
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, } from './meta-parse.js';
34
- import { projectHostContext } from '@ggui-ai/protocol';
33
+ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, reportInvalidAppTheme, } from './meta-parse.js';
34
+ import { projectHostContext } from '@ggui-ai/protocol/wire';
35
35
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
36
- import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
36
+ import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, subscribeThemeChange as subscribeHostThemeChange, } from './host-context-emitter.js';
37
37
  import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
38
38
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
39
39
  import { createChannelTransportRouter, } from './channel-transport.js';
@@ -171,6 +171,11 @@ export function setCurrentApp(app) {
171
171
  function getCurrentApp() {
172
172
  return currentApp;
173
173
  }
174
+ /**
175
+ * The live theme re-injection's subscription (ggui#987 §4), held so a
176
+ * re-entered boot replaces it instead of stacking a second subscriber.
177
+ */
178
+ let activeThemeUnsubscribe = null;
174
179
  /**
175
180
  * The host's announced color mode, read from the connected App's
176
181
  * pre-merged hostContext (`ui/initialize` result, kept current by the
@@ -185,10 +190,11 @@ function getCurrentApp() {
185
190
  * `color-scheme` via `applyDocumentTheme` — nothing in the token
186
191
  * pipeline keys off it, so a dark host painted the light ladder
187
192
  * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
188
- * `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
189
- * every operator layer (live provider > per-render override > app
190
- * default > server default) still wins by being stamped; the host
191
- * fills the gap where no operator had an opinion.
193
+ * Under ggui#987 §4 the embedding host OWNS runtime mode: when it
194
+ * announces one, `buildOpts` paints it over every stamped opinion; the
195
+ * stamped layers decide only when the host is silent. Read live from
196
+ * the App's pre-merged hostContext, so a `hostcontextchanged` flip
197
+ * re-resolves on the next `buildOpts`.
192
198
  *
193
199
  * @internal — exported for unit tests (`setCurrentApp` injects the
194
200
  * App); production caller is `buildOpts` inside `bootSequence`.
@@ -198,36 +204,30 @@ export function hostAnnouncedThemeMode() {
198
204
  return theme === 'light' || theme === 'dark' ? theme : undefined;
199
205
  }
200
206
  /**
201
- * Resolve the mount's base-stylesheet color mode from the slice, with
202
- * the host as the final fallback (ggui#589, completing the ggui#551
203
- * precedence ruling — "slice wins, host is the fallback, absent ≠
204
- * light"):
207
+ * Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
208
+ * runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
205
209
  *
206
- * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
207
- * host-announced theme > undefined
210
+ * host-announced theme > stamped `themeMode` > `theme.mode`
211
+ * (the app theme's default) > undefined
208
212
  *
209
- * The middle leg is the ggui#589 fix: a render envelope carrying a
210
- * per-app theme (`theme: {mode, cssVariables}`) but no top-level
211
- * `themeMode` is still a slice-stamped mode opinion — before this,
212
- * the base token ladder ignored it and painted the LIGHT token set
213
- * under a dark overlay whenever the host (correctly, per the
214
- * adapter-boundary doctrine in the native host helpers) announced no
215
- * `hostContext.theme`: a light skeleton in a dark skin. The theme
216
- * object's own `mode` already drove `color-scheme`; now it also
217
- * selects the ladder.
213
+ * A card that ignored the host's announced mode painted a light
214
+ * skeleton inside a dark chat — the host's word is what the user
215
+ * actually sees around the card. The stamped layers (live provider >
216
+ * per-render override > app default > server default) decide only when
217
+ * the host is silent, which is the adapter-boundary case (a host
218
+ * helper that announces no `hostContext.theme`).
218
219
  *
219
220
  * `undefined` still means "no opinion anywhere" — the renderer's
220
- * default applies; never coerced to `'light'`.
221
+ * default applies; never coerced to `'light'` here (ggui#551).
221
222
  *
222
223
  * @internal — exported for unit tests; production caller is
223
224
  * `buildOpts` inside `bootSequence`.
224
225
  */
225
226
  export function resolveMountThemeMode(meta) {
226
227
  // The CLIENT projection of the protocol's normative theme-binding
227
- // total order (@ggui-ai/protocol/integrations/theme-binding,
228
- // ggui#598 leg 4) — the server stamps via the sibling projection and
229
- // the protocol's composition-law test pins that the two compose
230
- // without rank drift.
228
+ // total order (@ggui-ai/protocol/integrations/theme-binding) — the
229
+ // server stamps via the sibling projection and the protocol's
230
+ // composition-law test pins that the two compose without rank drift.
231
231
  return effectiveThemeMode({
232
232
  stamped: meta.themeMode,
233
233
  sessionSidecar: meta.theme?.mode,
@@ -235,20 +235,14 @@ export function resolveMountThemeMode(meta) {
235
235
  });
236
236
  }
237
237
  /**
238
- * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
238
+ * Resolve the mount's compiled-ladder id from the slice (ggui#987 D2):
239
239
  *
240
- * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
241
- * > undefined (renderer default ladder)
240
+ * stamped `themeId` > undefined (renderer default ladder)
242
241
  *
243
- * The name leg makes a slice theme whose `name` matches a REGISTERED
244
- * theme id select that theme as the BASE token ladder — the overlay's
245
- * cssVariables still apply above it, so the base carries the full
246
- * brand ramp coverage a sparse overlay cannot (the "only the mapped
247
- * vars carry brand" store-frame class). An unregistered name is
248
- * harmless by construction: `getScopedThemeCss` falls back to the
249
- * default theme, which is byte-identical to the no-themeId path
250
- * (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
251
- * naming its theme "My Custom" renders exactly as before.
242
+ * The app theme's `name` is a LABEL the author's tooling reads back —
243
+ * it never selects a ladder. An app theme carries its own full
244
+ * projection (`overlays`, exactly the manifest minus the floor), so
245
+ * there is no coverage gap for a compiled theme to fill beneath it.
252
246
  *
253
247
  * @internal — exported for unit tests; production caller is
254
248
  * `buildOpts` inside `bootSequence`.
@@ -256,10 +250,7 @@ export function resolveMountThemeMode(meta) {
256
250
  export function resolveMountThemeId(meta) {
257
251
  // Client projection of the normative theme-binding order — see
258
252
  // resolveMountThemeMode above.
259
- return effectiveThemeId({
260
- stamped: meta.themeId,
261
- sidecarName: meta.theme?.name,
262
- });
253
+ return effectiveThemeId({ stamped: meta.themeId });
263
254
  }
264
255
  /**
265
256
  * The host's announced palette, read from the connected App's
@@ -421,11 +412,35 @@ export async function bootSequence(opts) {
421
412
  // covers every boot path; arrivals before a renderer publishes
422
413
  // `applyRender` no-op inside the listener.
423
414
  installPersistentToolResultListener(app);
415
+ // Fresh boot ⇒ the relay latch and the connection store start
416
+ // aligned (ggui#670): a standing latch from a prior mount in a
417
+ // re-booted document must not outlive the store it was written to.
418
+ // It runs BEFORE the handshake so that every boot passes the close
419
+ // edge before it can latch — the boot-failed write below is then
420
+ // always on-edge (ggui#830 fold): a prior zone ends with its
421
+ // 'cleared' edge and its summary, never buried under a second
422
+ // 'latched' with its tally erased.
423
+ resetRelayLatchForBoot();
424
424
  const initResult = await connectApp(app, transport);
425
425
  if (!initResult.ok) {
426
426
  setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
427
427
  retireShellLoadingIndicator(doc);
428
428
  emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
429
+ // The maximal dead zone (ggui#830): the host refused the handshake
430
+ // every session begins with, so nothing can be delivered here — the
431
+ // relay latch sets through its one writer (store false, 'latched'
432
+ // edge, standing notice) instead of leaving this document with
433
+ // `isConnected === true`, no explanation and no count. No gesture
434
+ // is needed for that knowledge, and no gesture is suppressed by it:
435
+ // every later tap still attempts (attempt-always) and lands as a
436
+ // counted dead tap. The scope is empty because no bootstrap has
437
+ // named a session yet; it clears the only way a handshake failure
438
+ // can — the next boot's close edge, which this boot itself already
439
+ // passed above, so this write is on-edge by construction. The
440
+ // gesture gate's pre-capture rule ("never latch before the host has
441
+ // been asked") does not apply here: the host HAS been asked — this
442
+ // is its answer to `ui/initialize`, resolved as failure.
443
+ announceRelayIncapability('boot-failed', {});
429
444
  return { ok: false, mountedRender };
430
445
  }
431
446
  // Record what this host said it can do (ggui#440). Earliest valid
@@ -437,7 +452,7 @@ export async function bootSequence(opts) {
437
452
  // `tools/call` (dispatchSubmitAction, channel-transport router)
438
453
  // routes through `app.callServerTool` instead of the legacy raw
439
454
  // postMessage pump. Idempotent: re-boot with the same App is a
440
- // no-op; a different App throws (the iframe is single-tenant).
455
+ // no-op; a different App throws (the iframe is single-app).
441
456
  setCurrentApp(app);
442
457
  // Slice-meta resolution — spec-canonical primary, no in-house
443
458
  // Reading-B (retired Phase 1.19b.3 with the App-class swap; the
@@ -678,10 +693,6 @@ export async function bootSequence(opts) {
678
693
  // Fresh boot ⇒ not superseded (matters only for test re-runs that
679
694
  // reuse the module; a real iframe boots once).
680
695
  mountSuperseded = false;
681
- // Fresh boot ⇒ the relay latch and the connection store start
682
- // aligned (ggui#670): a standing latch from a prior mount in a
683
- // re-booted document must not outlive the store it was written to.
684
- resetRelayLatchForBoot();
685
696
  telemetry?.record('boot.path', JSON.stringify({
686
697
  hasStaticContent,
687
698
  hasLiveTrio,
@@ -1336,7 +1347,7 @@ export async function resolveMetaViaReadDoor(app, locator) {
1336
1347
  const envelope = readGguiShellEnvelope(text);
1337
1348
  if (envelope === undefined)
1338
1349
  return null;
1339
- const parsed = parseMcpAppAiGguiRenderMeta(envelope);
1350
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope, { onInvalidTheme: reportInvalidAppTheme });
1340
1351
  if (!parsed.ok || parsed.meta === undefined)
1341
1352
  return null;
1342
1353
  const validated = validateMeta(parsed.meta);
@@ -1386,7 +1397,7 @@ function awaitToolResultMeta(timeoutMs) {
1386
1397
  * App instance even across re-mounts (e.g. an agent fires a second
1387
1398
  * `ggui_render` and the listener re-applies through the published
1388
1399
  * `applyRender`). Keyed by App — not a module boolean — so a fresh App
1389
- * (a re-booted document in tests; production is single-tenant per
1400
+ * (a re-booted document in tests; production is single-app per
1390
1401
  * iframe) always gets its own registration, never a stale latch.
1391
1402
  */
1392
1403
  const persistentToolResultListenerApps = new WeakSet();
@@ -1657,7 +1668,11 @@ async function callServerToolSpec(toolName, args) {
1657
1668
  *
1658
1669
  * Unknown codes NEVER confirm — they may be proxy- or server-minted
1659
1670
  * (e.g. `-32603` internal error), and classifying them would misbrand
1660
- * capable mounts. They stay on the per-gesture transient path.
1671
+ * capable mounts. They never CONFIRM (they can latch only through
1672
+ * the advert-silent leg, never on an advertising host); off-latch they
1673
+ * stay on the per-gesture transient path, and inside a standing zone
1674
+ * they count as dead taps like every other undelivered outcome
1675
+ * (ggui#826).
1661
1676
  */
1662
1677
  const CONFIRMED_RELAY_REFUSAL_CODES = new Set([-32601]);
1663
1678
  /**
@@ -1775,6 +1790,20 @@ function classifySubmitActionResponse(resp) {
1775
1790
  * carries a result envelope, so neither is evidence the relay worked.
1776
1791
  */
1777
1792
  function isRelayShapedFailure(resp) {
1793
+ // "Relay-shaped" IS "undelivered" on this leg, by construction — not
1794
+ // by hope (ggui#832, ruled from the hosts' source): every JSON-RPC
1795
+ // error the iframe sees here is host- or transport-minted. The MCP
1796
+ // SDK server turns a tool handler's throw into an `isError` RESULT and
1797
+ // mints JSON-RPC codes only for its own gate; the guuey view-host
1798
+ // answers a rejecting relay with an in-band unavailable RESULT, never
1799
+ // an error; ggui's embed host proxies over HTTP and mints its OWN
1800
+ // codes for a failed proxy. So a server-side outcome always arrives
1801
+ // as a `CallToolResult` (`ok:true` and `ok:false` alike clear the
1802
+ // latch), and an error envelope always means the call did not round
1803
+ // trip. A third-party host that forwarded a server's JSON-RPC error
1804
+ // verbatim would be indistinguishable by code — that is the
1805
+ // host-helper conformance catalog's business (H3 / R1), not this
1806
+ // predicate's.
1778
1807
  // Null-tolerant on `error`, matching `classifySubmitActionResponse`'s
1779
1808
  // own check: an `{error:null, result:{...}}` envelope classifies as
1780
1809
  // success there, so it must also read as NOT relay-shaped-failure
@@ -1845,7 +1874,7 @@ function applyFreezeCue(root) {
1845
1874
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
1846
1875
  fontSize: '11px',
1847
1876
  fontWeight: '600',
1848
- color: 'var(--ggui-color-onSurface, inherit)',
1877
+ color: 'var(--ggui-color-onContainer, inherit)',
1849
1878
  background: 'rgba(128,128,128,0.16)',
1850
1879
  border: '1px solid currentColor',
1851
1880
  opacity: '0.85',
@@ -2282,7 +2311,7 @@ let relayLatchTrigger;
2282
2311
  let relayLatchScope = {};
2283
2312
  function transitionRelayLatch(next, trigger, scope = {}) {
2284
2313
  relayIncapabilityAnnounced = next;
2285
- connectionStore.set(!next);
2314
+ runtimeConnectionWriter().set(!next);
2286
2315
  if (next && trigger !== undefined) {
2287
2316
  relayLatchedAt = Date.now();
2288
2317
  relayDeadTaps = 0;
@@ -2308,6 +2337,22 @@ function transitionRelayLatch(next, trigger, scope = {}) {
2308
2337
  relayLatchTrigger = undefined;
2309
2338
  relayLatchScope = {};
2310
2339
  }
2340
+ /**
2341
+ * Set the latch AND present it — the three steps every latch-set path
2342
+ * takes, in one place: the transition through the ONE writer (store
2343
+ * flip + `'latched'` edge), a fresh dead zone (unspent cue throttles —
2344
+ * the state belongs to THIS notice, so a second latch-set path never
2345
+ * has to know the clear edge already reset it), and the standing
2346
+ * explanation, marked so the per-gesture cue arms on its dismissal.
2347
+ * Callers: the dispatch outcome gate (a gesture's confirmed relay
2348
+ * failure) and the boot sequence (a refused `ui/initialize`, ggui#830).
2349
+ */
2350
+ function announceRelayIncapability(trigger, scope) {
2351
+ transitionRelayLatch(true, trigger, scope);
2352
+ resetRelayCueThrottles();
2353
+ showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required');
2354
+ markRelayNotice();
2355
+ }
2311
2356
  /**
2312
2357
  * One {@link RelayDeadTapEvent} per gesture attempted inside a standing
2313
2358
  * dead zone that came back UNDELIVERED (ggui#670 Phase 3). Called from
@@ -2381,6 +2426,10 @@ function isRelayNoticeVisible() {
2381
2426
  * @internal — exported for unit tests.
2382
2427
  */
2383
2428
  export function resetRelayLatchForBoot() {
2429
+ // The runtime's claim on the document's connection writer is taken HERE,
2430
+ // eagerly, before any transition (ggui#843): a second runtime in this
2431
+ // document fails at boot naming itself, never continuing unclaimed.
2432
+ runtimeConnectionWriter();
2384
2433
  if (typeof document !== 'undefined') {
2385
2434
  const el = document.getElementById(ACTION_TOAST_ID);
2386
2435
  if (el !== null && el.hasAttribute(RELAY_NOTICE_ATTR))
@@ -2391,14 +2440,14 @@ export function resetRelayLatchForBoot() {
2391
2440
  transitionRelayLatch(false);
2392
2441
  }
2393
2442
  else {
2394
- connectionStore.set(true);
2443
+ runtimeConnectionWriter().set(true);
2395
2444
  }
2396
2445
  }
2397
2446
  /**
2398
2447
  * Module-level handle to the CURRENT boot's telemetry sink so
2399
2448
  * module-scope paths (the doorbell emitter below) can report without
2400
2449
  * threading the sink through every call chain. Per-iframe
2401
- * single-tenancy makes a single slot correct — same justification as
2450
+ * single-app scope makes a single slot correct — same justification as
2402
2451
  * `getCurrentApp()`.
2403
2452
  */
2404
2453
  let currentTelemetrySink = null;
@@ -2486,7 +2535,7 @@ function resetRelayCueThrottles() {
2486
2535
  /** @internal — exported for unit tests to reset module state. */
2487
2536
  export function __resetRelayNoticeForTest() {
2488
2537
  relayIncapabilityAnnounced = false;
2489
- connectionStore.set(true);
2538
+ runtimeConnectionWriter().set(true);
2490
2539
  relayLatchedAt = undefined;
2491
2540
  relayDeadTaps = 0;
2492
2541
  relayLatchTrigger = undefined;
@@ -2891,39 +2940,46 @@ export function dispatchSubmitAction(args) {
2891
2940
  // helper-minted refusal code, which outranks any positive
2892
2941
  // advertisement (the advertises-but-refuses host previously got a
2893
2942
  // fresh transient toast per tap, forever). Timeouts and unknown
2894
- // codes never reach the second leg; the un-classify edge for BOTH
2895
- // is the same response-arrival clear guard above (attempt-always
2896
- // preserved — nothing here suppresses dispatch, per the #443
2897
- // doctrine: the attempt is the self-heal sensor).
2943
+ // codes never reach the second leg — they never CONFIRM (the
2944
+ // advert-silent leg alone can latch on them); inside a standing
2945
+ // zone they are counted like every other undelivered outcome by
2946
+ // the branch below (ggui#826). The un-classify edge for
2947
+ // BOTH legs is the same response-arrival clear guard above
2948
+ // (attempt-always preserved — nothing here suppresses dispatch,
2949
+ // per the #443 doctrine: the attempt is the self-heal sensor).
2898
2950
  const confirmedRefusal = isConfirmedRelayRefusal(resp);
2951
+ if (relayIncapabilityAnnounced && isRelayShapedFailure(resp)) {
2952
+ // A gesture whose undelivered outcome landed inside a standing
2953
+ // dead zone — a dead tap, counted for the host (ggui#670 Phase 3);
2954
+ // an in-flight gesture that raced the latching one counts too.
2955
+ // Counted on the OUTCOME and keyed on the LATCH, never on
2956
+ // advertisement (ggui#826): the advertises-but-refuses host
2957
+ // latches by a confirmed refusal, and its later code-less or
2958
+ // timeout failures are exactly the undelivered gestures the
2959
+ // RelayDeadTapEvent contract promises to count. The eligibility
2960
+ // predicate below is the LATCH-SET rule — a timeout or unknown
2961
+ // code never CONFIRMS; it can latch only through the advert-silent
2962
+ // leg, never on an advertising host — and is the wrong key for
2963
+ // counting. Every outcome that
2964
+ // reaches here while the latch stands is relay-shaped by
2965
+ // construction (the response-arrival guard above cleared on any
2966
+ // well-formed result); the conjunct states the contract instead
2967
+ // of trusting that. The latching gesture is the 'latched' edge
2968
+ // and a gesture that heals the zone is the 'cleared' edge —
2969
+ // neither is a dead tap.
2970
+ emitRelayDeadTap(intent, { sessionId, appId });
2971
+ return;
2972
+ }
2899
2973
  if (hostCapabilitiesCaptured() &&
2900
2974
  ((!hostCanRelayToolCalls() && isRelayShapedFailure(resp)) ||
2901
2975
  confirmedRefusal)) {
2902
- if (!relayIncapabilityAnnounced) {
2903
- // Transition edge — the paired 'cleared' edge lives at the
2904
- // response-arrival guard above. NEVER emit off-edge (e.g. per
2905
- // channel poll tick): the router ticks on an interval and
2906
- // would spam; the two edges carry the full information.
2907
- transitionRelayLatch(true, confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
2908
- // Establish the flag's invariant where the notice it describes
2909
- // is created: a freshly-shown notice is undismissed, and its
2910
- // dead zone starts with an unspent throttle. Today every
2911
- // re-latch already passes through the clear edge above (which
2912
- // resets both), so this is the same value arriving by a second
2913
- // route — kept because the state belongs to THIS notice, and a
2914
- // future second latch-set path shouldn't have to know that.
2915
- resetRelayCueThrottles();
2916
- showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required');
2917
- markRelayNotice();
2918
- }
2919
- else {
2920
- // A gesture attempted inside a standing dead zone came back
2921
- // undelivered — a dead tap, counted for the host (ggui#670
2922
- // Phase 3). The latching gesture above is the 'latched' edge,
2923
- // and a gesture that heals the zone is the 'cleared' edge —
2924
- // neither is a dead tap.
2925
- emitRelayDeadTap(intent, { sessionId, appId });
2926
- }
2976
+ // Transition edge — the paired 'cleared' edge lives at the
2977
+ // response-arrival guard above. NEVER emit off-edge (e.g. per
2978
+ // channel poll tick): the router ticks on an interval and
2979
+ // would spam; the two edges carry the full information. A
2980
+ // standing latch never reaches here: its undelivered outcomes
2981
+ // were counted above, and a delivered one cleared it.
2982
+ announceRelayIncapability(confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
2927
2983
  return;
2928
2984
  }
2929
2985
  showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
@@ -2932,19 +2988,22 @@ export function dispatchSubmitAction(args) {
2932
2988
  /**
2933
2989
  * Channel-transport router's `tools/call` invoker (ggui#440) —
2934
2990
  * iframe-polling transport, `tools/call` against the parent MCP host
2935
- * via `app.callServerTool` (spec-canonical) when the App handle is
2936
- * set; falls back to raw postMessage pre-handshake or in tests.
2991
+ * via `app.callServerTool` (spec-canonical); with no App bound the
2992
+ * call answers a code-less error envelope — never a raw postMessage.
2937
2993
  * Direct call (no LLM consent loop). Returns the tool's
2938
2994
  * structuredContent (or `content[0]` if that's where the payload
2939
2995
  * landed) as a `JsonValue`. On RPC error we throw — the router
2940
2996
  * catches and silently retries on the next tick.
2941
2997
  *
2942
- * Once a REAL gesture has confirmed this host can't relay (the latch
2943
- * above — set only after {@link dispatchSubmitAction} actually tried
2944
- * and failed on a host with no `serverTools` advertised, with
2945
- * capabilities already captured, AND the failure was relay-shaped —
2946
- * not a well-formed `{ok:false}` result; see `isRelayShapedFailure`),
2947
- * every subsequent poll will fail identically.
2998
+ * Once this document has confirmed it cannot relay (the latch above —
2999
+ * set through the ONE writer on exactly three paths: a REAL gesture
3000
+ * that failed relay-shaped on a host that never advertised
3001
+ * `serverTools`, with capabilities already captured (`advert-silent`);
3002
+ * a declared refusal code answered under a positive advertisement
3003
+ * (`confirmed-refusal`); or the host's own failure of the mandatory
3004
+ * `ui/initialize` handshake, before any gesture (`boot-failed`,
3005
+ * ggui#830) — never a well-formed `{ok:false}` result; see
3006
+ * `isRelayShapedFailure`), every subsequent poll will fail identically.
2948
3007
  *
2949
3008
  * This guard does NOT stop the router's poll loop, and nothing here
2950
3009
  * should ever be written so that it could (ggui#443). It does two
@@ -2970,10 +3029,13 @@ export function dispatchSubmitAction(args) {
2970
3029
  * (ggui's own embed host pre-Task-2 is exactly this case) — silently
2971
3030
  * killing working channel polls forever. That is "absence of a
2972
3031
  * capability blocks an attempt", which the fail-safe constraint
2973
- * forbids. The latch implies `!hostCanRelayToolCalls()` (it only
2974
- * ever sets inside that guard), so testing it alone is sufficient —
2975
- * a host that never fails a real gesture never latches, and its
2976
- * channels keep working.
3032
+ * forbids. The latch is set only by a CONFIRMED OUTCOME — a failed
3033
+ * real gesture (the advert-silent leg, or a confirmed refusal under a
3034
+ * positive advertisement, ggui#599 cycle-2) or a failed handshake
3035
+ * (ggui#830), never by an advertisement's absence — so testing the
3036
+ * latch alone is sufficient: a host that answers its handshake and
3037
+ * never fails a real gesture never latches, and its channels keep
3038
+ * working.
2977
3039
  *
2978
3040
  * Extracted to a named export (rather than an inline closure inside
2979
3041
  * `bootProduction`) so unit tests can drive it directly through the
@@ -3645,10 +3707,10 @@ async function bootProduction(opts) {
3645
3707
  // DEFAULT-handlers compositions only (a custom `handlers` list
3646
3708
  // drains its own pipe; bridging the channel onto an instance
3647
3709
  // nobody drains would buffer gestures into the void — see the
3648
- // hoist note in server.ts). The hosted pod IS a custom-handlers
3649
- // caller: on it, a WS gesture lands on the retained ledger only
3710
+ // hoist note in server.ts). A deployment composing its own `handlers`
3711
+ // IS such a caller: on it, a WS gesture lands on the retained ledger only
3650
3712
  // and never reaches the agent. Any WS action fallback (ggui#599's
3651
- // open design leg) therefore requires the server/pod bridge
3713
+ // open design leg) therefore requires the server-side bridge
3652
3714
  // FIRST — it is a capability gap on that composition today. The WS pipe otherwise carries
3653
3715
  // streamSpec subscriptions (inbound `ggui_emit` fanout +
3654
3716
  // `props_update` + `render` + `data` + `drain_ack` +
@@ -3797,29 +3859,27 @@ async function bootProduction(opts) {
3797
3859
  !('createdAt' in render)
3798
3860
  ? { codeModuleUrl: meta.codeModuleUrl }
3799
3861
  : {}),
3800
- // Base-theme ladder (ggui#589 ask 3 — see `resolveMountThemeId`):
3801
- // stamped themeId > the slice theme object's NAME (a
3802
- // registered name binds the full brand base; unregistered
3803
- // names fall back to the default ladder, byte-identical to
3804
- // the no-themeId path).
3862
+ // Compiled ladder (see `resolveMountThemeId`): the stamped
3863
+ // themeId alone; absent = the renderer's default ladder.
3805
3864
  ...(() => {
3806
3865
  const themeId = resolveMountThemeId(meta);
3807
3866
  return themeId !== undefined ? { themeId } : {};
3808
3867
  })(),
3809
- // Mode ladder (ggui#551 + #589 — see `resolveMountThemeMode`):
3810
- // stamped `themeMode` > the slice theme OBJECT's `mode` >
3811
- // host-announced theme. Absent everywhere means "no opinion",
3812
- // not "light".
3868
+ // Mode (ggui#987 §4 — see `resolveMountThemeMode`): host-
3869
+ // announced theme > stamped `themeMode` > the app theme's
3870
+ // default `mode`. Absent everywhere means "no opinion", not
3871
+ // "light". Re-resolved on every buildOpts, so the live
3872
+ // hostcontextchanged re-injection below picks up a flip.
3813
3873
  ...(() => {
3814
3874
  const themeMode = resolveMountThemeMode(meta);
3815
3875
  return themeMode !== undefined ? { themeMode } : {};
3816
3876
  })(),
3817
- // Per-app theme overlay (St3 M2.2). Threaded straight from the
3877
+ // Per-app theme (ggui#987 v2). Threaded straight from the
3818
3878
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3819
3879
  // already injection-validated by the wire parser) onto the mount
3820
- // options so the renderer applies the `--ggui-*` overrides +
3821
- // `color-scheme` at `:root`. THEME IS COMPONENT-ONLY — system
3822
- // cards theme via the SystemCardHost/ThemeProvider path.
3880
+ // options; the renderer injects the effective mode's overlay
3881
+ // into the scoped block + `:root`. THEME IS COMPONENT-ONLY —
3882
+ // system cards theme via the SystemCardHost/ThemeProvider path.
3823
3883
  ...(meta.theme !== undefined ? { appTheme: meta.theme } : {}),
3824
3884
  // Host palette: always threaded when the host announced one —
3825
3885
  // unlike mode there is no either/or gate here, because the
@@ -3845,14 +3905,52 @@ async function bootProduction(opts) {
3845
3905
  * layer just calls `applyRender(render)` without owning
3846
3906
  * lifecycle state.
3847
3907
  */
3908
+ // Every in-place update of the mounted slot goes through ONE
3909
+ // chain: the WS render-frame / props_update path and the live
3910
+ // theme re-injection below are independent callers, and the
3911
+ // handle's `update` has no lock of its own — serializing them
3912
+ // here is what keeps a host flip landing mid-render from
3913
+ // interleaving two updates.
3914
+ let updateChain = Promise.resolve();
3915
+ const queueUpdate = (opts) => {
3916
+ const run = updateChain.then(async () => {
3917
+ if (renderHandle === null)
3918
+ return;
3919
+ await renderHandle.update(opts());
3920
+ });
3921
+ updateChain = run.catch(() => undefined);
3922
+ return run;
3923
+ };
3848
3924
  const applyRender = async (render) => {
3849
3925
  currentRender = render;
3850
3926
  if (renderHandle === null) {
3851
3927
  renderHandle = await mountRender(renderInto, buildOpts(render));
3852
3928
  return;
3853
3929
  }
3854
- await renderHandle.update(buildOpts(render));
3930
+ await queueUpdate(() => buildOpts(render));
3855
3931
  };
3932
+ // Live re-injection (ggui#987 §4): the host owns runtime mode and
3933
+ // may flip it mid-session. The trigger is the emitter's THEME
3934
+ // subscription — the raw `theme` / `styles` payload — because the
3935
+ // host-context projection excludes both by design and its
3936
+ // equality gate would never fire for a theme-only flip. The App
3937
+ // pre-merges the new hostContext before the emitter fans out, so
3938
+ // re-running `buildOpts` on the CURRENT render re-resolves mode +
3939
+ // palette and the renderer repaints from the retained other
3940
+ // overlay — no fetch, no remount. A rejection is reported to the
3941
+ // embedding host, never dropped on the floor.
3942
+ activeThemeUnsubscribe?.();
3943
+ activeThemeUnsubscribe = subscribeHostThemeChange(() => {
3944
+ if (renderHandle === null || currentRender === null)
3945
+ return;
3946
+ const render = currentRender;
3947
+ queueUpdate(() => buildOpts(render)).catch((err) => {
3948
+ postObservabilityToParent({
3949
+ kind: 'theme-reinject-failed',
3950
+ message: err instanceof Error ? err.message : String(err),
3951
+ });
3952
+ });
3953
+ });
3856
3954
  // Publish `applyRender` module-level. The persistent tool-result
3857
3955
  // listener is ALREADY registered — `bootSequence` installs it
3858
3956
  // pre-connect (ggui#586) — and reads this slot dynamically, so