@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/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, AppThemeMemberStrippedEvent, 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,23 @@ 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;
28
+ /**
29
+ * The read door KEPT the slice's theme but stripped top-level members
30
+ * this release does not name (ggui#1093 belt, VERSION-POLICY §3.6):
31
+ * report the member names to the embedding host so a newer writer's
32
+ * member is visible, not swallowed. Passed as `onStrippedThemeMembers`
33
+ * on every `parseMcpAppAiGguiRenderMeta` call in this package.
34
+ */
35
+ export declare function reportStrippedThemeMembers(keys: readonly string[]): void;
19
36
  /**
20
37
  * Parse the render slice from `globalThis.__GGUI_META__` — the
21
38
  * synchronous self-contained shell delivery channel.
@@ -52,6 +52,7 @@
52
52
  */
53
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,27 @@ 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
+ }
338
+ /**
339
+ * The read door KEPT the slice's theme but stripped top-level members
340
+ * this release does not name (ggui#1093 belt, VERSION-POLICY §3.6):
341
+ * report the member names to the embedding host so a newer writer's
342
+ * member is visible, not swallowed. Passed as `onStrippedThemeMembers`
343
+ * on every `parseMcpAppAiGguiRenderMeta` call in this package.
344
+ */
345
+ export function reportStrippedThemeMembers(keys) {
346
+ postObservabilityToParent({ kind: 'app-theme-member-stripped', keys: [...keys] });
347
+ }
326
348
  /**
327
349
  * Parse the render slice from `globalThis.__GGUI_META__` — the
328
350
  * synchronous self-contained shell delivery channel.
@@ -345,7 +367,10 @@ export function parseMetaFromGlobal() {
345
367
  if (!isPlainObject(raw)) {
346
368
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
347
369
  }
348
- const parsed = parseMcpAppAiGguiRenderMeta(raw);
370
+ const parsed = parseMcpAppAiGguiRenderMeta(raw, {
371
+ onInvalidTheme: reportInvalidAppTheme,
372
+ onStrippedThemeMembers: reportStrippedThemeMembers,
373
+ });
349
374
  if (!parsed.ok) {
350
375
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
351
376
  }
@@ -396,7 +421,10 @@ export function parseMetaFromToolResult(params) {
396
421
  return { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
397
422
  }
398
423
  const meta = topMeta;
399
- const parsed = parseMcpAppAiGguiRenderMeta(meta);
424
+ const parsed = parseMcpAppAiGguiRenderMeta(meta, {
425
+ onInvalidTheme: reportInvalidAppTheme,
426
+ onStrippedThemeMembers: reportStrippedThemeMembers,
427
+ });
400
428
  if (!parsed.ok) {
401
429
  return { ok: false, reason: 'MALFORMED_BOOTSTRAP' };
402
430
  }
@@ -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 | AppThemeMemberStrippedEvent | ThemeReinjectFailedEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | ComponentEmptyEvent | 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
@@ -295,6 +295,25 @@ export interface RelayDeadTapEvent {
295
295
  readonly sessionId?: string;
296
296
  readonly appId?: string;
297
297
  }
298
+ /**
299
+ * The mount painted NOTHING (ggui#1103): the renderer held no component,
300
+ * so the scope element carries only its stylesheet — a blank card, with no
301
+ * error boundary to catch anything (the boundary wraps a null child). The
302
+ * host is told which mount moment it was and why, because a card that
303
+ * renders nothing is invisible on every other channel: `reason: 'no-code'`
304
+ * is a payload that carried no component code, `'eval-failed'` a module
305
+ * that threw while evaluating. Names the moment only — never the code, the
306
+ * props or the error text, which stay in the operator-visible console line.
307
+ *
308
+ * @public
309
+ */
310
+ export interface ComponentEmptyEvent {
311
+ readonly kind: 'component-empty';
312
+ /** Which lifecycle moment painted nothing. */
313
+ readonly where: 'mount' | 'update';
314
+ /** Why the renderer held no component. */
315
+ readonly reason: 'no-code' | 'eval-failed';
316
+ }
298
317
  /**
299
318
  * Catch-all branch for event kinds the host's typings don't recognize.
300
319
  * Lets the union stay extensible without forcing protocol-version
@@ -303,6 +322,59 @@ export interface RelayDeadTapEvent {
303
322
  *
304
323
  * @public
305
324
  */
