@ggui-ai/iframe-runtime 0.15.0 → 0.17.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.d.ts CHANGED
@@ -31,6 +31,7 @@ import { type McpAppAiGguiRenderMeta, type McpAppBootstrapFailedMessage, type Mc
31
31
  import type { GguiSessionSeedInput } from './types.js';
32
32
  import type { McpAppAiGguiMetaParseFailureReason } from './types.js';
33
33
  import { App } from '@modelcontextprotocol/ext-apps';
34
+ import type { McpUiDisplayMode } from '@modelcontextprotocol/ext-apps';
34
35
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
35
36
  import type { GadgetPackageRegistry } from './globals.js';
36
37
  import { StreamBus } from './wire-config.js';
@@ -97,36 +98,40 @@ export declare function setCurrentApp(app: App): void;
97
98
  * `color-scheme` via `applyDocumentTheme` — nothing in the token
98
99
  * pipeline keys off it, so a dark host painted the light ladder
99
100
  * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
100
- * `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
101
- * every operator layer (live provider > per-render override > app
102
- * default > server default) still wins by being stamped; the host
103
- * fills the gap where no operator had an opinion.
101
+ * Under ggui#987 §4 the embedding host OWNS runtime mode: when it
102
+ * announces one, `buildOpts` paints it over every stamped opinion; the
103
+ * stamped layers decide only when the host is silent. Read live from
104
+ * the App's pre-merged hostContext, so a `hostcontextchanged` flip
105
+ * re-resolves on the next `buildOpts`.
104
106
  *
105
107
  * @internal — exported for unit tests (`setCurrentApp` injects the
106
108
  * App); production caller is `buildOpts` inside `bootSequence`.
107
109
  */
108
110
  export declare function hostAnnouncedThemeMode(): 'light' | 'dark' | undefined;
109
111
  /**
110
- * Resolve the mount's base-stylesheet color mode from the slice, with
111
- * the host as the final fallback (ggui#589, completing the ggui#551
112
- * precedence ruling — "slice wins, host is the fallback, absent ≠
113
- * light"):
112
+ * The host's announced display mode, from the same pre-merged host context the
113
+ * theme mode reads (ggui#1041). `fullscreen` means the host shows this page as
114
+ * the whole of a canvas — the runtime then composes the tree with `fit: 'fill'`
115
+ * so the mounted root drops its own silhouette and fills the page; anything
116
+ * else (or an absent context) keeps the inline card.
117
+ */
118
+ export declare function hostAnnouncedDisplayMode(): McpUiDisplayMode | undefined;
119
+ /**
120
+ * Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
121
+ * runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
114
122
  *
115
- * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
116
- * host-announced theme > undefined
123
+ * host-announced theme > stamped `themeMode` > `theme.mode`
124
+ * (the app theme's default) > undefined
117
125
  *
118
- * The middle leg is the ggui#589 fix: a render envelope carrying a
119
- * per-app theme (`theme: {mode, cssVariables}`) but no top-level
120
- * `themeMode` is still a slice-stamped mode opinion — before this,
121
- * the base token ladder ignored it and painted the LIGHT token set
122
- * under a dark overlay whenever the host (correctly, per the
123
- * adapter-boundary doctrine in the native host helpers) announced no
124
- * `hostContext.theme`: a light skeleton in a dark skin. The theme
125
- * object's own `mode` already drove `color-scheme`; now it also
126
- * selects the ladder.
126
+ * A card that ignored the host's announced mode painted a light
127
+ * skeleton inside a dark chat — the host's word is what the user
128
+ * actually sees around the card. The stamped layers (live provider >
129
+ * per-render override > app default > server default) decide only when
130
+ * the host is silent, which is the adapter-boundary case (a host
131
+ * helper that announces no `hostContext.theme`).
127
132
  *
128
133
  * `undefined` still means "no opinion anywhere" — the renderer's
129
- * default applies; never coerced to `'light'`.
134
+ * default applies; never coerced to `'light'` here (ggui#551).
130
135
  *
131
136
  * @internal — exported for unit tests; production caller is
132
137
  * `buildOpts` inside `bootSequence`.
@@ -134,36 +139,24 @@ export declare function hostAnnouncedThemeMode(): 'light' | 'dark' | undefined;
134
139
  export declare function resolveMountThemeMode(meta: {
135
140
  readonly themeMode?: 'light' | 'dark';
136
141
  readonly theme?: {
137
- readonly mode: 'light' | 'dark';
138
- readonly cssVariables?: Record<string, string>;
142
+ readonly mode?: 'light' | 'dark';
139
143
  };
140
144
  }): 'light' | 'dark' | undefined;
141
145
  /**
142
- * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
146
+ * Resolve the mount's compiled-ladder id from the slice (ggui#987 D2):
143
147
  *
144
- * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
145
- * > undefined (renderer default ladder)
148
+ * stamped `themeId` > undefined (renderer default ladder)
146
149
  *
147
- * The name leg makes a slice theme whose `name` matches a REGISTERED
148
- * theme id select that theme as the BASE token ladder — the overlay's
149
- * cssVariables still apply above it, so the base carries the full
150
- * brand ramp coverage a sparse overlay cannot (the "only the mapped
151
- * vars carry brand" store-frame class). An unregistered name is
152
- * harmless by construction: `getScopedThemeCss` falls back to the
153
- * default theme, which is byte-identical to the no-themeId path
154
- * (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
155
- * naming its theme "My Custom" renders exactly as before.
150
+ * The app theme's `name` is a LABEL the author's tooling reads back —
151
+ * it never selects a ladder. An app theme carries its own full
152
+ * projection (`overlays`, exactly the manifest minus the floor), so
153
+ * there is no coverage gap for a compiled theme to fill beneath it.
156
154
  *
157
155
  * @internal — exported for unit tests; production caller is
158
156
  * `buildOpts` inside `bootSequence`.
159
157
  */
160
158
  export declare function resolveMountThemeId(meta: {
161
159
  readonly themeId?: string;
162
- readonly theme?: {
163
- readonly name?: string;
164
- readonly mode?: 'light' | 'dark';
165
- readonly cssVariables?: Record<string, string>;
166
- };
167
160
  }): string | undefined;
168
161
  /**
169
162
  * The host's announced palette, read from the connected App's
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';
33
+ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, reportInvalidAppTheme, reportStrippedThemeMembers, } from './meta-parse.js';
34
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,40 @@ 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
+ * The host's announced display mode, from the same pre-merged host context the
208
+ * theme mode reads (ggui#1041). `fullscreen` means the host shows this page as
209
+ * the whole of a canvas — the runtime then composes the tree with `fit: 'fill'`
210
+ * so the mounted root drops its own silhouette and fills the page; anything
211
+ * else (or an absent context) keeps the inline card.
212
+ */
213
+ export function hostAnnouncedDisplayMode() {
214
+ return currentApp?.getHostContext()?.displayMode;
215
+ }
216
+ /**
217
+ * Resolve the mount's color mode (ggui#987 §4 — the embedding host owns
218
+ * runtime mode; the app theme's `mode` is a DEFAULT, never a pin):
205
219
  *
206
- * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
207
- * host-announced theme > undefined
220
+ * host-announced theme > stamped `themeMode` > `theme.mode`
221
+ * (the app theme's default) > undefined
208
222
  *
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.
223
+ * A card that ignored the host's announced mode painted a light
224
+ * skeleton inside a dark chat — the host's word is what the user
225
+ * actually sees around the card. The stamped layers (live provider >
226
+ * per-render override > app default > server default) decide only when
227
+ * the host is silent, which is the adapter-boundary case (a host
228
+ * helper that announces no `hostContext.theme`).
218
229
  *
219
230
  * `undefined` still means "no opinion anywhere" — the renderer's
220
- * default applies; never coerced to `'light'`.
231
+ * default applies; never coerced to `'light'` here (ggui#551).
221
232
  *
222
233
  * @internal — exported for unit tests; production caller is
223
234
  * `buildOpts` inside `bootSequence`.
224
235
  */
225
236
  export function resolveMountThemeMode(meta) {
226
237
  // 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.
238
+ // total order (@ggui-ai/protocol/integrations/theme-binding) — the
239
+ // server stamps via the sibling projection and the protocol's
240
+ // composition-law test pins that the two compose without rank drift.
231
241
  return effectiveThemeMode({
232
242
  stamped: meta.themeMode,
233
243
  sessionSidecar: meta.theme?.mode,
@@ -235,20 +245,14 @@ export function resolveMountThemeMode(meta) {
235
245
  });
236
246
  }
237
247
  /**
238
- * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
248
+ * Resolve the mount's compiled-ladder id from the slice (ggui#987 D2):
239
249
  *
240
- * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
241
- * > undefined (renderer default ladder)
250
+ * stamped `themeId` > undefined (renderer default ladder)
242
251
  *
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.
252
+ * The app theme's `name` is a LABEL the author's tooling reads back —
253
+ * it never selects a ladder. An app theme carries its own full
254
+ * projection (`overlays`, exactly the manifest minus the floor), so
255
+ * there is no coverage gap for a compiled theme to fill beneath it.
252
256
  *
253
257
  * @internal — exported for unit tests; production caller is
254
258
  * `buildOpts` inside `bootSequence`.
@@ -256,10 +260,7 @@ export function resolveMountThemeMode(meta) {
256
260
  export function resolveMountThemeId(meta) {
257
261
  // Client projection of the normative theme-binding order — see
258
262
  // resolveMountThemeMode above.
259
- return effectiveThemeId({
260
- stamped: meta.themeId,
261
- sidecarName: meta.theme?.name,
262
- });
263
+ return effectiveThemeId({ stamped: meta.themeId });
263
264
  }
264
265
  /**
265
266
  * The host's announced palette, read from the connected App's
@@ -461,7 +462,7 @@ export async function bootSequence(opts) {
461
462
  // `tools/call` (dispatchSubmitAction, channel-transport router)
462
463
  // routes through `app.callServerTool` instead of the legacy raw
463
464
  // postMessage pump. Idempotent: re-boot with the same App is a
464
- // no-op; a different App throws (the iframe is single-tenant).
465
+ // no-op; a different App throws (the iframe is single-app).
465
466
  setCurrentApp(app);
466
467
  // Slice-meta resolution — spec-canonical primary, no in-house
467
468
  // Reading-B (retired Phase 1.19b.3 with the App-class swap; the
@@ -1356,7 +1357,10 @@ export async function resolveMetaViaReadDoor(app, locator) {
1356
1357
  const envelope = readGguiShellEnvelope(text);
1357
1358
  if (envelope === undefined)
1358
1359
  return null;
1359
- const parsed = parseMcpAppAiGguiRenderMeta(envelope);
1360
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope, {
1361
+ onInvalidTheme: reportInvalidAppTheme,
1362
+ onStrippedThemeMembers: reportStrippedThemeMembers,
1363
+ });
1360
1364
  if (!parsed.ok || parsed.meta === undefined)
1361
1365
  return null;
1362
1366
  const validated = validateMeta(parsed.meta);
@@ -1406,7 +1410,7 @@ function awaitToolResultMeta(timeoutMs) {
1406
1410
  * App instance even across re-mounts (e.g. an agent fires a second
1407
1411
  * `ggui_render` and the listener re-applies through the published
1408
1412
  * `applyRender`). Keyed by App — not a module boolean — so a fresh App
1409
- * (a re-booted document in tests; production is single-tenant per
1413
+ * (a re-booted document in tests; production is single-app per
1410
1414
  * iframe) always gets its own registration, never a stale latch.
1411
1415
  */
1412
1416
  const persistentToolResultListenerApps = new WeakSet();
@@ -1883,7 +1887,7 @@ function applyFreezeCue(root) {
1883
1887
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
1884
1888
  fontSize: '11px',
1885
1889
  fontWeight: '600',
1886
- color: 'var(--ggui-color-onSurface, inherit)',
1890
+ color: 'var(--ggui-color-onContainer, inherit)',
1887
1891
  background: 'rgba(128,128,128,0.16)',
1888
1892
  border: '1px solid currentColor',
1889
1893
  opacity: '0.85',
@@ -2320,7 +2324,7 @@ let relayLatchTrigger;
2320
2324
  let relayLatchScope = {};
2321
2325
  function transitionRelayLatch(next, trigger, scope = {}) {
2322
2326
  relayIncapabilityAnnounced = next;
2323
- connectionStore.set(!next);
2327
+ runtimeConnectionWriter().set(!next);
2324
2328
  if (next && trigger !== undefined) {
2325
2329
  relayLatchedAt = Date.now();
2326
2330
  relayDeadTaps = 0;
@@ -2435,6 +2439,10 @@ function isRelayNoticeVisible() {
2435
2439
  * @internal — exported for unit tests.
2436
2440
  */
2437
2441
  export function resetRelayLatchForBoot() {
2442
+ // The runtime's claim on the document's connection writer is taken HERE,
2443
+ // eagerly, before any transition (ggui#843): a second runtime in this
2444
+ // document fails at boot naming itself, never continuing unclaimed.
2445
+ runtimeConnectionWriter();
2438
2446
  if (typeof document !== 'undefined') {
2439
2447
  const el = document.getElementById(ACTION_TOAST_ID);
2440
2448
  if (el !== null && el.hasAttribute(RELAY_NOTICE_ATTR))
@@ -2445,14 +2453,14 @@ export function resetRelayLatchForBoot() {
2445
2453
  transitionRelayLatch(false);
2446
2454
  }
2447
2455
  else {
2448
- connectionStore.set(true);
2456
+ runtimeConnectionWriter().set(true);
2449
2457
  }
2450
2458
  }
2451
2459
  /**
2452
2460
  * Module-level handle to the CURRENT boot's telemetry sink so
2453
2461
  * module-scope paths (the doorbell emitter below) can report without
2454
2462
  * threading the sink through every call chain. Per-iframe
2455
- * single-tenancy makes a single slot correct — same justification as
2463
+ * single-app scope makes a single slot correct — same justification as
2456
2464
  * `getCurrentApp()`.
2457
2465
  */
2458
2466
  let currentTelemetrySink = null;
@@ -2540,7 +2548,7 @@ function resetRelayCueThrottles() {
2540
2548
  /** @internal — exported for unit tests to reset module state. */
2541
2549
  export function __resetRelayNoticeForTest() {
2542
2550
  relayIncapabilityAnnounced = false;
2543
- connectionStore.set(true);
2551
+ runtimeConnectionWriter().set(true);
2544
2552
  relayLatchedAt = undefined;
2545
2553
  relayDeadTaps = 0;
2546
2554
  relayLatchTrigger = undefined;
@@ -3712,10 +3720,10 @@ async function bootProduction(opts) {
3712
3720
  // DEFAULT-handlers compositions only (a custom `handlers` list
3713
3721
  // drains its own pipe; bridging the channel onto an instance
3714
3722
  // nobody drains would buffer gestures into the void — see the
3715
- // hoist note in server.ts). The hosted pod IS a custom-handlers
3716
- // caller: on it, a WS gesture lands on the retained ledger only
3723
+ // hoist note in server.ts). A deployment composing its own `handlers`
3724
+ // IS such a caller: on it, a WS gesture lands on the retained ledger only
3717
3725
  // and never reaches the agent. Any WS action fallback (ggui#599's
3718
- // open design leg) therefore requires the server/pod bridge
3726
+ // open design leg) therefore requires the server-side bridge
3719
3727
  // FIRST — it is a capability gap on that composition today. The WS pipe otherwise carries
3720
3728
  // streamSpec subscriptions (inbound `ggui_emit` fanout +
3721
3729
  // `props_update` + `render` + `data` + `drain_ack` +
@@ -3864,29 +3872,31 @@ async function bootProduction(opts) {
3864
3872
  !('createdAt' in render)
3865
3873
  ? { codeModuleUrl: meta.codeModuleUrl }
3866
3874
  : {}),
3867
- // Base-theme ladder (ggui#589 ask 3 — see `resolveMountThemeId`):
3868
- // stamped themeId > the slice theme object's NAME (a
3869
- // registered name binds the full brand base; unregistered
3870
- // names fall back to the default ladder, byte-identical to
3871
- // the no-themeId path).
3875
+ // Compiled ladder (see `resolveMountThemeId`): the stamped
3876
+ // themeId alone; absent = the renderer's default ladder.
3872
3877
  ...(() => {
3873
3878
  const themeId = resolveMountThemeId(meta);
3874
3879
  return themeId !== undefined ? { themeId } : {};
3875
3880
  })(),
3876
- // Mode ladder (ggui#551 + #589 — see `resolveMountThemeMode`):
3877
- // stamped `themeMode` > the slice theme OBJECT's `mode` >
3878
- // host-announced theme. Absent everywhere means "no opinion",
3879
- // not "light".
3881
+ // Mode (ggui#987 §4 — see `resolveMountThemeMode`): host-
3882
+ // announced theme > stamped `themeMode` > the app theme's
3883
+ // default `mode`. Absent everywhere means "no opinion", not
3884
+ // "light". Re-resolved on every buildOpts, so the live
3885
+ // hostcontextchanged re-injection below picks up a flip.
3880
3886
  ...(() => {
3881
3887
  const themeMode = resolveMountThemeMode(meta);
3882
3888
  return themeMode !== undefined ? { themeMode } : {};
3883
3889
  })(),
3884
- // Per-app theme overlay (St3 M2.2). Threaded straight from the
3890
+ // The host's canvas is the chrome (ggui#1041): a `fullscreen` display mode
3891
+ // composes the tree with `fit: 'fill'`; the hostcontextchanged re-injection
3892
+ // below picks up a flip exactly as it does the theme mode.
3893
+ ...(hostAnnouncedDisplayMode() === 'fullscreen' ? { fit: 'fill' } : {}),
3894
+ // Per-app theme (ggui#987 v2). Threaded straight from the
3885
3895
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3886
3896
  // already injection-validated by the wire parser) onto the mount
3887
- // options so the renderer applies the `--ggui-*` overrides +
3888
- // `color-scheme` at `:root`. THEME IS COMPONENT-ONLY — system
3889
- // cards theme via the SystemCardHost/ThemeProvider path.
3897
+ // options; the renderer injects the effective mode's overlay
3898
+ // into the scoped block + `:root`. THEME IS COMPONENT-ONLY —
3899
+ // system cards theme via the SystemCardHost/ThemeProvider path.
3890
3900
  ...(meta.theme !== undefined ? { appTheme: meta.theme } : {}),
3891
3901
  // Host palette: always threaded when the host announced one —
3892
3902
  // unlike mode there is no either/or gate here, because the
@@ -3912,14 +3922,52 @@ async function bootProduction(opts) {
3912
3922
  * layer just calls `applyRender(render)` without owning
3913
3923
  * lifecycle state.
3914
3924
  */
3925
+ // Every in-place update of the mounted slot goes through ONE
3926
+ // chain: the WS render-frame / props_update path and the live
3927
+ // theme re-injection below are independent callers, and the
3928
+ // handle's `update` has no lock of its own — serializing them
3929
+ // here is what keeps a host flip landing mid-render from
3930
+ // interleaving two updates.
3931
+ let updateChain = Promise.resolve();
3932
+ const queueUpdate = (opts) => {
3933
+ const run = updateChain.then(async () => {
3934
+ if (renderHandle === null)
3935
+ return;
3936
+ await renderHandle.update(opts());
3937
+ });
3938
+ updateChain = run.catch(() => undefined);
3939
+ return run;
3940
+ };
3915
3941
  const applyRender = async (render) => {
3916
3942
  currentRender = render;
3917
3943
  if (renderHandle === null) {
3918
3944
  renderHandle = await mountRender(renderInto, buildOpts(render));
3919
3945
  return;
3920
3946
  }
3921
- await renderHandle.update(buildOpts(render));
3947
+ await queueUpdate(() => buildOpts(render));
3922
3948
  };
3949
+ // Live re-injection (ggui#987 §4): the host owns runtime mode and
3950
+ // may flip it mid-session. The trigger is the emitter's THEME
3951
+ // subscription — the raw `theme` / `styles` payload — because the
3952
+ // host-context projection excludes both by design and its
3953
+ // equality gate would never fire for a theme-only flip. The App
3954
+ // pre-merges the new hostContext before the emitter fans out, so
3955
+ // re-running `buildOpts` on the CURRENT render re-resolves mode +
3956
+ // palette and the renderer repaints from the retained other
3957
+ // overlay — no fetch, no remount. A rejection is reported to the
3958
+ // embedding host, never dropped on the floor.
3959
+ activeThemeUnsubscribe?.();
3960
+ activeThemeUnsubscribe = subscribeHostThemeChange(() => {
3961
+ if (renderHandle === null || currentRender === null)
3962
+ return;
3963
+ const render = currentRender;
3964
+ queueUpdate(() => buildOpts(render)).catch((err) => {
3965
+ postObservabilityToParent({
3966
+ kind: 'theme-reinject-failed',
3967
+ message: err instanceof Error ? err.message : String(err),
3968
+ });
3969
+ });
3970
+ });
3923
3971
  // Publish `applyRender` module-level. The persistent tool-result
3924
3972
  // listener is ALREADY registered — `bootSequence` installs it
3925
3973
  // pre-connect (ggui#586) — and reads this slot dynamically, so
@@ -35,6 +35,13 @@ export function ensureStatusDom(doc) {
35
35
  (() => {
36
36
  const el = doc.createElement('ul');
37
37
  el.setAttribute('data-ggui-session-root', '');
38
+ // The list is the runtime's mount target, not a list: without this
39
+ // reset the browser's defaults (16 px margins, a 40 px indent, disc
40
+ // bullets) sat inside every compact card's iframe — the card 40 px
41
+ // right of the pane edge and 32 px taller than its box (ggui#1096).
42
+ el.style.margin = '0';
43
+ el.style.padding = '0';
44
+ el.style.listStyle = 'none';
38
45
  doc.body.appendChild(el);
39
46
  return el;
40
47
  })();
@@ -36,7 +36,7 @@ function GguiWordmark() {
36
36
  // off-white in dark). `gray-300` flips too — the gray scale in
37
37
  // `darkTheme` is intentionally inverted, so the wordmark's
38
38
  // contrasting "chrome" tone reads correctly under either theme.
39
- const ink = 'var(--ggui-color-onSurface, #292929)';
39
+ const ink = 'var(--ggui-color-onContainer, #292929)';
40
40
  const chrome = 'var(--ggui-color-neutral-300, #d1d5db)';
41
41
  return (_jsxs("svg", { viewBox: "0 0 224 50", width: "72", height: "16", role: "img", "aria-label": "ggui", style: { display: 'block', flexShrink: 0 }, children: [_jsx("path", { d: "M 0 0 H 50 V 25 H 25 V 50 H 0 Z", fill: chrome }), _jsx("rect", { x: "33", y: "33", width: "17", height: "17", fill: ink }), _jsx("path", { d: "M 58 0 H 108 V 25 H 83 V 50 H 58 Z", fill: ink }), _jsx("rect", { x: "91", y: "33", width: "17", height: "17", fill: chrome }), _jsx("path", { d: "M 141 50 C 154.807 50 166 38.8071 166 25 V 0 H 116 V 25 C 116 38.8071 127.193 50 141 50 Z", fill: ink }), _jsx("rect", { x: "174", y: "0", width: "50", height: "50", fill: chrome })] }));
42
42
  }
@@ -87,10 +87,10 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
87
87
  // INVERTED in darkTheme so `gray-100` is light-on-dark when in
88
88
  // dark mode and dark-on-light when in light mode).
89
89
  //
90
- // - `--ggui-color-surface` — opaque card surface (white in light,
90
+ // - `--ggui-color-container` — opaque card surface (white in light,
91
91
  // slate-800 in dark). Used for the URL block + QR backdrop.
92
- // - `--ggui-color-onSurface` — high-contrast body text.
93
- // - `--ggui-color-onSurfaceVariant` — muted captions/headings.
92
+ // - `--ggui-color-onContainer` — high-contrast body text.
93
+ // - `--ggui-color-onSunken` — muted captions/headings.
94
94
  // - `--ggui-color-neutral-100` — subtle tinted background that
95
95
  // auto-flips. Used for the inner "details" panel — slightly
96
96
  // differentiated from outer transparent canvas without being
@@ -103,7 +103,7 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
103
103
  // background creates a card-on-card look. Padding stays so content
104
104
  // doesn't crowd the iframe edges. Outside Claude (preview iframe,
105
105
  // console inspector), the ThemeProvider's body background is the
106
- // theme's `--ggui-color-surface`, so a transparent card just
106
+ // theme's `--ggui-color-container`, so a transparent card just
107
107
  // shows the theme's canvas behind it — also fine.
108
108
  _jsx(Card, { padding: "lg", border: false, shadow: "none", style: { backgroundColor: 'transparent' }, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "md", children: [_jsxs("div", { style: {
109
109
  display: 'flex',
@@ -118,11 +118,11 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
118
118
  }, children: [_jsx(Heading, { level: 3, children: "Connect an LLM provider" }), _jsx("code", { style: {
119
119
  fontFamily: 'var(--ggui-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
120
120
  fontSize: 'var(--ggui-font-size-xs, 11px)',
121
- color: 'var(--ggui-color-onSurfaceVariant, #71717a)',
121
+ color: 'var(--ggui-color-onSunken, #71717a)',
122
122
  opacity: 0.7,
123
123
  userSelect: 'all',
124
124
  }, title: "iframe-runtime build identifier \u2014 useful for diagnosing stale caches", children: BUILD_ID })] }), _jsx(Text, { style: {
125
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
125
+ color: 'var(--ggui-color-onSunken, #52525b)',
126
126
  lineHeight: '1.55',
127
127
  }, children: "ggui needs an LLM key to render UIs. Open this server\u2019s settings page to paste an Anthropic, OpenAI, Google, or OpenRouter API key." })] }), _jsx("div", { style: {
128
128
  display: 'flex',
@@ -148,7 +148,7 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
148
148
  borderRadius: 'var(--ggui-shape-radius-sm, 6px)',
149
149
  lineHeight: 0,
150
150
  }, children: _jsx(QrCode, { value: settingsUrl, size: 120, foreground: "#000000", background: "#ffffff" }) }), _jsx(Text, { size: "xs", style: {
151
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
151
+ color: 'var(--ggui-color-onSunken, #52525b)',
152
152
  fontSize: 'var(--ggui-font-size-xs, 11px)',
153
153
  textAlign: 'center',
154
154
  }, children: "Scan with your phone" })] }), _jsxs("div", { style: {
@@ -158,7 +158,7 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
158
158
  flex: '1 1 240px',
159
159
  minWidth: '200px',
160
160
  }, children: [_jsx(Text, { size: "xs", style: {
161
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
161
+ color: 'var(--ggui-color-onSunken, #52525b)',
162
162
  fontWeight: 'var(--ggui-font-weight-semibold, 600)',
163
163
  textTransform: 'uppercase',
164
164
  letterSpacing: '0.06em',
@@ -166,11 +166,11 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
166
166
  }, children: "Or open this URL in your browser" }), _jsx("div", { style: {
167
167
  fontFamily: 'var(--ggui-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
168
168
  fontSize: 'var(--ggui-font-size-sm, 13px)',
169
- color: 'var(--ggui-color-onSurface, #111)',
169
+ color: 'var(--ggui-color-onContainer, #111)',
170
170
  wordBreak: 'break-all',
171
171
  userSelect: 'all',
172
172
  padding: 'var(--ggui-spacing-3, 12px)',
173
- background: 'var(--ggui-color-surface, #fff)',
173
+ background: 'var(--ggui-color-container, #fff)',
174
174
  border: '1px solid var(--ggui-color-neutral-200, #e4e4e7)',
175
175
  borderRadius: 'var(--ggui-shape-radius-sm, 6px)',
176
176
  lineHeight: '1.45',
@@ -180,10 +180,10 @@ export function NoCredentialsCard({ settingsUrl, intent, }) {
180
180
  gap: 'var(--ggui-spacing-3, 12px)',
181
181
  flexWrap: 'wrap',
182
182
  }, children: [_jsx(Button, { variant: "primary", size: "md", onClick: onPrimary, disabled: ctaDisabled, children: ctaLabel }), ctaState === 'idle' || ctaState === 'working' ? (_jsx(Text, { size: "xs", style: {
183
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
183
+ color: 'var(--ggui-color-onSunken, #52525b)',
184
184
  opacity: 0.85,
185
185
  }, children: "or tap-and-hold the URL above to copy" })) : null] })] })] }) }), intent ? (_jsxs(Text, { size: "xs", style: {
186
- color: 'var(--ggui-color-onSurfaceVariant, #52525b)',
186
+ color: 'var(--ggui-color-onSunken, #52525b)',
187
187
  opacity: 0.7,
188
188
  }, children: ["Pending prompt: ", intent] })) : null] }) }));
189
189
  }