@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.
- package/dist/channel-transport.d.ts +3 -3
- package/dist/channels/channel-payload.d.ts +1 -1
- package/dist/channels/data.d.ts +1 -1
- package/dist/channels/drain-ack.d.ts +1 -1
- package/dist/channels/props-update.d.ts +1 -1
- package/dist/channels/render-frame.d.ts +1 -1
- package/dist/compiled-validators.d.ts +1 -1
- package/dist/connection.d.ts +34 -12
- package/dist/connection.js +40 -12
- package/dist/context-observer.d.ts +1 -1
- package/dist/context-observer.js +1 -1
- package/dist/events-polling.d.ts +1 -1
- package/dist/host-context-emitter.d.ts +23 -2
- package/dist/host-context-emitter.js +38 -5
- package/dist/host-fonts.d.ts +10 -0
- package/dist/host-fonts.js +113 -0
- package/dist/host-palette-bridge.js +20 -15
- package/dist/iframe-runtime.js +97 -108
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/mcp-app-iframe-host.d.ts +15 -0
- package/dist/mcp-app-iframe-host.js +5 -1
- package/dist/meta-parse.d.ts +9 -0
- package/dist/meta-parse.js +15 -3
- package/dist/observability.d.ts +73 -14
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/react-renderer.d.ts +19 -37
- package/dist/react-renderer.js +64 -72
- package/dist/registry-subscribe.d.ts +1 -1
- package/dist/render-item.d.ts +7 -14
- package/dist/runtime-telemetry.js +1 -1
- package/dist/runtime.d.ts +48 -56
- package/dist/runtime.js +213 -115
- package/dist/system-cards/NoCredentialsCard.js +13 -13
- package/dist/system-cards/ProtocolProbeCard.js +11 -11
- package/dist/system-cards/QrCode.js +1 -1
- package/dist/system-cards/index.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/validation.d.ts +1 -1
- package/dist/validation.js +1 -1
- package/dist/wire-config.d.ts +3 -3
- package/package.json +7 -7
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* router runs a setInterval that fires `tools/call` against
|
|
18
18
|
* the parent MCP host (direct, no bridge). Works
|
|
19
19
|
* for any tool the agent has access to, including third-party
|
|
20
|
-
* MCP servers the local
|
|
20
|
+
* MCP servers the local server can't subscribe for.
|
|
21
21
|
*
|
|
22
22
|
* The dispatch decision is per-channel + per-render (each new render
|
|
23
23
|
* brings a potentially-new contract; we re-run the routing). The
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
* un-routed channel posture pre-1c, where any payload that landed on the
|
|
53
53
|
* data frame was treated as authoritative).
|
|
54
54
|
*/
|
|
55
|
-
import type { JsonObject, JsonValue, StreamSpec } from '@ggui-ai/protocol';
|
|
55
|
+
import type { JsonObject, JsonValue, StreamSpec } from '@ggui-ai/protocol/wire';
|
|
56
56
|
import type { WebSocketMessage } from '@ggui-ai/protocol/transport/websocket';
|
|
57
57
|
import type { ChannelPollDegradedEvent, ChannelPollRecoveredEvent, ChannelTransportFallbackEvent, ChannelTransportPickedEvent, ChannelTransportResubscribedEvent } from './observability.js';
|
|
58
58
|
import type { StreamBus } from './wire-config.js';
|
|
@@ -121,7 +121,7 @@ export type ToolsCallInvoker = (args: {
|
|
|
121
121
|
export interface ChannelTransportRouterOptions {
|
|
122
122
|
/** GguiSession id the router scopes channel_subscribe frames against. */
|
|
123
123
|
readonly sessionId: string;
|
|
124
|
-
/** App
|
|
124
|
+
/** App id paired with sessionId on the subscribe frame. */
|
|
125
125
|
readonly appId: string;
|
|
126
126
|
/**
|
|
127
127
|
* Allowlist of `source.tool` names this server can subscribe-for
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* extraction.
|
|
12
12
|
*/
|
|
13
13
|
import type { ChannelHandler } from '@ggui-ai/live-channel';
|
|
14
|
-
import type { ChannelErrorPayload, ChannelPayloadFrame } from '@ggui-ai/protocol';
|
|
14
|
+
import type { ChannelErrorPayload, ChannelPayloadFrame } from '@ggui-ai/protocol/wire';
|
|
15
15
|
import type { ChannelTransportRouter } from '../channel-transport.js';
|
|
16
16
|
export interface ChannelRouterHandlerDeps {
|
|
17
17
|
readonly getChannelTransport: () => ChannelTransportRouter;
|
package/dist/channels/data.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* render, so the lookup is direct.
|
|
12
12
|
*/
|
|
13
13
|
import type { ChannelHandler } from '@ggui-ai/live-channel';
|
|
14
|
-
import type { GguiSession, StreamEnvelope } from '@ggui-ai/protocol';
|
|
14
|
+
import type { GguiSession, StreamEnvelope } from '@ggui-ai/protocol/wire';
|
|
15
15
|
import type { GguiSessionSeedInput } from '../types.js';
|
|
16
16
|
import { type RendererValidatorContext } from '../validation.js';
|
|
17
17
|
import type { StreamBus } from '../wire-config.js';
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* frame arrives on the wire.
|
|
7
7
|
*/
|
|
8
8
|
import type { ChannelHandler } from '@ggui-ai/live-channel';
|
|
9
|
-
import type { DrainAckPayload } from '@ggui-ai/protocol';
|
|
9
|
+
import type { DrainAckPayload } from '@ggui-ai/protocol/wire';
|
|
10
10
|
export interface DrainAckHandlerDeps {
|
|
11
11
|
readonly dispatch: (payload: DrainAckPayload) => void;
|
|
12
12
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* `PollingTransport` dispatches.
|
|
30
30
|
*/
|
|
31
31
|
import type { ChannelHandler } from '@ggui-ai/live-channel';
|
|
32
|
-
import type { PropsUpdatePayload, GguiSession } from '@ggui-ai/protocol';
|
|
32
|
+
import type { PropsUpdatePayload, GguiSession } from '@ggui-ai/protocol/wire';
|
|
33
33
|
import type { GguiSessionSeedInput } from '../types.js';
|
|
34
34
|
export interface PropsUpdateHandlerDeps {
|
|
35
35
|
/**
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* happens. The boot-without-renderer path remains testable.
|
|
19
19
|
*/
|
|
20
20
|
import type { ChannelHandler } from '@ggui-ai/live-channel';
|
|
21
|
-
import type { RenderPayload, GguiSession } from '@ggui-ai/protocol';
|
|
21
|
+
import type { RenderPayload, GguiSession } from '@ggui-ai/protocol/wire';
|
|
22
22
|
import type { ChannelTransportRouter } from '../channel-transport.js';
|
|
23
23
|
import { type StatusRefs } from '../status-dom.js';
|
|
24
24
|
export interface RenderHandlerDeps {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* {@link CompiledValidatorSet}.
|
|
13
13
|
*/
|
|
14
14
|
import type { CompiledContractValidators } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
15
|
-
import type { ValidateFunction } from '@ggui-ai/protocol';
|
|
15
|
+
import type { ValidateFunction } from '@ggui-ai/protocol/wire';
|
|
16
16
|
/**
|
|
17
17
|
* Precompiled validators for the active render's contract, keyed to
|
|
18
18
|
* match the four runtime-validated surfaces. `props` is a single
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,20 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The document's connection
|
|
3
|
-
* external store behind `useRender().isConnected
|
|
2
|
+
* The document's connection state (ggui#670, ggui#843) — the in-document
|
|
3
|
+
* external store behind `useRender().isConnected`, seen from the runtime.
|
|
4
4
|
*
|
|
5
|
-
* One store per document, by design: the relay latch describes the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* One store per document, by design: the relay latch describes the HOST,
|
|
6
|
+
* not the render, and every mount in this document shares the host (there
|
|
7
|
+
* is exactly one boot per document; re-mounts inherit it). The runtime is
|
|
8
|
+
* the ONE writer, through `transitionRelayLatch` in `runtime.ts` — the
|
|
9
|
+
* latch's two edges are the only transitions — and it proves it by
|
|
10
|
+
* CLAIMING the writer: once, eagerly at boot (`resetRelayLatchForBoot`),
|
|
11
|
+
* under its own name, through `@ggui-ai/wire/internal`. Readers — the
|
|
12
|
+
* wire's `useRender()`, generated component code through the root barrel —
|
|
13
|
+
* hold the read view `connectionSource` and nothing else. (The wire docs
|
|
14
|
+
* generator prints every WireConfig member into the prompt, so the store
|
|
15
|
+
* is deliberately NOT a config member.)
|
|
16
|
+
*
|
|
17
|
+
* A second copy of the runtime in the same document — a second evaluation
|
|
18
|
+
* of the bundle — adopts the SAME store (wire anchors it on the document,
|
|
19
|
+
* not the module copy) and fails at boot, here, naming itself
|
|
20
|
+
* (`ConnectionWriterConflictError`, owner === attemptedBy): the error IS
|
|
21
|
+
* the detector for a duplicate bundle, and boot never continues with an
|
|
22
|
+
* unclaimed writer.
|
|
14
23
|
*
|
|
15
24
|
* Not a reserved stream channel: nothing on the wire can reach it
|
|
16
25
|
* (SPEC §4.4 forbids renderer-authored `_ggui:*` deliveries, and a
|
|
17
26
|
* registered-but-ungated channel would have been a forgery path — the
|
|
18
27
|
* adversarial pass on ggui#670).
|
|
19
28
|
*/
|
|
20
|
-
|
|
29
|
+
import { type ConnectionWriter } from '@ggui-ai/wire/internal';
|
|
30
|
+
export { connectionSource } from '@ggui-ai/wire';
|
|
31
|
+
/**
|
|
32
|
+
* The runtime's claim on the document's connection writer — taken on first
|
|
33
|
+
* call and memoized for the document's life. Boot calls it first, so the
|
|
34
|
+
* claim (and any conflict) surfaces before the first transition.
|
|
35
|
+
*/
|
|
36
|
+
export declare function runtimeConnectionWriter(): ConnectionWriter;
|
|
37
|
+
/**
|
|
38
|
+
* Test seam: release the runtime's claim and forget it, so a test can hand
|
|
39
|
+
* the document's writer to another party (a refusing-boot case) or boot a
|
|
40
|
+
* fresh module graph. Never called by the runtime itself.
|
|
41
|
+
*/
|
|
42
|
+
export declare function __resetRuntimeConnectionWriterForTest(): void;
|
package/dist/connection.js
CHANGED
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The document's connection
|
|
3
|
-
* external store behind `useRender().isConnected
|
|
2
|
+
* The document's connection state (ggui#670, ggui#843) — the in-document
|
|
3
|
+
* external store behind `useRender().isConnected`, seen from the runtime.
|
|
4
4
|
*
|
|
5
|
-
* One store per document, by design: the relay latch describes the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* One store per document, by design: the relay latch describes the HOST,
|
|
6
|
+
* not the render, and every mount in this document shares the host (there
|
|
7
|
+
* is exactly one boot per document; re-mounts inherit it). The runtime is
|
|
8
|
+
* the ONE writer, through `transitionRelayLatch` in `runtime.ts` — the
|
|
9
|
+
* latch's two edges are the only transitions — and it proves it by
|
|
10
|
+
* CLAIMING the writer: once, eagerly at boot (`resetRelayLatchForBoot`),
|
|
11
|
+
* under its own name, through `@ggui-ai/wire/internal`. Readers — the
|
|
12
|
+
* wire's `useRender()`, generated component code through the root barrel —
|
|
13
|
+
* hold the read view `connectionSource` and nothing else. (The wire docs
|
|
14
|
+
* generator prints every WireConfig member into the prompt, so the store
|
|
15
|
+
* is deliberately NOT a config member.)
|
|
16
|
+
*
|
|
17
|
+
* A second copy of the runtime in the same document — a second evaluation
|
|
18
|
+
* of the bundle — adopts the SAME store (wire anchors it on the document,
|
|
19
|
+
* not the module copy) and fails at boot, here, naming itself
|
|
20
|
+
* (`ConnectionWriterConflictError`, owner === attemptedBy): the error IS
|
|
21
|
+
* the detector for a duplicate bundle, and boot never continues with an
|
|
22
|
+
* unclaimed writer.
|
|
14
23
|
*
|
|
15
24
|
* Not a reserved stream channel: nothing on the wire can reach it
|
|
16
25
|
* (SPEC §4.4 forbids renderer-authored `_ggui:*` deliveries, and a
|
|
17
26
|
* registered-but-ungated channel would have been a forgery path — the
|
|
18
27
|
* adversarial pass on ggui#670).
|
|
19
28
|
*/
|
|
20
|
-
|
|
29
|
+
import { claimConnectionWriter } from '@ggui-ai/wire/internal';
|
|
30
|
+
export { connectionSource } from '@ggui-ai/wire';
|
|
31
|
+
let writer;
|
|
32
|
+
/**
|
|
33
|
+
* The runtime's claim on the document's connection writer — taken on first
|
|
34
|
+
* call and memoized for the document's life. Boot calls it first, so the
|
|
35
|
+
* claim (and any conflict) surfaces before the first transition.
|
|
36
|
+
*/
|
|
37
|
+
export function runtimeConnectionWriter() {
|
|
38
|
+
return (writer ??= claimConnectionWriter('iframe-runtime'));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Test seam: release the runtime's claim and forget it, so a test can hand
|
|
42
|
+
* the document's writer to another party (a refusing-boot case) or boot a
|
|
43
|
+
* fresh module graph. Never called by the runtime itself.
|
|
44
|
+
*/
|
|
45
|
+
export function __resetRuntimeConnectionWriterForTest() {
|
|
46
|
+
writer?.release();
|
|
47
|
+
writer = undefined;
|
|
48
|
+
}
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* `console.warn` and drop silently in production (per Q4 design lock).
|
|
44
44
|
*/
|
|
45
45
|
import type { Context, ReactElement, ReactNode } from 'react';
|
|
46
|
-
import type { JsonSchema } from '@ggui-ai/protocol';
|
|
46
|
+
import type { JsonSchema } from '@ggui-ai/protocol/wire';
|
|
47
47
|
import type { GguiContextRegistry } from './globals.js';
|
|
48
48
|
/**
|
|
49
49
|
* Wire-shape entry for a single contextSpec slot, mirrored from
|
package/dist/context-observer.js
CHANGED
package/dist/events-polling.d.ts
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* fallback, and live updates.
|
|
49
49
|
*/
|
|
50
50
|
import type { RegistryPollingOptions } from '@ggui-ai/live-channel';
|
|
51
|
-
import type { GguiRuntimePullInput } from '@ggui-ai/protocol';
|
|
51
|
+
import type { GguiRuntimePullInput } from '@ggui-ai/protocol/wire';
|
|
52
52
|
/**
|
|
53
53
|
* Shared, monotonic event-ledger cursor for the failover ladder.
|
|
54
54
|
*
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* the host's `theme` updated but ggui doesn't project theme).
|
|
17
17
|
* 4. Apply host theme + styles + fonts to the iframe DOM via the
|
|
18
18
|
* spec-canonical {@link applyDocumentTheme} /
|
|
19
|
-
* {@link applyHostStyleVariables}
|
|
19
|
+
* {@link applyHostStyleVariables} helper + this package's
|
|
20
|
+
* `installHostFonts` (ggui#987 §5: replace-on-payload)
|
|
20
21
|
* from `@modelcontextprotocol/ext-apps`. Fires on EVERY raw
|
|
21
22
|
* `McpUiHostContext` the runtime observes (initial + change
|
|
22
23
|
* notifications). The application path is INDEPENDENT of the
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
* happens in one place.
|
|
54
55
|
*/
|
|
55
56
|
import { type App } from '@modelcontextprotocol/ext-apps';
|
|
56
|
-
import { type HostContextObservedPayload, type HostContextProjection } from '@ggui-ai/protocol';
|
|
57
|
+
import { type HostContextObservedPayload, type HostContextProjection } from '@ggui-ai/protocol/wire';
|
|
57
58
|
/**
|
|
58
59
|
* Function the emitter calls to ship a `host_context_observed` envelope.
|
|
59
60
|
* Wired by `bootProduction` to the live WS transport's `send` —
|
|
@@ -132,6 +133,26 @@ export declare function detach(_targetWindow?: Window): void;
|
|
|
132
133
|
* @internal
|
|
133
134
|
*/
|
|
134
135
|
export declare function _peekCurrent(): HostContextProjection | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* Which of the two theming fields a `host-context-changed` payload
|
|
138
|
+
* carried. `HostContextProjection` EXCLUDES `theme` and `styles` by
|
|
139
|
+
* design (they flow through ggui's own theming pipeline), so the
|
|
140
|
+
* projection's equality gate can never announce a theme flip — this is
|
|
141
|
+
* the trigger that can (ggui#987 §4: the host owns runtime mode and may
|
|
142
|
+
* flip it mid-session). The listener re-reads the App's pre-merged
|
|
143
|
+
* `getHostContext()` itself; the raw values are not forwarded.
|
|
144
|
+
*/
|
|
145
|
+
export interface HostThemeChange {
|
|
146
|
+
readonly theme: boolean;
|
|
147
|
+
readonly styles: boolean;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Subscribe to `host-context-changed` payloads that carry `theme` and/or
|
|
151
|
+
* `styles` — fired on the RAW payload, before and regardless of the
|
|
152
|
+
* projection's equality gate, once per payload. Returns an unsubscribe.
|
|
153
|
+
* `detach()` clears every theme subscriber with the rest of the state.
|
|
154
|
+
*/
|
|
155
|
+
export declare function subscribeThemeChange(listener: (change: HostThemeChange) => void): () => void;
|
|
135
156
|
export declare function subscribeLocal(listener: (projection: HostContextProjection) => void): () => void;
|
|
136
157
|
/**
|
|
137
158
|
* Apply the host's `theme`, `styles.variables`, `styles.css.fonts`, and
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* the host's `theme` updated but ggui doesn't project theme).
|
|
17
17
|
* 4. Apply host theme + styles + fonts to the iframe DOM via the
|
|
18
18
|
* spec-canonical {@link applyDocumentTheme} /
|
|
19
|
-
* {@link applyHostStyleVariables}
|
|
19
|
+
* {@link applyHostStyleVariables} helper + this package's
|
|
20
|
+
* `installHostFonts` (ggui#987 §5: replace-on-payload)
|
|
20
21
|
* from `@modelcontextprotocol/ext-apps`. Fires on EVERY raw
|
|
21
22
|
* `McpUiHostContext` the runtime observes (initial + change
|
|
22
23
|
* notifications). The application path is INDEPENDENT of the
|
|
@@ -52,8 +53,9 @@
|
|
|
52
53
|
* - Future widening (echo more fields, debounce burst changes, etc.)
|
|
53
54
|
* happens in one place.
|
|
54
55
|
*/
|
|
55
|
-
import { applyDocumentTheme,
|
|
56
|
-
import {
|
|
56
|
+
import { applyDocumentTheme, applyHostStyleVariables, } from '@modelcontextprotocol/ext-apps';
|
|
57
|
+
import { installHostFonts } from './host-fonts.js';
|
|
58
|
+
import { hostContextProjectionsEqual, projectHostContext, } from '@ggui-ai/protocol/wire';
|
|
57
59
|
let state = null;
|
|
58
60
|
// =============================================================================
|
|
59
61
|
// Public surface
|
|
@@ -156,6 +158,7 @@ export function detach(_targetWindow = window) {
|
|
|
156
158
|
// scenarios. Without this, a subscriber registered in test 1 leaks
|
|
157
159
|
// into test 2's seed and fires against a stale closure.
|
|
158
160
|
localSubscribers.clear();
|
|
161
|
+
themeSubscribers.clear();
|
|
159
162
|
}
|
|
160
163
|
/**
|
|
161
164
|
* Test-only read of the current held projection. Production code
|
|
@@ -181,6 +184,19 @@ export function _peekCurrent() {
|
|
|
181
184
|
* tests + the production mount can coexist.
|
|
182
185
|
*/
|
|
183
186
|
const localSubscribers = new Set();
|
|
187
|
+
const themeSubscribers = new Set();
|
|
188
|
+
/**
|
|
189
|
+
* Subscribe to `host-context-changed` payloads that carry `theme` and/or
|
|
190
|
+
* `styles` — fired on the RAW payload, before and regardless of the
|
|
191
|
+
* projection's equality gate, once per payload. Returns an unsubscribe.
|
|
192
|
+
* `detach()` clears every theme subscriber with the rest of the state.
|
|
193
|
+
*/
|
|
194
|
+
export function subscribeThemeChange(listener) {
|
|
195
|
+
themeSubscribers.add(listener);
|
|
196
|
+
return () => {
|
|
197
|
+
themeSubscribers.delete(listener);
|
|
198
|
+
};
|
|
199
|
+
}
|
|
184
200
|
export function subscribeLocal(listener) {
|
|
185
201
|
localSubscribers.add(listener);
|
|
186
202
|
// Fire immediately if seed has already happened.
|
|
@@ -238,11 +254,14 @@ export function applyHostContextStyling(raw) {
|
|
|
238
254
|
try {
|
|
239
255
|
const fontsCss = ctx.styles?.css?.fonts;
|
|
240
256
|
if (typeof fontsCss === 'string' && fontsCss.length > 0) {
|
|
241
|
-
|
|
257
|
+
// Replace-on-payload under ONE style element (ggui#987 §5) — a
|
|
258
|
+
// host that changes fonts repaints; the reference helper's
|
|
259
|
+
// append-per-call would accumulate every payload ever announced.
|
|
260
|
+
installHostFonts(fontsCss);
|
|
242
261
|
}
|
|
243
262
|
}
|
|
244
263
|
catch {
|
|
245
|
-
//
|
|
264
|
+
// installHostFonts writes a <style> tag; jsdom may reject the
|
|
246
265
|
// operation under strict modes. Swallow.
|
|
247
266
|
}
|
|
248
267
|
// safeAreaInsets — the spec carries these for mobile chrome
|
|
@@ -290,6 +309,20 @@ function handleHostContextChangedMessage(raw) {
|
|
|
290
309
|
function handleHostContextChangedParams(params) {
|
|
291
310
|
if (state === null)
|
|
292
311
|
return;
|
|
312
|
+
// The theming fields never reach the projection below — announce
|
|
313
|
+
// them here, on the raw payload, so a theme-only flip repaints
|
|
314
|
+
// (ggui#987 §4; the projection excludes theme/styles by design).
|
|
315
|
+
const change = { theme: 'theme' in params, styles: 'styles' in params };
|
|
316
|
+
if (change.theme || change.styles) {
|
|
317
|
+
for (const listener of themeSubscribers) {
|
|
318
|
+
try {
|
|
319
|
+
listener(change);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// A subscriber throwing must not stop the DOM apply + echo.
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
293
326
|
// Apply spec-canonical theme/styles/fonts to the iframe DOM BEFORE
|
|
294
327
|
// the WS-echo path. The DOM-apply path covers fields the projection
|
|
295
328
|
// drops (theme, styles) so the LLM-generated UI inside the iframe
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The single `<style>` the host's faces live under. */
|
|
2
|
+
export declare const HOST_FONTS_STYLE_ID = "ggui-host-fonts";
|
|
3
|
+
/**
|
|
4
|
+
* Install (or replace) the host's `@font-face` rules. Idempotent on
|
|
5
|
+
* identical payloads; a different payload replaces the previous one
|
|
6
|
+
* wholesale. Arms the CSP-violation reporter on first use.
|
|
7
|
+
*/
|
|
8
|
+
export declare function installHostFonts(fontsCss: string): void;
|
|
9
|
+
/** @internal — test isolation: drop the style, the report ledger, and the listener. */
|
|
10
|
+
export declare function __resetHostFontsForTest(): void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host font installation (ggui#987 §5).
|
|
3
|
+
*
|
|
4
|
+
* An MCP Apps host may hand the card `@font-face` rules in
|
|
5
|
+
* `hostContext.styles.css.fonts`. The card installs them under ONE
|
|
6
|
+
* `<style id="ggui-host-fonts">`, REPLACING the previous payload on
|
|
7
|
+
* every `hostcontextchanged` — a host that switches fonts repaints, a
|
|
8
|
+
* host that re-announces the same fonts is a no-op. (The reference
|
|
9
|
+
* helper appends a fresh `<style>` per call, so a long-lived card
|
|
10
|
+
* accumulated every font the host ever announced.)
|
|
11
|
+
*
|
|
12
|
+
* The document's CSP decides whether a face's `src` may load. When it
|
|
13
|
+
* refuses one, the browser fires `securitypolicyviolation` with the
|
|
14
|
+
* `font-src` directive and the blocked URL (origin-stripped for
|
|
15
|
+
* cross-origin — hence the (family, host) key); the card reports it to
|
|
16
|
+
* the embedding host ONCE per (family, host) as `font-face-blocked`,
|
|
17
|
+
* so the operator sees the allowlist gap instead of a silent fallback.
|
|
18
|
+
*/
|
|
19
|
+
import { postObservabilityToParent } from './observability.js';
|
|
20
|
+
/** The single `<style>` the host's faces live under. */
|
|
21
|
+
export const HOST_FONTS_STYLE_ID = 'ggui-host-fonts';
|
|
22
|
+
let installedFaces = [];
|
|
23
|
+
const reported = new Set();
|
|
24
|
+
let violationListener = null;
|
|
25
|
+
function hostOf(url) {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(url).host || undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Parse `@font-face` blocks → (family, src hosts). Tolerant: a block
|
|
34
|
+
* missing either is skipped, never thrown on. */
|
|
35
|
+
function parseFaces(css) {
|
|
36
|
+
const faces = [];
|
|
37
|
+
for (const block of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
|
|
38
|
+
const body = block[1] ?? '';
|
|
39
|
+
const familyMatch = /font-family\s*:\s*(["']?)([^;"']+)\1/i.exec(body);
|
|
40
|
+
if (familyMatch === null)
|
|
41
|
+
continue;
|
|
42
|
+
const family = (familyMatch[2] ?? '').trim();
|
|
43
|
+
const hosts = new Set();
|
|
44
|
+
const srcs = new Set();
|
|
45
|
+
for (const src of body.matchAll(/url\(\s*(["']?)([^)"']+)\1\s*\)/g)) {
|
|
46
|
+
const url = (src[2] ?? '').trim();
|
|
47
|
+
const host = hostOf(url);
|
|
48
|
+
if (host === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
hosts.add(host);
|
|
51
|
+
srcs.add(url);
|
|
52
|
+
}
|
|
53
|
+
if (family.length > 0 && hosts.size > 0)
|
|
54
|
+
faces.push({ family, srcs, hosts });
|
|
55
|
+
}
|
|
56
|
+
return faces;
|
|
57
|
+
}
|
|
58
|
+
function onViolation(ev) {
|
|
59
|
+
const v = ev;
|
|
60
|
+
if (v.effectiveDirective !== 'font-src' || typeof v.blockedURI !== 'string')
|
|
61
|
+
return;
|
|
62
|
+
const host = hostOf(v.blockedURI);
|
|
63
|
+
if (host === undefined)
|
|
64
|
+
return;
|
|
65
|
+
// CSP strips a cross-origin `blockedURI` to its origin; a same-origin
|
|
66
|
+
// report carries the full URL. With a path in hand the report is
|
|
67
|
+
// ours only when it names one of OUR `src`s — another stylesheet's
|
|
68
|
+
// font on the same host is not this face's failure.
|
|
69
|
+
const carriesPath = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/./i.test(v.blockedURI);
|
|
70
|
+
for (const face of installedFaces) {
|
|
71
|
+
if (carriesPath ? !face.srcs.has(v.blockedURI) : !face.hosts.has(host))
|
|
72
|
+
continue;
|
|
73
|
+
const key = `${face.family}\u0000${host}`;
|
|
74
|
+
if (reported.has(key))
|
|
75
|
+
continue;
|
|
76
|
+
reported.add(key);
|
|
77
|
+
postObservabilityToParent({ kind: 'font-face-blocked', family: face.family, host });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Install (or replace) the host's `@font-face` rules. Idempotent on
|
|
82
|
+
* identical payloads; a different payload replaces the previous one
|
|
83
|
+
* wholesale. Arms the CSP-violation reporter on first use.
|
|
84
|
+
*/
|
|
85
|
+
export function installHostFonts(fontsCss) {
|
|
86
|
+
if (typeof document === 'undefined')
|
|
87
|
+
return;
|
|
88
|
+
let el = document.getElementById(HOST_FONTS_STYLE_ID);
|
|
89
|
+
if (el === null) {
|
|
90
|
+
el = document.createElement('style');
|
|
91
|
+
el.id = HOST_FONTS_STYLE_ID;
|
|
92
|
+
document.head.appendChild(el);
|
|
93
|
+
}
|
|
94
|
+
if (el.textContent !== fontsCss)
|
|
95
|
+
el.textContent = fontsCss;
|
|
96
|
+
installedFaces = parseFaces(fontsCss);
|
|
97
|
+
if (violationListener === null) {
|
|
98
|
+
violationListener = onViolation;
|
|
99
|
+
document.addEventListener('securitypolicyviolation', violationListener);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** @internal — test isolation: drop the style, the report ledger, and the listener. */
|
|
103
|
+
export function __resetHostFontsForTest() {
|
|
104
|
+
if (typeof document === 'undefined')
|
|
105
|
+
return;
|
|
106
|
+
document.getElementById(HOST_FONTS_STYLE_ID)?.remove();
|
|
107
|
+
installedFaces = [];
|
|
108
|
+
reported.clear();
|
|
109
|
+
if (violationListener !== null) {
|
|
110
|
+
document.removeEventListener('securitypolicyviolation', violationListener);
|
|
111
|
+
violationListener = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -37,19 +37,23 @@
|
|
|
37
37
|
* flat semantic tokens).
|
|
38
38
|
*/
|
|
39
39
|
const SPEC_TO_GGUI = {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
'--color-
|
|
45
|
-
'--color-
|
|
46
|
-
'--color-
|
|
47
|
-
'--color-
|
|
48
|
-
'--color-text-
|
|
49
|
-
'--color-text-
|
|
50
|
-
'--color-text-
|
|
51
|
-
'--color-
|
|
52
|
-
'--color-
|
|
40
|
+
// Surface-layering roles (ggui#987 §2.1): the host's primary background
|
|
41
|
+
// is the canvas a card sits on (`ground`), its secondary the card
|
|
42
|
+
// itself (`container`), its tertiary the recessed wells (`sunken`);
|
|
43
|
+
// primary text is the ink on both the canvas and the card.
|
|
44
|
+
'--color-background-primary': ['--ggui-color-ground'],
|
|
45
|
+
'--color-background-secondary': ['--ggui-color-container'],
|
|
46
|
+
'--color-background-tertiary': ['--ggui-color-sunken'],
|
|
47
|
+
'--color-background-danger': ['--ggui-color-errorContainer'],
|
|
48
|
+
'--color-text-primary': ['--ggui-color-onGround', '--ggui-color-onContainer'],
|
|
49
|
+
'--color-text-secondary': ['--ggui-color-onSunken'],
|
|
50
|
+
'--color-text-tertiary': ['--ggui-color-neutral-500'],
|
|
51
|
+
'--color-text-danger': ['--ggui-color-error-500'],
|
|
52
|
+
'--color-text-success': ['--ggui-color-success-500'],
|
|
53
|
+
'--color-text-warning': ['--ggui-color-warning-500'],
|
|
54
|
+
'--color-text-info': ['--ggui-color-info-500'],
|
|
55
|
+
'--color-border-primary': ['--ggui-color-outline'],
|
|
56
|
+
'--color-border-secondary': ['--ggui-color-outlineVariant'],
|
|
53
57
|
};
|
|
54
58
|
/**
|
|
55
59
|
* Host palette values arrive over postMessage and are string-joined
|
|
@@ -93,14 +97,15 @@ export function mapHostPaletteToGguiVars(variables) {
|
|
|
93
97
|
return undefined;
|
|
94
98
|
const mapped = {};
|
|
95
99
|
let count = 0;
|
|
96
|
-
for (const [specKey,
|
|
100
|
+
for (const [specKey, gguiKeys] of Object.entries(SPEC_TO_GGUI)) {
|
|
97
101
|
const raw = variables[specKey];
|
|
98
102
|
if (typeof raw !== 'string')
|
|
99
103
|
continue;
|
|
100
104
|
const value = raw.trim();
|
|
101
105
|
if (!isSafeCssValue(value))
|
|
102
106
|
continue;
|
|
103
|
-
|
|
107
|
+
for (const gguiKey of gguiKeys)
|
|
108
|
+
mapped[gguiKey] = value;
|
|
104
109
|
count += 1;
|
|
105
110
|
}
|
|
106
111
|
return count > 0 ? mapped : undefined;
|