325
+ /**
326
+ * A host-delivered `@font-face` src the document's CSP refused
327
+ * (ggui#987 §5): the card installed the host's `styles.css.fonts`, the
328
+ * browser blocked the fetch under `font-src`, and the family will fall
329
+ * back to its stack. Reported ONCE per (family, host) per document —
330
+ * the operator's signal that the shell's `font-src` allowlist and the
331
+ * host's font origin disagree.
332
+ *
333
+ * @public
334
+ */
335
+ export interface FontFaceBlockedEvent {
336
+ readonly kind: 'font-face-blocked';
337
+ /** The `font-family` the refused `@font-face` block declared. */
338
+ readonly family: string;
339
+ /** Host of the blocked `src` URL (`blockedURI`, origin-stripped by CSP). */
340
+ readonly host: string;
341
+ }
342
+ /**
343
+ * The render slice carried a `theme` the wire schema refused (ggui#987
344
+ * §3.4): the parser dropped it and the card painted the compiled ladder
345
+ * alone. `issues` are the schema's own messages — the operator's
346
+ * pointer to what the write door should have refused.
347
+ *
348
+ * @public
349
+ */
350
+ export interface AppThemeInvalidEvent {
351
+ readonly kind: 'app-theme-invalid';
352
+ readonly issues: readonly string[];
353
+ }
354
+ /**
355
+ * The render slice's `theme` carried top-level members THIS release does
356
+ * not name (ggui#1093 belt, VERSION-POLICY §3.6): the read door kept the
357
+ * theme and stripped them, and the card says which — a newer writer's
358
+ * member is never silently swallowed. `keys` are the member names only,
359
+ * never their content.
360
+ *
361
+ * @public
362
+ */
363
+ export interface AppThemeMemberStrippedEvent {
364
+ readonly kind: 'app-theme-member-stripped';
365
+ readonly keys: readonly string[];
366
+ }
367
+ /**
368
+ * The live re-injection on a host theme flip (ggui#987 §4) failed: the
369
+ * renderer's update rejected, so the card kept its previous overlay /
370
+ * ladder / `color-scheme`. `message` is the rejection's.
371
+ *
372
+ * @public
373
+ */
374
+ export interface ThemeReinjectFailedEvent {
375
+ readonly kind: 'theme-reinject-failed';
376
+ readonly message: string;
377
+ }
306
378
  export interface UnknownObservabilityEvent {
307
379
  readonly kind: string & {};
308
380
  readonly [field: string]: unknown;
@@ -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
@@ -113,6 +95,8 @@ export interface ReactRootMountOptions {
113
95
  */
114
96
  readonly hostPalette?: Readonly<Record<string, string>>;
115
97
  readonly cssOverrides?: string;
98
+ /** `fill` — the root is the whole canvas: no silhouette, fills the page (ggui#1041; from the host's `displayMode`). */
99
+ readonly fit?: 'fill';
116
100
  readonly onError?: (error: Error) => void;
117
101
  /**
118
102
  * Children injected BETWEEN the mount DOM (scope + CSS) and the
@@ -31,7 +31,8 @@
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 { postObservabilityToParent } from './observability.js';
35
+ import { stripMarkers, rewriteImports, composeThemeCss, } from '@ggui-ai/design/rendering';
35
36
  import { hoistImports, loadModule, loadModuleInline, probeUrlModuleLoad, } from '@ggui-ai/design/module-loader';
36
37
  /**
37
38
  * Document-lifetime verdict: URL-scheme module loading (`blob:` main
@@ -66,11 +67,6 @@ function makeScopeClass() {
66
67
  * bridge's sanitizer), so the string-join is safe — do NOT
67
68
  * re-sanitize here.
68
69
  */
69
- function toCssDecls(vars) {
70
- return Object.entries(vars)
71
- .map(([k, v]) => `${k}: ${v};`)
72
- .join('');
73
- }
74
70
  // =============================================================================
75
71
  // Error boundary — port of RCR's internal ErrorBoundary (L68–203).
76
72
  // =============================================================================
@@ -109,7 +105,7 @@ class RcrErrorBoundary extends Component {
109
105
  // The two fallback UIs match the host-SDK error boundary
110
106
  // verbatim (same inline styles so operator-visible DOM is
111
107
  // identical between the host-SDK and iframe-renderer paths).
112
- // Colors inherit the themed body (`--ggui-color-onSurface`) with
108
+ // Colors inherit the themed body (`--ggui-color-onContainer`) with
113
109
  // opacity for hierarchy — hardcoded white-alpha text was
114
110
  // illegible on light shells (#481); the boundary must stay
115
111
  // readable on WHATEVER background the theme painted, including
@@ -126,7 +122,7 @@ class RcrErrorBoundary extends Component {
126
122
  gap: 8,
127
123
  fontFamily: FONT,
128
124
  fontSize: 13,
129
- color: 'var(--ggui-color-onSurface, inherit)',
125
+ color: 'var(--ggui-color-onContainer, inherit)',
130
126
  opacity: 0.6,
131
127
  },
132
128
  }, createElement('div', {
@@ -151,7 +147,7 @@ class RcrErrorBoundary extends Component {
151
147
  gap: 12,
152
148
  textAlign: 'center',
153
149
  fontFamily: FONT,
154
- color: 'var(--ggui-color-onSurface, inherit)',
150
+ color: 'var(--ggui-color-onContainer, inherit)',
155
151
  },
156
152
  }, createElement('div', {
157
153
  style: {
@@ -314,57 +310,29 @@ export async function mountReactRoot(container, opts) {
314
310
  }
315
311
  }
316
312
  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);
344
- if (opts.hostPalette) {
345
- // Host palette = the fallback layer (ggui#572, #573 ruling):
346
- // after the base scoped block so it can repaint at all (the base
347
- // re-specifies every token), BEFORE the appTheme overlay so the
348
- // slice's stamped theme wins by document order. Keys + values
349
- // arrive pre-sanitized from the host-palette bridge.
350
- themeCss += `.${scopeClass}{${toCssDecls(opts.hostPalette)}}`;
351
- }
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
- }
313
+ // The effective mode (ggui#987 §4: the host owns it; the runtime
314
+ // resolves it ONCE into `themeMode`). Absent means no opinion
315
+ // anywhere — the light ladder paints, and every mode-keyed layer
316
+ // below selects with the same `m`, so the ladder, the overlay, the
317
+ // keyframes and `color-scheme` can never disagree.
318
+ const m = opts.themeMode ?? 'light';
319
+ // ONE composition (`@ggui-ai/design/rendering` `composeThemeCss`): the
320
+ // scoped tree block — compiled ladder < host palette < overlays[m] <
321
+ // cssVariables < cssOverrides, then the mode's keyframes and the
322
+ // frameless rule. The `:root` chrome block below and an evaluator's
323
+ // page are the same function's other layers, so no two paints can
324
+ // disagree on a token (ggui#1020: a judge that composed its own
325
+ // scored an ink-on-ink hero readable).
326
+ const themeCss = composeThemeCss({
327
+ layer: 'tree',
328
+ scopeClass,
329
+ mode: m,
330
+ ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
331
+ ...(opts.hostPalette !== undefined ? { hostPalette: opts.hostPalette } : {}),
332
+ ...(opts.appTheme !== undefined ? { appTheme: opts.appTheme } : {}),
333
+ ...(opts.cssOverrides !== undefined ? { cssOverrides: opts.cssOverrides } : {}),
334
+ ...(opts.fit !== undefined ? { fit: opts.fit } : {}),
335
+ });
368
336
  // Also inject theme CSS at `:root` on `document.head`. The scoped
369
337
  // injection above isolates token resolution to the React tree, but
370
338
  // the embedding shell's body styles (font-family, color, background)
@@ -375,37 +343,20 @@ export async function mountReactRoot(container, opts) {
375
343
  // `ThemeProvider` so a Studio/Portal context with both runtime + RP
376
344
  // active doesn't double-stack.
377
345
  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);
389
- if (opts.hostPalette) {
390
- // BODY-CHROME ONLY, same as the appTheme `:root` append below —
391
- // and beneath it, mirroring the scoped-block order (#573:
392
- // slice wins, host fallback). Keeps the embedding shell's
393
- // body font/color/background coherent with the tree.
394
- rootCss += `:root{${toCssDecls(opts.hostPalette)}}`;
395
- }
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
- }
346
+ // Same ladder, same mode as the scoped block above — the
347
+ // standalone viewer's body chrome and its tree content resolve
348
+ // the same values.
349
+ // BODY-CHROME ONLY — the same composer's `chrome` layer: the ladder,
350
+ // the host palette, `color-scheme` + the app theme's variable layers
351
+ // (no keyframes, no overrides); tree content resolves the scoped block
352
+ // above, which wins by document order.
353
+ const rootCss = composeThemeCss({
354
+ layer: 'chrome',
355
+ mode: m,
356
+ ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
357
+ ...(opts.hostPalette !== undefined ? { hostPalette: opts.hostPalette } : {}),
358
+ ...(opts.appTheme !== undefined ? { appTheme: opts.appTheme } : {}),
359
+ });
409
360
  const styleId = 'ggui-theme-vars';
410
361
  let styleEl = document.getElementById(styleId);
411
362
  if (!styleEl) {
@@ -426,17 +377,51 @@ export async function mountReactRoot(container, opts) {
426
377
  // The scope `<div>` + inline `<style>` + error-boundary-wrapped
427
378
  // children match RCR L376–383. `key={currentCode?.length}` mirrors
428
379
  // RCR's "reset boundary when code changes" heuristic.
429
- root.render(createElement('div', { className: scopeClass }, createElement('style', null, `${themeCss}${opts.cssOverrides ?? ''}`), createElement(RcrErrorBoundary, {
380
+ root.render(createElement('div', { className: scopeClass }, createElement('style', null, themeCss), createElement(RcrErrorBoundary, {
430
381
  key: currentCode?.length ?? 0,
431
382
  ...(opts.onError ? { onError: opts.onError } : {}),
432
383
  }, createElement(Fragment, null, wrapped))));
433
384
  }
385
+ /**
386
+ * The mount painted NOTHING (ggui#1103). `wrapped` is `null` whenever the
387
+ * renderer holds no component, so the scope element carries only its
388
+ * `<style>`: height 0, no text, and the error boundary never engages —
389
+ * it wraps a null child. Every such moment is reported on all three
390
+ * channels (an operator-visible console line, the caller's `onError`, the
391
+ * host's observability seam), because until now the two empty-code paths
392
+ * were silent on ALL of them and an empty card could not be told apart
393
+ * from a card whose module threw. Never carries the code or the props.
394
+ */
395
+ function reportNoComponent(where, reason, error, onError) {
396
+ if (reason === 'eval-failed') {
397
+ // eslint-disable-next-line no-console -- operator-visible failure signal
398
+ console.error(`[ggui] mountReactRoot: the ${where} painted nothing (eval-failed) —`, error);
399
+ }
400
+ else {
401
+ // A payload that carried no code is not an exception — nothing threw,
402
+ // and `onError` stays the caller's "the component failed" channel, so a
403
+ // host that paints an error panel does not paint one here. It is still
404
+ // operator-visible, and the host still hears it on the seam below.
405
+ // eslint-disable-next-line no-console -- operator-visible failure signal
406
+ console.warn(`[ggui] mountReactRoot: the ${where} painted nothing (no-code) — ${error.message}`);
407
+ }
408
+ try {
409
+ postObservabilityToParent({ kind: 'component-empty', where, reason });
410
+ }
411
+ catch {
412
+ // The host seam is best-effort: a blocked postMessage must not turn a
413
+ // blank card into a thrown mount.
414
+ }
415
+ if (reason === 'eval-failed')
416
+ onError?.(error);
417
+ }
434
418
  async function initialEvaluate() {
435
419
  const code = currentOpts.render.componentCode ?? '';
436
420
  if (code.trim().length === 0) {
437
421
  currentComponent = null;
438
422
  currentCode = null;
439
423
  renderTree(currentOpts);
424
+ reportNoComponent('mount', 'no-code', new Error('mount carried no component code'), currentOpts.onError);
440
425
  return;
441
426
  }
442
427
  try {
@@ -450,12 +435,10 @@ export async function mountReactRoot(container, opts) {
450
435
  // not enough on its own — not every mount path wires one, so
451
436
  // without this log a mount with a broken component renders a
452
437
  // blank iframe and emits nothing.
453
- // eslint-disable-next-line no-console -- operator-visible failure signal
454
- console.error('[ggui] mountReactRoot: component evaluation failed —', e);
455
438
  currentComponent = null;
456
439
  currentCode = null;
457
440
  renderTree(currentOpts);
458
- currentOpts.onError?.(e);
441
+ reportNoComponent('mount', 'eval-failed', e, currentOpts.onError);
459
442
  }
460
443
  }
461
444
  await initialEvaluate();
@@ -476,6 +459,7 @@ export async function mountReactRoot(container, opts) {
476
459
  currentComponent = null;
477
460
  currentCode = null;
478
461
  renderTree(currentOpts);
462
+ reportNoComponent('update', 'no-code', new Error('update carried no component code'), next.onError);
479
463
  return;
480
464
  }
481
465
  try {
@@ -488,7 +472,7 @@ export async function mountReactRoot(container, opts) {
488
472
  currentComponent = null;
489
473
  currentCode = null;
490
474
  renderTree(currentOpts);
491
- next.onError?.(e);
475
+ reportNoComponent('update', 'eval-failed', e, next.onError);
492
476
  }
493
477
  },
494
478
  unmount() {
@@ -47,20 +47,19 @@ 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 mounted root fills its surface without a silhouette of its own (ggui#1041):
51
+ * set when the host's context says `displayMode: 'fullscreen'` — a canvas panel
52
+ * is the chrome. Absent for inline mounts.
58
53
  */
59
- readonly appTheme?: {
60
- readonly mode: 'light' | 'dark';
61
- readonly cssVariables: Record<string, string>;
62
- readonly base?: NonNullable<AppTheme['base']>;
63
- };
54
+ readonly fit?: 'fill';
55
+ /**
56
+ * The per-app theme (protocol's `AppTheme`, ggui#987 v2) forwarded
57
+ * verbatim to {@link ReactRootMountOptions.appTheme}: the renderer
58
+ * injects the overlay for the effective `themeMode` into the scoped
59
+ * block and at `:root`. Sourced from the bootstrap's
60
+ * `_meta["ai.ggui/render"].theme`.
61
+ */
62
+ readonly appTheme?: AppTheme;
64
63
  /**
65
64
  * Host-announced palette, pre-mapped onto `--ggui-*` tokens by the
66
65
  * host-palette bridge. Forwarded to
@@ -183,6 +183,10 @@ export async function mountRender(container, opts) {
183
183
  ...(currentOpts.cssOverrides !== undefined
184
184
  ? { cssOverrides: currentOpts.cssOverrides }
185
185
  : {}),
186
+ // The host's canvas is the chrome (ggui#1041): `fit` MUST cross this
187
+ // boundary like every other compose option — ggui#1073 shipped
188
+ // without it, and every served hello composed as an inline card.
189
+ ...(currentOpts.fit !== undefined ? { fit: currentOpts.fit } : {}),
186
190
  ...(currentOpts.onError !== undefined ? { onError: currentOpts.onError } : {}),
187
191
  renderWrapper: wrapInScopedProvider,
188
192
  });
@@ -249,6 +253,7 @@ export async function mountRender(container, opts) {
249
253
  ...(next.appTheme !== undefined ? { appTheme: next.appTheme } : {}),
250
254
  ...(next.hostPalette !== undefined ? { hostPalette: next.hostPalette } : {}),
251
255
  ...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
256
+ ...(next.fit !== undefined ? { fit: next.fit } : {}),
252
257
  ...(next.onError !== undefined ? { onError: next.onError } : {}),
253
258
  renderWrapper: wrapInScopedProvider,
254
259
  });