@jini-ai/ui 0.6.12 → 0.6.15-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +14 -0
  3. package/dist/features/admin-widgets/components/InfoTip.d.ts +3 -2
  4. package/dist/features/admin-widgets/components/InfoTip.hooks.d.ts +17 -2
  5. package/dist/features/admin-widgets/components/InfoTip.hooks.js +64 -14
  6. package/dist/features/admin-widgets/components/InfoTip.js +2 -2
  7. package/dist/features/admin-widgets/index.d.ts +1 -1
  8. package/dist/features/admin-widgets/index.js +1 -1
  9. package/dist/features/execution/react/components/AgentCliEnvFields.js +27 -8
  10. package/dist/features/execution/react/components/ByokProviderForm.js +16 -19
  11. package/dist/features/mcp-ui/surfaces/document.d.ts +1 -1
  12. package/dist/features/mcp-ui/surfaces/document.js +28 -0
  13. package/dist/features/mcp-ui/surfaces/fields.d.ts +28 -1
  14. package/dist/features/mcp-ui/surfaces/fields.js +5 -1
  15. package/dist/features/mcp-ui/surfaces/form.js +86 -16
  16. package/dist/features/mcp-ui/surfaces/text-input.d.ts +3 -3
  17. package/dist/features/mcp-ui/surfaces/text-input.js +26 -11
  18. package/dist/features/media-providers/catalog.d.ts +3 -0
  19. package/dist/features/media-providers/catalog.js +3 -0
  20. package/dist/features/media-providers/react/components/MediaProviderCard.js +5 -4
  21. package/dist/features/media-providers/react/components/media-providers-tab-labels.js +2 -1
  22. package/dist/features/panel-kit/fetch-query/adapter.react.d.ts +10 -5
  23. package/dist/features/panel-kit/fetch-query/adapter.react.js +9 -3
  24. package/dist/features/panel-kit/fetch-query/binding.react.d.ts +4 -0
  25. package/dist/features/panel-kit/fetch-query/binding.react.js +29 -10
  26. package/dist/features/panel-kit/fetch-query/index.d.ts +1 -1
  27. package/dist/features/panel-kit/fetch-query/index.js +1 -1
  28. package/dist/features/panel-kit/fetch-query/tanstack.d.ts +1 -1
  29. package/dist/features/panel-kit/fetch-query/tanstack.js +1 -1
  30. package/dist/features/privacy/react/components/PrivacyTab.d.ts +1 -3
  31. package/dist/features/privacy/react/components/PrivacyTab.js +5 -8
  32. package/dist/features/settings/dialog/styles/settings-dialog.css +5 -70
  33. package/dist/features/source-config-list/react/components/SourceConfigField.js +27 -26
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.js +1 -0
  36. package/dist/react/components/SecretInput.d.ts +47 -0
  37. package/dist/react/components/SecretInput.hooks.d.ts +26 -0
  38. package/dist/react/components/SecretInput.hooks.js +25 -0
  39. package/dist/react/components/SecretInput.js +30 -0
  40. package/dist/styles/lightbox.css +1 -1
  41. package/dist/styles/variables.css +1 -1
  42. package/package.json +9 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @jini-ai/ui
2
2
 
