@jini-ai/ui 0.5.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## 0.5.1 — 2026-10-13
4
9
 
5
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).
@@ -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.
@@ -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}`;
@@ -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()))
@@ -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.1",
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": {
@@ -48,6 +48,9 @@
48
48
  "./fetch-query/tanstack": "panel-kit"
49
49
  },
50
50
  "shared": [
51
+ "src/features/charts",
52
+ "src/features/interactive-ui/registry.ts",
53
+ "src/features/interactive-ui/table-data.ts",
51
54
  "src/theme/default.ts",
52
55
  "src/theme/stylesheet.ts",
53
56
  "src/theme/validation.ts",
@@ -233,10 +236,10 @@
233
236
  "shiki": "^1.24.4",
234
237
  "tailwind-merge": "^2.6.0",
235
238
  "zod": "^3.25.76",
236
- "@jini-ai/platform": "^0.5.6",
237
- "@jini-ai/agentic": "^0.4.4",
238
239
  "@jini-ai/core": "^0.4.5",
239
- "@jini-ai/integrations": "^0.4.2"
240
+ "@jini-ai/platform": "^0.5.6",
241
+ "@jini-ai/integrations": "^0.4.2",
242
+ "@jini-ai/agentic": "^0.4.4"
240
243
  },
241
244
  "dependencies": {
242
245
  "@jini-ai/core": "^0.4.5"