@jini-ai/ui 0.5.0 → 0.5.2

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 (28) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/features/admin-widgets/components/InfoTip.rules.d.ts +1 -1
  3. package/dist/features/admin-widgets/components/InfoTip.rules.js +1 -1
  4. package/dist/features/html-viewer/dependencies.d.ts +1 -1
  5. package/dist/features/html-viewer/dependencies.js +2 -2
  6. package/dist/features/html-viewer/react/hooks/useDeckNavigation.d.ts +2 -2
  7. package/dist/features/html-viewer/react/hooks/useDeckNavigation.js +3 -3
  8. package/dist/features/i18n/universal.d.ts +3 -0
  9. package/dist/features/i18n/universal.js +3 -0
  10. package/dist/features/interactive-ui/providers/shadcn/button.d.ts +1 -1
  11. package/dist/features/mcp-ui/surfaces/document.d.ts +1 -1
  12. package/dist/features/mcp-ui/surfaces/document.js +1 -1
  13. package/dist/features/mcp-ui/surfaces/host-theme.d.ts +30 -0
  14. package/dist/features/mcp-ui/surfaces/host-theme.js +85 -0
  15. package/dist/features/mcp-ui/surfaces/index.d.ts +1 -0
  16. package/dist/features/mcp-ui/surfaces/index.js +1 -0
  17. package/dist/features/mcp-ui/surfaces/tokens.d.ts +11 -9
  18. package/dist/features/mcp-ui/surfaces/tokens.js +21 -12
  19. package/dist/features/panel-kit/fetch-query/adapter.tanstack.js +47 -18
  20. package/dist/features/panel-kit/helpers/format-timestamp.js +1 -1
  21. package/dist/features/settings/dialog/styles/settings-dialog.css +19 -3
  22. package/dist/features/tab-strip/react/components/TabBar.hooks.d.ts +16 -1
  23. package/dist/features/tab-strip/react/components/TabBar.hooks.js +31 -0
  24. package/dist/features/tab-strip/react/components/TabBar.js +3 -2
  25. package/dist/react/mcp-ui/McpUiHost.js +16 -3
  26. package/dist/react/mcp-ui/useHostSurfaceTokens.d.ts +16 -0
  27. package/dist/react/mcp-ui/useHostSurfaceTokens.js +49 -0
  28. package/package.json +14 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @jini-ai/ui
2
2
 
3
+ ## 0.5.2 — 2026-10-13
4
+
5
+ - MCP-UI surfaces follow the host's live theme (da019dde): `McpUiHost` reads the host's resolved `--jini-*` theme where a card mounts and writes it into Jini-built surface documents as a `:root` rule after the token block; a MutationObserver re-themes cards already on screen when the theme or accent changes. Third-party Views are not rewritten. Builder token overrides render as `:root:root`, so a card that sets its own tokens keeps them. New `--jini-mcpui-accent-ink` token (host `--jini-primary-ink`) for primary button text.
6
+ - Rides along, no API change: interactive-ui/charts shared code declared as isolation-shared metadata instead of a file move (28e84348 moved it, bc2945ee reverted the move); html-editor lookup sets are `ReadonlySet` (27965ef0); comment scrubs (8e508fb6).
7
+
8
+ ## 0.5.1 — 2026-10-13
9
+
10
+ - Phone layout fixes. Media provider cards put one field per line on phones and keep the fields plus Clear on one row on wider screens (was a three-column grid that squeezed inputs to a few characters). The snippet block's Copy button no longer covers the command: phones start the code below it, wider screens reserve its width. `TabBar` scrolls a sideways-scrolling strip so the active tab is fully visible (only the strip scrolls, never the page).
11
+ - HTML viewer imports `renderers/new-tab-preview` and `renderers/sandbox-bridge` directly instead of the renderer barrel (no Markdown renderers or srcdoc assembly in its closure).
12
+ - New universal `./i18n` subpath re-exporting `interpolate` from the existing template-i18n owner.
13
+ - TanStack fetch-query adapter: replacement writes win at TanStack's save boundary, so an older read that settles after `replace()` can no longer overwrite it (hook data, loaders and `peek()` keep their public types).
14
+
3
15
  ## 0.5.0 — 2026-10-13
4
16
 
5
17
  - Add framework-neutral chart contracts, browser/table and React hosts, provider-defined kinds and legacy chart wire aliases. Extract Recharts implementations and peers into the private browser artifact; chart component export wrappers are removed (no external consumers found).
