@ggui-ai/iframe-runtime 0.13.0 → 0.14.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, RelayIncapabilityEvent, UnknownObservabilityEvent, } from './observability.js';
41
- export { postObservabilityToParent } from './observability.js';
40
+ export type { ObservabilityEvent, ObservabilityEmitter, ObservabilityMessage, SchemaVersionMismatchEvent, SubscribeFailedEvent, ChannelTransportPickedEvent, ChannelTransportFallbackEvent, ChannelTransportResubscribedEvent, ChannelPollDegradedEvent, ChannelPollRecoveredEvent, UiFeedbackEvent, RelayDeadTapEvent, RelayIncapabilityEvent, RelayLatchTrigger, UnknownObservabilityEvent, } from './observability.js';
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';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
2
2
  export { defaultProtocolErrorEmitter, fromBootstrapFailure, fromClientContractViolation, fromTransportFailure, fromAuthFailure, fromUpgradeRequired, fromUnknown, } from './protocol-error.js';
3
- export { postObservabilityToParent } from './observability.js';
3
+ export { isObservabilityMessage, isRelayDeadTapEvent, isRelayIncapabilityEvent, postObservabilityToParent, } from './observability.js';
4
4
  export { postLifecycleToParent, makeLifecycleEvent } from './lifecycle.js';
@@ -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 | UnknownObservabilityEvent;
36
+ export type ObservabilityEvent = SchemaVersionMismatchEvent | SubscribeFailedEvent | ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent | UiFeedbackEvent | RelayIncapabilityEvent | RelayDeadTapEvent | UnknownObservabilityEvent;
37
37
  /**
38
38
  * Fired when the protocol-version handshake rejects the connection.
39
39
  * Parallel to `UpgradeRequiredError` — the host can choose to render
@@ -178,26 +178,41 @@ export interface UiFeedbackEvent {
178
178
  /** Tool that produced the render (e.g. `ggui_render`). */
179
179
  readonly toolName?: string;
180
180
  }
181
+ /**
182
+ * The evidence that latched a relay dead zone (ggui#599 cycle-2):
183
+ * `'confirmed-refusal'` — the relay answered a declared refusal code
184
+ * (helper-minted; outranks any positive advertisement), vs
185
+ * `'advert-silent'` — the host never advertised `serverTools` and the
186
+ * attempt failed relay-shaped.
187
+ *
188
+ * @public
189
+ */
190
+ export type RelayLatchTrigger = 'confirmed-refusal' | 'advert-silent';
181
191
  /**
182
192
  * Fired at the two transition edges of the renderer's
183
193
  * relay-incapability latch — the runtime's confirmed determination
184
194
  * that the host cannot relay `tools/call` to the MCP server:
185
195
  *
186
- * - `'latched'` — a real user gesture just failed relay-shaped (no
187
- * well-formed result envelope came back at all) on a host whose
188
- * captured capability handshake never advertised `serverTools`.
196
+ * - `'latched'` — a real user gesture just failed relay-shaped on a
197
+ * host whose captured capability handshake never advertised
198
+ * `serverTools`, or the relay answered a declared refusal code.
189
199
  * The runtime now treats relay as confirmed-unavailable: it shows
190
- * one persistent explanation instead of a per-gesture error toast,
191
- * and channel polls fail fast without a transport round-trip.
200
+ * one persistent explanation, arms the dead-zone cue for every
201
+ * later gesture, and channel polls fail fast without a transport
202
+ * round-trip.
192
203
  * - `'cleared'` — a later well-formed result envelope arrived
193
204
  * (`ok:true` and `ok:false` alike — either proves the host relayed
194
- * the call there and back), so the determination no longer holds;
195
- * per-gesture feedback and channel transport attempts resume.
205
+ * the call there and back), so the determination no longer holds.
206
+ * Carries the dead zone's summary: how long it stood and how many
207
+ * gestures were attempted inside it.
196
208
  *
197
- * Emitted once per edge, never per channel poll tick: repeated failing
198
- * gestures while latched emit nothing further, and the router's
199
- * fail-fast ticks emit nothing at all — the edges carry the full
200
- * information.
209
+ * Emitted once per edge, never per channel poll tick. Gestures
210
+ * attempted while latched are NOT silent on this stream: each one is a
211
+ * {@link RelayDeadTapEvent} (ggui#670 Phase 3) — one per real user
212
+ * gesture, never per tick — so a host can count dead taps and measure
213
+ * dead-tap-to-recovery without watching the render. A boot that finds
214
+ * a latch standing from a prior mount closes it with a `'cleared'`
215
+ * edge before the new session starts.
201
216
  *
202
217
  * Always emitted via the postMessage-to-parent default — the emission
203
218
  * sites live in module-level gesture-dispatch code outside the boot
@@ -205,9 +220,61 @@ export interface UiFeedbackEvent {
205
220
  *
206
221
  * @public
207
222
  */
