@ggui-ai/iframe-runtime 0.11.0 → 0.13.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
@@ -151,10 +151,7 @@ export interface ChannelPollRecoveredEvent {
151
151
  * mirror `UiFeedbackPayload` in `@ggui-ai/mcp-apps-react` / `@ggui-ai/mcp-apps-react-native`
152
152
  * (the host-chrome twin of this affordance):
153
153
  *
154
- * - `verdict` — `'love'` / `'dislike'`, or `'other'` for the
155
- * free-text flow.
156
- * - `comment` — present only for `verdict: 'other'` with a non-empty
157
- * trimmed comment.
154
+ * - `verdict` — `'up'` / `'down'` (the two thumb verdicts, #653).
158
155
  * - `sessionId` / `toolName` — present exactly when the runtime knew
159
156
  * them at mount time.
160
157
  *
@@ -175,9 +172,7 @@ export interface ChannelPollRecoveredEvent {
175
172
  */
176
173
  export interface UiFeedbackEvent {
177
174
  readonly kind: 'ui-feedback';
178
- readonly verdict: 'love' | 'dislike' | 'other';
179
- /** Trimmed free-text comment; only on `verdict: 'other'`, never empty. */
180
- readonly comment?: string;
175
+ readonly verdict: 'up' | 'down';
181
176
  /** GguiSession id of the render the feedback is about. */
182
177
  readonly sessionId?: string;
183
178
  /** Tool that produced the render (e.g. `ggui_render`). */
@@ -81,6 +81,28 @@ export interface ReactRootMountOptions {
81
81
  readonly appTheme?: {
82
82
  readonly mode: 'light' | 'dark';
83
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
+ };
84
106
  };
85
107
  /**
86
108
  * Host-announced palette, already translated onto `--ggui-*` tokens
@@ -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
@@ -314,9 +314,25 @@ export async function mountReactRoot(container, opts) {
314
314
  }
315
315
  }
316
316
  function renderTree(opts) {
317
- let themeCss = opts.themeId
318
- ? getScopedThemeCss(opts.themeId, scopeClass, opts.themeMode)
319
- : 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);
320
336
  if (opts.hostPalette) {
321
337
  // Host palette = the fallback layer (ggui#572, #573 ruling):
322
338
  // after the base scoped block so it can repaint at all (the base
@@ -51,10 +51,19 @@ 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
  };
59
68
  /**
60
69
  * Host-announced palette, pre-mapped onto `--ggui-*` tokens by the
package/dist/runtime.d.ts CHANGED
@@ -276,6 +276,18 @@ export interface BootSequenceOptions {
276
276
  * or not this option is bound.
277
277
  */
278
278
  readonly onObserve?: ObservabilityEmitter;
279
+ /**
280
+ * Opt-in knob for the in-iframe UI-feedback chrome (#653). DEFAULT
281
+ * OFF by founder ruling — a generated component card carries no
282
+ * feedback affordance unless the embedding deployment passes `true`
283
+ * here. Even when opted in, the #471 live-sink gate still applies
284
+ * (see `shouldMountUiFeedbackChrome`): hosts that drop the
285
+ * `ggui:observe` envelope never get the affordance. Chrome
286
+ * configuration, not wire data — the agent cannot observe this knob
287
+ * (data-vs-behavior), so it is a boot option, never a contract
288
+ * field.
289
+ */
290
+ readonly uiFeedback?: boolean;
279
291
  /**
280
292
  * Optional {@link LifecycleEmitter} sink. Fires on every renderer
281
293
  * mount-state transition (`mounting` → `code-ready` | `error`,
package/dist/runtime.js CHANGED
@@ -28,6 +28,7 @@
28
28
  // schema-defining dependency initializes (see zod-jitless.ts).
29
29
  import './zod-jitless.js';
30
30
  import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
31
+ import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
31
32
  import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
32
33
  import { projectHostContext } from '@ggui-ai/protocol';
33
34
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
@@ -42,13 +43,13 @@ import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandl
42
43
  import { connectViaRegistry, } from './registry-subscribe.js';
43
44
  import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.js';
44
45
  import { unwrapCallToolResult } from './call-tool-unwrap.js';
45
- import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
46
+ import { ensureStatusDom, setStatus, setConnectedStatus, retireShellLoadingIndicator, } from './status-dom.js';
46
47
  import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
47
48
  import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
48
49
  import { mountRender, } from './render-item.js';
49
50
  import { fromBootstrapFailure, } from './protocol-error.js';
50
51
  import { postObservabilityToParent, } from './observability.js';
51
- import { mountUiFeedbackChrome } from './ui-feedback-chrome.js';
52
+ import { ACTION_TOAST_Z_INDEX, mountUiFeedbackChrome, shouldMountUiFeedbackChrome, } from './ui-feedback-chrome.js';
52
53
  import { hostCanReceiveMessages, hostCanRelayToolCalls, hostCapabilitiesCaptured, setHostCapabilities, } from './host-capabilities.js';
53
54
  import { makeLifecycleEvent, postLifecycleToParent, } from './lifecycle.js';
54
55
  import { contextSlotLastValues, createContextStateHost, installContextRegistry, reemitLastContextValues, } from './context-observer.js';
@@ -213,7 +214,16 @@ export function hostAnnouncedThemeMode() {
213
214
  * `buildOpts` inside `bootSequence`.
214
215
  */
215
216
  export function resolveMountThemeMode(meta) {
216
- return meta.themeMode ?? meta.theme?.mode ?? hostAnnouncedThemeMode();
217
+ // The CLIENT projection of the protocol's normative theme-binding
218
+ // total order (@ggui-ai/protocol/integrations/theme-binding,
219
+ // ggui#598 leg 4) — the server stamps via the sibling projection and
220
+ // the protocol's composition-law test pins that the two compose
221
+ // without rank drift.
222
+ return effectiveThemeMode({
223
+ stamped: meta.themeMode,
224
+ sessionSidecar: meta.theme?.mode,
225
+ hostAnnounced: hostAnnouncedThemeMode(),
226
+ });
217
227
  }
218
228
  /**
219
229
  * Resolve the mount's base-theme id from the slice (ggui#589 ask 3):
@@ -235,7 +245,12 @@ export function resolveMountThemeMode(meta) {
235
245
  * `buildOpts` inside `bootSequence`.
236
246
  */
237
247
  export function resolveMountThemeId(meta) {
238
- return meta.themeId ?? meta.theme?.name;
248
+ // Client projection of the normative theme-binding order — see
249
+ // resolveMountThemeMode above.
250
+ return effectiveThemeId({
251
+ stamped: meta.themeId,
252
+ sidecarName: meta.theme?.name,
253
+ });
239
254
  }
240
255
  /**
241
256
  * The host's announced palette, read from the connected App's
@@ -320,7 +335,7 @@ function postBootFailure(reason, message) {
320
335
  }
321
336
  }
322
337
  export async function bootSequence(opts) {
323
- const { doc, app, transport, notifyParent, renderer: rendererHooks, onProtocolError, onObserve, onLifecycle } = opts;
338
+ const { doc, app, transport, notifyParent, renderer: rendererHooks, onProtocolError, onObserve, onLifecycle, uiFeedback } = opts;
324
339
  const connectFn = opts.connectFn ?? connectViaRegistry;
325
340
  const toolResultTimeoutMs = opts.toolResultTimeoutMs ?? POSTMESSAGE_BOOT_TIMEOUT_MS;
326
341
  // Emit typed {@link ProtocolError} for every bootstrap-failure site
@@ -400,6 +415,7 @@ export async function bootSequence(opts) {
400
415
  const initResult = await connectApp(app, transport);
401
416
  if (!initResult.ok) {
402
417
  setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
418
+ retireShellLoadingIndicator(doc);
403
419
  emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
404
420
  return { ok: false, mountedRender };
405
421
  }
@@ -496,6 +512,7 @@ export async function bootSequence(opts) {
496
512
  if (!parsed.ok) {
497
513
  const message = `slice-meta parse failed: ${parsed.reason}`;
498
514
  setStatus(refs, message, 'error');
515
+ retireShellLoadingIndicator(doc);
499
516
  emitBootFailure(parsed.reason, message);
500
517
  return { ok: false, mountedRender };
501
518
  }
@@ -532,10 +549,12 @@ export async function bootSequence(opts) {
532
549
  ...(onObserve !== undefined ? { onObserve } : {}),
533
550
  })
534
551
  : null;
535
- // In-iframe UI-feedback affordance (ggui#244) — mounted adjacent to
536
- // the session root, gated inside the helper on `window.parent !==
537
- // window` (top-level `/r/<shortCode>` tabs have no `ggui:observe`
538
- // egress, so they get NO affordance) AND here on a LIVE SINK: an
552
+ // In-iframe UI-feedback affordance (ggui#244) — DEFAULT OFF since
553
+ // #653 (founder ruling): mounts only when the boot options opt in
554
+ // via `uiFeedback: true`. When opted in it is additionally gated
555
+ // inside the helper on `window.parent !== window` (top-level
556
+ // `/r/<shortCode>` tabs have no `ggui:observe` egress, so they get
557
+ // NO affordance) AND here on a LIVE SINK: an
539
558
  // injected `onObserve` emitter, or a first-party embed host — the
540
559
  // only parent that consumes the `ggui:observe` envelope
541
560
  // (mcp-app-iframe-host answers `ui/initialize` with exactly this
@@ -545,9 +564,12 @@ export async function bootSequence(opts) {
545
564
  // retest surfaced it as unstyled clutter that could never deliver
546
565
  // feedback anywhere. Fire-and-forget: chrome never blocks or fails
547
566
  // the boot.
548
- const feedbackSinkLive = onObserve !== undefined ||
549
- app.getHostVersion()?.name === 'ggui-iframe-runtime-embed-host';
550
- if (feedbackSinkLive) {
567
+ const mountFeedback = shouldMountUiFeedbackChrome({
568
+ uiFeedback,
569
+ onObserveBound: onObserve !== undefined,
570
+ hostName: app.getHostVersion()?.name,
571
+ });
572
+ if (mountFeedback) {
551
573
  void mountUiFeedbackChrome(doc, {
552
574
  emit: onObserve ?? postObservabilityToParent,
553
575
  sessionId: meta.sessionId,
@@ -615,6 +637,9 @@ export async function bootSequence(opts) {
615
637
  if (codeReadyEmitted)
616
638
  return;
617
639
  codeReadyEmitted = true;
640
+ // First real paint — the served shell's working-state mark (#667)
641
+ // becomes a lie the moment content lands.
642
+ retireShellLoadingIndicator(doc);
618
643
  onLifecycle?.(makeLifecycleEvent('code-ready', { sessionId: meta.sessionId }));
619
644
  };
620
645
  // Mode discriminators. The mount surface is DECOUPLED from the live
@@ -706,6 +731,7 @@ export async function bootSequence(opts) {
706
731
  if (!hasLiveTrio && mountedRender === null) {
707
732
  const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
708
733
  setStatus(refs, message, 'error');
734
+ retireShellLoadingIndicator(doc);
709
735
  emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
710
736
  return { ok: false, mountedRender };
711
737
  }
@@ -1630,9 +1656,11 @@ export function emitAudit(args) {
1630
1656
  * Returns `'success'` when the handler said `ok:true`. Returns
1631
1657
  * `'fallback'` for any other outcome — pipe missing
1632
1658
  * (`PIPE_NOT_FOUND`), envelope rejected (`INVALID_ACTION_KIND`),
1633
- * relay error, no host relay wired, or any unexpected shape. The
1634
- * iframe-runtime then falls through to `ui/message` so the gesture
1635
- * still reaches the agent on its next turn.
1659
+ * relay error, no host relay wired, or any unexpected shape. On
1660
+ * `'fallback'` the gesture was NOT enqueued and is dropped with local
1661
+ * feedback only (toast / #440 latch) — no `ui/message` fires (the
1662
+ * doorbell is success-path-only: there is nothing to point it at), and
1663
+ * no alternate transport is attempted today (ggui#599's open leg).
1636
1664
  */
1637
1665
  /**
1638
1666
  * Unwrap the payload record of a relayed submit-action tool result.
@@ -1698,6 +1726,16 @@ function isRelayShapedFailure(resp) {
1698
1726
  * consumer" — the doorbell rings. Shares {@link submitActionPayload}
1699
1727
  * with {@link classifySubmitActionResponse} so both reads agree on
1700
1728
  * which envelope tier carries the payload.
1729
+ *
1730
+ * RELAY CONTRACT (ggui#603): this read only works when the host's
1731
+ * `tools/call` relay threads the server's tool result VERBATIM — the
1732
+ * `consumerPresent` marker rides `structuredContent` on the real
1733
+ * result. A relay that wraps or synthesizes the response silently
1734
+ * strips the marker, and every healthy gesture then misclassifies as
1735
+ * "agent not listening" (a persistent `action_required` toast per
1736
+ * gesture). Verbatim threading is the same obligation that keeps
1737
+ * `_meta` alive on `resources/read` relays; a conformance vector for
1738
+ * it is tracked on ggui#603.
1701
1739
  */
1702
1740
  function extractConsumerPresent(resp) {
1703
1741
  const inner = submitActionPayload(resp);
@@ -1908,7 +1946,7 @@ function showActionToast(text, kind, onDismiss) {
1908
1946
  'bottom:16px',
1909
1947
  'left:50%',
1910
1948
  'transform:translateX(-50%)',
1911
- 'z-index:2147483647',
1949
+ `z-index:${ACTION_TOAST_Z_INDEX}`,
1912
1950
  'pointer-events:auto',
1913
1951
  'max-width:90%',
1914
1952
  'padding:8px 14px',
@@ -3404,20 +3442,30 @@ async function bootProduction(opts) {
3404
3442
  }
3405
3443
  },
3406
3444
  };
3407
- // Spec-canonical outbound dispatch. The WS pipe is for streamSpec
3408
- // subscriptions ONLY (inbound `ggui_emit` fanout + `props_update` +
3409
- // `render` + `data` + `drain_ack` + `channel_payload`).
3410
- // Outbound user actions go through the MCP-Apps host relay per
3411
- // spec §401: postMessage `tools/call:ggui_runtime_submit_action`
3412
- // to the parent → `AppRenderer.onCallTool` → sample agent's
3413
- // `/relay/tools-call` → ggui MCP server →
3414
- // `createGguiSubmitActionHandler.append` → `pendingEventConsumer`
3415
- // → `ggui_consume` wakes the agent. The server's WS
3416
- // `handleInboundAction` writes to the render ledger only — no
3417
- // downstream consumer — so the WS action path silently drops
3418
- // clicks. `routeDispatch` is the shared named-export helper
3419
- // (production + tests exercise the same code path); threading it
3420
- // here keeps LIVE-mode on the canonical dispatch pipeline.
3445
+ // Spec-canonical outbound dispatch: user actions go through the
3446
+ // MCP-Apps host relay (SPEC §4.7): postMessage
3447
+ // `tools/call:ggui_runtime_submit_action` to the parent →
3448
+ // `AppRenderer.onCallTool` → host's MCP client → ggui MCP server
3449
+ // → `createGguiSubmitActionHandler.append` → `pendingEventConsumer`
3450
+ // → `ggui_consume` wakes the agent. NOTE (truth, ggui#596 +
3451
+ // adversarial-cycle correction): the server's WS ingress
3452
+ // dual-writes accepted `data:submit` frames onto the consume pipe
3453
+ // ONLY when the live channel is composed with a
3454
+ // `pendingEventConsumer` — which `createGguiServer` threads for
3455
+ // DEFAULT-handlers compositions only (a custom `handlers` list
3456
+ // drains its own pipe; bridging the channel onto an instance
3457
+ // nobody drains would buffer gestures into the void — see the
3458
+ // hoist note in server.ts). The hosted pod IS a custom-handlers
3459
+ // caller: on it, a WS gesture lands on the retained ledger only
3460
+ // and never reaches the agent. Any WS action fallback (ggui#599's
3461
+ // open design leg) therefore requires the server/pod bridge
3462
+ // FIRST — it is a capability gap on that composition today. The WS pipe otherwise carries
3463
+ // streamSpec subscriptions (inbound `ggui_emit` fanout +
3464
+ // `props_update` + `render` + `data` + `drain_ack` +
3465
+ // `channel_payload`). `routeDispatch` is the shared named-export
3466
+ // helper (production + tests exercise the same code path);
3467
+ // threading it here keeps LIVE-mode on the canonical dispatch
3468
+ // pipeline.
3421
3469
  // Single mounted render — populated by `applyRender` on the first
3422
3470
  // render frame. The wire config + data channel handler read it
3423
3471
  // through `currentRender`-returning thunks so they always see the
@@ -75,3 +75,12 @@ export declare function setStatus(refs: StatusRefs, text: string, state: StatusK
75
75
  * intercepting `console.log`) get a stable string on every render.
76
76
  */
77
77
  export declare function setConnectedStatus(refs: StatusRefs): void;
78
+ /**
79
+ * Retire the served shell's pre-render working-state mark (#667 —
80
+ * `[data-ggui-shell-loading]`, the ggui glyph treatment or an
81
+ * operator's court block). Called at `code-ready` (the first real
82
+ * paint makes a loading mark a lie) and on terminal boot failure (the
83
+ * failure card must not sit under an "everything is fine" animation).
84
+ * Idempotent; a shell without the mark is a no-op.
85
+ */
86
+ export declare function retireShellLoadingIndicator(doc: Document): void;
@@ -66,3 +66,14 @@ export function setStatus(refs, text, state) {
66
66
  export function setConnectedStatus(refs) {
67
67
  setStatus(refs, 'Connected.', 'connected');
68
68
  }
69
+ /**
70
+ * Retire the served shell's pre-render working-state mark (#667 —
71
+ * `[data-ggui-shell-loading]`, the ggui glyph treatment or an
72
+ * operator's court block). Called at `code-ready` (the first real
73
+ * paint makes a loading mark a lie) and on terminal boot failure (the
74
+ * failure card must not sit under an "everything is fine" animation).
75
+ * Idempotent; a shell without the mark is a no-op.
76
+ */
77
+ export function retireShellLoadingIndicator(doc) {
78
+ doc.querySelector('[data-ggui-shell-loading]')?.remove();
79
+ }
@@ -3,14 +3,19 @@
3
3
  * ("did this generated UI work for you?").
4
4
  *
5
5
  * The in-iframe twin of the host-chrome `UiFeedback` component in
6
- * `@ggui-ai/mcp-apps-react`: same three verdicts — Love / Dislike / Other
7
- * (free-text comment) — same acknowledgement after submit, same
8
- * dismiss control, same payload semantics (trimmed comment only on
9
- * `verdict: 'other'`, context stamps present exactly when known).
10
- * Where the host-chrome twin hands a payload to an `onUiFeedback`
11
- * callback, this card builds a {@link UiFeedbackEvent} and hands it to
12
- * the injected {@link ObservabilityEmitter} — production binds the
13
- * `ggui:observe` postMessage-to-parent default, tests record.
6
+ * `@ggui-ai/mcp-apps-react`: same two verdicts — thumbs up / thumbs
7
+ * down (#653) — same acknowledgement after submit, same dismiss
8
+ * control, same payload semantics (context stamps present exactly when
9
+ * known). Where the host-chrome twin hands a payload to an
10
+ * `onUiFeedback` callback, this card builds a {@link UiFeedbackEvent}
11
+ * and hands it to the injected {@link ObservabilityEmitter} —
12
+ * production binds the `ggui:observe` postMessage-to-parent default,
13
+ * tests record.
14
+ *
15
+ * The verdict buttons are icon-only: monochrome stroked SVGs drawn in
16
+ * `currentColor`, so the icon ink follows the button's `--ggui-*`
17
+ * color token in both modes (the #653 theme-applicability
18
+ * requirement). The verdict's name lives in the `aria-label`.
14
19
  *
15
20
  * This is CHROME the runtime mounts adjacent to the session root
16
21
  * (see `ui-feedback-chrome.ts`), NOT a system card: it has no
@@ -1,17 +1,22 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * UiFeedbackCard — the runtime's in-iframe UI-feedback affordance
4
4
  * ("did this generated UI work for you?").
5
5
  *
6
6
  * The in-iframe twin of the host-chrome `UiFeedback` component in
7
- * `@ggui-ai/mcp-apps-react`: same three verdicts — Love / Dislike / Other
8
- * (free-text comment) — same acknowledgement after submit, same
9
- * dismiss control, same payload semantics (trimmed comment only on
10
- * `verdict: 'other'`, context stamps present exactly when known).
11
- * Where the host-chrome twin hands a payload to an `onUiFeedback`
12
- * callback, this card builds a {@link UiFeedbackEvent} and hands it to
13
- * the injected {@link ObservabilityEmitter} — production binds the
14
- * `ggui:observe` postMessage-to-parent default, tests record.
7
+ * `@ggui-ai/mcp-apps-react`: same two verdicts — thumbs up / thumbs
8
+ * down (#653) — same acknowledgement after submit, same dismiss
9
+ * control, same payload semantics (context stamps present exactly when
10
+ * known). Where the host-chrome twin hands a payload to an
11
+ * `onUiFeedback` callback, this card builds a {@link UiFeedbackEvent}
12
+ * and hands it to the injected {@link ObservabilityEmitter} —
13
+ * production binds the `ggui:observe` postMessage-to-parent default,
14
+ * tests record.
15
+ *
16
+ * The verdict buttons are icon-only: monochrome stroked SVGs drawn in
17
+ * `currentColor`, so the icon ink follows the button's `--ggui-*`
18
+ * color token in both modes (the #653 theme-applicability
19
+ * requirement). The verdict's name lives in the `aria-label`.
15
20
  *
16
21
  * This is CHROME the runtime mounts adjacent to the session root
17
22
  * (see `ui-feedback-chrome.ts`), NOT a system card: it has no
@@ -56,6 +61,8 @@ function buildStyles(scheme) {
56
61
  },
57
62
  verdictButton: {
58
63
  appearance: 'none',
64
+ display: 'inline-flex',
65
+ alignItems: 'center',
59
66
  background: dark
60
67
  ? 'var(--ggui-color-neutral-50, #1f2937)'
61
68
  : 'var(--ggui-color-neutral-50, #f9fafb)',
@@ -63,9 +70,10 @@ function buildStyles(scheme) {
63
70
  ? '1px solid var(--ggui-color-neutral-200, #374151)'
64
71
  : '1px solid var(--ggui-color-neutral-200, #e5e7eb)',
65
72
  borderRadius: 'var(--ggui-shape-radius-sm, 4px)',
66
- padding: '2px 8px',
73
+ padding: '3px 7px',
67
74
  fontFamily: 'inherit',
68
75
  fontSize: 'inherit',
76
+ // The icons stroke in currentColor — this token IS the icon ink.
69
77
  color: dark
70
78
  ? 'var(--ggui-color-neutral-600, #d1d5db)'
71
79
  : 'var(--ggui-color-neutral-600, #4b5563)',
@@ -83,51 +91,34 @@ function buildStyles(scheme) {
83
91
  : 'var(--ggui-color-neutral-400, #9ca3af)',
84
92
  cursor: 'pointer',
85
93
  },
86
- commentForm: {
87
- display: 'inline-flex',
88
- alignItems: 'center',
89
- gap: 4,
90
- margin: 0,
91
- },
92
- commentInput: {
93
- fontFamily: 'inherit',
94
- fontSize: 'inherit',
95
- padding: '2px 6px',
96
- minWidth: 140,
97
- border: dark
98
- ? '1px solid var(--ggui-color-neutral-200, #374151)'
99
- : '1px solid var(--ggui-color-neutral-200, #e5e7eb)',
100
- borderRadius: 'var(--ggui-shape-radius-sm, 4px)',
101
- background: dark
102
- ? 'var(--ggui-color-neutral-50, #111827)'
103
- : 'var(--ggui-color-neutral-50, #ffffff)',
104
- color: dark
105
- ? 'var(--ggui-color-neutral-800, #e5e7eb)'
106
- : 'var(--ggui-color-neutral-800, #1f2937)',
107
- },
108
94
  };
109
95
  }
96
+ /**
97
+ * Monochrome stroked thumb icons. `stroke="currentColor"` is the
98
+ * theme-applicability contract: the glyph takes whatever ink the
99
+ * button's `color` token resolves to, in either mode.
100
+ */
101
+ function ThumbIcon({ direction, }) {
102
+ return (_jsx("svg", { width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: direction === 'up' ? (_jsxs(_Fragment, { children: [_jsx("path", { d: "M7 10v12" }), _jsx("path", { d: "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" })] })) : (_jsxs(_Fragment, { children: [_jsx("path", { d: "M17 14V2" }), _jsx("path", { d: "M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" })] })) }));
103
+ }
104
+ const VERDICT_LABELS = {
105
+ up: 'Thumbs up',
106
+ down: 'Thumbs down',
107
+ };
110
108
  export function UiFeedbackCard({ emit, sessionId, toolName, }) {
111
109
  const [phase, setPhase] = React.useState('idle');
112
- const [comment, setComment] = React.useState('');
113
110
  const scheme = useColorScheme();
114
111
  const styles = buildStyles(scheme);
115
- const send = React.useCallback((verdict, commentText) => {
116
- const trimmed = commentText?.trim();
112
+ const send = React.useCallback((verdict) => {
117
113
  emit({
118
114
  kind: 'ui-feedback',
119
115
  verdict,
120
- ...(trimmed !== undefined && trimmed.length > 0 ? { comment: trimmed } : {}),
121
116
  ...(sessionId !== undefined ? { sessionId } : {}),
122
117
  ...(toolName !== undefined ? { toolName } : {}),
123
118
  });
124
119
  setPhase('sent');
125
120
  }, [emit, sessionId, toolName]);
126
- const onCommentSubmit = React.useCallback((e) => {
127
- e.preventDefault();
128
- send('other', comment);
129
- }, [send, comment]);
130
121
  if (phase === 'dismissed')
131
122
  return null;
132
- return (_jsxs("div", { "data-ggui-ui-feedback": true, role: "group", "aria-label": "Share feedback on this UI", style: styles.root, children: [phase === 'sent' ? (_jsx("span", { "data-ggui-ui-feedback-thanks": true, children: "Thanks for the feedback" })) : phase === 'comment' ? (_jsxs("form", { onSubmit: onCommentSubmit, style: styles.commentForm, "data-ggui-ui-feedback-comment-form": true, children: [_jsx("input", { type: "text", value: comment, onChange: (e) => setComment(e.target.value), placeholder: "What happened?", "aria-label": "Feedback comment", "data-ggui-ui-feedback-comment": true, style: styles.commentInput, autoFocus: true }), _jsx("button", { type: "submit", style: styles.verdictButton, "data-ggui-ui-feedback-send": true, children: "Send" })] })) : (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", style: styles.verdictButton, "data-ggui-ui-feedback-verdict": "love", onClick: () => send('love'), children: "Love" }), _jsx("button", { type: "button", style: styles.verdictButton, "data-ggui-ui-feedback-verdict": "dislike", onClick: () => send('dislike'), children: "Dislike" }), _jsx("button", { type: "button", style: styles.verdictButton, "data-ggui-ui-feedback-verdict": "other", onClick: () => setPhase('comment'), children: "Other\u2026" })] })), _jsx("button", { type: "button", style: styles.dismissButton, "aria-label": "Dismiss feedback", "data-ggui-ui-feedback-dismiss": true, onClick: () => setPhase('dismissed'), children: '×' })] }));
123
+ return (_jsxs("div", { "data-ggui-ui-feedback": true, role: "group", "aria-label": "Share feedback on this UI", style: styles.root, children: [phase === 'sent' ? (_jsx("span", { "data-ggui-ui-feedback-thanks": true, children: "Thanks for the feedback" })) : (['up', 'down'].map((verdict) => (_jsx("button", { type: "button", style: styles.verdictButton, "data-ggui-ui-feedback-verdict": verdict, "aria-label": VERDICT_LABELS[verdict], title: VERDICT_LABELS[verdict], onClick: () => send(verdict), children: _jsx(ThumbIcon, { direction: verdict }) }, verdict)))), _jsx("button", { type: "button", style: styles.dismissButton, "aria-label": "Dismiss feedback", "data-ggui-ui-feedback-dismiss": true, onClick: () => setPhase('dismissed'), children: '×' })] }));
133
124
  }
@@ -1,4 +1,50 @@
1
1
  import type { ObservabilityEmitter } from './observability.js';
2
+ /**
3
+ * Stacking contract between the two body-appended runtime layers
4
+ * (ggui#603): the feedback chrome must sit strictly ABOVE the action
5
+ * toast. The persistent `action_required` toast parks
6
+ * `pointer-events:auto` at fixed bottom-center — the same region this
7
+ * chrome occupies — and is re-armed per gesture; without this order an
8
+ * armed toast eats every tap aimed at the feedback controls. The toast
9
+ * remains dismissable on its own uncovered area (it accepts the click
10
+ * anywhere on itself), so partial occlusion costs it nothing.
11
+ *
12
+ * Single source: `runtime.ts`'s toast reads {@link ACTION_TOAST_Z_INDEX};
13
+ * this module applies {@link UI_FEEDBACK_CHROME_Z_INDEX} to the chrome
14
+ * container. The test suite pins the relationship, not the numbers.
15
+ */
16
+ export declare const UI_FEEDBACK_CHROME_Z_INDEX = 2147483647;
17
+ export declare const ACTION_TOAST_Z_INDEX = 2147483646;
18
+ /** Inputs to the boot path's mount decision — see
19
+ * {@link shouldMountUiFeedbackChrome}. */
20
+ export interface UiFeedbackMountDecisionInput {
21
+ /**
22
+ * The opt-in knob from the boot options (#653). Feedback chrome is
23
+ * DEFAULT OFF: only an explicit `true` can mount it.
24
+ */
25
+ readonly uiFeedback: boolean | undefined;
26
+ /** Whether the boot bound an `onObserve` emitter. */
27
+ readonly onObserveBound: boolean;
28
+ /** The MCP-Apps host's announced name, when known. */
29
+ readonly hostName: string | undefined;
30
+ }
31
+ /**
32
+ * Pure mount decision the boot path consults before touching the DOM.
33
+ * Two independent gates compose:
34
+ *
35
+ * 1. **The #653 opt-in knob** — the founder's default-off ruling:
36
+ * generated-component cards carry NO feedback chrome unless the
37
+ * embedding deployment explicitly opts in (`uiFeedback: true` on
38
+ * the boot options). Absent or `false` ⇒ never mount, regardless
39
+ * of sink state.
40
+ * 2. **The #471 live-sink gate** — even opted in, only a parent that
41
+ * actually consumes the `ggui:observe` envelope gets the
42
+ * affordance: a bound `onObserve` emitter, or the first-party
43
+ * embed host. Third-party MCP-Apps hosts drop the envelope, and a
44
+ * feedback control that can never deliver feedback is worse than
45
+ * none.
46
+ */
47
+ export declare function shouldMountUiFeedbackChrome(input: UiFeedbackMountDecisionInput): boolean;
2
48
  export interface UiFeedbackChromeOptions {
3
49
  /**
4
50
  * Observability sink the card emits `ui-feedback` events into.