@@ -22,7 +22,7 @@ export interface InfoTipPlacement {
22
22
  *
23
23
  * Why the real measured width: the old clamp assumed every bubble was 18rem wide, so every short tip
24
24
  * within 152px of the right edge got the same left — "Remove" sat ~86px away from its ✕ in the
25
- * dock (Chrome re-check, 2026-10-11). Shared by `useInfoTip` and Tovu's dock-header tips so both
25
+ * dock (Chrome re-check, 2026-10-11). Shared by `useInfoTip` and the host's dock-header tips so both
26
26
  * place identically.
27
27
  * @param input.anchor The icon's `getBoundingClientRect()`.
28
28
  * @param input.bubble The rendered bubble's measured size; `null` before the portal exists.
@@ -16,7 +16,7 @@ const ARROW_INSET_PX = 10;
16
16
  *
17
17
  * Why the real measured width: the old clamp assumed every bubble was 18rem wide, so every short tip
18
18
  * within 152px of the right edge got the same left — "Remove" sat ~86px away from its ✕ in the
19
- * dock (Chrome re-check, 2026-10-11). Shared by `useInfoTip` and Tovu's dock-header tips so both
19
+ * dock (Chrome re-check, 2026-10-11). Shared by `useInfoTip` and the host's dock-header tips so both
20
20
  * place identically.
21
21
  * @param input.anchor The icon's `getBoundingClientRect()`.
22
22
  * @param input.bubble The rendered bubble's measured size; `null` before the portal exists.
@@ -1,7 +1,7 @@
1
1
  import type { FullscreenPort, HtmlViewerDependencies, NewTabPreviewPort } from './ports.js';
2
2
  /** Real browser Fullscreen API binding. Every method is SSR-guarded (`typeof document === 'undefined'`). */
3
3
  export declare function createBrowserFullscreenPort(): FullscreenPort;
4
- /** Reuses `@jini-ai/ui`'s own `./renderers` subpath's `openSandboxedPreviewInNewTab` — the sandboxed-iframe core this whole slice is the first consumer of within this package. */
4
+ /** Reuses `renderers/new-tab-preview.ts`, the owner of sandboxed new-tab previews, without loading the renderer barrel. */
5
5
  export declare function createBrowserNewTabPreviewPort(): NewTabPreviewPort;
6
6
  export declare function createDefaultHtmlViewerDependencies(): HtmlViewerDependencies;
7
7
  export declare const defaultHtmlViewerDependencies: HtmlViewerDependencies;
@@ -1,4 +1,4 @@
1
- import { openSandboxedPreviewInNewTab } from '../../renderers/index.js';
1
+ import { openSandboxedPreviewInNewTab } from '../../renderers/new-tab-preview.js';
2
2
  /** Real browser Fullscreen API binding. Every method is SSR-guarded (`typeof document === 'undefined'`). */
3
3
  export function createBrowserFullscreenPort() {
4
4
  return {
@@ -28,7 +28,7 @@ export function createBrowserFullscreenPort() {
28
28
  },
29
29
  };
30
30
  }
31
- /** Reuses `@jini-ai/ui`'s own `./renderers` subpath's `openSandboxedPreviewInNewTab` — the sandboxed-iframe core this whole slice is the first consumer of within this package. */
31
+ /** Reuses `renderers/new-tab-preview.ts`, the owner of sandboxed new-tab previews, without loading the renderer barrel. */
32
32
  export function createBrowserNewTabPreviewPort() {
33
33
  return {
34
34
  openInNewTab(html, title) {
@@ -14,8 +14,8 @@ export interface DeckNavigationController {
14
14
  goTo(index: number): void;
15
15
  }
16
16
  /**
17
- * Drives deck/slide navigation over `@jini-ai/ui`'s own `./renderers`
18
- * subpath's `useSandboxBridge`, speaking the neutral `jini:deck-*` protocol from
17
+ * Drives deck/slide navigation over `renderers/sandbox-bridge.ts`'s
18
+ * `useSandboxBridge`, speaking the neutral `jini:deck-*` protocol from
19
19
  * `constants.ts`. The sandboxed content itself must implement the deck
20
20
  * framework that responds to these messages — this hook only sends
21
21
  * navigation requests and tracks the state the content reports back.
@@ -1,10 +1,10 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
- import { useSandboxBridge } from '../../../../renderers/index.js';
2
+ import { useSandboxBridge } from '../../../../renderers/sandbox-bridge.js';
3
3
  import { DECK_NAVIGATE_MESSAGE_TYPE, DECK_STATE_MESSAGE_TYPE } from '../../constants.js';
4
4
  import { canGoNext, canGoPrev, parseDeckStateMessage, slideCounterLabel } from '../../rules.js';
5
5
  /**
6
- * Drives deck/slide navigation over `@jini-ai/ui`'s own `./renderers`
7
- * subpath's `useSandboxBridge`, speaking the neutral `jini:deck-*` protocol from
6
+ * Drives deck/slide navigation over `renderers/sandbox-bridge.ts`'s
7
+ * `useSandboxBridge`, speaking the neutral `jini:deck-*` protocol from
8
8
  * `constants.ts`. The sandboxed content itself must implement the deck
9
9
  * framework that responds to these messages — this hook only sends
10
10
  * navigation requests and tracks the state the content reports back.
@@ -0,0 +1,3 @@
1
+ /** Universal template helpers; implementation stays with the existing template-i18n owner. */
2
+ export { interpolate } from './template-i18n.js';
3
+ //# sourceMappingURL=universal.d.ts.map
@@ -0,0 +1,3 @@
1
+ /** Universal template helpers; implementation stays with the existing template-i18n owner. */
2
+ export { interpolate } from './template-i18n.js';
3
+ //# sourceMappingURL=universal.js.map
@@ -7,7 +7,7 @@
7
7
  import * as React from 'react';
8
8
  import { type VariantProps } from 'class-variance-authority';
9
9
  declare const buttonVariants: (props?: ({
10
- variant?: "link" | "default" | "outline" | "ghost" | "destructive" | "secondary" | null | undefined;
10
+ variant?: "link" | "default" | "ghost" | "outline" | "destructive" | "secondary" | null | undefined;
11
11
  size?: "default" | "icon" | "sm" | "lg" | null | undefined;
12
12
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
13
13
  export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
@@ -8,7 +8,7 @@ import { type SurfaceTokenName } from './tokens.js';
8
8
  * be a color no caller could reskin. `var(--x, fallback)` appears only where a value is genuinely
9
9
  * optional.
10
10
  */
11
- export declare const SURFACE_BASE_CSS = "*, *::before, *::after { box-sizing: border-box; }\nhtml, body { margin: 0; padding: 0; }\nbody {\n /* Transparent, not var(--jini-mcpui-bg): this document has no page of its own to paint. It is an\n allow-scripts-only sandboxed iframe embedded directly in a host's chat transcript, and html's\n background is left at its (also transparent) initial value, so the host's own pane shows\n straight through to .mcpui-surface below rather than through a second, unrelated rectangle of\n fill color sitting between the two. --jini-mcpui-bg stays declared for a caller styling\n something else via extraCss -- nothing in this stylesheet reads it anymore. */\n background: transparent;\n color: var(--jini-mcpui-text);\n font-family: var(--jini-mcpui-font);\n font-size: 14px;\n line-height: 1.5;\n padding: 16px;\n -webkit-font-smoothing: antialiased;\n}\n.mcpui-surface {\n background: linear-gradient(135deg,\n color-mix(in srgb, var(--jini-mcpui-accent) 5%, var(--jini-mcpui-panel)) 0%,\n var(--jini-mcpui-panel) 60%);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 18%, var(--jini-mcpui-border));\n border-radius: var(--jini-mcpui-radius-md);\n box-shadow: var(--jini-mcpui-surface-shadow);\n padding: 18px 20px 16px;\n max-width: 640px;\n margin: 0 auto;\n}\n.mcpui-title {\n margin: 0;\n font-size: 16px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-description { margin: 6px 0 0; color: var(--jini-mcpui-text-muted); }\n.mcpui-details {\n display: grid;\n grid-template-columns: max-content minmax(0, 1fr);\n gap: 4px 14px;\n margin: 14px 0 0;\n}\n.mcpui-details dt { color: var(--jini-mcpui-text-soft); font-weight: 500; }\n.mcpui-details dd { margin: 0; overflow-wrap: anywhere; color: var(--jini-mcpui-text); }\n.mcpui-details-outcome { display: flex; flex-direction: column; gap: 8px; }\n.mcpui-outcome-row { display: flex; align-items: flex-start; gap: 14px; }\n.mcpui-outcome-row dt { flex: 1; min-width: 0; overflow-wrap: anywhere; }\n.mcpui-outcome-row dt .mcpui-hint { display: block; font-weight: 400; }\n.mcpui-outcome-row dd { flex: 1; min-width: 0; }\n/* Capped so a long status cannot squeeze the label back to one character per line; the full text\n stays in the cell's title. */\n.mcpui-outcome-row dd.mcpui-detail-status {\n flex: 0 1 auto; max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n/* The iframe has 16px body padding on each side: 352px viewport = 320px card. Stacked, the value\n has the full width, so it wraps between words instead of truncating. */\n@media (max-width: 352px) {\n .mcpui-outcome-row { flex-direction: column; gap: 2px; }\n .mcpui-outcome-row dd.mcpui-detail-status { max-width: 100%; white-space: normal; overflow-wrap: normal; }\n}\n.mcpui-code {\n margin: 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n max-height: 16em;\n overflow: auto;\n font-family: var(--jini-mcpui-font-mono);\n font-size: 12.5px;\n line-height: 1.45;\n padding: 6px 8px;\n border: 1px solid var(--jini-mcpui-border);\n border-radius: var(--jini-mcpui-radius-sm);\n background: var(--jini-mcpui-bg);\n}\n.mcpui-warning {\n margin: 14px 0 0;\n padding: 9px 11px;\n border-radius: var(--jini-mcpui-radius-md);\n border: 1px solid var(--jini-mcpui-danger);\n background: var(--jini-mcpui-danger-tint);\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-fields { display: flex; flex-direction: column; gap: 12px; margin: 16px 0 0; border: 0; padding: 0; }\n.mcpui-field { display: flex; flex-direction: column; gap: 4px; }\n.mcpui-field-inline { flex-direction: row; align-items: flex-start; gap: 8px; }\n.mcpui-label { font-weight: 500; color: var(--jini-mcpui-text-strong); }\n.mcpui-required { color: var(--jini-mcpui-accent); margin-left: 2px; }\n.mcpui-hint { color: var(--jini-mcpui-text-soft); font-size: 13px; }\n.mcpui-input, .mcpui-select, .mcpui-textarea {\n font: inherit;\n color: var(--jini-mcpui-text);\n background: var(--jini-mcpui-panel);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n border-radius: var(--jini-mcpui-radius-sm);\n padding: 7px 9px;\n width: 100%;\n min-width: 0;\n}\n.mcpui-textarea { resize: vertical; min-height: 72px; font-family: var(--jini-mcpui-font); }\n.mcpui-checkbox { margin: 3px 0 0; accent-color: var(--jini-mcpui-accent); }\n.mcpui-choice-group { border: 0; padding: 0; margin: 0; min-width: 0; }\n.mcpui-choice { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; margin: 6px 0 0; }\n.mcpui-choice-label { color: var(--jini-mcpui-text); font-weight: 400; }\n.mcpui-input:focus-visible, .mcpui-select:focus-visible, .mcpui-textarea:focus-visible,\n.mcpui-checkbox:focus-visible, .mcpui-button:focus-visible {\n outline: 2px solid var(--jini-mcpui-accent-ring);\n outline-offset: 1px;\n border-color: var(--jini-mcpui-accent);\n}\n.mcpui-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }\n.mcpui-button {\n font: inherit;\n font-weight: 500;\n padding: 7px 15px;\n border-radius: var(--jini-mcpui-radius-pill);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n background: var(--jini-mcpui-panel);\n color: var(--jini-mcpui-text);\n cursor: pointer;\n}\n.mcpui-button:hover:not([disabled]) { background: var(--jini-mcpui-accent-tint); }\n.mcpui-button-primary {\n background: var(--jini-mcpui-accent);\n border-color: var(--jini-mcpui-accent);\n color: var(--jini-mcpui-panel);\n}\n.mcpui-button-primary:hover:not([disabled]) { background: var(--jini-mcpui-accent-hover); border-color: var(--jini-mcpui-accent-hover); }\n.mcpui-button-danger {\n background: var(--jini-mcpui-danger);\n border-color: var(--jini-mcpui-danger);\n color: var(--jini-mcpui-panel);\n}\n.mcpui-button-danger:hover:not([disabled]) { background: var(--jini-mcpui-danger-hover); border-color: var(--jini-mcpui-danger-hover); }\n.mcpui-button[disabled] { opacity: 0.55; cursor: default; }\n.mcpui-status { margin: 12px 0 0; min-height: 1.4em; color: var(--jini-mcpui-text-muted); }\n.mcpui-status[data-state=\"failed\"], .mcpui-status[data-state=\"invalid\"], .mcpui-status[data-state=\"expired\"] { color: var(--jini-mcpui-danger); }\n.mcpui-status[data-state=\"done\"] { color: var(--jini-mcpui-text-strong); }\n/* Neither a plain success nor a failure \u2014 e.g. an outcome surface reporting \"uploaded, not yet\n reachable\" (outcome.ts's own state: partial). Distinct from both --jini-mcpui-danger (would\n wrongly read as \"this failed\") and --jini-mcpui-text-strong (would wrongly read as \"this is\n fully done\"); the accent token reads as \"worth a second look\" without asserting either. */\n.mcpui-status[data-state=\"partial\"] { color: var(--jini-mcpui-accent); }\n.mcpui-code { font-family: var(--jini-mcpui-font-mono); font-size: 13px; color: var(--jini-mcpui-text-faint); }";
11
+ export declare const SURFACE_BASE_CSS = "*, *::before, *::after { box-sizing: border-box; }\nhtml, body { margin: 0; padding: 0; }\nbody {\n /* Transparent, not var(--jini-mcpui-bg): this document has no page of its own to paint. It is an\n allow-scripts-only sandboxed iframe embedded directly in a host's chat transcript, and html's\n background is left at its (also transparent) initial value, so the host's own pane shows\n straight through to .mcpui-surface below rather than through a second, unrelated rectangle of\n fill color sitting between the two. --jini-mcpui-bg stays declared for a caller styling\n something else via extraCss -- nothing in this stylesheet reads it anymore. */\n background: transparent;\n color: var(--jini-mcpui-text);\n font-family: var(--jini-mcpui-font);\n font-size: 14px;\n line-height: 1.5;\n padding: 16px;\n -webkit-font-smoothing: antialiased;\n}\n.mcpui-surface {\n background: linear-gradient(135deg,\n color-mix(in srgb, var(--jini-mcpui-accent) 5%, var(--jini-mcpui-panel)) 0%,\n var(--jini-mcpui-panel) 60%);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 18%, var(--jini-mcpui-border));\n border-radius: var(--jini-mcpui-radius-md);\n box-shadow: var(--jini-mcpui-surface-shadow);\n padding: 18px 20px 16px;\n max-width: 640px;\n margin: 0 auto;\n}\n.mcpui-title {\n margin: 0;\n font-size: 16px;\n font-weight: 600;\n letter-spacing: -0.01em;\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-description { margin: 6px 0 0; color: var(--jini-mcpui-text-muted); }\n.mcpui-details {\n display: grid;\n grid-template-columns: max-content minmax(0, 1fr);\n gap: 4px 14px;\n margin: 14px 0 0;\n}\n.mcpui-details dt { color: var(--jini-mcpui-text-soft); font-weight: 500; }\n.mcpui-details dd { margin: 0; overflow-wrap: anywhere; color: var(--jini-mcpui-text); }\n.mcpui-details-outcome { display: flex; flex-direction: column; gap: 8px; }\n.mcpui-outcome-row { display: flex; align-items: flex-start; gap: 14px; }\n.mcpui-outcome-row dt { flex: 1; min-width: 0; overflow-wrap: anywhere; }\n.mcpui-outcome-row dt .mcpui-hint { display: block; font-weight: 400; }\n.mcpui-outcome-row dd { flex: 1; min-width: 0; }\n/* Capped so a long status cannot squeeze the label back to one character per line; the full text\n stays in the cell's title. */\n.mcpui-outcome-row dd.mcpui-detail-status {\n flex: 0 1 auto; max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n/* The iframe has 16px body padding on each side: 352px viewport = 320px card. Stacked, the value\n has the full width, so it wraps between words instead of truncating. */\n@media (max-width: 352px) {\n .mcpui-outcome-row { flex-direction: column; gap: 2px; }\n .mcpui-outcome-row dd.mcpui-detail-status { max-width: 100%; white-space: normal; overflow-wrap: normal; }\n}\n.mcpui-code {\n margin: 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n max-height: 16em;\n overflow: auto;\n font-family: var(--jini-mcpui-font-mono);\n font-size: 12.5px;\n line-height: 1.45;\n padding: 6px 8px;\n border: 1px solid var(--jini-mcpui-border);\n border-radius: var(--jini-mcpui-radius-sm);\n background: var(--jini-mcpui-bg);\n}\n.mcpui-warning {\n margin: 14px 0 0;\n padding: 9px 11px;\n border-radius: var(--jini-mcpui-radius-md);\n border: 1px solid var(--jini-mcpui-danger);\n background: var(--jini-mcpui-danger-tint);\n color: var(--jini-mcpui-text-strong);\n}\n.mcpui-fields { display: flex; flex-direction: column; gap: 12px; margin: 16px 0 0; border: 0; padding: 0; }\n.mcpui-field { display: flex; flex-direction: column; gap: 4px; }\n.mcpui-field-inline { flex-direction: row; align-items: flex-start; gap: 8px; }\n.mcpui-label { font-weight: 500; color: var(--jini-mcpui-text-strong); }\n.mcpui-required { color: var(--jini-mcpui-accent); margin-left: 2px; }\n.mcpui-hint { color: var(--jini-mcpui-text-soft); font-size: 13px; }\n.mcpui-input, .mcpui-select, .mcpui-textarea {\n font: inherit;\n color: var(--jini-mcpui-text);\n background: var(--jini-mcpui-panel);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n border-radius: var(--jini-mcpui-radius-sm);\n padding: 7px 9px;\n width: 100%;\n min-width: 0;\n}\n.mcpui-textarea { resize: vertical; min-height: 72px; font-family: var(--jini-mcpui-font); }\n.mcpui-checkbox { margin: 3px 0 0; accent-color: var(--jini-mcpui-accent); }\n.mcpui-choice-group { border: 0; padding: 0; margin: 0; min-width: 0; }\n.mcpui-choice { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; margin: 6px 0 0; }\n.mcpui-choice-label { color: var(--jini-mcpui-text); font-weight: 400; }\n.mcpui-input:focus-visible, .mcpui-select:focus-visible, .mcpui-textarea:focus-visible,\n.mcpui-checkbox:focus-visible, .mcpui-button:focus-visible {\n outline: 2px solid var(--jini-mcpui-accent-ring);\n outline-offset: 1px;\n border-color: var(--jini-mcpui-accent);\n}\n.mcpui-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 0; }\n.mcpui-button {\n font: inherit;\n font-weight: 500;\n padding: 7px 15px;\n border-radius: var(--jini-mcpui-radius-pill);\n border: 1px solid color-mix(in srgb, var(--jini-mcpui-accent) 14%, var(--jini-mcpui-border-strong));\n background: var(--jini-mcpui-panel);\n color: var(--jini-mcpui-text);\n cursor: pointer;\n}\n.mcpui-button:hover:not([disabled]) { background: var(--jini-mcpui-accent-tint); }\n.mcpui-button-primary {\n background: var(--jini-mcpui-accent);\n border-color: var(--jini-mcpui-accent);\n color: var(--jini-mcpui-accent-ink);\n}\n.mcpui-button-primary:hover:not([disabled]) { background: var(--jini-mcpui-accent-hover); border-color: var(--jini-mcpui-accent-hover); }\n.mcpui-button-danger {\n background: var(--jini-mcpui-danger);\n border-color: var(--jini-mcpui-danger);\n color: var(--jini-mcpui-panel);\n}\n.mcpui-button-danger:hover:not([disabled]) { background: var(--jini-mcpui-danger-hover); border-color: var(--jini-mcpui-danger-hover); }\n.mcpui-button[disabled] { opacity: 0.55; cursor: default; }\n.mcpui-status { margin: 12px 0 0; min-height: 1.4em; color: var(--jini-mcpui-text-muted); }\n.mcpui-status[data-state=\"failed\"], .mcpui-status[data-state=\"invalid\"], .mcpui-status[data-state=\"expired\"] { color: var(--jini-mcpui-danger); }\n.mcpui-status[data-state=\"done\"] { color: var(--jini-mcpui-text-strong); }\n/* Neither a plain success nor a failure \u2014 e.g. an outcome surface reporting \"uploaded, not yet\n reachable\" (outcome.ts's own state: partial). Distinct from both --jini-mcpui-danger (would\n wrongly read as \"this failed\") and --jini-mcpui-text-strong (would wrongly read as \"this is\n fully done\"); the accent token reads as \"worth a second look\" without asserting either. */\n.mcpui-status[data-state=\"partial\"] { color: var(--jini-mcpui-accent); }\n.mcpui-code { font-family: var(--jini-mcpui-font-mono); font-size: 13px; color: var(--jini-mcpui-text-faint); }";
12
12
  /**
13
13
  * The Content-Security-Policy every generated surface declares in its own `<head>`.
14
14
  *
@@ -147,7 +147,7 @@ body {
147
147
  .mcpui-button-primary {
148
148
  background: var(--jini-mcpui-accent);
149
149
  border-color: var(--jini-mcpui-accent);
150
- color: var(--jini-mcpui-panel);
150
+ color: var(--jini-mcpui-accent-ink);
151
151
  }
152
152
  .mcpui-button-primary:hover:not([disabled]) { background: var(--jini-mcpui-accent-hover); border-color: var(--jini-mcpui-accent-hover); }
153
153
  .mcpui-button-danger {
@@ -0,0 +1,30 @@
1
+ import type { SurfaceTokenName } from './tokens.js';
2
+ /** The one slice of `CSSStyleDeclaration` this module reads — a test passes a plain object. */
3
+ export interface HostThemeStyle {
4
+ getPropertyValue(name: string): string;
5
+ }
6
+ export type HostSurfaceTokens = Readonly<Partial<Record<SurfaceTokenName, string>>>;
7
+ /**
8
+ * Reads the host's resolved theme at one element.
9
+ *
10
+ * @param style - The element's computed style (`getComputedStyle(el)`), where `--jini-*` resolve
11
+ * through every `var()` the host's theme inputs pass through.
12
+ * @returns The surface tokens to override. A variable the host leaves undeclared (or declares with
13
+ * an unsafe value) is omitted, so the surface keeps its own default for it.
14
+ */
15
+ export declare function readHostSurfaceTokens(style: HostThemeStyle): HostSurfaceTokens;
16
+ /**
17
+ * Writes host tokens into a surface document.
18
+ *
19
+ * The rule is plain `:root` placed after the document's first `<style>`, so it beats the token
20
+ * block's defaults by source order; a builder's own `tokens` override is emitted as `:root:root`
21
+ * (`renderTokenBlock`), so a card that set its own colors keeps them. Documents carrying no
22
+ * `--jini-mcpui-` token at all are returned unchanged: a third-party View declares none of these
23
+ * names, and its HTML is not this module's to edit.
24
+ *
25
+ * @param html - The surface document.
26
+ * @param tokens - From {@link readHostSurfaceTokens}.
27
+ * @returns The document with the host rule inserted, or `html` itself when there is nothing to do.
28
+ */
29
+ export declare function applyHostSurfaceTokens(html: string, tokens: HostSurfaceTokens): string;
30
+ //# sourceMappingURL=host-theme.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @module features/mcp-ui/surfaces/host-theme
3
+ *
4
+ * Makes a surface follow the embedding host's LIVE theme. A surface is a sandboxed document that
5
+ * cannot inherit the host's CSS (see `tokens.ts`), so without this every card wore Jini's neutral
6
+ * default palette — a near-black accent on grey — whatever the host's brand (owner, 2026-10-13:
7
+ * "every chat card should follow the theme color. It shouldn't be that ugly gray").
8
+ *
9
+ * The host side reads Jini's standard theme variables (`variables.css`'s `--jini-*`, which a host
10
+ * brands through `--jini-theme-*` inputs) where the card is mounted, and {@link applyHostSurfaceTokens}
11
+ * writes them into the document as a second `:root` rule right after the surface's token block. It
12
+ * rewrites the document at render time rather than asking the View to apply a value it was sent,
13
+ * so a card saved in an old chat — whose stored HTML predates this module — follows the theme too.
14
+ */
15
+ /**
16
+ * Which host variable each surface token follows. Radius and fonts are absent on purpose: a sandboxed
17
+ * frame has no network, so a host's web font cannot load there, and the surface's radius scale is
18
+ * its own. Text on an accent fill follows the host's primary ink, so a card's primary button
19
+ * reads exactly as legibly as the host's own.
20
+ */
21
+ const HOST_THEME_SOURCES = {
22
+ '--jini-mcpui-bg': '--jini-bg',
23
+ '--jini-mcpui-panel': '--jini-surface',
24
+ '--jini-mcpui-border': '--jini-border',
25
+ '--jini-mcpui-border-strong': '--jini-border',
26
+ '--jini-mcpui-text': '--jini-text',
27
+ '--jini-mcpui-text-strong': '--jini-text',
28
+ '--jini-mcpui-text-muted': '--jini-muted',
29
+ '--jini-mcpui-text-soft': '--jini-muted',
30
+ '--jini-mcpui-text-faint': '--jini-muted',
31
+ '--jini-mcpui-accent': '--jini-primary',
32
+ '--jini-mcpui-accent-strong': '--jini-primary',
33
+ '--jini-mcpui-accent-ink': '--jini-primary-ink',
34
+ '--jini-mcpui-danger': '--jini-danger',
35
+ };
36
+ /**
37
+ * A value that could close the `<style>` it is written into, or end its declaration early, is
38
+ * dropped rather than escaped. Custom-property values accept almost any token, and the accent is an
39
+ * operator-typed setting, so `</style><script>` is a value the CSS parser would keep.
40
+ */
41
+ const UNSAFE_VALUE = /[<>{};\\]/;
42
+ /**
43
+ * Reads the host's resolved theme at one element.
44
+ *
45
+ * @param style - The element's computed style (`getComputedStyle(el)`), where `--jini-*` resolve
46
+ * through every `var()` the host's theme inputs pass through.
47
+ * @returns The surface tokens to override. A variable the host leaves undeclared (or declares with
48
+ * an unsafe value) is omitted, so the surface keeps its own default for it.
49
+ */
50
+ export function readHostSurfaceTokens(style) {
51
+ const tokens = {};
52
+ for (const [token, source] of Object.entries(HOST_THEME_SOURCES)) {
53
+ const value = style.getPropertyValue(source).trim();
54
+ if (value !== '' && !UNSAFE_VALUE.test(value))
55
+ tokens[token] = value;
56
+ }
57
+ return tokens;
58
+ }
59
+ /** Marks the rule {@link applyHostSurfaceTokens} writes. */
60
+ const HOST_THEME_STYLE_ATTRIBUTE = 'data-jini-host-theme';
61
+ /**
62
+ * Writes host tokens into a surface document.
63
+ *
64
+ * The rule is plain `:root` placed after the document's first `<style>`, so it beats the token
65
+ * block's defaults by source order; a builder's own `tokens` override is emitted as `:root:root`
66
+ * (`renderTokenBlock`), so a card that set its own colors keeps them. Documents carrying no
67
+ * `--jini-mcpui-` token at all are returned unchanged: a third-party View declares none of these
68
+ * names, and its HTML is not this module's to edit.
69
+ *
70
+ * @param html - The surface document.
71
+ * @param tokens - From {@link readHostSurfaceTokens}.
72
+ * @returns The document with the host rule inserted, or `html` itself when there is nothing to do.
73
+ */
74
+ export function applyHostSurfaceTokens(html, tokens) {
75
+ const entries = Object.entries(tokens);
76
+ if (entries.length === 0 || !html.includes('--jini-mcpui-'))
77
+ return html;
78
+ const close = html.indexOf('</style>');
79
+ if (close === -1)
80
+ return html;
81
+ const at = close + '</style>'.length;
82
+ const rule = `\n<style ${HOST_THEME_STYLE_ATTRIBUTE}>\n:root {\n${entries.map(([name, value]) => ` ${name}: ${value};`).join('\n')}\n}\n</style>`;
83
+ return `${html.slice(0, at)}${rule}${html.slice(at)}`;
84
+ }
85
+ //# sourceMappingURL=host-theme.js.map
@@ -5,6 +5,7 @@ export * from './confirmation.js';
5
5
  export * from './document.js';
6
6
  export * from './fields.js';
7
7
  export * from './form.js';
8
+ export * from './host-theme.js';
8
9
  export * from './outcome.js';
9
10
  export * from './select.js';
10
11
  export * from './text-input.js';
@@ -5,6 +5,7 @@ export * from './confirmation.js';
5
5
  export * from './document.js';
6
6
  export * from './fields.js';
7
7
  export * from './form.js';
8
+ export * from './host-theme.js';
8
9
  export * from './outcome.js';
9
10
  export * from './select.js';
10
11
  export * from './text-input.js';
@@ -18,7 +18,8 @@
18
18
  * because all four resolve through it.
19
19
  *
20
20
  * The sandbox cannot inherit the host's CSS. These names are a document adapter of
21
- * defaultAdminTheme; hosts forward their active values through the existing overrides port.
21
+ * defaultAdminTheme; the React host forwards the active theme into every surface it mounts
22
+ * (`host-theme.ts`), and a builder's `tokens` overrides outrank that.
22
23
  *
23
24
  * ## Dark mode
24
25
  *
@@ -32,6 +33,7 @@
32
33
  *
33
34
  * Currently moot: {@link FORCE_LIGHT_SURFACE_THEME} suppresses the dark block entirely, because
34
35
  * `prefers-color-scheme` reads the OS/browser, not any real app theme — see that constant's own doc.
36
+ * The host's live theme, dark or light, arrives through `host-theme.ts` instead.
35
37
  */
36
38
  /**
37
39
  * The base palette, radius scale, and font stack.
@@ -52,6 +54,7 @@ export declare const SURFACE_TOKENS: {
52
54
  readonly '--jini-mcpui-text-faint': string;
53
55
  readonly '--jini-mcpui-accent': string;
54
56
  readonly '--jini-mcpui-accent-strong': string;
57
+ readonly '--jini-mcpui-accent-ink': string;
55
58
  readonly '--jini-mcpui-danger': string;
56
59
  readonly '--jini-mcpui-radius-xs': "4px";
57
60
  readonly '--jini-mcpui-radius-sm': "6px";
@@ -77,12 +80,11 @@ export declare const SURFACE_TOKENS_DARK: Record<SurfaceTokenName, string>;
77
80
  * Whether every generated surface is pinned to the light palette regardless of the embedding
78
81
  * browser/OS's `prefers-color-scheme`.
79
82
  *
80
- * True by default because without an explicit app-level theme signal, a
81
- * surface that honors the OS preference doesn't track any actual app theme — it tracks whatever the
82
- * operator's OS happens to be set to, which produces a dark card floating in an always-light admin
83
- * UI. Flip this to `false` once the host product has a real app-level theme signal to forward in;
84
- * nothing else about
85
- * {@link renderTokenBlock} needs to change.
83
+ * True by default because `prefers-color-scheme` reads the operator's OS, not any app theme, and a
84
+ * surface that honored it produced a dark card floating in an always-light admin UI. The real app
85
+ * theme reaches a surface another way: the React host writes the host's resolved `--jini-*` values
86
+ * into the document at render time (`host-theme.ts`), so a host rendering in dark gets a dark card
87
+ * through that rule, and this pin only decides what a surface shows with no host theme at all.
86
88
  */
87
89
  export declare const FORCE_LIGHT_SURFACE_THEME = true;
88
90
  /**
@@ -95,8 +97,8 @@ export declare const FORCE_LIGHT_SURFACE_THEME = true;
95
97
  * verbatim — a caller passing a token value is trusted the same way it is trusted with the surface's
96
98
  * own body HTML. A caller wanting scheme-specific values is not served by this parameter; it exists
97
99
  * for a host's brand tokens, which by design apply the same way regardless of scheme.
98
- * @returns A `:root { … }` block, followed by a `@media (prefers-color-scheme: dark) { :root { … } }`
99
- * block unless {@link FORCE_LIGHT_SURFACE_THEME} suppresses it, ready to paste into the first
100
+ * @returns A `:root { … }` block, then a `:root:root { … }` block holding the overrides (when any),
101
+ * followed by a `@media (prefers-color-scheme: dark) { :root { … } }` block unless {@link FORCE_LIGHT_SURFACE_THEME} suppresses it, ready to paste into the first
100
102
  * `<style>` tag.
101
103
  * @complexity O(n) in the number of tokens.
102
104
  */
@@ -19,7 +19,8 @@ import { defaultAdminTheme } from '../../../core/theme/default.js';
19
19
  * because all four resolve through it.
20
20
  *
21
21
  * The sandbox cannot inherit the host's CSS. These names are a document adapter of
22
- * defaultAdminTheme; hosts forward their active values through the existing overrides port.
22
+ * defaultAdminTheme; the React host forwards the active theme into every surface it mounts
23
+ * (`host-theme.ts`), and a builder's `tokens` overrides outrank that.
23
24
  *
24
25
  * ## Dark mode
25
26
  *
@@ -33,6 +34,7 @@ import { defaultAdminTheme } from '../../../core/theme/default.js';
33
34
  *
34
35
  * Currently moot: {@link FORCE_LIGHT_SURFACE_THEME} suppresses the dark block entirely, because
35
36
  * `prefers-color-scheme` reads the OS/browser, not any real app theme — see that constant's own doc.
37
+ * The host's live theme, dark or light, arrives through `host-theme.ts` instead.
36
38
  */
37
39
  /**
38
40
  * The base palette, radius scale, and font stack.
@@ -53,6 +55,8 @@ export const SURFACE_TOKENS = {
53
55
  '--jini-mcpui-text-faint': defaultAdminTheme.light.muted,
54
56
  '--jini-mcpui-accent': defaultAdminTheme.light.primary,
55
57
  '--jini-mcpui-accent-strong': defaultAdminTheme.light.primary,
58
+ // Text on an accent fill — the host's primary ink, so a card's primary button reads like the host's own.
59
+ '--jini-mcpui-accent-ink': defaultAdminTheme.light.primaryInk,
56
60
  // Desaturated and earthy, in the same family as the accent rather than a saturated pure red — a
57
61
  // destructive action should read as serious, not as an alarm the eye learns to discount.
58
62
  '--jini-mcpui-danger': defaultAdminTheme.light.danger,
@@ -85,6 +89,7 @@ export const SURFACE_TOKENS_DARK = {
85
89
  '--jini-mcpui-text-faint': defaultAdminTheme.dark.muted,
86
90
  '--jini-mcpui-accent': defaultAdminTheme.dark.primary,
87
91
  '--jini-mcpui-accent-strong': defaultAdminTheme.dark.primary,
92
+ '--jini-mcpui-accent-ink': defaultAdminTheme.dark.primaryInk,
88
93
  '--jini-mcpui-danger': defaultAdminTheme.dark.danger,
89
94
  '--jini-mcpui-radius-xs': SURFACE_TOKENS['--jini-mcpui-radius-xs'],
90
95
  '--jini-mcpui-radius-sm': SURFACE_TOKENS['--jini-mcpui-radius-sm'],
@@ -123,12 +128,11 @@ const DARK_SURFACE_SHADOW = '0 1px 2px color-mix(in srgb, var(--jini-mcpui-bg) 3
123
128
  * Whether every generated surface is pinned to the light palette regardless of the embedding
124
129
  * browser/OS's `prefers-color-scheme`.
125
130
  *
126
- * True by default because without an explicit app-level theme signal, a
127
- * surface that honors the OS preference doesn't track any actual app theme — it tracks whatever the
128
- * operator's OS happens to be set to, which produces a dark card floating in an always-light admin
129
- * UI. Flip this to `false` once the host product has a real app-level theme signal to forward in;
130
- * nothing else about
131
- * {@link renderTokenBlock} needs to change.
131
+ * True by default because `prefers-color-scheme` reads the operator's OS, not any app theme, and a
132
+ * surface that honored it produced a dark card floating in an always-light admin UI. The real app
133
+ * theme reaches a surface another way: the React host writes the host's resolved `--jini-*` values
134
+ * into the document at render time (`host-theme.ts`), so a host rendering in dark gets a dark card
135
+ * through that rule, and this pin only decides what a surface shows with no host theme at all.
132
136
  */
133
137
  export const FORCE_LIGHT_SURFACE_THEME = true;
134
138
  /**
@@ -141,22 +145,27 @@ export const FORCE_LIGHT_SURFACE_THEME = true;
141
145
  * verbatim — a caller passing a token value is trusted the same way it is trusted with the surface's
142
146
  * own body HTML. A caller wanting scheme-specific values is not served by this parameter; it exists
143
147
  * for a host's brand tokens, which by design apply the same way regardless of scheme.
144
- * @returns A `:root { … }` block, followed by a `@media (prefers-color-scheme: dark) { :root { … } }`
145
- * block unless {@link FORCE_LIGHT_SURFACE_THEME} suppresses it, ready to paste into the first
148
+ * @returns A `:root { … }` block, then a `:root:root { … }` block holding the overrides (when any),
149
+ * followed by a `@media (prefers-color-scheme: dark) { :root { … } }` block unless {@link FORCE_LIGHT_SURFACE_THEME} suppresses it, ready to paste into the first
146
150
  * `<style>` tag.
147
151
  * @complexity O(n) in the number of tokens.
148
152
  */
149
153
  export function renderTokenBlock(overrides = {}) {
150
154
  const declare = (entries) => entries.map(([name, value]) => ` ${name}: ${value};`).join('\n');
155
+ const notOverridden = ([name]) => !(name in overrides);
151
156
  const light = declare([
152
- ...Object.entries({ ...SURFACE_TOKENS, ...overrides }),
157
+ ...Object.entries(SURFACE_TOKENS).filter(notOverridden),
153
158
  ...Object.entries(DERIVED_TOKENS),
154
159
  ]);
155
- const lightBlock = `:root {\n${light}\n}`;
160
+ // Overrides get their own, more specific rule so they outrank the host theme's plain `:root` rule
161
+ // (`host-theme.ts`) in both schemes: a card that chose its own colors keeps them.
162
+ const overrideEntries = Object.entries(overrides);
163
+ const overrideBlock = overrideEntries.length === 0 ? '' : `\n:root:root {\n${declare(overrideEntries)}\n}`;
164
+ const lightBlock = `:root {\n${light}\n}${overrideBlock}`;
156
165
  if (FORCE_LIGHT_SURFACE_THEME)
157
166
  return lightBlock;
158
167
  const dark = declare([
159
- ...Object.entries({ ...SURFACE_TOKENS_DARK, ...overrides }),
168
+ ...Object.entries(SURFACE_TOKENS_DARK).filter(notOverridden),
160
169
  ['--jini-mcpui-surface-shadow', DARK_SURFACE_SHADOW],
161
170
  ]);
162
171
  return `${lightBlock}\n@media (prefers-color-scheme: dark) {\n :root {\n${dark}\n }\n}`;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /** Optional TanStack translation layer. No library types escape the fetch-query contract. */
3
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
4
- import { isCancelledError, QueryClient, QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query';
4
+ import { isCancelledError, replaceEqualDeep, QueryClient, QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query';
5
5
  import { DEFAULT_STALE_TIME, FetchQueryCache, IDLE_RETENTION, toQueryError } from '@jini-ai/core/fetch';
6
6
  import { browserFetchQueryEnvironment } from './environment.browser.js';
7
7
  import { resolveFetchQueryError, resolveFetchQueryStatus } from './adapter.react.js';
@@ -64,8 +64,8 @@ function historyFor({ scope, query }) {
64
64
  }
65
65
  return history;
66
66
  }
67
- /** Replacement writes win over either outcome of an older transport, across all loaders
68
- * and reactive reads. Weak keys follow TanStack's own query eviction lifecycle. */
67
+ /** Tag transport outcomes; final arbitration belongs to TanStack's synchronous save boundary.
68
+ * Weak query keys follow TanStack's own eviction lifecycle. */
69
69
  async function read({ scope, key, fetch }) {
70
70
  const query = scope.client.getQueryCache().find({ queryKey: key, exact: true });
71
71
  const history = historyFor({ scope, query });
@@ -77,21 +77,36 @@ async function read({ scope, key, fetch }) {
77
77
  return scope.client.getQueryData(key);
78
78
  if (value === undefined)
79
79
  throw new Error(`${JSON.stringify(key)} data is undefined`);
80
- if (request === history.request)
81
- history.error = null;
82
- return value;
80
+ return { generation, request, value };
83
81
  }
84
82
  catch (error) {
85
83
  if (generation !== history.replacement)
86
84
  return scope.client.getQueryData(key);
87
- // Earlier data remains useful, but a failed refresh is not a fresh cache hit.
88
- // The next loader/mount must retry even when its staleTime would retain that data.
89
- if (request === history.request) {
90
- history.error = toQueryError(error);
85
+ return { generation, request, error };
86
+ }
87
+ }
88
+ /** Structural sharing runs inside setData, immediately before publication. A check in read()
89
+ * alone leaves a promise-continuation gap, even when the retryer has already resolved.
90
+ * Current failures throw here; stale failures resolve to the newer replacement like stale data.
91
+ * @returns The accepted envelope, reusing TanStack's normal deep structural sharing.
92
+ */
93
+ function saveResult({ scope, key, previous, incoming }) {
94
+ const query = scope.client.getQueryCache().find({ queryKey: key, exact: true });
95
+ const history = historyFor({ scope, query });
96
+ const result = incoming;
97
+ if (result.generation !== history.replacement)
98
+ return scope.client.getQueryData(key);
99
+ if ('error' in result) {
100
+ // Earlier data stays useful, but a failed refresh is not a fresh cache hit.
101
+ if (result.request === history.request) {
102
+ history.error = toQueryError(result.error);
91
103
  query.invalidate();
92
104
  }
93
- throw error;
105
+ throw result.error;
94
106
  }
107
+ if (result.request === history.request)
108
+ history.error = null;
109
+ return replaceEqualDeep(previous, result);
95
110
  }
96
111
  /** Match the built-in cache's idle retention. TanStack's default gcTime (5 min) would
97
112
  * otherwise evict an unobserved entry before a longer staleTime says it is stale. */
@@ -102,7 +117,8 @@ function gcTimeFor({ staleTime }) {
102
117
  function useFetchQuery({ key, fetch }, { enabled = true, staleTime = DEFAULT_STALE_TIME, refetchOnWindowFocus = false } = {}) {
103
118
  const scope = useScope();
104
119
  const online = useOnline(scope);
105
- const query = useQuery({ queryKey: key, queryFn: () => read({ scope, key, fetch }), enabled: enabled && online, staleTime, gcTime: gcTimeFor({ staleTime }) }, scope.client);
120
+ const query = useQuery({ queryKey: key, queryFn: () => read({ scope, key, fetch }),
121
+ structuralSharing: (previous, incoming) => saveResult({ scope, key, previous, incoming }), enabled: enabled && online, staleTime, gcTime: gcTimeFor({ staleTime }) }, scope.client);
106
122
  const { refetch: refresh } = query;
107
123
  const refetch = useCallback(() => {
108
124
  if (scope.environment.isOnline()) {
@@ -127,9 +143,10 @@ function useFetchQuery({ key, fetch }, { enabled = true, staleTime = DEFAULT_STA
127
143
  // recovery, so a retry is never mistaken for evidence that the last failure is gone.
128
144
  const cached = scope.client.getQueryCache().find({ queryKey: key, exact: true });
129
145
  const history = historyFor({ scope, query: cached });
130
- const hasData = query.data !== undefined;
146
+ const data = query.data && 'value' in query.data ? query.data.value : undefined;
147
+ const hasData = data !== undefined;
131
148
  return {
132
- data: query.data,
149
+ data,
133
150
  status: resolveFetchQueryStatus({ disabled: !enabled, hasData, status: query.status }),
134
151
  error: resolveFetchQueryError({ error: history.error ?? query.error, disabled: !enabled, hasData }),
135
152
  isFetching: online && query.isFetching, refetch,
@@ -161,11 +178,13 @@ function useFetchMutation({ run }, { invalidates } = {}) {
161
178
  function useCachedLoader({ key, fetch }, { staleTime = DEFAULT_STALE_TIME } = {}) {
162
179
  const scope = useScope();
163
180
  return useMemo(() => {
164
- const options = { queryKey: key, queryFn: () => read({ scope, key, fetch }), staleTime, gcTime: gcTimeFor({ staleTime }) };
181
+ const options = { queryKey: key, queryFn: () => read({ scope, key, fetch }),
182
+ structuralSharing: (previous, incoming) => saveResult({ scope, key, previous, incoming }), staleTime, gcTime: gcTimeFor({ staleTime }) };
165
183
  return {
166
- peek: () => scope.client.getQueryData(key),
184
+ peek: () => scope.client.getQueryData(key)?.value,
167
185
  load: (required = { key, fetch }) => {
168
- const selected = { ...options, queryKey: required.key, queryFn: () => read({ scope, key: required.key, fetch: required.fetch }) };
186
+ const selected = { ...options, queryKey: required.key, queryFn: () => read({ scope, key: required.key, fetch: required.fetch }),
187
+ structuralSharing: (previous, incoming) => saveResult({ scope, key: required.key, previous, incoming }) };
169
188
  return scope.client.fetchQuery(selected).catch(error => {
170
189
  // A forced refresh can supersede a loader's request without aborting its transport.
171
190
  // Follow the new request instead of exposing TanStack's cancellation vocabulary.
@@ -177,6 +196,16 @@ function useCachedLoader({ key, fetch }, { staleTime = DEFAULT_STALE_TIME } = {}
177
196
  if (query.state.isInvalidated)
178
197
  return scope.client.fetchQuery(selected);
179
198
  return (query.promise ?? scope.client.getQueryData(required.key));
199
+ }).then(result => {
200
+ const query = scope.client.getQueryCache().find({ queryKey: required.key, exact: true });
201
+ const history = historyFor({ scope, query });
202
+ // Query.fetch returns the transport envelope, not structuralSharing's accepted value.
203
+ // Unwrap against the current generation so awaiting loaders agree with observers.
204
+ if (result.generation !== history.replacement)
205
+ return scope.client.getQueryData(required.key).value;
206
+ if ('error' in result)
207
+ throw result.error;
208
+ return result.value;
180
209
  });
181
210
  },
182
211
  replace: ({ value }) => {
@@ -184,7 +213,7 @@ function useCachedLoader({ key, fetch }, { staleTime = DEFAULT_STALE_TIME } = {}
184
213
  const history = historyFor({ scope, query });
185
214
  history.replacement += 1;
186
215
  history.error = null;
187
- scope.client.setQueryData(key, value);
216
+ scope.client.setQueryData(key, { generation: history.replacement, request: history.request, value });
188
217
  },
189
218
  };
190
219
  }, [scope, key, fetch, staleTime]);
@@ -6,7 +6,7 @@
6
6
  /** Render YYYY-MM-DD HH:MM; hosts can select the viewer's local timezone explicitly. */
7
7
  export function formatTimestamp({ iso }, { timeZone = "stored" } = {}) {
8
8
  if (timeZone === "local") {
9
- // A stored UTC prefix can label server time as viewer time (Tovu D-06). Local getters
9
+ // A stored UTC prefix can label server time as viewer time (host issue D-06). Local getters
10
10
  // follow the browser's timezone and DST; zone-less ISO is already local wall time.
11
11
  const date = new Date(iso);
12
12
  if (Number.isNaN(date.getTime()))
@@ -1718,9 +1718,11 @@ select.jini-input {
1718
1718
  position: relative;
1719
1719
  }
1720
1720
 
1721
+ /* The right padding keeps the absolutely positioned copy button off the wrapped code; on a phone
1722
+ that much side padding would halve the line, so the code starts below the button instead. */
1721
1723
  .jini-snippet-pre {
1722
1724
  margin: 0;
1723
- padding: 14px 16px;
1725
+ padding: 14px 104px 14px 16px;
1724
1726
  overflow-x: auto;
1725
1727
  background: var(--jini-text-strong);
1726
1728
  border-radius: var(--jini-radius);
@@ -1748,6 +1750,12 @@ select.jini-input {
1748
1750
  background: color-mix(in srgb, var(--jini-text-strong) 75%, transparent);
1749
1751
  }
1750
1752
 
1753
+ @media (max-width: 640px) {
1754
+ .jini-snippet-pre {
1755
+ padding: 56px 16px 14px;
1756
+ }
1757
+ }
1758
+
1751
1759
  /* ============================================================
1752
1760
  Connectors tab — PORTED from OD's real source, not authored by eye.
1753
1761
  Sources: `foundry/integrations/open-design/reference/od-web-src.orig/
@@ -3585,13 +3593,21 @@ button.connector-action.is-loading { cursor: wait; }
3585
3593
  flex-wrap: wrap;
3586
3594
  }
3587
3595
 
3596
+ /* A wrapping row, not a fixed column grid: a card holds two or three fields (the model field only
3597
+ when the provider lists models) plus Clear, and the old three-column grid squeezed each input to
3598
+ a few characters on a phone and pushed Clear onto its own full-column row everywhere. Each field
3599
+ asks for 11rem, so a phone gets one field per line, wider screens fit the whole row, and the cap
3600
+ keeps a desktop field from stretching across the card. */
3588
3601
  .jini-media-provider-fields {
3589
- display: grid;
3590
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
3602
+ display: flex;
3603
+ flex-wrap: wrap;
3604
+ align-items: center;
3591
3605
  gap: 6px;
3592
3606
  }
3593
3607
 
3594
3608
  .jini-media-provider-field {
3609
+ flex: 1 1 11rem;
3610
+ max-width: 28rem;
3595
3611
  min-width: 0;
3596
3612
  }
3597
3613
 
@@ -1,4 +1,4 @@
1
- import type { KeyboardEvent as ReactKeyboardEvent } from "react";
1
+ import { type KeyboardEvent as ReactKeyboardEvent, type RefObject } from "react";
2
2
  import type { TabBarTab } from "./TabBar.js";
3
3
  /**
4
4
  * @file TabBar's derived props and WAI-ARIA automatic-activation keyboard behavior.
@@ -74,4 +74,19 @@ export declare function useTabBarKeyboard({ tabs, activeId, onChange }: {
74
74
  activeId: string;
75
75
  onChange: (id: string) => void;
76
76
  }, _optional?: Record<string, never>): TabBarKeyboardHandlers;
77
+ /**
78
+ * Keeps the selected tab visible when the strip scrolls sideways — a host may turn the wrapping
79
+ * row into one swipeable row on phones, and a deep link to a later tab (`?tab=…`) otherwise opens
80
+ * with its own label cut off at the edge. Only the strip's own `scrollLeft` moves: `scrollIntoView`
81
+ * would also scroll the page vertically to the strip, a jump on every tab change below the fold.
82
+ * A strip that wraps (no horizontal overflow) is left alone.
83
+ *
84
+ * @param input.activeId - The currently active tab's id; the strip re-checks when it changes.
85
+ * @returns A ref for the tablist container.
86
+ * @complexity O(n) per active-id change in the strip's tab count (one selector query).
87
+ * @example const stripRef = useActiveTabInView({ activeId });
88
+ */
89
+ export declare function useActiveTabInView({ activeId }: {
90
+ activeId: string;
91
+ }, _optional?: Record<string, never>): RefObject<HTMLDivElement | null>;
77
92
  //# sourceMappingURL=TabBar.hooks.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { useEffect, useRef } from "react";
1
2
  import { agentHandle } from "@jini-ai/agentic";
2
3
  /**
3
4
  * @file TabBar's derived props and WAI-ARIA automatic-activation keyboard behavior.
@@ -112,4 +113,34 @@ export function useTabBarKeyboard({ tabs, activeId, onChange }, _optional = {})
112
113
  }
113
114
  return { onKeyDown };
114
115
  }
116
+ /**
117
+ * Keeps the selected tab visible when the strip scrolls sideways — a host may turn the wrapping
118
+ * row into one swipeable row on phones, and a deep link to a later tab (`?tab=…`) otherwise opens
119
+ * with its own label cut off at the edge. Only the strip's own `scrollLeft` moves: `scrollIntoView`
120
+ * would also scroll the page vertically to the strip, a jump on every tab change below the fold.
121
+ * A strip that wraps (no horizontal overflow) is left alone.
122
+ *
123
+ * @param input.activeId - The currently active tab's id; the strip re-checks when it changes.
124
+ * @returns A ref for the tablist container.
125
+ * @complexity O(n) per active-id change in the strip's tab count (one selector query).
126
+ * @example const stripRef = useActiveTabInView({ activeId });
127
+ */
128
+ export function useActiveTabInView({ activeId }, _optional = {}) {
129
+ const stripRef = useRef(null);
130
+ useEffect(() => {
131
+ const strip = stripRef.current;
132
+ const tab = strip?.querySelector('[role="tab"][aria-selected="true"]');
133
+ if (!strip || !tab || strip.scrollWidth <= strip.clientWidth)
134
+ return;
135
+ const stripBox = strip.getBoundingClientRect();
136
+ const tabBox = tab.getBoundingClientRect();
137
+ // A host's end padding (e.g. room for an edge fade) counts as off-screen.
138
+ const visibleRight = stripBox.right - (parseFloat(getComputedStyle(strip).paddingRight) || 0);
139
+ if (tabBox.left < stripBox.left)
140
+ strip.scrollLeft += tabBox.left - stripBox.left;
141
+ else if (tabBox.right > visibleRight)
142
+ strip.scrollLeft += tabBox.right - visibleRight;
143
+ }, [activeId]);
144
+ return stripRef;
145
+ }
115
146
  //# sourceMappingURL=TabBar.hooks.js.map
@@ -1,7 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { TabStripFrame } from "./TabStripFrame.js";
3
3
  import { agentHandle } from "@jini-ai/agentic";
4
- import { resolveTabBarTabIndex, tabHandleProps, useTabBarKeyboard } from "./TabBar.hooks.js";
4
+ import { resolveTabBarTabIndex, tabHandleProps, useActiveTabInView, useTabBarKeyboard } from "./TabBar.hooks.js";
5
5
  /** The visually-hidden accessible-name SUFFIX {@link TabBarTab.dot} adds — e.g. "GitHub Pages,
6
6
  * Connected", never "Connected GitHub Pages". Deliberately its own function rather than folded into
7
7
  * the visual dot span rendered before the label (`TabBarButton` below): the visual dot is
@@ -24,6 +24,7 @@ function TabBarButton({ tab, active, tabIndex, onChange, }) {
24
24
  /** Controlled button-based selection strip, including roving focus and optional agent handles. */
25
25
  export function TabBar({ tabs, activeId, onChange, ariaLabel, containerHandle }, _optional = {}) {
26
26
  const { onKeyDown } = useTabBarKeyboard({ tabs: tabs, activeId: activeId, onChange: onChange });
27
- return (_jsx(TabStripFrame, { className: "tab-bar", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, ...(containerHandle ? agentHandle({ handle: containerHandle }, { role: "region", label: ariaLabel }) : {}), children: tabs.map((tab) => (_jsx(TabBarButton, { tab: tab, active: activeId === tab.id, tabIndex: resolveTabBarTabIndex({ tabs: tabs, activeId: activeId, tab: tab }), onChange: onChange }, tab.id))) }));
27
+ const stripRef = useActiveTabInView({ activeId: activeId });
28
+ return (_jsx(TabStripFrame, { ref: stripRef, className: "tab-bar", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, ...(containerHandle ? agentHandle({ handle: containerHandle }, { role: "region", label: ariaLabel }) : {}), children: tabs.map((tab) => (_jsx(TabBarButton, { tab: tab, active: activeId === tab.id, tabIndex: resolveTabBarTabIndex({ tabs: tabs, activeId: activeId, tab: tab }), onChange: onChange }, tab.id))) }));
28
29
  }
29
30
  //# sourceMappingURL=TabBar.js.map
@@ -9,8 +9,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  *
10
10
  * Thin on purpose, same as before — every protocol decision lives in `useMcpUiHost.ts`, so a
11
11
  * consumer needing different chrome writes that chrome against the hook rather than forking this.
12
+ *
13
+ * It also themes the View: the host's live `--jini-*` theme is written into a Jini-built surface
14
+ * document before it is handed over (`surfaces/host-theme.ts`), and a theme change starts a fresh
15
+ * session with the re-themed document — the same remount any changed document gets.
12
16
  */
13
17
  import { AppRenderer } from '@mcp-ui/client';
18
+ import { useRef } from 'react';
19
+ import { applyHostSurfaceTokens } from '../../features/mcp-ui/surfaces/host-theme.js';
20
+ import { useHostSurfaceTokens } from './useHostSurfaceTokens.js';
14
21
  import { useMcpUiHost } from './useMcpUiHost.js';
15
22
  const DEFAULT_INITIAL_HEIGHT = 220;
16
23
  const DEFAULT_MAX_HEIGHT = 720;
@@ -21,14 +28,20 @@ const DEFAULT_MAX_HEIGHT = 720;
21
28
  */
22
29
  export function McpUiHost(props) {
23
30
  const { title, className, initialHeight = DEFAULT_INITIAL_HEIGHT, autoResize = true, maxHeight = DEFAULT_MAX_HEIGHT } = props;
24
- const host = useMcpUiHost(props);
31
+ const wrapperRef = useRef(null);
32
+ const hostTokens = useHostSurfaceTokens(wrapperRef);
33
+ const html = hostTokens === undefined ? props.html : applyHostSurfaceTokens(props.html, hostTokens);
34
+ // Only a document the theme actually changed takes the theme into its key, so a third-party View
35
+ // (left untouched) never remounts when the host's theme moves.
36
+ const sessionKey = `${String(props.sessionKey ?? props.html)}${html === props.html ? '' : JSON.stringify(hostTokens)}`;
37
+ const host = useMcpUiHost({ ...props, html, sessionKey });
25
38
  const reported = host.size?.height;
26
39
  const height = autoResize && reported !== undefined ? Math.min(Math.max(reported, 1), maxHeight) : initialHeight;
27
- return (_jsx("div", { className: className, "data-mcpui-host": "", "data-mcpui-state": host.state, "aria-label": title, style: { display: 'block', width: '100%', height }, children: _jsx(AppRenderer
40
+ return (_jsx("div", { ref: wrapperRef, className: className, "data-mcpui-host": "", "data-mcpui-state": host.state, "aria-label": title, style: { display: 'block', width: '100%', height }, children: hostTokens !== undefined && _jsx(AppRenderer
28
41
  // Keyed by the session so replacing the document remounts `AppRenderer` — a fresh
29
42
  // `AppBridge`, a fresh sandbox iframe, a fresh handshake. `AppRenderer` itself provides no
30
43
  // "replace the document in place" mode; remount via `key` is the supported way to start a
31
44
  // new session, per `@mcp-ui/client`'s own docs.
32
- , { ref: host.rendererRef, ...host.rendererProps }, String(props.sessionKey ?? props.html)) }));
45
+ , { ref: host.rendererRef, ...host.rendererProps }, sessionKey) }));
33
46
  }
34
47
  //# sourceMappingURL=McpUiHost.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @module react/mcp-ui/useHostSurfaceTokens
3
+ *
4
+ * The live half of `features/mcp-ui/surfaces/host-theme.ts`: reads the host's resolved `--jini-*`
5
+ * theme where a surface is mounted, and reads it again whenever the theme can have changed, so an
6
+ * accent picked in a host's appearance settings reaches the cards already on screen.
7
+ */
8
+ import { type RefObject } from 'react';
9
+ import { type HostSurfaceTokens } from '../../features/mcp-ui/surfaces/host-theme.js';
10
+ /**
11
+ * @param ref - The element the surface is mounted in; its computed style is where the host theme is read.
12
+ * @returns The host tokens, or `undefined` until the first read — a caller holds the surface back
13
+ * until then, so a themed card never mounts first in the default palette and then again in the host's.
14
+ */
15
+ export declare function useHostSurfaceTokens(ref: RefObject<HTMLElement | null>): HostSurfaceTokens | undefined;
16
+ //# sourceMappingURL=useHostSurfaceTokens.d.ts.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module react/mcp-ui/useHostSurfaceTokens
3
+ *
4
+ * The live half of `features/mcp-ui/surfaces/host-theme.ts`: reads the host's resolved `--jini-*`
5
+ * theme where a surface is mounted, and reads it again whenever the theme can have changed, so an
6
+ * accent picked in a host's appearance settings reaches the cards already on screen.
7
+ */
8
+ import { useLayoutEffect, useState } from 'react';
9
+ import { readHostSurfaceTokens } from '../../features/mcp-ui/surfaces/host-theme.js';
10
+ /**
11
+ * The attributes a host switches a theme with: an inline `--jini-theme-*` input (`style`), a theme
12
+ * class, or one of the scheme attributes `variables.css` keys on. Watched on every ancestor, not the
13
+ * whole subtree — a streaming transcript changes attributes constantly, and none of those can move
14
+ * a value this card inherits.
15
+ */
16
+ const THEME_ATTRIBUTES = ['style', 'class', 'data-theme', 'data-color-scheme', 'data-admin-theme'];
17
+ /**
18
+ * @param ref - The element the surface is mounted in; its computed style is where the host theme is read.
19
+ * @returns The host tokens, or `undefined` until the first read — a caller holds the surface back
20
+ * until then, so a themed card never mounts first in the default palette and then again in the host's.
21
+ */
22
+ export function useHostSurfaceTokens(ref) {
23
+ const [tokens, setTokens] = useState();
24
+ useLayoutEffect(() => {
25
+ const element = ref.current;
26
+ const view = element?.ownerDocument.defaultView;
27
+ if (element === null || element === undefined || view === null || view === undefined) {
28
+ setTokens({});
29
+ return;
30
+ }
31
+ let last;
32
+ const read = () => {
33
+ const next = readHostSurfaceTokens(view.getComputedStyle(element));
34
+ const key = JSON.stringify(next);
35
+ if (key === last)
36
+ return;
37
+ last = key;
38
+ setTokens(next);
39
+ };
40
+ read();
41
+ const observer = new view.MutationObserver(read);
42
+ for (let node = element.parentElement; node !== null; node = node.parentElement) {
43
+ observer.observe(node, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
44
+ }
45
+ return () => observer.disconnect();
46
+ }, [ref]);
47
+ return tokens;
48
+ }
49
+ //# sourceMappingURL=useHostSurfaceTokens.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/ui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Generic, product-neutral React UI primitives and feature domains. Framework-free types, decision rules, catalogs, and host port contracts live behind ./core (no React, no DOM). The Excalidraw and Lexical editors live behind ./sketch-editor and ./lexical-rich-text-editor; the GrapesJS-backed inline HTML editor lives behind ./html-editor.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -15,6 +15,7 @@
15
15
  "entries": {
16
16
  ".": "browser",
17
17
  "./core": "universal",
18
+ "./i18n": "universal",
18
19
  "./sketch-editor": "browser",
19
20
  "./lexical-rich-text-editor": "browser",
20
21
  "./html-editor": "browser",
@@ -47,6 +48,9 @@
47
48
  "./fetch-query/tanstack": "panel-kit"
48
49
  },
49
50
  "shared": [
51
+ "src/features/charts",
52
+ "src/features/interactive-ui/registry.ts",
53
+ "src/features/interactive-ui/table-data.ts",
50
54
  "src/theme/default.ts",
51
55
  "src/theme/stylesheet.ts",
52
56
  "src/theme/validation.ts",
@@ -135,6 +139,11 @@
135
139
  "import": "./dist/features/admin-widgets/index.js",
136
140
  "default": "./dist/features/admin-widgets/index.js"
137
141
  },
142
+ "./i18n": {
143
+ "types": "./dist/features/i18n/universal.d.ts",
144
+ "import": "./dist/features/i18n/universal.js",
145
+ "default": "./dist/features/i18n/universal.js"
146
+ },
138
147
  "./panel-kit": {
139
148
  "types": "./dist/features/panel-kit/index.d.ts",
140
149
  "import": "./dist/features/panel-kit/index.js",
@@ -227,13 +236,13 @@
227
236
  "shiki": "^1.24.4",
228
237
  "tailwind-merge": "^2.6.0",
229
238
  "zod": "^3.25.76",
230
- "@jini-ai/integrations": "^0.4.2",
239
+ "@jini-ai/core": "^0.4.5",
231
240
  "@jini-ai/platform": "^0.5.6",
232
- "@jini-ai/agentic": "^0.4.4",
233
- "@jini-ai/core": "^0.4.4"
241
+ "@jini-ai/integrations": "^0.4.2",
242
+ "@jini-ai/agentic": "^0.4.4"
234
243
  },
235
244
  "dependencies": {
236
- "@jini-ai/core": "^0.4.4"
245
+ "@jini-ai/core": "^0.4.5"
237
246
  },
238
247
  "peerDependencies": {
239
248
  "@jini-ai/integrations": "^0.4.2",