208
- export interface RelayIncapabilityEvent {
223
+ export type RelayIncapabilityEvent = {
209
224
  readonly kind: 'relay-incapability';
210
- readonly state: 'latched' | 'cleared';
225
+ readonly state: 'latched';
226
+ readonly trigger: RelayLatchTrigger;
227
+ /** GguiSession id of the render, when the latching gesture carried one. */
228
+ readonly sessionId?: string;
229
+ /** App id of the render, when the latching gesture carried one. */
230
+ readonly appId?: string;
231
+ } | {
232
+ readonly kind: 'relay-incapability';
233
+ readonly state: 'cleared';
234
+ /** How long the dead zone stood, in milliseconds. */
235
+ readonly latchedForMs: number;
236
+ /**
237
+ * Gestures attempted inside the zone — equals the last
238
+ * {@link RelayDeadTapEvent} `ordinal` this edge closes (0 when
239
+ * none was attempted).
240
+ */
241
+ readonly deadTaps: number;
242
+ readonly sessionId?: string;
243
+ readonly appId?: string;
244
+ };
245
+ /**
246
+ * A user gesture attempted while the relay-incapability latch stands
247
+ * (ggui#670 Phase 3). The runtime never suppresses the attempt — the
248
+ * attempt is the self-heal sensor (ggui#443) — and presents it in the
249
+ * document (pulse, spoken cue, fallback toast); this event makes the
250
+ * attempt COUNTABLE by the host.
251
+ *
252
+ * Contract: exactly one per gesture attempted while latched whose
253
+ * attempt comes back undelivered (relay-shaped failure or a declared
254
+ * refusal); never emitted off-latch, and never from the channel
255
+ * router's fail-fast ticks. Counted at the outcome, not the tap: the
256
+ * gesture that latched the zone is the `'latched'` edge and the gesture
257
+ * that heals it is the `'cleared'` edge — neither is a dead tap.
258
+ * `ordinal` counts from 1 within one dead zone; the `'cleared'` edge
259
+ * reports the total as `deadTaps`. `latchAgeMs` is measured when the
260
+ * outcome lands. Hosts MUST tolerate the event being absent (an older
261
+ * renderer) — absence means no attempt was observed, not that none
262
+ * happened.
263
+ *
264
+ * @public
265
+ */
266
+ export interface RelayDeadTapEvent {
267
+ readonly kind: 'relay-dead-tap';
268
+ /** The gesture's intent — the action name the component dispatched. */
269
+ readonly intent: string;
270
+ /** Milliseconds since the latch set. */
271
+ readonly latchAgeMs: number;
272
+ /** 1-based position of this attempt within the current dead zone. */
273
+ readonly ordinal: number;
274
+ /** The evidence that latched the zone this attempt landed in. */
275
+ readonly trigger: RelayLatchTrigger;
276
+ readonly sessionId?: string;
277
+ readonly appId?: string;
211
278
  }
212
279
  /**
213
280
  * Catch-all branch for event kinds the host's typings don't recognize.
@@ -245,6 +312,52 @@ export interface ObservabilityMessage {
245
312
  * @public
246
313
  */
247
314
  export type ObservabilityEmitter = (event: ObservabilityEvent) => void;
315
+ /**
316
+ * Type guard for the `ggui:observe` envelope as it arrives on a host's
317
+ * `message` listener — narrows `event.data` to {@link ObservabilityMessage}.
318
+ * Read fields through the per-kind guards, never through
319
+ * `switch (event.kind)`: the union's open tail (`kind: string & {}`)
320
+ * keeps TypeScript from using `kind` as a discriminant, so a `case`
321
+ * body sees the whole union. Unknown kinds stay in that tail: hosts
322
+ * MUST ignore them, never throw.
323
+ *
324
+ * @example
325
+ * ```ts
326
+ * import {
327
+ * isObservabilityMessage,
328
+ * isRelayDeadTapEvent,
329
+ * isRelayIncapabilityEvent,
330
+ * } from '@ggui-ai/iframe-runtime/observability';
331
+ *
332
+ * window.addEventListener('message', ({ data, source }) => {
333
+ * if (source !== iframe.contentWindow || !isObservabilityMessage(data)) return;
334
+ * const { event } = data;
335
+ * if (isRelayDeadTapEvent(event)) count(event.intent, event.latchAgeMs);
336
+ * else if (isRelayIncapabilityEvent(event) && event.state === 'cleared') recovered(event.deadTaps);
337
+ * // any other kind: ignore
338
+ * });
339
+ * ```
340
+ *
341
+ * @public
342
+ */
343
+ export declare function isObservabilityMessage(value: unknown): value is ObservabilityMessage;
344
+ /**
345
+ * Narrows to {@link RelayDeadTapEvent}. The union's open tail
346
+ * (`kind: string & {}`) keeps TypeScript from using `kind` as a
347
+ * discriminant, so `switch (event.kind)` never narrows across
348
+ * {@link ObservabilityEvent} — hosts read fields through the per-kind
349
+ * guards (first-consumer finding, ggui#670 Phase 3).
350
+ *
351
+ * @public
352
+ */
353
+ export declare function isRelayDeadTapEvent(event: ObservabilityEvent): event is RelayDeadTapEvent;
354
+ /**
355
+ * Narrows to {@link RelayIncapabilityEvent}; inside it `state`
356
+ * discriminates the `'latched'` / `'cleared'` edges normally.
357
+ *
358
+ * @public
359
+ */
360
+ export declare function isRelayIncapabilityEvent(event: ObservabilityEvent): event is RelayIncapabilityEvent;
248
361
  /**
249
362
  * Default emitter — posts an {@link ObservabilityMessage} to
250
363
  * `window.parent`. Mirrors `postBootFailure` / `postRendererReady`
@@ -1,4 +1,63 @@
1
1
  import { MCP_APP_OBSERVE_TYPE } from '@ggui-ai/protocol/integrations/mcp-apps';
2
+ /**
3
+ * Type guard for the `ggui:observe` envelope as it arrives on a host's
4
+ * `message` listener — narrows `event.data` to {@link ObservabilityMessage}.
5
+ * Read fields through the per-kind guards, never through
6
+ * `switch (event.kind)`: the union's open tail (`kind: string & {}`)
7
+ * keeps TypeScript from using `kind` as a discriminant, so a `case`
8
+ * body sees the whole union. Unknown kinds stay in that tail: hosts
9
+ * MUST ignore them, never throw.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import {
14
+ * isObservabilityMessage,
15
+ * isRelayDeadTapEvent,
16
+ * isRelayIncapabilityEvent,
17
+ * } from '@ggui-ai/iframe-runtime/observability';
18
+ *
19
+ * window.addEventListener('message', ({ data, source }) => {
20
+ * if (source !== iframe.contentWindow || !isObservabilityMessage(data)) return;
21
+ * const { event } = data;
22
+ * if (isRelayDeadTapEvent(event)) count(event.intent, event.latchAgeMs);
23
+ * else if (isRelayIncapabilityEvent(event) && event.state === 'cleared') recovered(event.deadTaps);
24
+ * // any other kind: ignore
25
+ * });
26
+ * ```
27
+ *
28
+ * @public
29
+ */
30
+ export function isObservabilityMessage(value) {
31
+ if (typeof value !== 'object' || value === null)
32
+ return false;
33
+ const probe = value;
34
+ if (probe.type !== MCP_APP_OBSERVE_TYPE)
35
+ return false;
36
+ if (typeof probe.event !== 'object' || probe.event === null)
37
+ return false;
38
+ return typeof probe.event.kind === 'string';
39
+ }
40
+ /**
41
+ * Narrows to {@link RelayDeadTapEvent}. The union's open tail
42
+ * (`kind: string & {}`) keeps TypeScript from using `kind` as a
43
+ * discriminant, so `switch (event.kind)` never narrows across
44
+ * {@link ObservabilityEvent} — hosts read fields through the per-kind
45
+ * guards (first-consumer finding, ggui#670 Phase 3).
46
+ *
47
+ * @public
48
+ */
49
+ export function isRelayDeadTapEvent(event) {
50
+ return event.kind === 'relay-dead-tap';
51
+ }
52
+ /**
53
+ * Narrows to {@link RelayIncapabilityEvent}; inside it `state`
54
+ * discriminates the `'latched'` / `'cleared'` edges normally.
55
+ *
56
+ * @public
57
+ */
58
+ export function isRelayIncapabilityEvent(event) {
59
+ return event.kind === 'relay-incapability';
60
+ }
2
61
  /**
3
62
  * Default emitter — posts an {@link ObservabilityMessage} to
4
63
  * `window.parent`. Mirrors `postBootFailure` / `postRendererReady`
@@ -30,6 +30,7 @@
30
30
  * TypeScript config doesn't need a JSX runtime plumbing.
31
31
  */
32
32
  import React, { type ReactNode } from 'react';
33
+ import type { AppTheme } from '@ggui-ai/protocol';
33
34
  export interface ReactRootMountOptions {
34
35
  /**
35
36
  * GguiSession whose componentCode + props drive the mount. Only
@@ -98,11 +99,7 @@ export interface ReactRootMountOptions {
98
99
  * painted ladder to its registration record); the renderer
99
100
  * carries it but exposes no observability surface for it today.
100
101
  */
101
- readonly base?: {
102
- readonly documentHash: string;
103
- readonly light: Readonly<Record<string, string>>;
104
- readonly dark: Readonly<Record<string, string>>;
105
- };
102
+ readonly base?: NonNullable<AppTheme['base']>;
106
103
  };
107
104
  /**
108
105
  * Host-announced palette, already translated onto `--ggui-*` tokens
@@ -329,7 +329,15 @@ export async function mountReactRoot(container, opts) {
329
329
  // ladder gets — base inherits, derived gradient/effect tokens
330
330
  // (color-mix fallback split), box-sizing, font-inherit — from
331
331
  // ONE shared builder, so the two paths cannot drift.
332
- assembleDeliveredThemeCss(scopeClass, opts.themeMode === 'dark' ? deliveredBase.dark : deliveredBase.light)
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
+ })
333
341
  : opts.themeId
334
342
  ? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
335
343
  : getScopedCssTokens(scopeClass, opts.themeMode);
@@ -367,9 +375,17 @@ export async function mountReactRoot(container, opts) {
367
375
  // `ThemeProvider` so a Studio/Portal context with both runtime + RP
368
376
  // active doesn't double-stack.
369
377
  if (typeof document !== 'undefined') {
370
- let rootCss = opts.themeId
371
- ? getThemeCss(opts.themeId, opts.themeMode)
372
- : getCssTokens(opts.themeMode);
378
+ // ggui#613 residual 5: under a DELIVERED base the body-chrome
379
+ // ladder is the delivered set too — mirroring the scoped ternary
380
+ // above — so the standalone viewer's body chrome and its tree
381
+ // content resolve the same values. Pre-fix this started from the
382
+ // compiled/default ladder regardless, and body chrome painted the
383
+ // default brand under a registered theme.
384
+ let rootCss = deliveredBase !== undefined
385
+ ? `:root{${toCssDecls(opts.themeMode === 'dark' ? deliveredBase.dark : deliveredBase.light)}}`
386
+ : opts.themeId
387
+ ? getThemeCss(opts.themeId, opts.themeMode)
388
+ : getCssTokens(opts.themeMode);
373
389
  if (opts.hostPalette) {
374
390
  // BODY-CHROME ONLY, same as the appTheme `:root` append below —
375
391
  // and beneath it, mirroring the scoped-block order (#573:
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { GguiSession } from '@ggui-ai/protocol';
2
+ import type { AppTheme, GguiSession } from '@ggui-ai/protocol';
3
3
  import type { GguiSessionSeedInput } from './types.js';
4
4
  import { type WireConfig } from '@ggui-ai/wire';
5
5
  import type { StreamBus } from './wire-config.js';
@@ -59,11 +59,7 @@ export interface RenderItemOptions {
59
59
  readonly appTheme?: {
60
60
  readonly mode: 'light' | 'dark';
61
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
- };
62
+ readonly base?: NonNullable<AppTheme['base']>;
67
63
  };
68
64
  /**
69
65
  * Host-announced palette, pre-mapped onto `--ggui-*` tokens by the
package/dist/runtime.d.ts CHANGED
@@ -56,6 +56,15 @@ import { type LifecycleEmitter } from './lifecycle.js';
56
56
  */
57
57
  type DrainAckListener = (payload: DrainAckPayload) => boolean | void;
58
58
  export declare function subscribeDrainAck(listener: DrainAckListener): () => void;
59
+ /**
60
+ * The `drain_ack` ingress — fans a frame out to subscribers until one
61
+ * claims it.
62
+ *
63
+ * @internal — exported for unit tests: a late ack for an earlier
64
+ * delivered gesture is the path that retires the standing relay notice
65
+ * without any user click (ggui#670 Phase 3, truth-2).
66
+ */
67
+ export declare function dispatchDrainAck(payload: DrainAckPayload): void;
59
68
  export type ConnectAppResult = {
60
69
  readonly ok: true;
61
70
  } | {
@@ -660,6 +669,16 @@ interface ToastLiveRegions {
660
669
  * @internal — exported for unit tests.
661
670
  */
662
671
  export declare function ensureToastAnnouncer(doc: Document): ToastLiveRegions | null;
672
+ /**
673
+ * Boot-time reset of the relay latch (ggui#670 Phase 3). A latch
674
+ * standing from a prior mount in a re-booted document is closed through
675
+ * the ONE writer — the host gets its `cleared` edge with the zone's
676
+ * summary — and the prior mount's standing notice is removed rather
677
+ * than left describing a session that no longer exists.
678
+ *
679
+ * @internal — exported for unit tests.
680
+ */
681
+ export declare function resetRelayLatchForBoot(): void;
663
682
  /** @internal — exported for unit tests to reset module state. */
664
683
  export declare function __resetRelayNoticeForTest(): void;
665
684
  /**