@ggui-ai/iframe-runtime 0.10.0 → 0.12.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.
@@ -77,8 +77,13 @@ export function mountMcpAppIframe(container, opts) {
77
77
  iframe.style.maxWidth = dims.maxWidth !== undefined ? `${dims.maxWidth}px` : '100%';
78
78
  if (dims.maxHeight !== undefined)
79
79
  iframe.style.maxHeight = `${dims.maxHeight}px`;
80
- iframe.style.border = '1px solid #e5e5e5';
81
- iframe.style.borderRadius = '8px';
80
+ // Round-6 doctrine (ggui#589 @6e15724a1, kit C1 grade): the helper
81
+ // paints CONTAINMENT ONLY — silhouette (border/radius) belongs to
82
+ // the embedding host's own chrome, tokens to the theme. The former
83
+ // 1px #e5e5e5 / radius-8 here was the same ungoverned-chrome class
84
+ // the RN helper shipped, surfaced by the host-helper catalog's
85
+ // first-party run (ggui#600 OPEN FINDING, closed here).
86
+ iframe.style.border = 'none';
82
87
  iframe.style.display = 'block';
83
88
  container.appendChild(iframe);
84
89
  // Cache contentWindow — on detach, `iframe.contentWindow` is null
@@ -62,17 +62,59 @@ export interface ReactRootMountOptions {
62
62
  */
63
63
  readonly themeMode?: 'light' | 'dark';
64
64
  /**
65
- * Per-app theme overlay applied at the iframe's `:root` AFTER the base
66
- * token block, so the operator-supplied `--ggui-*` values win the
67
- * cascade (a partial set — unset vars keep their token defaults). A
68
- * structural subset of protocol's `AppTheme` (`name` is display-only,
69
- * not needed at render). `mode` drives `color-scheme` so native
70
- * controls / scrollbars / canvas pick up dark vs light.
65
+ * Per-app theme overlay — a partial set of `--ggui-*` values; unset
66
+ * vars keep their token defaults. Merged INTO the scoped in-body
67
+ * token block after the base block (and after `hostPalette`), which
68
+ * is the only placement that can recolor tree content: a `:root`-only
69
+ * append is cascade-dead for components — the scoped block sits later
70
+ * in document order and re-specifies every token (browser-verified,
71
+ * see the scoped-merge comment in `renderTree`). The `:root` append
72
+ * still happens, but for body chrome only. A structural subset of
73
+ * protocol's `AppTheme` (`name` is display-only, not needed at
74
+ * render). `mode` drives `color-scheme` so native controls /
75
+ * scrollbars / canvas pick up dark vs light.
76
+ *
77
+ * Precedence (ggui#573 ruling): base tokens < `hostPalette` <
78
+ * `appTheme` < `cssOverrides` — the slice's stamped theme always
79
+ * wins; the host palette is the fallback layer beneath it.
71
80
  */
72
81
  readonly appTheme?: {
73
82
  readonly mode: 'light' | 'dark';
74
83
  readonly cssVariables: Record<string, string>;
84
+ /**
85
+ * The REGISTERED base ladder, delivered over the wire (ggui#598-C
86
+ * runtime theme registration — see protocol's `appThemeSchema`).
87
+ * BOTH modes' resolved variable sets ride the envelope; when
88
+ * present, the mode-selected set REPLACES the compiled ladder
89
+ * block (`getScopedThemeCss`/`getScopedCssTokens`) at the base
90
+ * position of the #573 precedence above — delivery changes WHERE
91
+ * the ladder's bytes come from, never WHICH layer wins. Mode
92
+ * selection reuses the mount's already-resolved `themeMode` (one
93
+ * projection — no second mode resolution here), and a mode switch
94
+ * re-paints from the retained other set with no network activity.
95
+ * Values are injection-safe by the wire schema — never
96
+ * re-validated here; serialization is key-sorted for byte-stable
97
+ * output. `documentHash` is the registration's identity (joins a
98
+ * painted ladder to its registration record); the renderer
99
+ * carries it but exposes no observability surface for it today.
100
+ */
101
+ readonly base?: {
102
+ readonly documentHash: string;
103
+ readonly light: Readonly<Record<string, string>>;
104
+ readonly dark: Readonly<Record<string, string>>;
105
+ };
75
106
  };
107
+ /**
108
+ * Host-announced palette, already translated onto `--ggui-*` tokens
109
+ * (see `host-palette-bridge.ts` — the spec `--color-*` vocabulary
110
+ * never reaches this layer). Merged into the scoped token block
111
+ * after the base block and BEFORE `appTheme`, so it repaints only
112
+ * the slots no operator theme re-specifies (ggui#572; precedence
113
+ * per the ggui#573 ruling above). Values are sanitized by the
114
+ * bridge before they get here — host input never reaches this
115
+ * string-join raw.
116
+ */
117
+ readonly hostPalette?: Readonly<Record<string, string>>;
76
118
  readonly cssOverrides?: string;
77
119
  readonly onError?: (error: Error) => void;
78
120
  /**
@@ -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, getThemeCss, getCssTokens, } from '@ggui-ai/design/rendering';
34
+ import { stripMarkers, rewriteImports, getScopedThemeCss, getScopedCssTokens, assembleDeliveredThemeCss, 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
@@ -59,6 +59,18 @@ function makeScopeClass() {
59
59
  scopeCounter += 1;
60
60
  return `ggui-rcr-${scopeCounter}`;
61
61
  }
62
+ /**
63
+ * Join a custom-property record into CSS declarations. Values were
64
+ * validated injection-safe upstream (appTheme: write-side
65
+ * `appThemeSchema` + the wire parser; hostPalette: the host-palette
66
+ * bridge's sanitizer), so the string-join is safe — do NOT
67
+ * re-sanitize here.
68
+ */
69
+ function toCssDecls(vars) {
70
+ return Object.entries(vars)
71
+ .map(([k, v]) => `${k}: ${v};`)
72
+ .join('');
73
+ }
62
74
  // =============================================================================
63
75
  // Error boundary — port of RCR's internal ErrorBoundary (L68–203).
64
76
  // =============================================================================
@@ -302,13 +314,35 @@ export async function mountReactRoot(container, opts) {
302
314
  }
303
315
  }
304
316
  function renderTree(opts) {
305
- let themeCss = opts.themeId
306
- ? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
307
- : getScopedCssTokens(scopeClass, opts.themeMode);
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
+ : opts.themeId
334
+ ? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
335
+ : getScopedCssTokens(scopeClass, opts.themeMode);
336
+ if (opts.hostPalette) {
337
+ // Host palette = the fallback layer (ggui#572, #573 ruling):
338
+ // after the base scoped block so it can repaint at all (the base
339
+ // re-specifies every token), BEFORE the appTheme overlay so the
340
+ // slice's stamped theme wins by document order. Keys + values
341
+ // arrive pre-sanitized from the host-palette bridge.
342
+ themeCss += `.${scopeClass}{${toCssDecls(opts.hostPalette)}}`;
343
+ }
308
344
  if (opts.appTheme) {
309
- const decls = Object.entries(opts.appTheme.cssVariables)
310
- .map(([k, v]) => `${k}: ${v};`)
311
- .join('');
345
+ const decls = toCssDecls(opts.appTheme.cssVariables);
312
346
  // The overlay must be merged HERE, into the scoped style that
313
347
  // mounts INSIDE the scope div — the `:root` append below is
314
348
  // cascade-dead for tree content: the in-scope block sits later
@@ -316,9 +350,11 @@ export async function mountReactRoot(container, opts) {
316
350
  // specificity the base scoped tokens always win over a head-level
317
351
  // override (browser-verified in rnd/gen-ui/beauty/experiments/001,
318
352
  // the probe sequence that found this). Appended AFTER the base
319
- // block and BEFORE `cssOverrides`, so: base < operator overlay <
320
- // explicit overrides. Values validated injection-safe upstream
321
- // (same guarantee the `:root` append cites).
353
+ // block + host palette and BEFORE `cssOverrides`, so: base <
354
+ // host palette < operator overlay < explicit overrides
355
+ // (ggui#573: slice wins, host fallback). Values validated
356
+ // injection-safe upstream (same guarantee the `:root` append
357
+ // cites).
322
358
  themeCss += `.${scopeClass}{${decls}}`;
323
359
  }
324
360
  // Also inject theme CSS at `:root` on `document.head`. The scoped
@@ -334,10 +370,15 @@ export async function mountReactRoot(container, opts) {
334
370
  let rootCss = opts.themeId
335
371
  ? getThemeCss(opts.themeId, opts.themeMode)
336
372
  : getCssTokens(opts.themeMode);
373
+ if (opts.hostPalette) {
374
+ // BODY-CHROME ONLY, same as the appTheme `:root` append below —
375
+ // and beneath it, mirroring the scoped-block order (#573:
376
+ // slice wins, host fallback). Keeps the embedding shell's
377
+ // body font/color/background coherent with the tree.
378
+ rootCss += `:root{${toCssDecls(opts.hostPalette)}}`;
379
+ }
337
380
  if (opts.appTheme) {
338
- const decls = Object.entries(opts.appTheme.cssVariables)
339
- .map(([k, v]) => `${k}: ${v};`)
340
- .join('');
381
+ const decls = toCssDecls(opts.appTheme.cssVariables);
341
382
  // BODY-CHROME ONLY. This `:root` append themes the embedding
342
383
  // shell's body styles (font/color/background outside the scope
343
384
  // div). It does NOT reach tree content — the scoped in-body
@@ -51,11 +51,28 @@ export interface RenderItemOptions {
51
51
  * so the renderer injects the `--ggui-*` overrides + `color-scheme` at
52
52
  * `:root`. Sourced from the bootstrap's `_meta["ai.ggui/render"].theme`.
53
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}).
54
58
  */
55
59
  readonly appTheme?: {
56
60
  readonly mode: 'light' | 'dark';
57
61
  readonly cssVariables: Record<string, string>;
62
+ readonly base?: {
63
+ readonly documentHash: string;
64
+ readonly light: Readonly<Record<string, string>>;
65
+ readonly dark: Readonly<Record<string, string>>;
66
+ };
58
67
  };
68
+ /**
69
+ * Host-announced palette, pre-mapped onto `--ggui-*` tokens by the
70
+ * host-palette bridge. Forwarded to
71
+ * {@link ReactRootMountOptions.hostPalette}: the fallback layer
72
+ * beneath `appTheme` in the scoped token block (ggui#572, merge
73
+ * order per the ggui#573 ruling — slice wins, host fallback).
74
+ */
75
+ readonly hostPalette?: Readonly<Record<string, string>>;
59
76
  readonly cssOverrides?: string;
60
77
  readonly onError?: (err: Error) => void;
61
78
  /**
@@ -177,6 +177,9 @@ export async function mountRender(container, opts) {
177
177
  ...(currentOpts.themeId !== undefined ? { themeId: currentOpts.themeId } : {}),
178
178
  ...(currentOpts.themeMode !== undefined ? { themeMode: currentOpts.themeMode } : {}),
179
179
  ...(currentOpts.appTheme !== undefined ? { appTheme: currentOpts.appTheme } : {}),
180
+ ...(currentOpts.hostPalette !== undefined
181
+ ? { hostPalette: currentOpts.hostPalette }
182
+ : {}),
180
183
  ...(currentOpts.cssOverrides !== undefined
181
184
  ? { cssOverrides: currentOpts.cssOverrides }
182
185
  : {}),
@@ -244,6 +247,7 @@ export async function mountRender(container, opts) {
244
247
  ...(next.themeId !== undefined ? { themeId: next.themeId } : {}),
245
248
  ...(next.themeMode !== undefined ? { themeMode: next.themeMode } : {}),
246
249
  ...(next.appTheme !== undefined ? { appTheme: next.appTheme } : {}),
250
+ ...(next.hostPalette !== undefined ? { hostPalette: next.hostPalette } : {}),
247
251
  ...(next.cssOverrides !== undefined ? { cssOverrides: next.cssOverrides } : {}),
248
252
  ...(next.onError !== undefined ? { onError: next.onError } : {}),
249
253
  renderWrapper: wrapInScopedProvider,
package/dist/runtime.d.ts CHANGED
@@ -97,6 +97,87 @@ export declare function setCurrentApp(app: App): void;
97
97
  * App); production caller is `buildOpts` inside `bootSequence`.
98
98
  */
99
99
  export declare function hostAnnouncedThemeMode(): 'light' | 'dark' | undefined;
100
+ /**
101
+ * Resolve the mount's base-stylesheet color mode from the slice, with
102
+ * the host as the final fallback (ggui#589, completing the ggui#551
103
+ * precedence ruling — "slice wins, host is the fallback, absent ≠
104
+ * light"):
105
+ *
106
+ * stamped `themeMode` > `theme.mode` (the slice theme OBJECT) >
107
+ * host-announced theme > undefined
108
+ *
109
+ * The middle leg is the ggui#589 fix: a render envelope carrying a
110
+ * per-app theme (`theme: {mode, cssVariables}`) but no top-level
111
+ * `themeMode` is still a slice-stamped mode opinion — before this,
112
+ * the base token ladder ignored it and painted the LIGHT token set
113
+ * under a dark overlay whenever the host (correctly, per the
114
+ * adapter-boundary doctrine in the native host helpers) announced no
115
+ * `hostContext.theme`: a light skeleton in a dark skin. The theme
116
+ * object's own `mode` already drove `color-scheme`; now it also
117
+ * selects the ladder.
118
+ *
119
+ * `undefined` still means "no opinion anywhere" — the renderer's
120
+ * default applies; never coerced to `'light'`.
121
+ *
122
+ * @internal — exported for unit tests; production caller is
123
+ * `buildOpts` inside `bootSequence`.
124
+ */
125
+ export declare function resolveMountThemeMode(meta: {
126
+ readonly themeMode?: 'light' | 'dark';
127
+ readonly theme?: {
128
+ readonly mode: 'light' | 'dark';
129
+ readonly cssVariables?: Record<string, string>;
130
+ };
131
+ }): 'light' | 'dark' | undefined;
132
+ /**
133
+ * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
134
+ *
135
+ * stamped `themeId` > `theme.name` (the slice theme OBJECT's name)
136
+ * > undefined (renderer default ladder)
137
+ *
138
+ * The name leg makes a slice theme whose `name` matches a REGISTERED
139
+ * theme id select that theme as the BASE token ladder — the overlay's
140
+ * cssVariables still apply above it, so the base carries the full
141
+ * brand ramp coverage a sparse overlay cannot (the "only the mapped
142
+ * vars carry brand" store-frame class). An unregistered name is
143
+ * harmless by construction: `getScopedThemeCss` falls back to the
144
+ * default theme, which is byte-identical to the no-themeId path
145
+ * (`getScopedCssTokens` ≡ `getScopedThemeCss(default)`), so a slice
146
+ * naming its theme "My Custom" renders exactly as before.
147
+ *
148
+ * @internal — exported for unit tests; production caller is
149
+ * `buildOpts` inside `bootSequence`.
150
+ */
151
+ export declare function resolveMountThemeId(meta: {
152
+ readonly themeId?: string;
153
+ readonly theme?: {
154
+ readonly name?: string;
155
+ readonly mode?: 'light' | 'dark';
156
+ readonly cssVariables?: Record<string, string>;
157
+ };
158
+ }): string | undefined;
159
+ /**
160
+ * The host's announced palette, read from the connected App's
161
+ * pre-merged hostContext and translated onto `--ggui-*` tokens by the
162
+ * host-palette bridge. `undefined` when no App is connected, the host
163
+ * sent no `styles.variables`, or nothing in it maps/survives
164
+ * sanitization — callers spread-skip the option, exactly like
165
+ * {@link hostAnnouncedThemeMode}.
166
+ *
167
+ * This is the palette input leg of host-theme adaptation (ggui#572,
168
+ * the color analog of #551's mode leg above): the spec `--color-*`
169
+ * variables land as inline custom properties on `<html>` via the
170
+ * canonical ext-apps helper, but nothing ggui renders consumes that
171
+ * vocabulary, and the scoped token block shadows root inheritance
172
+ * anyway — so without this mapping a host palette repaints nothing.
173
+ * `buildOpts` threads it onto the mount options; the renderer merges
174
+ * it as the fallback layer BENEATH the slice's own theme (ggui#573
175
+ * ruling: slice wins, host fallback).
176
+ *
177
+ * @internal — exported for unit tests (`setCurrentApp` injects the
178
+ * App); production caller is `buildOpts` inside `bootSequence`.
179
+ */
180
+ export declare function hostAnnouncedPalette(): Readonly<Record<string, string>> | undefined;
100
181
  /**
101
182
  * @internal — exported for unit tests to reset module state between
102
183
  * scenarios.
@@ -733,7 +814,7 @@ export declare function requestDisplayModeInParent(args: {
733
814
  * or honor an upstream `defaultPrevented` from a higher-priority
734
815
  * handler.
735
816
  *
736
- * Idempotent: re-mounts (via `installPostMountListener`) call this
817
+ * Idempotent: re-mounts (via `installPersistentToolResultListener`) call this
737
818
  * helper again; the {@link anchorClickInterceptInstalled} guard makes
738
819
  * subsequent calls a no-op.
739
820
  */