@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
@@ -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 pod can't subscribe-for.
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 (tenant) id paired with sessionId on the subscribe frame. */
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;
@@ -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
@@ -1,20 +1,42 @@
1
1
  /**
2
- * The document's connection store (ggui#670) — the single in-document
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
- * HOST, not the render, and every mount in this document shares the
7
- * host (there is exactly one boot per document; re-mounts inherit it).
8
- * The runtime is the only writer, through `transitionRelayLatch` in
9
- * `runtime.ts` — the latch's two edges are the only transitions. Lives
10
- * re-exported here so the runtime's writer sites name one local
11
- * module; the store itself is wire's package singleton (the wire docs
12
- * generator prints every WireConfig member into the prompt, so the
13
- * store is deliberately NOT a config member).
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
- export { connectionStore } from '@ggui-ai/wire';
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;
@@ -1,20 +1,48 @@
1
1
  /**
2
- * The document's connection store (ggui#670) — the single in-document
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
- * HOST, not the render, and every mount in this document shares the
7
- * host (there is exactly one boot per document; re-mounts inherit it).
8
- * The runtime is the only writer, through `transitionRelayLatch` in
9
- * `runtime.ts` — the latch's two edges are the only transitions. Lives
10
- * re-exported here so the runtime's writer sites name one local
11
- * module; the store itself is wire's package singleton (the wire docs
12
- * generator prints every WireConfig member into the prompt, so the
13
- * store is deliberately NOT a config member).
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
- export { connectionStore } from '@ggui-ai/wire';
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
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CONTEXT_DEBOUNCE_MS } from '@ggui-ai/protocol';
1
+ import { DEFAULT_CONTEXT_DEBOUNCE_MS } from '@ggui-ai/protocol/wire';
2
2
  import { validateContextValue } from './validation.js';
3
3
  /**
4
4
  * Module-level map keyed by `name` (the camelCase slot key, NOT the
@@ -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} / {@link applyHostFonts} helpers
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} / {@link applyHostFonts} helpers
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, applyHostFonts, applyHostStyleVariables, } from '@modelcontextprotocol/ext-apps';
56
- import { hostContextProjectionsEqual, projectHostContext, } from '@ggui-ai/protocol';
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
- applyHostFonts(fontsCss);
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
- // applyHostFonts injects a <style> tag; jsdom may reject the
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
- '--color-background-primary': '--ggui-color-surface',
41
- '--color-background-secondary': '--ggui-color-surfaceVariant',
42
- '--color-background-tertiary': '--ggui-color-container',
43
- '--color-background-danger': '--ggui-color-errorContainer',
44
- '--color-text-primary': '--ggui-color-onSurface',
45
- '--color-text-secondary': '--ggui-color-onSurfaceVariant',
46
- '--color-text-tertiary': '--ggui-color-neutral-500',
47
- '--color-text-danger': '--ggui-color-error-500',
48
- '--color-text-success': '--ggui-color-success-500',
49
- '--color-text-warning': '--ggui-color-warning-500',
50
- '--color-text-info': '--ggui-color-info-500',
51
- '--color-border-primary': '--ggui-color-outline',
52
- '--color-border-secondary': '--ggui-color-outlineVariant',
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, gguiKey] of Object.entries(SPEC_TO_GGUI)) {
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
- mapped[gguiKey] = value;
107
+ for (const gguiKey of gguiKeys)
108
+ mapped[gguiKey] = value;
104
109
  count += 1;
105
110
  }
106
111
  return count > 0 ? mapped : undefined;