3
+ ## 0.6.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
8
+ shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
9
+ (guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
10
+ `@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
11
+ filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
12
+ breaks are listed in `owner-store-to-server` and `guard-host-ports`.
13
+ - 0a2b903: Share accessible eye toggles across React credential fields, including multiline question answers,
14
+ CLI overrides, provider keys, login, new users, password resets, and source configuration. Preserve
15
+ autofill protection, translated toggle names, agent handles, and secret-answer redaction. Hidden
16
+ source environment blocks keep names visible and stored values masked until explicitly revealed.
17
+
18
+ Chat and user-management require `@jini-ai/ui ^0.5.7` for the new `SecretInput` and `SecretTextarea`
19
+ exports. Release UI 0.5.7 (or adjust those peer floors to the actual release containing these exports)
20
+ with these consumers; older UI versions cannot satisfy their imports. Hosts supply `.jini-secret`,
21
+ `.jini-secret-toggle`, and `.jini-secret--multiline` visual rules.
22
+
23
+ - 01de8c4: Allow the built-in fetch-query provider to accept core clock and scheduler ports while retaining existing timing defaults and provider-scoped cleanup.
24
+ - Updated dependencies [7479159]
25
+ - Updated dependencies [c31264d]
26
+ - Updated dependencies [0728914]
27
+ - Updated dependencies [6ecbdb2]
28
+ - Updated dependencies [63bacc5]
29
+ - @jini-ai/core@0.6.13
30
+
3
31
  ## 0.6.12
4
32
 
5
33
  ### Patch Changes
package/README.md CHANGED
@@ -478,6 +478,20 @@ Fetch-query caches accept the shared `Clock` from `@jini-ai/core/primitives` thr
478
478
  by default and idle retention five minutes. Browser HTTP defaults now use injected
479
479
  native fetch ports and have no platform imports.
480
480
 
481
+ The built-in `@jini-ai/ui/fetch-query` provider accepts those same optional ports:
482
+
483
+ ```tsx
484
+ <FetchQueryProvider environment={environment} clock={clock} scheduler={scheduler}>
485
+ <Records />
486
+ </FetchQueryProvider>
487
+ ```
488
+
489
+ `clock.nowMs()` controls cache timestamps and freshness. The scheduler's
490
+ `schedule({ callback, delayMs })` returns a handle; `cancel({ handle })` releases it.
491
+ Omitted ports retain real-clock/native-timer defaults. Keep the injected objects stable:
492
+ replacing `environment`, `clock` or `scheduler` recreates the cache and disposes the previous
493
+ scope's timers and subscriptions. Timing injection applies only to the built-in provider.
494
+
481
495
  The A2UI interpreter uses that same core `Clock` with `nowMs()`; import the clock type
482
496
  directly from `@jini-ai/core/primitives`. The obsolete `A2uiClockPort` re-export is removed.
483
497
  Create the basic catalog with `createLabCatalog({})` and inject it through
@@ -10,8 +10,9 @@ import { useInfoTip } from "./InfoTip.hooks.js";
10
10
  * preference, with the hook's headroom guard choosing below near the viewport's top; near a side
11
11
  * edge (or a host panel marked `data-jini-tip-bounds`) it shifts by its real width, never squeezes.
12
12
  * Keep state and DOM measurement in the injectable hook so rendering can use fixed fake state.
13
- * Touch has no hover, so a tap (or Enter/Space) pins the tip open until a second tap, an outside
14
- * tap, blur or Escape; a hover-opened tip still closes when the pointer leaves.
13
+ * Touch has no hover, so a tap pins the tip open until a second tap, an outside tap, blur, Escape
14
+ * or a 2.5 s timeout; Enter/Space pin it without the timeout. A hover- or mouse-click-opened tip
15
+ * closes when the pointer leaves, and only one tip is open at a time.
15
16
  */
16
17
  export interface InfoTipProps {
17
18
  /** The explanation the bubble shows; newlines separate lines, and "• " lines show as a hanging list. */
@@ -3,6 +3,16 @@ import { type KeyboardEvent } from "react";
3
3
  * measurement, so another async dependency/port layer would provide no rejection seam to test. */
4
4
  /** Hosts mark a panel (e.g. an assistant dock) with this attribute to keep tips inside it. */
5
5
  export declare const TIP_BOUNDS_ATTRIBUTE = "data-jini-tip-bounds";
6
+ /** How long a tap-opened tip stays up on its own; touch has no pointer-leave to close it. */
7
+ export declare const TAP_TIP_MS = 2500;
8
+ /**
9
+ * Makes `close` the one open tip, closing whichever tip held that slot before. Hosts with their own
10
+ * tip state (e.g. a dock-header tip) can call it too, to join the same one-open rule.
11
+ * @param close Closes the claiming tip; identity must stay stable while it is open.
12
+ * @returns A release that empties the slot only if `close` still holds it.
13
+ * @complexity O(1).
14
+ */
15
+ export declare function claimOpenTip(close: () => void): () => void;
6
16
  export interface InfoTipController {
7
17
  open: boolean;
8
18
  placement: "above" | "below";
@@ -21,9 +31,14 @@ export interface InfoTipController {
21
31
  hide: () => void;
22
32
  /** Pointer left the icon: closes only a hover-opened tip, never one the operator tapped open. */
23
33
  leave: () => void;
24
- /** Tap/click/Enter/Space: pins an open tip so it survives the pointer leaving (touch has no
25
- * hover to keep it up), or closes a pinned one. Hover alone never pins. */
34
+ /** Tap/Enter/Space: pins an open tip so it survives the pointer leaving (touch has no hover to
35
+ * keep it up), or closes a pinned one. Hover and mouse clicks never pin. */
26
36
  toggle: () => void;
37
+ /** Records the pointer type of the press a following click comes from; optional for injected
38
+ * controllers. */
39
+ pointerDown?: (e: {
40
+ pointerType: string;
41
+ }) => void;
27
42
  /** Escape closes in place; Enter/Space toggle like a button; blur covers Tab/Shift+Tab. */
28
43
  handleIconKeyDown: (e: KeyboardEvent<HTMLSpanElement>) => void;
29
44
  }
@@ -5,6 +5,27 @@ import { placeInfoTipBubble } from "./InfoTip.rules.js";
5
5
  * measurement, so another async dependency/port layer would provide no rejection seam to test. */
6
6
  /** Hosts mark a panel (e.g. an assistant dock) with this attribute to keep tips inside it. */
7
7
  export const TIP_BOUNDS_ATTRIBUTE = "data-jini-tip-bounds";
8
+ /** How long a tap-opened tip stays up on its own; touch has no pointer-leave to close it. */
9
+ export const TAP_TIP_MS = 2500;
10
+ /** The close of the one tip open app-wide. A pinned or focused tip used to stay up beside the one
11
+ * the pointer had just reached (owner, 2026-10-25: two "Soon" tips open at once). */
12
+ let closeOpenTip = null;
13
+ /**
14
+ * Makes `close` the one open tip, closing whichever tip held that slot before. Hosts with their own
15
+ * tip state (e.g. a dock-header tip) can call it too, to join the same one-open rule.
16
+ * @param close Closes the claiming tip; identity must stay stable while it is open.
17
+ * @returns A release that empties the slot only if `close` still holds it.
18
+ * @complexity O(1).
19
+ */
20
+ export function claimOpenTip(close) {
21
+ if (closeOpenTip && closeOpenTip !== close)
22
+ closeOpenTip();
23
+ closeOpenTip = close;
24
+ return () => {
25
+ if (closeOpenTip === close)
26
+ closeOpenTip = null;
27
+ };
28
+ }
8
29
  /** Prefer a host's placement when the measured bubble fits; default preserves automatic flipping.
9
30
  * @complexity Time/space: O(1), with one icon and one bubble measurement.
10
31
  */
@@ -12,9 +33,16 @@ export function useInfoTip(_input = {}, { placement: preferredPlacement } = {})
12
33
  const [open, setOpen] = useState(false);
13
34
  const [placement, setPlacement] = useState("above");
14
35
  const [coords, setCoords] = useState({ top: 0, left: 0 });
36
+ // "tap" pins auto-hide; "key" (Enter/Space) pins stay until blur or Escape.
15
37
  const [pinned, setPinned] = useState(false);
16
38
  const iconRef = useRef(null);
17
39
  const bubbleRef = useRef(null);
40
+ const pointerTypeRef = useRef(null);
41
+ // Stable identity for the one-open-tip slot; state setters never change.
42
+ const hide = useRef(() => {
43
+ setOpen(false);
44
+ setPinned(false);
45
+ }).current;
18
46
  // Placement math lives in `placeInfoTipBubble`; this only gathers the measurements. Before the
19
47
  // portal exists the bubble is unmeasured and the rules assume the worst-case width.
20
48
  const place = () => {
@@ -43,36 +71,58 @@ export function useInfoTip(_input = {}, { placement: preferredPlacement } = {})
43
71
  if (open)
44
72
  place();
45
73
  }, [open, preferredPlacement]);
46
- const hide = () => {
47
- setOpen(false);
48
- setPinned(false);
49
- };
50
74
  const leave = () => {
51
75
  if (!pinned)
52
76
  setOpen(false);
53
77
  };
78
+ const pointerDown = (e) => {
79
+ pointerTypeRef.current = e.pointerType;
80
+ };
54
81
  const toggle = () => {
82
+ const via = pointerTypeRef.current;
83
+ pointerTypeRef.current = null;
55
84
  if (open && pinned) {
56
85
  hide();
57
86
  return;
58
87
  }
59
88
  show();
60
- setPinned(true);
89
+ // A mouse already has hover to hold the tip up; pinning on a mouse click is what left tips
90
+ // stuck open after the pointer moved on. No pointer press means Enter/Space.
91
+ if (via !== "mouse")
92
+ setPinned(via ? "tap" : "key");
61
93
  };
62
- // A tapped-open tip stays up until the operator taps elsewhere; the bubble itself is
63
- // pointer-events:none, so "outside the icon" is every other target.
64
- useDismissOnOutsideOrEscape({ onDismiss: hide }, { enabled: pinned, containerRef: iconRef });
65
- // The bubble is position:fixed at measured viewport coordinates, so a scroll while it is open
66
- // would leave it floating over the wrong content. Re-measure instead of closing: a pinned tip
67
- // the operator is reading should follow its icon.
68
94
  useEffect(() => {
69
95
  if (!open)
70
96
  return;
97
+ return claimOpenTip(hide);
98
+ }, [open]);
99
+ // A tapped-open tip stays up until the operator taps elsewhere or it times out; the bubble
100
+ // itself is pointer-events:none, so "outside the icon" is every other target.
101
+ useDismissOnOutsideOrEscape({ onDismiss: hide }, { enabled: pinned !== false, containerRef: iconRef });
102
+ useEffect(() => {
103
+ if (pinned !== "tap")
104
+ return;
105
+ const timer = setTimeout(hide, TAP_TIP_MS);
106
+ return () => clearTimeout(timer);
107
+ }, [pinned]);
108
+ // The bubble is position:fixed at measured viewport coordinates, so a scroll that moves the icon
109
+ // would leave it floating over the wrong content, and the pointer has usually left the icon
110
+ // without a mouseleave firing yet: close it. A scroll in an unrelated pane (a streaming chat)
111
+ // leaves it be. A resize re-measures instead, so it keeps following its icon.
112
+ useEffect(() => {
113
+ if (!open)
114
+ return;
115
+ const closeIfMoved = (e) => {
116
+ const icon = iconRef.current;
117
+ if (icon && e.target instanceof Node && !e.target.contains(icon))
118
+ return;
119
+ hide();
120
+ };
71
121
  const follow = () => show();
72
- window.addEventListener("scroll", follow, true);
122
+ window.addEventListener("scroll", closeIfMoved, true);
73
123
  window.addEventListener("resize", follow);
74
124
  return () => {
75
- window.removeEventListener("scroll", follow, true);
125
+ window.removeEventListener("scroll", closeIfMoved, true);
76
126
  window.removeEventListener("resize", follow);
77
127
  };
78
128
  }, [open]);
@@ -89,6 +139,6 @@ export function useInfoTip(_input = {}, { placement: preferredPlacement } = {})
89
139
  toggle();
90
140
  }
91
141
  }
92
- return { open, placement, coords, iconRef, bubbleRef, show, hide, leave, toggle, handleIconKeyDown };
142
+ return { open, placement, coords, iconRef, bubbleRef, show, hide, leave, toggle, pointerDown, handleIconKeyDown };
93
143
  }
94
144
  //# sourceMappingURL=InfoTip.hooks.js.map
@@ -7,10 +7,10 @@ import { infoTipBubbleStyle, infoTipLines } from "./InfoTip.rules.js";
7
7
  /** Focus/hover tooltip that portals outside clipping ancestors; Escape dismisses in place. */
8
8
  export function InfoTip({ label, triggerLabel, descriptionId, placement: preferredPlacement, useTip = useInfoTip, agentHandle: handle, icon }) {
9
9
  const inline = preferredPlacement === "inline";
10
- const { open, placement, coords, iconRef, bubbleRef, show, hide, leave, toggle, handleIconKeyDown } = useTip({}, { placement: inline ? undefined : preferredPlacement });
10
+ const { open, placement, coords, iconRef, bubbleRef, show, hide, leave, toggle, pointerDown, handleIconKeyDown } = useTip({}, { placement: inline ? undefined : preferredPlacement });
11
11
  const autoId = useId();
12
12
  const { name, textId, describedBy } = infoTipNaming({ label, autoId }, { triggerLabel, descriptionId });
13
- return (_jsxs("span", { className: inline ? "info-tip info-tip--inline" : "info-tip", children: [_jsx("span", { ref: iconRef, className: icon ? "info-tip-icon info-tip-icon--custom" : "info-tip-icon", role: "button", tabIndex: 0, "aria-label": name, "aria-expanded": open, ...(describedBy ? { "aria-describedby": describedBy } : {}), onMouseEnter: show, onMouseLeave: leave, onFocus: show, onBlur: hide, onClick: toggle, onKeyDown: handleIconKeyDown, ...(handle ? agentHandle({ handle }, { role: "button", label: name }) : {}), children: icon ?? "ⓘ" }), textId ? _jsx("span", { id: textId, hidden: true, children: label }) : null, open ? _jsx(InfoTipOpenCopy, { label: label, inline: inline, placement: placement, coords: coords, iconRef: iconRef, bubbleRef: bubbleRef }) : null] }));
13
+ return (_jsxs("span", { className: inline ? "info-tip info-tip--inline" : "info-tip", children: [_jsx("span", { ref: iconRef, className: icon ? "info-tip-icon info-tip-icon--custom" : "info-tip-icon", role: "button", tabIndex: 0, "aria-label": name, "aria-expanded": open, ...(describedBy ? { "aria-describedby": describedBy } : {}), onMouseEnter: show, onMouseLeave: leave, onFocus: show, onBlur: hide, onPointerDown: pointerDown, onClick: toggle, onKeyDown: handleIconKeyDown, ...(handle ? agentHandle({ handle }, { role: "button", label: name }) : {}), children: icon ?? "ⓘ" }), textId ? _jsx("span", { id: textId, hidden: true, children: label }) : null, open ? _jsx(InfoTipOpenCopy, { label: label, inline: inline, placement: placement, coords: coords, iconRef: iconRef, bubbleRef: bubbleRef }) : null] }));
14
14
  }
15
15
  /**
16
16
  * The icon's accessible name and the id of the hidden copy of `label` (if any).
@@ -6,7 +6,7 @@ export { InfoTip } from './components/InfoTip.js';
6
6
  export type { InfoTipProps } from './components/InfoTip.js';
7
7
  export { useInfoTip } from './components/InfoTip.hooks.js';
8
8
  export type { InfoTipController } from './components/InfoTip.hooks.js';
9
- export { TIP_BOUNDS_ATTRIBUTE } from './components/InfoTip.hooks.js';
9
+ export { TIP_BOUNDS_ATTRIBUTE, TAP_TIP_MS, claimOpenTip } from './components/InfoTip.hooks.js';
10
10
  export { placeInfoTipBubble, infoTipBubbleStyle } from './components/InfoTip.rules.js';
11
11
  export type { InfoTipPlacement } from './components/InfoTip.rules.js';
12
12
  export { SeeMore, resolveSeeMoreView } from './components/SeeMore/SeeMore.js';
@@ -2,7 +2,7 @@ export { Select, resolveSelectTriggerLabel } from './components/Select/Select.js
2
2
  export { useSelectDropdown, usePanelPosition } from './components/Select/Select.hooks.js';
3
3
  export { InfoTip } from './components/InfoTip.js';
4
4
  export { useInfoTip } from './components/InfoTip.hooks.js';
5
- export { TIP_BOUNDS_ATTRIBUTE } from './components/InfoTip.hooks.js';
5
+ export { TIP_BOUNDS_ATTRIBUTE, TAP_TIP_MS, claimOpenTip } from './components/InfoTip.hooks.js';
6
6
  export { placeInfoTipBubble, infoTipBubbleStyle } from './components/InfoTip.rules.js';
7
7
  export { SeeMore, resolveSeeMoreView } from './components/SeeMore/SeeMore.js';
8
8
  export { useSeeMoreClamp } from './components/SeeMore/SeeMore.hooks.js';
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
2
3
  import { useT } from '../../../i18n/index.js';
4
+ import { SecretInput } from '../../../../react/components/SecretInput.js';
3
5
  import { agentCliEnvValue, cliEnvFieldsForAgent } from '../../rules.js';
4
6
  /**
5
7
  * Per-agent CLI environment overrides — proxy URLs, custom config
@@ -17,16 +19,33 @@ import { agentCliEnvValue, cliEnvFieldsForAgent } from '../../rules.js';
17
19
  */
18
20
  export function AgentCliEnvFields({ agentId, fields, config, onChange }) {
19
21
  const t = useT();
22
+ const idBase = useId();
20
23
  const relevant = cliEnvFieldsForAgent(fields, agentId);
21
24
  if (relevant.length === 0)
22
25
  return null;
23
- return (_jsxs("details", { className: "jini-agent-cli-env", "data-testid": `jini-agent-cli-env-${agentId}`, children: [_jsx("summary", { className: "jini-agent-cli-env-summary", children: t('Advanced: proxy & custom paths') }), _jsxs("div", { className: "jini-agent-cli-env-body", children: [_jsx("p", { className: "jini-field-hint", children: t("Environment variables passed to this CLI when it runs.") }), _jsx("div", { className: "jini-agent-cli-env-grid", children: relevant.map((field) => (_jsxs("label", { className: "jini-field", children: [_jsx("span", { className: "jini-field-label", children: field.label }), _jsx("input", { className: "jini-input", type: field.secret ? 'password' : 'text', value: agentCliEnvValue(config, agentId, field.envKey), placeholder: field.placeholder, spellCheck: false,
24
- /* Per-field, because this input is both kinds at once. A `secret` field renders
25
- `type="password"`, and Chrome deliberately ignores `autoComplete="off"` on
26
- credential-shaped fields — the same defect fixed on `ByokProviderForm`'s API-key
27
- input; see its comment for the full reasoning. A NON-secret field (a proxy URL, a
28
- config path) keeps `off`: `new-password` there would invite Chrome to offer to
29
- GENERATE a password for a field that holds a file path. */
30
- autoComplete: field.secret ? 'new-password' : 'off', "data-testid": `jini-agent-cli-env-${agentId}-${field.envKey}`, onChange: (event) => onChange(agentId, field.envKey, event.target.value) })] }, `${field.agentId}:${field.envKey}`))) })] })] }));
26
+ return (_jsxs("details", { className: "jini-agent-cli-env", "data-testid": `jini-agent-cli-env-${agentId}`, children: [_jsx("summary", { className: "jini-agent-cli-env-summary", children: t('Advanced: proxy & custom paths') }), _jsxs("div", { className: "jini-agent-cli-env-body", children: [_jsx("p", { className: "jini-field-hint", children: t("Environment variables passed to this CLI when it runs.") }), _jsx("div", { className: "jini-agent-cli-env-grid", children: relevant.map((field) => {
27
+ const id = `${idBase}-${field.envKey}`;
28
+ const input = {
29
+ id,
30
+ className: 'jini-input',
31
+ value: agentCliEnvValue(config, agentId, field.envKey),
32
+ placeholder: field.placeholder,
33
+ spellCheck: false,
34
+ 'data-testid': `jini-agent-cli-env-${agentId}-${field.envKey}`,
35
+ onChange: (event) => onChange(agentId, field.envKey, event.target.value),
36
+ };
37
+ /* Per-field, because this input is both kinds at once. A `secret` field is a password
38
+ input, and Chrome deliberately ignores `autoComplete="off"` on credential-shaped
39
+ fields — the same defect fixed on `ByokProviderForm`'s API-key input; see its comment
40
+ for the full reasoning. A NON-secret field (a proxy URL, a config path) keeps `off`:
41
+ `new-password` there would invite Chrome to offer to GENERATE a password for a field
42
+ that holds a file path. */
43
+ return (
44
+ // A `<div>` + `<label htmlFor>`: the secret field's eye toggle must stay out of a
45
+ // wrapping label, or its "Show …" name would join the input's own.
46
+ _jsxs("div", { className: "jini-field", children: [_jsx("label", { className: "jini-field-label", htmlFor: id, children: field.label }), field.secret
47
+ ? _jsx(SecretInput, { ...input, label: field.label, autoComplete: "new-password" })
48
+ : _jsx("input", { ...input, type: "text", autoComplete: "off" })] }, `${field.agentId}:${field.envKey}`));
49
+ }) })] })] }));
31
50
  }
32
51
  //# sourceMappingURL=AgentCliEnvFields.js.map
@@ -5,6 +5,7 @@ import { useT } from '../../../i18n/index.js';
5
5
  import { CUSTOM_MODEL_SENTINEL } from '../../constants.js';
6
6
  import { apiKeyFormatWarning, parseMaxTokens, presetRequiresApiKey, shouldShowCustomModelInput, showsBaseUrlField, } from '../../rules.js';
7
7
  import { SearchableModelSelect } from './SearchableModelSelect.js';
8
+ import { SecretInput } from '../../../../react/components/SecretInput.js';
8
9
  import { useByokFieldValidation } from '../hooks/use-byok-field-validation.hooks.js';
9
10
  /** `' is-missing'` when `field` is a required field the operator still has to fill. */
10
11
  function missingClass(validation, field) {
@@ -56,31 +57,27 @@ function ByokConnectionFields({ config, patch, preset, agentHandle, validation }
56
57
  function ByokApiKeyField({ config, patch, preset, agentHandle, validation, presets, revealKey, onToggleReveal, apiKeyFooter, apiKeyPlaceholder, apiKeyActions, }) {
57
58
  const t = useT();
58
59
  const keyInputId = useId();
59
- const KeyField = apiKeyActions ? 'div' : 'label';
60
60
  /** Deliberately NOT folded into `missing` above: that set drives `is-missing` styling and the
61
61
  * Test-connection disable, and a wrong-SHAPED key must never reach either. */
62
62
  const keyFormatWarning = apiKeyFormatWarning(config, preset, presets);
63
- return (_jsxs(_Fragment, { children: [_jsxs(KeyField, { className: "jini-field", onBlurCapture: () => validation.markTouched({ field: 'apiKey' }), children: [_jsxs("span", { className: "jini-field-label", children: [_jsx(ByokApiKeyLabel, { keyInputId: apiKeyActions ? keyInputId : undefined }), preset?.apiKeyConsoleUrl ? (_jsx("a", { className: "jini-field-link", href: preset.apiKeyConsoleUrl, target: "_blank", rel: "noreferrer noopener", children: t('Get key') })) : null] }), _jsxs("span", { className: "jini-field-input-row", children: [_jsx("input", { ...(apiKeyActions ? { id: keyInputId } : {}), className: 'jini-input' + missingClass(validation, 'apiKey'), type: revealKey ? 'text' : 'password',
64
- /* `new-password`, NOT `"off"` — Chrome deliberately ignores `off` on credential-shaped
65
- fields (a long-standing intentional decision, not a bug). `off` here is what let the
66
- reported autofill through: Chrome filled this field with the admin's own saved
67
- PASSWORD, the form sent it, and the provider answered with its own "API key not
68
- valid" — a true message about a string the operator never typed. `new-password` is
69
- the value Chrome/Safari/Firefox actually honor for "this is not a saved-login
70
- field", which suppresses both the credential dropdown and the silent fill.
71
-
72
- Known trade-off, not fixed here: Chrome may now offer to GENERATE a password on
73
- this field — a suggestion popup, not a silently wrong value. */
74
- autoComplete: "new-password", spellCheck: false, placeholder: apiKeyPlaceholder, value: config.apiKey, onChange: (event) => patch({ apiKey: event.target.value }), ...agentHandleProps({}, { base: agentHandle, ...({ action: 'api-key', role: 'field', label: t('API key') }) }) }), _jsx("button", { type: "button", className: "jini-input-affix-btn", "aria-pressed": revealKey, onClick: onToggleReveal, ...agentHandleProps({}, { base: agentHandle, ...({ action: 'reveal-key', role: 'button', label: t('Reveal API key') }) }), children: revealKey ? t('Hide') : t('Show') })] }), apiKeyActions ? _jsx("span", { className: "jini-byok-key-actions", children: apiKeyActions }) : null, _jsx("span", { className: "jini-field-hint", children: t('Stored only by this host.') }), keyFormatWarning ? (_jsx("span", { className: "jini-field-hint is-error", role: "status", children: t(keyFormatWarning.message, keyFormatWarning.vars) })) : null] }), _jsx(ByokSlot, { className: "jini-byok-key-footer", children: apiKeyFooter })] }));
63
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "jini-field", onBlurCapture: () => validation.markTouched({ field: 'apiKey' }), children: [_jsxs("span", { className: "jini-field-label", children: [_jsx(ByokApiKeyLabel, { keyInputId: keyInputId }), preset?.apiKeyConsoleUrl ? (_jsx("a", { className: "jini-field-link", href: preset.apiKeyConsoleUrl, target: "_blank", rel: "noreferrer noopener", children: t('Get key') })) : null] }), _jsx(SecretInput, { id: keyInputId, label: t('API key'), revealed: revealKey, onRevealedChange: onToggleReveal, className: 'jini-input' + missingClass(validation, 'apiKey'),
64
+ /* `new-password`, NOT `"off"` — Chrome deliberately ignores `off` on credential-shaped
65
+ fields (a long-standing intentional decision, not a bug). `off` here is what let the
66
+ reported autofill through: Chrome filled this field with the admin's own saved
67
+ PASSWORD, the form sent it, and the provider answered with its own "API key not
68
+ valid" — a true message about a string the operator never typed. `new-password` is
69
+ the value Chrome/Safari/Firefox actually honor for "this is not a saved-login
70
+ field", which suppresses both the credential dropdown and the silent fill.
71
+
72
+ Known trade-off, not fixed here: Chrome may now offer to GENERATE a password on
73
+ this field — a suggestion popup, not a silently wrong value. */
74
+ autoComplete: "new-password", spellCheck: false, placeholder: apiKeyPlaceholder, value: config.apiKey, onChange: (event) => patch({ apiKey: event.target.value }), toggleAttrs: agentHandleProps({}, { base: agentHandle, ...({ action: 'reveal-key', role: 'button', label: t('Reveal API key') }) }), ...agentHandleProps({}, { base: agentHandle, ...({ action: 'api-key', role: 'field', label: t('API key') }) }) }), apiKeyActions ? _jsx("span", { className: "jini-byok-key-actions", children: apiKeyActions }) : null, _jsx("span", { className: "jini-field-hint", children: t('Stored only by this host.') }), keyFormatWarning ? (_jsx("span", { className: "jini-field-hint is-error", role: "status", children: t(keyFormatWarning.message, keyFormatWarning.vars) })) : null] }), _jsx(ByokSlot, { className: "jini-byok-key-footer", children: apiKeyFooter })] }));
75
75
  }
76
- /** "API key *". With host key actions the field is a `<div>`, so the name gets its own `<label>`
77
- * pointing at the input by id; otherwise the whole field is already the label. */
76
+ /** "API key *". The field is a `<div>` (the eye toggle must stay out of any wrapping label), so
77
+ * the name gets its own `<label>` pointing at the input by id. */
78
78
  function ByokApiKeyLabel({ keyInputId }) {
79
79
  const t = useT();
80
- if (keyInputId) {
81
- return (_jsxs("label", { className: "jini-field-label", htmlFor: keyInputId, children: [_jsx("span", { children: t('API key') }), _jsx("span", { className: "jini-field-required", "aria-hidden": "true", children: "*" })] }));
82
- }
83
- return (_jsxs(_Fragment, { children: [t('API key'), _jsx("span", { className: "jini-field-required", "aria-hidden": "true", children: "*" })] }));
80
+ return (_jsxs("label", { className: "jini-field-label", htmlFor: keyInputId, children: [_jsx("span", { children: t('API key') }), _jsx("span", { className: "jini-field-required", "aria-hidden": "true", children: "*" })] }));
84
81
  }
85
82
  /** The Model field (live picker, free-text box, or both) with "Test connection" beside it. */
86
83
  function ByokModelRow({ config, patch, preset, agentHandle, validation, modelDiscovery, explicitCustomModel, setExplicitCustomModel, connectionTest, onTestConnection, canTestConnection, }) {
@@ -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/* fit-content(40%), not max-content: one long label used to take the whole row and squeeze every\n value to one character per line. Labels wrap at 40% instead. */\n.mcpui-details {\n display: grid;\n grid-template-columns: fit-content(40%) 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; overflow-wrap: anywhere; }\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/* Frames up to 480px: stack each label above its value so the value gets the full width.\n Outcome lists keep their own row layout. */\n@media (max-width: 480px) {\n .mcpui-details:not(.mcpui-details-outcome) { grid-template-columns: minmax(0, 1fr); row-gap: 0; }\n .mcpui-details:not(.mcpui-details-outcome) dd { margin-bottom: 8px; }\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); }";
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/* fit-content(40%), not max-content: one long label used to take the whole row and squeeze every\n value to one character per line. Labels wrap at 40% instead. */\n.mcpui-details {\n display: grid;\n grid-template-columns: fit-content(40%) 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; overflow-wrap: anywhere; }\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/* Frames up to 480px: stack each label above its value so the value gets the full width.\n Outcome lists keep their own row layout. */\n@media (max-width: 480px) {\n .mcpui-details:not(.mcpui-details-outcome) { grid-template-columns: minmax(0, 1fr); row-gap: 0; }\n .mcpui-details:not(.mcpui-details-outcome) dd { margin-bottom: 8px; }\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/* A secret control's eye toggle sits inside the field's right edge. The control is padded so long\n values never run under it; the button is a 40px tap target centred on the input, transparent,\n so it overhangs the ~36px input invisibly instead of resizing the field. */\n.mcpui-secret { position: relative; display: flex; flex-direction: column; }\n.mcpui-secret > .mcpui-input, .mcpui-secret > .mcpui-textarea { padding-right: 44px; }\n.mcpui-secret-toggle {\n position: absolute;\n top: 50%;\n right: 0;\n width: 40px;\n height: 40px;\n margin-top: -20px;\n padding: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border: 0;\n border-radius: var(--jini-mcpui-radius-sm);\n background: transparent;\n color: var(--jini-mcpui-text-soft);\n cursor: pointer;\n}\n.mcpui-secret > .mcpui-textarea + .mcpui-secret-toggle { top: 0; margin-top: -2px; }\n.mcpui-secret-toggle:hover:not([disabled]) { color: var(--jini-mcpui-text-strong); }\n.mcpui-secret-toggle[disabled] { opacity: 0.55; cursor: default; }\n.mcpui-secret-toggle:focus-visible { outline: 2px solid var(--jini-mcpui-accent-ring); outline-offset: -6px; }\n.mcpui-secret-toggle[aria-pressed=\"true\"] .mcpui-secret-icon-show,\n.mcpui-secret-toggle:not([aria-pressed=\"true\"]) .mcpui-secret-icon-hide { display: none; }\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
  *
@@ -130,6 +130,34 @@ body {
130
130
  min-width: 0;
131
131
  }
132
132
  .mcpui-textarea { resize: vertical; min-height: 72px; font-family: var(--jini-mcpui-font); }
133
+ /* A secret control's eye toggle sits inside the field's right edge. The control is padded so long
134
+ values never run under it; the button is a 40px tap target centred on the input, transparent,
135
+ so it overhangs the ~36px input invisibly instead of resizing the field. */
136
+ .mcpui-secret { position: relative; display: flex; flex-direction: column; }
137
+ .mcpui-secret > .mcpui-input, .mcpui-secret > .mcpui-textarea { padding-right: 44px; }
138
+ .mcpui-secret-toggle {
139
+ position: absolute;
140
+ top: 50%;
141
+ right: 0;
142
+ width: 40px;
143
+ height: 40px;
144
+ margin-top: -20px;
145
+ padding: 0;
146
+ display: inline-flex;
147
+ align-items: center;
148
+ justify-content: center;
149
+ border: 0;
150
+ border-radius: var(--jini-mcpui-radius-sm);
151
+ background: transparent;
152
+ color: var(--jini-mcpui-text-soft);
153
+ cursor: pointer;
154
+ }
155
+ .mcpui-secret > .mcpui-textarea + .mcpui-secret-toggle { top: 0; margin-top: -2px; }
156
+ .mcpui-secret-toggle:hover:not([disabled]) { color: var(--jini-mcpui-text-strong); }
157
+ .mcpui-secret-toggle[disabled] { opacity: 0.55; cursor: default; }
158
+ .mcpui-secret-toggle:focus-visible { outline: 2px solid var(--jini-mcpui-accent-ring); outline-offset: -6px; }
159
+ .mcpui-secret-toggle[aria-pressed="true"] .mcpui-secret-icon-show,
160
+ .mcpui-secret-toggle:not([aria-pressed="true"]) .mcpui-secret-icon-hide { display: none; }
133
161
  .mcpui-checkbox { margin: 3px 0 0; accent-color: var(--jini-mcpui-accent); }
134
162
  .mcpui-choice-group { border: 0; padding: 0; margin: 0; min-width: 0; }
135
163
  .mcpui-choice { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; margin: 6px 0 0; }
@@ -7,10 +7,24 @@ interface SurfaceFieldBase {
7
7
  /** Help text, wired up with `aria-describedby`. */
8
8
  readonly hint?: string;
9
9
  readonly required?: boolean;
10
+ /** Waive on an exact choice or an alternative control; headerNames restricts entered headers. Kept secrets are validated by the host. */
11
+ readonly requiredUnless?: {
12
+ readonly field: string;
13
+ readonly equals: string;
14
+ readonly orPresent?: string;
15
+ readonly headerNames?: readonly string[];
16
+ };
17
+ /** Require only while the controlling public field matches one of these choices. */
18
+ readonly requiredWhen?: {
19
+ readonly field: string;
20
+ readonly in: readonly string[];
21
+ };
10
22
  readonly disabled?: boolean;
11
23
  }
12
24
  export interface StringField extends SurfaceFieldBase {
13
25
  readonly kind: 'string';
26
+ /** A sealed saved value satisfies validation while the requirement remains visible. */
27
+ readonly hasSavedValue?: boolean;
14
28
  readonly value?: string;
15
29
  readonly placeholder?: string;
16
30
  readonly multiline?: boolean;
@@ -73,8 +87,21 @@ export interface FieldReadSpec {
73
87
  readonly label: string;
74
88
  readonly kind: SurfaceField['kind'];
75
89
  readonly required: boolean;
90
+ readonly hasSavedValue?: boolean;
91
+ readonly requiredUnless?: {
92
+ readonly field: string;
93
+ readonly equals: string;
94
+ readonly orPresent?: string;
95
+ readonly headerNames?: readonly string[];
96
+ };
97
+ /** Require only while the controlling public field matches one of these choices. */
98
+ readonly requiredWhen?: {
99
+ readonly field: string;
100
+ readonly in: readonly string[];
101
+ };
102
+ readonly hint?: string;
76
103
  }
77
- /** Projects fields down to what the inline script needs, so the generated JS carries no labels, hints, or option lists it will never read. */
104
+ /** Projects fields to runtime validation data; conditional requirements also carry their guidance. */
78
105
  export declare function toFieldReadSpecs(fields: readonly SurfaceField[]): readonly FieldReadSpec[];
79
106
  export {};
80
107
  //# sourceMappingURL=fields.d.ts.map
@@ -67,13 +67,17 @@ export function renderFieldControl(field, text) {
67
67
  return renderChoiceGroup({ ...base(field), selection: 'multiple', options: field.options, ...present(field, ['value']) });
68
68
  }
69
69
  }
70
- /** Projects fields down to what the inline script needs, so the generated JS carries no labels, hints, or option lists it will never read. */
70
+ /** Projects fields to runtime validation data; conditional requirements also carry their guidance. */
71
71
  export function toFieldReadSpecs(fields) {
72
72
  return fields.map((field) => ({
73
73
  name: field.name,
74
74
  label: field.label,
75
75
  kind: field.kind,
76
76
  required: field.required === true,
77
+ ...(field.requiredUnless ? { requiredUnless: field.requiredUnless } : {}),
78
+ ...(field.requiredWhen ? { requiredWhen: field.requiredWhen } : {}),
79
+ ...(field.kind === 'string' && field.hasSavedValue ? { hasSavedValue: true } : {}),
80
+ ...((field.requiredUnless || field.requiredWhen) && field.hint ? { hint: field.hint } : {}),
77
81
  }));
78
82
  }
79
83
  //# sourceMappingURL=fields.js.map