@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/index.d.ts CHANGED
@@ -37,7 +37,7 @@ export { parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './m
37
37
  export type { RendererBootFailureReason, RendererBootFailedMessage, } from './runtime.js';
38
38
  export type { ProtocolError, ProtocolErrorEmitter, BootstrapFailureReason, } from './protocol-error.js';
39
39
  export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
40
- export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, SchemaVersionMismatchEvent, SubscribeFailedEvent, ChannelTransportPickedEvent, ChannelTransportFallbackEvent, ChannelTransportResubscribedEvent, ChannelPollDegradedEvent, ChannelPollRecoveredEvent, UiFeedbackEvent, RelayDeadTapEvent, RelayIncapabilityEvent, RelayLatchTrigger, UnknownObservabilityEvent, } from './observability.js';
40
+ export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, SchemaVersionMismatchEvent, FontFaceBlockedEvent, AppThemeInvalidEvent, ThemeReinjectFailedEvent, SubscribeFailedEvent, ChannelTransportPickedEvent, ChannelTransportFallbackEvent, ChannelTransportResubscribedEvent, ChannelPollDegradedEvent, ChannelPollRecoveredEvent, UiFeedbackEvent, RelayDeadTapEvent, RelayIncapabilityEvent, RelayLatchTrigger, UnknownObservabilityEvent, } from './observability.js';
41
41
  export { isObservabilityMessage, isRelayDeadTapEvent, isRelayIncapabilityEvent, postObservabilityToParent, } from './observability.js';
42
42
  export type { LifecycleEmitter } from './lifecycle.js';
43
43
  export { postLifecycleToParent, makeLifecycleEvent } from './lifecycle.js';
@@ -50,6 +50,21 @@ export interface McpAppIframeMountOptions {
50
50
  /** Optional locale; defaults to `navigator.language` when
51
51
  * available. */
52
52
  readonly locale?: string;
53
+ /**
54
+ * The color mode this host paints around the card (ggui#987 §4: the
55
+ * embedding host owns runtime mode). Announced as
56
+ * `hostContext.theme`; the card's ladder, overlay and `color-scheme`
57
+ * follow it. Omit to announce nothing — the card falls back to the
58
+ * slice's stamped mode.
59
+ */
60
+ readonly theme?: 'light' | 'dark';
61
+ /**
62
+ * `@font-face` rules the card may install (ggui#987 §5), announced
63
+ * as `hostContext.styles.css.fonts`. The card's CSP decides whether
64
+ * each `src` loads; a refused face is reported back as
65
+ * `font-face-blocked`.
66
+ */
67
+ readonly fonts?: string;
53
68
  }
54
69
  export interface McpAppIframeMount {
55
70
  /** Tear down — posts `ui/resource-teardown` then removes the iframe. */
@@ -113,7 +113,9 @@ export function mountMcpAppIframe(container, opts) {
113
113
  // `hostCapabilities` + `hostContext`; the pre-App draft shape
114
114
  // fails that gate and kills the embedded mount. Adapter
115
115
  // boundary — `hostContext` carries `{locale,
116
- // containerDimensions}` ONLY (no outer ggui render state).
116
+ // containerDimensions}` plus the host's OWN `theme` / fonts
117
+ // when the embedder passed them (ggui#987 §4/§5); never any
118
+ // outer ggui render state.
117
119
  const requestedRaw = req.params?.['protocolVersion'];
118
120
  const requested = typeof requestedRaw === 'string' && requestedRaw.length > 0
119
121
  ? requestedRaw
@@ -133,6 +135,8 @@ export function mountMcpAppIframe(container, opts) {
133
135
  locale: opts.locale ??
134
136
  (typeof navigator !== 'undefined' ? navigator.language : 'en-US'),
135
137
  containerDimensions: dims,
138
+ ...(opts.theme !== undefined ? { theme: opts.theme } : {}),
139
+ ...(opts.fonts !== undefined ? { styles: { css: { fonts: opts.fonts } } } : {}),
136
140
  },
137
141
  };
138
142
  response = { jsonrpc: '2.0', id, result };
@@ -16,6 +16,15 @@ import type { McpAppAiGguiMetaParseResult } from './types.js';
16
16
  * has nothing to mount.
17
17
  */
18
18
  export declare function validateMeta(meta: McpAppAiGguiRenderMeta): McpAppAiGguiMetaParseResult;
19
+ /**
20
+ * The wire parser refused the slice's `theme` (ggui#987 §3.4): report it
21
+ * to the embedding host as `app-theme-invalid` and mount without it. A
22
+ * write door should have refused this upstream — the event is the
23
+ * operator's pointer to the door that did not. Passed as
24
+ * `onInvalidTheme` on every `parseMcpAppAiGguiRenderMeta` call in this
25
+ * package.
26
+ */
27
+ export declare function reportInvalidAppTheme(issues: readonly string[]): void;
19
28
  /**
20
29
  * Parse the render slice from `globalThis.__GGUI_META__` — the
21
30
  * synchronous self-contained shell delivery channel.
@@ -50,8 +50,9 @@
50
50
  * At least one mode MUST be present. Half-live (wsUrl without wsToken)
51
51
  * is structurally rejected by the combiner.
52
52
  */
53
- import { PUBLIC_ENV_APP_KEY_RE } from '@ggui-ai/protocol';
53
+ import { PUBLIC_ENV_APP_KEY_RE } from '@ggui-ai/protocol/wire';
54
54
  import { parseMcpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
55
+ import { postObservabilityToParent } from './observability.js';
55
56
  /**
56
57
  * Type guard — true iff `value` is a non-null, non-array plain object.
57
58
  */
@@ -323,6 +324,17 @@ export function validateMeta(meta) {
323
324
  return result;
324
325
  return { ok: true, meta: result.meta };
325
326
  }
327
+ /**
328
+ * The wire parser refused the slice's `theme` (ggui#987 §3.4): report it
329
+ * to the embedding host as `app-theme-invalid` and mount without it. A
330
+ * write door should have refused this upstream — the event is the
331
+ * operator's pointer to the door that did not. Passed as
332
+ * `onInvalidTheme` on every `parseMcpAppAiGguiRenderMeta` call in this
333
+ * package.
334
+ */
335
+ export function reportInvalidAppTheme(issues) {
336
+ postObservabilityToParent({ kind: 'app-theme-invalid', issues: [...issues] });
337
+ }
326
338
  /**
327
339
  * Parse the render slice from `globalThis.__GGUI_META__` — the
328
340
  * synchronous self-contained shell delivery channel.
@@ -345,7 +357,7 @@ export function parseMetaFromGlobal() {
345
357
  if (!isPlainObject(raw)) {
346
358
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
347
359
  }
348
- const parsed = parseMcpAppAiGguiRenderMeta(raw);
360
+ const parsed = parseMcpAppAiGguiRenderMeta(raw, { onInvalidTheme: reportInvalidAppTheme });
349
361
  if (!parsed.ok) {
350
362
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
351
363
  }
@@ -396,7 +408,7 @@ export function parseMetaFromToolResult(params) {
396
408
  return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
397
409
  }
398
410
  const meta = topMeta;
399
- const parsed = parseMcpAppAiGguiRenderMeta(meta);
411
+ const parsed = parseMcpAppAiGguiRenderMeta(meta, { onInvalidTheme: reportInvalidAppTheme });
400
412
  if (!parsed.ok) {
401
413
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
402
414
  }
@@ -33,7 +33,7 @@ import { MCP_APP_OBSERVE_TYPE } from '@ggui-ai/protocol/integrations/mcp-apps';
33
33
  *
34
34
  * @public
35
35
  */
36
- export type ObservabilityEvent = SchemaVersionMismatchEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | UnknownObservabilityEvent;
36
+ export type ObservabilityEvent = SchemaVersionMismatchEvent | FontFaceBlockedEvent | AppThemeInvalidEvent | ThemeReinjectFailedEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | UnknownObservabilityEvent;
37
37
  /**
38
38
  * Fired when the protocol-version handshake rejects the connection.
39
39
  * Parallel to `UpgradeRequiredError` — the host can choose to render
@@ -179,15 +179,29 @@ export interface UiFeedbackEvent {
179
179
  readonly toolName?: string;
180
180
  }
181
181
  /**
182
- * The evidence that latched a relay dead zone (ggui#599 cycle-2):
183
- * `'confirmed-refusal'` — the relay answered a declared refusal code
184
- * (helper-minted; outranks any positive advertisement), vs
185
- * `'advert-silent'` — the host never advertised `serverTools` and the
186
- * attempt failed relay-shaped.
182
+ * The evidence that latched a relay dead zone:
183
+ * - `'confirmed-refusal'` — the relay answered a declared refusal code
184
+ * (helper-minted; outranks any positive advertisement) (ggui#599
185
+ * cycle-2);
186
+ * - `'advert-silent'` — the host never advertised `serverTools` and
187
+ * the attempt failed relay-shaped;
188
+ * - `'boot-failed'` — the mandatory `ui/initialize` handshake did not
189
+ * complete (refused, timed out, or the transport failed), so
190
+ * nothing can ever be delivered in this document (ggui#830). Not a
191
+ * probe the runtime minted: the outcome of the one request every
192
+ * session begins with — the strongest confirmed outcome there is.
193
+ * It clears the only way a handshake failure can: the next boot's
194
+ * close edge.
195
+ *
196
+ * The union is closed per renderer version; nothing in a host branches
197
+ * on the trigger (the first-party host passes it through untouched) —
198
+ * the edge is the fact, the trigger is why. A host that does branch on
199
+ * it should treat a value it does not know as "latched, reason
200
+ * unknown", never as "not latched".
187
201
  *
188
202
  * @public
189
203
  */
190
- export type RelayLatchTrigger = 'confirmed-refusal' | 'advert-silent';
204
+ export type RelayLatchTrigger = 'confirmed-refusal' | 'advert-silent' | 'boot-failed';
191
205
  /**
192
206
  * Fired at the two transition edges of the renderer's
193
207
  * relay-incapability latch — the runtime's confirmed determination
@@ -195,7 +209,9 @@ export type RelayLatchTrigger = 'confirmed-refusal' | 'advert-silent';
195
209
  *
196
210
  * - `'latched'` — a real user gesture just failed relay-shaped on a
197
211
  * host whose captured capability handshake never advertised
198
- * `serverTools`, or the relay answered a declared refusal code.
212
+ * `serverTools`, or the relay answered a declared refusal code, or
213
+ * the host refused `ui/initialize` itself (`'boot-failed'`,
214
+ * ggui#830 — no gesture is needed to know nothing can be delivered).
199
215
  * The runtime now treats relay as confirmed-unavailable: it shows
200
216
  * one persistent explanation, arms the dead-zone cue for every
201
217
  * later gesture, and channel polls fail fast without a transport
@@ -214,9 +230,11 @@ export type RelayLatchTrigger = 'confirmed-refusal' | 'advert-silent';
214
230
  * a latch standing from a prior mount closes it with a `'cleared'`
215
231
  * edge before the new session starts.
216
232
  *
217
- * Always emitted via the postMessage-to-parent default — the emission
218
- * sites live in module-level gesture-dispatch code outside the boot
219
- * graph, so they never flow through an injected `onObserve` sink.
233
+ * Always emitted via the postMessage-to-parent default, by rule — the
234
+ * gesture-dispatch emission sites run outside any given boot call, and
235
+ * the boot-graph site (the `boot-failed` edge, ggui#830) follows the
236
+ * same rule — so these edges never flow through an injected
237
+ * `onObserve` sink and a host reads every edge the same way.
220
238
  *
221
239
  * @public
222
240
  */
@@ -249,9 +267,10 @@ export type RelayIncapabilityEvent = {
249
267
  * document (pulse, spoken cue, fallback toast); this event makes the
250
268
  * attempt COUNTABLE by the host.
251
269
  *
252
- * Contract: exactly one per gesture attempted while latched whose
253
- * attempt comes back undelivered (relay-shaped failure or a declared
254
- * refusal); never emitted off-latch, and never from the channel
270
+ * Contract: exactly one per gesture whose UNDELIVERED outcome
271
+ * (relay-shaped failure or a declared refusal) lands while the latch
272
+ * stands — an in-flight gesture that raced the latching one counts;
273
+ * never emitted off-latch, and never from the channel
255
274
  * router's fail-fast ticks. Counted at the outcome, not the tap: the
256
275
  * gesture that latched the zone is the `'latched'` edge and the gesture
257
276
  * that heals it is the `'cleared'` edge — neither is a dead tap.
@@ -284,6 +303,46 @@ export interface RelayDeadTapEvent {
284
303
  *
285
304
  * @public
286
305
  */
306
+ /**
307
+ * A host-delivered `@font-face` src the document's CSP refused
308
+ * (ggui#987 §5): the card installed the host's `styles.css.fonts`, the
309
+ * browser blocked the fetch under `font-src`, and the family will fall
310
+ * back to its stack. Reported ONCE per (family, host) per document —
311
+ * the operator's signal that the shell's `font-src` allowlist and the
312
+ * host's font origin disagree.
313
+ *
314
+ * @public
315
+ */
316
+ export interface FontFaceBlockedEvent {
317
+ readonly kind: 'font-face-blocked';
318
+ /** The `font-family` the refused `@font-face` block declared. */
319
+ readonly family: string;
320
+ /** Host of the blocked `src` URL (`blockedURI`, origin-stripped by CSP). */
321
+ readonly host: string;
322
+ }
323
+ /**
324
+ * The render slice carried a `theme` the wire schema refused (ggui#987
325
+ * §3.4): the parser dropped it and the card painted the compiled ladder
326
+ * alone. `issues` are the schema's own messages — the operator's
327
+ * pointer to what the write door should have refused.
328
+ *
329
+ * @public
330
+ */
331
+ export interface AppThemeInvalidEvent {
332
+ readonly kind: 'app-theme-invalid';
333
+ readonly issues: readonly string[];
334
+ }
335
+ /**
336
+ * The live re-injection on a host theme flip (ggui#987 §4) failed: the
337
+ * renderer's update rejected, so the card kept its previous overlay /
338
+ * ladder / `color-scheme`. `message` is the rejection's.
339
+ *
340
+ * @public
341
+ */
342
+ export interface ThemeReinjectFailedEvent {
343
+ readonly kind: 'theme-reinject-failed';
344
+ readonly message: string;
345
+ }
287
346
  export interface UnknownObservabilityEvent {
288
347
  readonly kind: string & {};
289
348
  readonly [field: string]: unknown;
@@ -31,7 +31,7 @@
31
31
  * while suspended, matching host-SDK behavior.
32
32
  */
33
33
  import React, { type ReactNode } from 'react';
34
- import type { StreamEnvelope } from '@ggui-ai/protocol';
34
+ import type { StreamEnvelope } from '@ggui-ai/protocol/wire';
35
35
  export interface ProvisionalMount {
36
36
  /**
37
37
  * Feed a stream envelope. Ignores non-PREVIEW_CHANNEL envelopes
@@ -30,7 +30,7 @@
30
30
  * TypeScript config doesn't need a JSX runtime plumbing.
31
31
  */
32
32
  import React, { type ReactNode } from 'react';
33
- import type { AppTheme } from '@ggui-ai/protocol';
33
+ import type { AppTheme } from '@ggui-ai/protocol/wire';
34
34
  export interface ReactRootMountOptions {
35
35
  /**
36
36
  * GguiSession whose componentCode + props drive the mount. Only
@@ -63,44 +63,26 @@ export interface ReactRootMountOptions {
63
63
  */
64
64
  readonly themeMode?: 'light' | 'dark';
65
65
  /**
66
- * Per-app theme overlay — a partial set of `--ggui-*` values; unset
67
- * vars keep their token defaults. Merged INTO the scoped in-body
68
- * token block after the base block (and after `hostPalette`), which
69
- * is the only placement that can recolor tree content: a `:root`-only
70
- * append is cascade-dead for components — the scoped block sits later
71
- * in document order and re-specifies every token (browser-verified,
72
- * see the scoped-merge comment in `renderTree`). The `:root` append
73
- * still happens, but for body chrome only. A structural subset of
74
- * protocol's `AppTheme` (`name` is display-only, not needed at
75
- * render). `mode` drives `color-scheme` so native controls /
76
- * scrollbars / canvas pick up dark vs light.
66
+ * The per-app theme (protocol's `AppTheme`, ggui#987 v2): BOTH modes'
67
+ * derived `--ggui-*` projections (`overlays.light` / `overlays.dark`),
68
+ * optional mode-agnostic `cssVariables`, per-mode `keyframes`, and
69
+ * the `frameless` flag. The renderer injects the overlay for the
70
+ * EFFECTIVE mode (`themeMode`, host-owned per §4; absent ⇒ light) —
71
+ * a mode switch re-paints from the retained other set with no
72
+ * network activity. Merged INTO the scoped in-body token block, the
73
+ * only placement that can recolor tree content (a `:root`-only
74
+ * append is cascade-dead for components — the scoped block sits
75
+ * later in document order and re-specifies every token; browser-
76
+ * verified, see the scoped-merge comment in `renderTree`). The
77
+ * `:root` append still happens, for body chrome only.
77
78
  *
78
- * Precedence (ggui#573 ruling): base tokens < `hostPalette` <
79
- * `appTheme` < `cssOverrides` — the slice's stamped theme always
80
- * wins; the host palette is the fallback layer beneath it.
79
+ * Injection order (§3.3), one document-order cascade:
80
+ * compiled ladder (`themeId` ?? default) < `hostPalette` <
81
+ * `overlays[mode]` < `cssVariables` < `cssOverrides`, then
82
+ * `keyframes[mode]` and the frameless rule. Values are injection-
83
+ * safe by the wire schema — never re-validated here.
81
84
  */
82
- readonly appTheme?: {
83
- readonly mode: 'light' | 'dark';
84
- readonly cssVariables: Record<string, string>;
85
- /**
86
- * The REGISTERED base ladder, delivered over the wire (ggui#598-C
87
- * runtime theme registration — see protocol's `appThemeSchema`).
88
- * BOTH modes' resolved variable sets ride the envelope; when
89
- * present, the mode-selected set REPLACES the compiled ladder
90
- * block (`getScopedThemeCss`/`getScopedCssTokens`) at the base
91
- * position of the #573 precedence above — delivery changes WHERE
92
- * the ladder's bytes come from, never WHICH layer wins. Mode
93
- * selection reuses the mount's already-resolved `themeMode` (one
94
- * projection — no second mode resolution here), and a mode switch
95
- * re-paints from the retained other set with no network activity.
96
- * Values are injection-safe by the wire schema — never
97
- * re-validated here; serialization is key-sorted for byte-stable
98
- * output. `documentHash` is the registration's identity (joins a
99
- * painted ladder to its registration record); the renderer
100
- * carries it but exposes no observability surface for it today.
101
- */
102
- readonly base?: NonNullable<AppTheme['base']>;
103
- };
85
+ readonly appTheme?: AppTheme;
104
86
  /**
105
87
  * Host-announced palette, already translated onto `--ggui-*` tokens
106
88
  * (see `host-palette-bridge.ts` — the spec `--color-*` vocabulary
@@ -31,7 +31,7 @@
31
31
  */
32
32
  import React, { Component, createElement, Fragment, } from 'react';
33
33
  import { createRoot } from 'react-dom/client';
34
- import { stripMarkers, rewriteImports, getScopedThemeCss, getScopedCssTokens, assembleDeliveredThemeCss, getThemeCss, getCssTokens, } from '@ggui-ai/design/rendering';
34
+ import { stripMarkers, rewriteImports, getScopedThemeCss, getScopedCssTokens, framelessSuppressionRule, getThemeCss, getCssTokens, } from '@ggui-ai/design/rendering';
35
35
  import { hoistImports, loadModule, loadModuleInline, probeUrlModuleLoad, } from '@ggui-ai/design/module-loader';
36
36
  /**
37
37
  * Document-lifetime verdict: URL-scheme module loading (`blob:` main
@@ -109,7 +109,7 @@ class RcrErrorBoundary extends Component {
109
109
  // The two fallback UIs match the host-SDK error boundary
110
110
  // verbatim (same inline styles so operator-visible DOM is
111
111
  // identical between the host-SDK and iframe-renderer paths).
112
- // Colors inherit the themed body (`--ggui-color-onSurface`) with
112
+ // Colors inherit the themed body (`--ggui-color-onContainer`) with
113
113
  // opacity for hierarchy — hardcoded white-alpha text was
114
114
  // illegible on light shells (#481); the boundary must stay
115
115
  // readable on WHATEVER background the theme painted, including
@@ -126,7 +126,7 @@ class RcrErrorBoundary extends Component {
126
126
  gap: 8,
127
127
  fontFamily: FONT,
128
128
  fontSize: 13,
129
- color: 'var(--ggui-color-onSurface, inherit)',
129
+ color: 'var(--ggui-color-onContainer, inherit)',
130
130
  opacity: 0.6,
131
131
  },
132
132
  }, createElement('div', {
@@ -151,7 +151,7 @@ class RcrErrorBoundary extends Component {
151
151
  gap: 12,
152
152
  textAlign: 'center',
153
153
  fontFamily: FONT,
154
- color: 'var(--ggui-color-onSurface, inherit)',
154
+ color: 'var(--ggui-color-onContainer, inherit)',
155
155
  },
156
156
  }, createElement('div', {
157
157
  style: {
@@ -314,33 +314,15 @@ export async function mountReactRoot(container, opts) {
314
314
  }
315
315
  }
316
316
  function renderTree(opts) {
317
- // Delivered base ladder (ggui#598-C): when the slice theme carries
318
- // `base`, the mode-selected set REPLACES the compiled ladder block
319
- // at the base position — same precedence slot, different byte
320
- // source. Mode selection reuses the already-resolved `themeMode`
321
- // (one projection; absent means light — the same default
322
- // `getScopedThemeCss` applies). Both sets stay retained on the
323
- // options, so a mode-switch re-render picks the other ladder from
324
- // memory — never the network.
325
- const deliveredBase = opts.appTheme?.base;
326
- let themeCss = deliveredBase !== undefined
327
- ? // Design-package assembler (ggui#598-C, injection-review flag 1):
328
- // the delivered ladder gets the SAME scaffolding the compiled
329
- // ladder gets — base inherits, derived gradient/effect tokens
330
- // (color-mix fallback split), box-sizing, font-inherit — from
331
- // ONE shared builder, so the two paths cannot drift.
332
- assembleDeliveredThemeCss(scopeClass, opts.themeMode === 'dark' ? deliveredBase.dark : deliveredBase.light, {
333
- // ggui#613 residual 2 — the two former v1 deltas ride:
334
- // the active mode's keyframes block + the frameless
335
- // silhouette suppression, both same-rule as compiled.
336
- keyframes: opts.themeMode === 'dark'
337
- ? deliveredBase.keyframes?.dark
338
- : deliveredBase.keyframes?.light,
339
- frameless: deliveredBase.frameless,
340
- })
341
- : opts.themeId
342
- ? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
343
- : getScopedCssTokens(scopeClass, opts.themeMode);
317
+ // The effective mode (ggui#987 §4: the host owns it; the runtime
318
+ // resolves it ONCE into `themeMode`). Absent means no opinion
319
+ // anywhere — the light ladder paints, and every mode-keyed layer
320
+ // below selects with the same `m`, so the ladder, the overlay, the
321
+ // keyframes and `color-scheme` can never disagree.
322
+ const m = opts.themeMode ?? 'light';
323
+ let themeCss = opts.themeId
324
+ ? getScopedThemeCss(opts.themeId, scopeClass, m)
325
+ : getScopedCssTokens(scopeClass, m);
344
326
  if (opts.hostPalette) {
345
327
  // Host palette = the fallback layer (ggui#572, #573 ruling):
346
328
  // after the base scoped block so it can repaint at all (the base
@@ -349,22 +331,35 @@ export async function mountReactRoot(container, opts) {
349
331
  // arrive pre-sanitized from the host-palette bridge.
350
332
  themeCss += `.${scopeClass}{${toCssDecls(opts.hostPalette)}}`;
351
333
  }
352
- if (opts.appTheme) {
353
- const decls = toCssDecls(opts.appTheme.cssVariables);
354
- // The overlay must be merged HERE, into the scoped style that
355
- // mounts INSIDE the scope div — the `:root` append below is
356
- // cascade-dead for tree content: the in-scope block sits later
357
- // in document order than anything in `<head>`, so at equal
358
- // specificity the base scoped tokens always win over a head-level
359
- // override (browser-verified in rnd/gen-ui/beauty/experiments/001,
360
- // the probe sequence that found this). Appended AFTER the base
361
- // block + host palette and BEFORE `cssOverrides`, so: base <
362
- // host palette < operator overlay < explicit overrides
363
- // (ggui#573: slice wins, host fallback). Values validated
364
- // injection-safe upstream (same guarantee the `:root` append
365
- // cites).
366
- themeCss += `.${scopeClass}{${decls}}`;
367
- }
334
+ // The per-app theme's two variable layers, in this order: the
335
+ // derived projection for the effective mode, then the mode-agnostic
336
+ // `cssVariables` above it. Both must be merged HERE, into the
337
+ // scoped style that mounts INSIDE the scope div — the `:root`
338
+ // append below is cascade-dead for tree content: the in-scope block
339
+ // sits later in document order than anything in `<head>`, so at
340
+ // equal specificity the base scoped tokens always win over a head-
341
+ // level override (browser-verified in rnd/gen-ui/beauty/
342
+ // experiments/001, the probe sequence that found this). Appended
343
+ // AFTER the base block + host palette and BEFORE `cssOverrides`:
344
+ // compiled < host palette < overlays[m] < cssVariables < overrides
345
+ // (ggui#573: slice wins, host fallback). Values validated
346
+ // injection-safe upstream (same guarantee the `:root` append cites).
347
+ const appVarBlocks = opts.appTheme
348
+ ? [opts.appTheme.overlays[m], opts.appTheme.cssVariables]
349
+ .filter((vars) => vars !== undefined)
350
+ .map((vars) => `.${scopeClass}{${toCssDecls(vars)}}`)
351
+ .join('')
352
+ : '';
353
+ themeCss += appVarBlocks;
354
+ // Trailing rules (§3.3): the effective mode's `@keyframes` block and
355
+ // the frameless silhouette suppression — the SAME rule text the
356
+ // compiled path emits for `$metadata.frameless` — after
357
+ // `cssOverrides`, so an override can never be shadowed by them.
358
+ // Keyframes ride VERBATIM: their admission is the write door's
359
+ // (`appThemeSchema.keyframes`), not re-checked here.
360
+ const appTrailingCss = opts.appTheme
361
+ ? `${opts.appTheme.keyframes?.[m] ?? ''}${opts.appTheme.frameless === true ? framelessSuppressionRule(scopeClass) : ''}`
362
+ : '';
368
363
  // Also inject theme CSS at `:root` on `document.head`. The scoped
369
364
  // injection above isolates token resolution to the React tree, but
370
365
  // the embedding shell's body styles (font-family, color, background)
@@ -375,17 +370,10 @@ export async function mountReactRoot(container, opts) {
375
370
  // `ThemeProvider` so a Studio/Portal context with both runtime + RP
376
371
  // active doesn't double-stack.
377
372
  if (typeof document !== 'undefined') {
378
- // ggui#613 residual 5: under a DELIVERED base the body-chrome
379
- // ladder is the delivered set too — mirroring the scoped ternary
380
- // above — so the standalone viewer's body chrome and its tree
381
- // content resolve the same values. Pre-fix this started from the
382
- // compiled/default ladder regardless, and body chrome painted the
383
- // default brand under a registered theme.
384
- let rootCss = deliveredBase !== undefined
385
- ? `:root{${toCssDecls(opts.themeMode === 'dark' ? deliveredBase.dark : deliveredBase.light)}}`
386
- : opts.themeId
387
- ? getThemeCss(opts.themeId, opts.themeMode)
388
- : getCssTokens(opts.themeMode);
373
+ // Same ladder, same mode as the scoped block above — the
374
+ // standalone viewer's body chrome and its tree content resolve
375
+ // the same values.
376
+ let rootCss = opts.themeId ? getThemeCss(opts.themeId, m) : getCssTokens(m);
389
377
  if (opts.hostPalette) {
390
378
  // BODY-CHROME ONLY, same as the appTheme `:root` append below —
391
379
  // and beneath it, mirroring the scoped-block order (#573:
@@ -393,19 +381,23 @@ export async function mountReactRoot(container, opts) {
393
381
  // body font/color/background coherent with the tree.
394
382
  rootCss += `:root{${toCssDecls(opts.hostPalette)}}`;
395
383
  }
396
- if (opts.appTheme) {
397
- const decls = toCssDecls(opts.appTheme.cssVariables);
398
- // BODY-CHROME ONLY. This `:root` append themes the embedding
399
- // shell's body styles (font/color/background outside the scope
400
- // div). It does NOT reach tree content — the scoped in-body
401
- // token block wins by document order (see the scoped merge in
402
- // `renderTree`, and rnd/gen-ui/beauty/experiments/001 for the
403
- // probe that falsified the old "inherits into the tree" claim).
404
- // Values were validated injection-safe upstream (write-side
405
- // `appThemeSchema` + the wire parser re-validates), so the
406
- // string-join here is safe — do NOT re-sanitize.
407
- rootCss += `:root{color-scheme:${opts.appTheme.mode};${decls}}`;
408
- }
384
+ // BODY-CHROME ONLY. `color-scheme` follows the effective mode so
385
+ // native controls / scrollbars / canvas agree with the ladder
386
+ // painted; the app theme's two variable layers ride the same
387
+ // block, in the scoped order. None of this reaches tree content —
388
+ // the scoped in-body token block wins by document order (see the
389
+ // scoped merge above, and rnd/gen-ui/beauty/experiments/001 for
390
+ // the probe that falsified the old "inherits into the tree"
391
+ // claim). Values were validated injection-safe upstream (write-
392
+ // side `appThemeSchema` + the wire parser re-validates), so the
393
+ // string-join here is safe — do NOT re-sanitize.
394
+ const rootAppDecls = opts.appTheme
395
+ ? [opts.appTheme.overlays[m], opts.appTheme.cssVariables]
396
+ .filter((vars) => vars !== undefined)
397
+ .map(toCssDecls)
398
+ .join('')
399
+ : '';
400
+ rootCss += `:root{color-scheme:${m};${rootAppDecls}}`;
409
401
  const styleId = 'ggui-theme-vars';
410
402
  let styleEl = document.getElementById(styleId);
411
403
  if (!styleEl) {
@@ -426,7 +418,7 @@ export async function mountReactRoot(container, opts) {
426
418
  // The scope `<div>` + inline `<style>` + error-boundary-wrapped
427
419
  // children match RCR L376–383. `key={currentCode?.length}` mirrors
428
420
  // RCR's "reset boundary when code changes" heuristic.
429
- root.render(createElement('div', { className: scopeClass }, createElement('style', null, `${themeCss}${opts.cssOverrides ?? ''}`), createElement(RcrErrorBoundary, {
421
+ root.render(createElement('div', { className: scopeClass }, createElement('style', null, `${themeCss}${opts.cssOverrides ?? ''}${appTrailingCss}`), createElement(RcrErrorBoundary, {
430
422
  key: currentCode?.length ?? 0,
431
423
  ...(opts.onError ? { onError: opts.onError } : {}),
432
424
  }, createElement(Fragment, null, wrapped))));
@@ -1,4 +1,4 @@
1
- import type { AckPayload } from '@ggui-ai/protocol';
1
+ import type { AckPayload } from '@ggui-ai/protocol/wire';
2
2
  import type { ConnectionStatus } from '@ggui-ai/protocol/transport/websocket';
3
3
  import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
4
4
  import type { AnyTransportHandle, ChannelLogger, ChannelRegistry, RegistryPollingOptions, RegistrySseOptions, WsTransportHandle } from '@ggui-ai/live-channel';
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { AppTheme, GguiSession } from '@ggui-ai/protocol';
2
+ import type { AppTheme, GguiSession } from '@ggui-ai/protocol/wire';
3
3
  import type { GguiSessionSeedInput } from './types.js';
4
4
  import { type WireConfig } from '@ggui-ai/wire';
5
5
  import type { StreamBus } from './wire-config.js';
@@ -47,20 +47,13 @@ export interface RenderItemOptions {
47
47
  */
48
48
  readonly themeMode?: 'light' | 'dark';
49
49
  /**
50
- * Per-app theme overlay forwarded to {@link ReactRootMountOptions.appTheme}
51
- * so the renderer injects the `--ggui-*` overrides + `color-scheme` at
52
- * `:root`. Sourced from the bootstrap's `_meta["ai.ggui/render"].theme`.
53
- * A structural subset of protocol's `AppTheme` (`name` is display-only).
54
- * `base` is the delivered registered ladder (ggui#598-C) — forwarded
55
- * verbatim; the renderer injects the mode-selected set in place of
56
- * the compiled ladder block (see
57
- * {@link ReactRootMountOptions.appTheme}).
50
+ * The per-app theme (protocol's `AppTheme`, ggui#987 v2) forwarded
51
+ * verbatim to {@link ReactRootMountOptions.appTheme}: the renderer
52
+ * injects the overlay for the effective `themeMode` into the scoped
53
+ * block and at `:root`. Sourced from the bootstrap's
54
+ * `_meta["ai.ggui/render"].theme`.
58
55
  */
59
- readonly appTheme?: {
60
- readonly mode: 'light' | 'dark';
61
- readonly cssVariables: Record<string, string>;
62
- readonly base?: NonNullable<AppTheme['base']>;
63
- };
56
+ readonly appTheme?: AppTheme;
64
57
  /**
65
58
  * Host-announced palette, pre-mapped onto `--ggui-*` tokens by the
66
59
  * host-palette bridge. Forwarded to
@@ -1,4 +1,4 @@
1
- import { RUNTIME_TELEMETRY_MAX_EVENTS } from '@ggui-ai/protocol';
1
+ import { RUNTIME_TELEMETRY_MAX_EVENTS } from '@ggui-ai/protocol/wire';
2
2
  const FIRST_FLUSH_DELAY_MS = 4_000;
3
3
  const FLUSH_THROTTLE_MS = 8_000;
4
4
  const MAX_FLUSHES_PER_SESSION = 12;