@jini-ai/ui 0.6.17 → 0.6.22

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.
@@ -25,6 +25,43 @@ export const rechartsPieChartManifest = {
25
25
  propsSchema: rechartsPieChartPropsSchema, description: 'Pie chart: named, weighted slices. Uses an enabled chart provider or a styled data table.',
26
26
  };
27
27
  export const chartWireManifests = [rechartsBarChartManifest, rechartsLineChartManifest, rechartsPieChartManifest];
28
+ /** A series point is a finite number, or null for a gap (a missing month on a line). */
29
+ function isSeriesValue(value) {
30
+ return value === null || (typeof value === 'number' && Number.isFinite(value));
31
+ }
32
+ function isCategoryValue(value) {
33
+ return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value));
34
+ }
35
+ /** Report the first bad row per key only, so a large table of strings yields one issue, not thousands. */
36
+ function checkColumn({ rows, key, accepts, expected, ctx }, _options = {}) {
37
+ const index = rows.findIndex((row) => !Object.hasOwn(row, key) || !accepts(row[key]));
38
+ if (index < 0)
39
+ return;
40
+ const problem = Object.hasOwn(rows[index], key) ? `is not ${expected}` : 'is missing';
41
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['data', index, key], message: `Row ${index} "${key}" ${problem}; every row needs it.` });
42
+ }
43
+ /** Rows are the shared x axis: every row carries every series key, so series lengths always match. */
44
+ function checkSeriesShape(spec, ctx) {
45
+ const keys = spec.series.map(({ key }) => key);
46
+ const duplicate = keys.find((key, index) => keys.indexOf(key) !== index);
47
+ if (duplicate !== undefined) {
48
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['series'], message: `Series key "${duplicate}" is listed twice.` });
49
+ }
50
+ if (spec.categoryKey !== undefined) {
51
+ checkColumn({ rows: spec.data, key: spec.categoryKey, accepts: isCategoryValue, expected: 'a string or number', ctx });
52
+ }
53
+ for (const key of new Set(keys))
54
+ checkColumn({ rows: spec.data, key, accepts: isSeriesValue, expected: 'a number or null', ctx });
55
+ }
56
+ /** `chart.spec` props: one neutral spec, any kind, one or more series. Stricter than the contract
57
+ * (which also carries converted legacy data) because an agent writes this shape directly.
58
+ */
59
+ export const chartSpecPropsSchema = z.object({
60
+ spec: chartSpecSchema.extend({
61
+ data: chartSpecSchema.shape.data.min(1),
62
+ series: chartSpecSchema.shape.series.min(1),
63
+ }).superRefine(checkSeriesShape),
64
+ }).passthrough();
28
65
  /** Convert validated legacy props to the neutral contract; optional provider overrides stay data.
29
66
  * @example legacyChartSpec({ kind: 'pie', props: { data: [{ name: 'A', value: 2 }] } })
30
67
  * @throws ZodError for an invalid wire shape or nonserializable row data.
@@ -8,6 +8,7 @@ import { shadcnDataTableManifest, DataTable as ShadcnDataTable, shadcnButtonMani
8
8
  import { createChartProviderRegistry } from '../charts/registry.js';
9
9
  import { createChartInteractiveRegistry } from '../charts/react.js';
10
10
  import { chartWireManifests } from '../charts/wire.js';
11
+ import { CHART_SPEC_COMPONENT_ID } from '../charts/catalog-manifests.js';
11
12
  /**
12
13
  * Every provider currently registered, shadcn preferred over the dependency-free native fallback
13
14
  * for `data-table`/`table` — the same order `manifests.ts`'s `ALL_MANIFESTS` declares, so a
@@ -35,9 +36,11 @@ const nonChartRegistry = new InteractiveUiRegistry({ entries: [
35
36
  ] });
36
37
  // Charts retain their wire IDs and validation while an unconfigured host renders the core table.
37
38
  // Runtime providers belong to the host's admitted boot snapshot, never to the default registry.
39
+ // `chart.spec` is here too, so a saved multi-series chart still renders (as a table) in a host that
40
+ // has no provider snapshot. It stays out of `ALL_MANIFESTS`: that list is the legacy wire set.
38
41
  const chartHosts = createChartInteractiveRegistry({ registry: nonChartRegistry, providers: createChartProviderRegistry({ providers: [] }) });
39
42
  export const DEFAULT_INTERACTIVE_UI_REGISTRY = new InteractiveUiRegistry({ entries: [
40
43
  ...nonChartRegistry.list(),
41
- ...chartWireManifests.map(({ id }) => chartHosts.resolveById({ id })),
44
+ ...[...chartWireManifests.map(({ id }) => id), CHART_SPEC_COMPONENT_ID].map((id) => chartHosts.resolveById({ id })),
42
45
  ] });
43
46
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { formatErrorForLog } from '@jini-ai/core';
3
4
  import { forwardRef, useImperativeHandle, useRef } from 'react';
4
5
  import { LexicalComposer } from '@lexical/react/LexicalComposer';
5
6
  import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin';
@@ -145,7 +146,7 @@ export const RichTextInput = forwardRef(function RichTextInput(props, ref) {
145
146
  theme: EDITOR_THEME,
146
147
  onError(err) {
147
148
  if (process.env.NODE_ENV !== 'production') {
148
- console.error('[rich-text-input]', err);
149
+ console.error('[rich-text-input]', formatErrorForLog({ error: err }));
149
150
  }
150
151
  },
151
152
  // editorState intentionally omitted → empty on first paint (SSR-safe).
@@ -33,7 +33,7 @@
33
33
  * every call site into "sometimes required, sometimes not" — worse than a small, purpose-built
34
34
  * sibling that carries only what a result needs.
35
35
  *
36
- * ## The one interactive element this DOES carry
36
+ * ## The external link and optional cancellation
37
37
  *
38
38
  * `openLinkUrl` renders a single button wired to the bridge's existing `openLink` (`ui/open-link`) —
39
39
  * no new bridge plumbing: a `<a href>` is dead inside this package's sandboxed `srcdoc` frame (no
@@ -41,9 +41,13 @@
41
41
  * all (`bridge.ts`'s own header). The URL is ALSO always rendered as a plain detail row regardless —
42
42
  * copyable/selectable text that needs no script to be useful, matching this package's general
43
43
  * "the document should say something true even if a caller never runs its script" posture.
44
+ * A partial outcome may also describe external work still waiting on the person. Its optional
45
+ * cancel action answers the caller's existing exchange without confirming an operation or
46
+ * navigating. Ordinary completed outcomes retain their read-only contract.
44
47
  */
45
48
  import { type UIResource, type UIResourceUri } from '../resource.js';
46
49
  import { type SurfaceDetail } from './document.js';
50
+ import type { ConfirmationToolAction } from './confirmation.js';
47
51
  import type { BridgeScriptSpec } from './bridge.js';
48
52
  import type { SurfaceTokenName } from './tokens.js';
49
53
  export interface SurfaceOutcomeSpec {
@@ -74,6 +78,8 @@ export interface SurfaceOutcomeSpec {
74
78
  * Only meaningful alongside `state: 'success'` — a caller should not supply this for a failure. */
75
79
  readonly openLinkUrl?: string;
76
80
  readonly openLinkLabel?: string;
81
+ /** A pending external action may be skipped through its existing surface exchange. */
82
+ readonly cancel?: ConfirmationToolAction;
77
83
  /** Identity reported to the Host in `ui/initialize`. Defaults to a generic one. */
78
84
  readonly app?: BridgeScriptSpec;
79
85
  readonly lang?: string;
@@ -33,7 +33,7 @@
33
33
  * every call site into "sometimes required, sometimes not" — worse than a small, purpose-built
34
34
  * sibling that carries only what a result needs.
35
35
  *
36
- * ## The one interactive element this DOES carry
36
+ * ## The external link and optional cancellation
37
37
  *
38
38
  * `openLinkUrl` renders a single button wired to the bridge's existing `openLink` (`ui/open-link`) —
39
39
  * no new bridge plumbing: a `<a href>` is dead inside this package's sandboxed `srcdoc` frame (no
@@ -41,10 +41,13 @@
41
41
  * all (`bridge.ts`'s own header). The URL is ALSO always rendered as a plain detail row regardless —
42
42
  * copyable/selectable text that needs no script to be useful, matching this package's general
43
43
  * "the document should say something true even if a caller never runs its script" posture.
44
+ * A partial outcome may also describe external work still waiting on the person. Its optional
45
+ * cancel action answers the caller's existing exchange without confirming an operation or
46
+ * navigating. Ordinary completed outcomes retain their read-only contract.
44
47
  */
45
48
  import { createUIResource } from '../resource.js';
46
49
  import { escapeHtml, escapeJsValue } from '../escape.js';
47
- import { renderActions, renderDetailList, renderSurfaceDocument, renderSurfaceHeader, SURFACE_SCRIPT_PRELUDE, SURFACE_STATUS_ELEMENT_ID, } from './document.js';
50
+ import { DEFAULT_SURFACE_STATUS_TEXT, renderActions, renderDetailList, renderSurfaceDocument, renderSurfaceHeader, SURFACE_SCRIPT_PRELUDE, SURFACE_STATUS_ELEMENT_ID, } from './document.js';
48
51
  const DEFAULT_APP = { appName: 'jini-mcp-ui-outcome', appVersion: '1' };
49
52
  const DEFAULT_OPEN_LINK_LABEL = 'Open site';
50
53
  const OPEN_LINK_ACTION_ID = 'open-link';
@@ -54,11 +57,45 @@ function statusStateOf(state) {
54
57
  return 'done';
55
58
  return state === 'partial' ? 'partial' : 'failed';
56
59
  }
57
- /** The open-link button, or nothing without a link. @complexity O(n) in the label length. */
60
+ /** One action list for the frame and its host mirror. @complexity O(n) in the label length. */
61
+ function outcomeActions(spec) {
62
+ const actions = [];
63
+ if (spec.openLinkUrl !== undefined)
64
+ actions.push({ id: OPEN_LINK_ACTION_ID, label: spec.openLinkLabel ?? DEFAULT_OPEN_LINK_LABEL, variant: spec.state === 'success' ? 'primary' : 'neutral' });
65
+ if (spec.cancel)
66
+ actions.push({ id: 'cancel', label: spec.cancel.label, variant: 'neutral' });
67
+ return actions;
68
+ }
58
69
  function renderOpenLinkActions(spec) {
59
- if (spec.openLinkUrl === undefined)
60
- return '';
61
- return renderActions([{ id: OPEN_LINK_ACTION_ID, label: spec.openLinkLabel ?? DEFAULT_OPEN_LINK_LABEL, variant: spec.state === 'success' ? 'primary' : 'neutral' }]);
70
+ const actions = outcomeActions(spec);
71
+ return actions.length === 0 ? '' : renderActions(actions);
72
+ }
73
+ /** Pending external work keeps its outbound link and answers cancellation through the same bridge.
74
+ * Existing read-only outcomes retain their script and need no exchange. @complexity O(n) rendered bytes. */
75
+ function cancellableOutcomeScript(spec) {
76
+ if (!spec.cancel)
77
+ return outcomeScript(spec.openLinkUrl);
78
+ return `(function () {
79
+ "use strict";
80
+ ${SURFACE_SCRIPT_PRELUDE}
81
+ var TEXT = ${escapeJsValue(DEFAULT_SURFACE_STATUS_TEXT)};
82
+ var CANCEL = ${escapeJsValue(spec.cancel)};
83
+ var OPEN_URL = ${escapeJsValue(spec.openLinkUrl ?? null)};
84
+ var locked = false;
85
+ function onClick(event) {
86
+ if (event.isTrusted !== true || locked) return;
87
+ if (event.currentTarget.getAttribute("data-mcpui-action") === "open-link") {
88
+ api.openLink(OPEN_URL);
89
+ return;
90
+ }
91
+ locked = true;
92
+ setBusy(true);
93
+ api.callTool(CANCEL.toolName, CANCEL.params).then(function () {
94
+ setStatus(TEXT.dismissed, "dismissed");
95
+ }, function (error) { locked = !reportCallFailure(error); });
96
+ }
97
+ for (var i = 0; i < actionButtons.length; i++) actionButtons[i].addEventListener("click", onClick);
98
+ }());`;
62
99
  }
63
100
  /** The inline script: wires the open-link button, or nothing at all. @complexity O(n) in the URL length. */
64
101
  function outcomeScript(openLinkUrl) {
@@ -99,7 +136,7 @@ export function renderOutcomeDocument(spec) {
99
136
  return renderSurfaceDocument({
100
137
  title: spec.title,
101
138
  bodyHtml,
102
- script: outcomeScript(spec.openLinkUrl),
139
+ script: cancellableOutcomeScript(spec),
103
140
  app: spec.app ?? DEFAULT_APP,
104
141
  ...(spec.lang === undefined ? {} : { lang: spec.lang }),
105
142
  ...(spec.tokens === undefined ? {} : { tokens: spec.tokens }),
@@ -120,10 +157,12 @@ export function buildOutcomeSurface(spec) {
120
157
  uri: spec.uri,
121
158
  htmlString: renderOutcomeDocument(spec),
122
159
  ...(spec.preferredFrameSize === undefined ? {} : { preferredFrameSize: spec.preferredFrameSize }),
160
+ ...(spec.cancel ? { actionPlan: { title: spec.title, actions: outcomeActions(spec) } } : {}),
123
161
  // No `actionPlan`: unlike a confirmation, this surface has nothing PENDING for
124
162
  // `page.find_elements`'s parent-DOM mirror to describe (`McpUiSurfaceCard`'s own
125
163
  // `readActionPlan`/`PendingSurfaceMirror` — that mirror exists for a confirmation a human has not
126
- // yet answered; a result has already been answered).
164
+ // yet answered; a result has already been answered). A cancellable external wait above is
165
+ // pending, so its mirror uses the same actions its frame renders, with no routing params.
127
166
  });
128
167
  }
129
168
  //# sourceMappingURL=outcome.js.map
@@ -0,0 +1,18 @@
1
+ import type { UserFacingError } from '@jini-ai/core';
2
+ import { type CopyToClipboardOptions } from '../../utils/copy-to-clipboard.js';
3
+ import type { Translate } from '../i18n/index.js';
4
+ /** A formatted failure, optional host translation, and injectable clipboard ports. */
5
+ export interface ErrorNoticeProps extends CopyToClipboardOptions {
6
+ readonly failure: UserFacingError;
7
+ readonly t?: Translate;
8
+ readonly className?: string;
9
+ /** History surfaces use polite announcements; active form failures keep the alert default. */
10
+ readonly role?: "alert" | "status";
11
+ }
12
+ /**
13
+ * Disclose exactly the formatted copy block in native, selectable details. Copying has no recovery effects.
14
+ * Failed writes leave the details available for manual copying; feedback resets with the incident.
15
+ * @complexity O(copyText length) per render/copy; one clipboard write plus the existing fallback on rejection.
16
+ */
17
+ export declare function ErrorNotice({ failure, t, className, role, ...ports }: ErrorNoticeProps): import("react").JSX.Element;
18
+ //# sourceMappingURL=ErrorNotice.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { copyToClipboard } from '../../utils/copy-to-clipboard.js';
4
+ /**
5
+ * Disclose exactly the formatted copy block in native, selectable details. Copying has no recovery effects.
6
+ * Failed writes leave the details available for manual copying; feedback resets with the incident.
7
+ * @complexity O(copyText length) per render/copy; one clipboard write plus the existing fallback on rejection.
8
+ */
9
+ export function ErrorNotice({ failure, t = text => text, className = 'notice error', role = 'alert', ...ports }) {
10
+ const [feedback, setFeedback] = useState(null);
11
+ async function copy() {
12
+ const copied = await copyToClipboard({ text: failure.copyText }, ports);
13
+ setFeedback({ text: failure.copyText, copied });
14
+ }
15
+ return (_jsxs("div", { className: className, role: role, children: [_jsx("p", { children: failure.message }), _jsx("p", { children: failure.action }), _jsxs("details", { children: [_jsx("summary", { children: t('Show details') }), _jsx("pre", { style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', userSelect: 'text' }, children: failure.copyText }), _jsx("button", { type: "button", onClick: () => void copy(), children: t('Copy details') }), feedback && feedback.text === failure.copyText ? _jsx("p", { role: "status", children: t(feedback.copied ? 'Copied' : 'Copy failed. Select the details and copy them manually.') }) : null] })] }));
16
+ }
17
+ //# sourceMappingURL=ErrorNotice.js.map
@@ -1,4 +1,6 @@
1
1
  export * from './fetch-query/index.js';
2
+ export { ErrorNotice } from './ErrorNotice.js';
3
+ export type { ErrorNoticeProps } from './ErrorNotice.js';
2
4
  export { useFocusTrap } from './hooks/use-focus-trap.hooks.js';
3
5
  export { useDirtyGuard } from './hooks/use-dirty-guard.hooks.js';
4
6
  export type { DirtyGuard } from './hooks/use-dirty-guard.hooks.js';
@@ -1,4 +1,5 @@
1
1
  export * from './fetch-query/index.js';
2
+ export { ErrorNotice } from './ErrorNotice.js';
2
3
  export { useFocusTrap } from './hooks/use-focus-trap.hooks.js';
3
4
  export { useDirtyGuard } from './hooks/use-dirty-guard.hooks.js';
4
5
  export { useAsyncAction } from './hooks/use-async-action.hooks.js';
@@ -1,3 +1,4 @@
1
+ import { formatErrorForLog } from '@jini-ai/core';
1
2
  import { useCallback, useEffect, useRef, useState } from 'react';
2
3
  import { EXPORTED_IMAGE_MIME_TYPE, SAVED_VISIBLE_MS } from '../../constants.js';
3
4
  import { defaultSketchEditorDependencies } from '../../dependencies.js';
@@ -85,7 +86,7 @@ export function useSketchSaveWorkflow(params) {
85
86
  });
86
87
  }
87
88
  catch (err) {
88
- console.warn('[SketchEditor] export image failed', err);
89
+ console.warn('[SketchEditor] export image failed', formatErrorForLog({ error: err }));
89
90
  setToast({ message: tRef.current('Could not export image'), tone: 'error' });
90
91
  }
91
92
  finally {
@@ -73,6 +73,12 @@ export function useTooltipLayer() {
73
73
  const rafRef = useRef(null);
74
74
  const lastInputRef = useRef('pointer');
75
75
  const suppressedTitleRef = useRef(null);
76
+ // Touch has no hover, and a tap's own pointerdown/click would hide the tip
77
+ // the instant it appeared — so a touch tap on a trigger TOGGLES the tip
78
+ // instead. `touchTapRef` remembers the trigger under the touch pointerdown
79
+ // (and whether its tip was already open) until the matching click.
80
+ const touchTapRef = useRef(null);
81
+ const openTargetRef = useRef(null);
76
82
  const [state, setState] = useState(null);
77
83
  const restoreNativeTitle = useCallback(() => {
78
84
  const suppressed = suppressedTitleRef.current;
@@ -170,6 +176,7 @@ export function useTooltipLayer() {
170
176
  });
171
177
  }, [updatePosition]);
172
178
  useLayoutEffect(() => {
179
+ openTargetRef.current = state?.target ?? null;
173
180
  if (!state)
174
181
  return;
175
182
  updatePosition();
@@ -209,11 +216,15 @@ export function useTooltipLayer() {
209
216
  };
210
217
  const onPointerOver = (event) => {
211
218
  lastInputRef.current = 'pointer';
219
+ if (event.pointerType === 'touch')
220
+ return;
212
221
  const target = readTooltipTarget(event.target);
213
222
  if (target)
214
223
  showTooltip(target);
215
224
  };
216
225
  const onTargetExit = (event) => {
226
+ if ('pointerType' in event && event.pointerType === 'touch')
227
+ return;
217
228
  const target = readTooltipTarget(event.target);
218
229
  if (!target)
219
230
  return;
@@ -224,14 +235,28 @@ export function useTooltipLayer() {
224
235
  };
225
236
  const onPointerDown = (event) => {
226
237
  lastInputRef.current = 'pointer';
227
- hideTooltipForActivation(readTooltipTarget(event.target));
238
+ const target = readTooltipTarget(event.target);
239
+ touchTapRef.current = null;
240
+ if (event.pointerType === 'touch' && target) {
241
+ touchTapRef.current = { target, wasOpen: openTargetRef.current === target };
242
+ return;
243
+ }
244
+ hideTooltipForActivation(target);
228
245
  };
229
246
  const onPointerCancel = () => {
230
247
  lastInputRef.current = 'pointer';
248
+ touchTapRef.current = null;
231
249
  hideTooltip();
232
250
  };
233
251
  const onClick = (event) => {
234
- hideTooltipForActivation(readTooltipTarget(event.target));
252
+ const target = readTooltipTarget(event.target);
253
+ const touchTap = touchTapRef.current;
254
+ touchTapRef.current = null;
255
+ if (target && touchTap?.target === target && !touchTap.wasOpen) {
256
+ showTooltip(target);
257
+ return;
258
+ }
259
+ hideTooltipForActivation(target);
235
260
  };
236
261
  const onFocusIn = (event) => {
237
262
  const target = readTooltipTarget(event.target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/ui",
3
- "version": "0.6.17",
3
+ "version": "0.6.22",
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": {
@@ -263,19 +263,19 @@
263
263
  "shiki": "^1.24.4",
264
264
  "tailwind-merge": "^2.6.0",
265
265
  "zod": "^3.25.76",
266
- "@jini-ai/agent-runtime": "^0.6.17",
267
- "@jini-ai/platform": "^0.6.17",
268
- "@jini-ai/agentic": "^0.6.17",
269
- "@jini-ai/core": "^0.6.17",
270
- "@jini-ai/integrations": "^0.6.17"
266
+ "@jini-ai/platform": "^0.6.22",
267
+ "@jini-ai/agent-runtime": "^0.6.22",
268
+ "@jini-ai/integrations": "^0.6.19",
269
+ "@jini-ai/agentic": "^0.6.19",
270
+ "@jini-ai/core": "^0.6.22"
271
271
  },
272
272
  "dependencies": {
273
273
  "micromark": "^4.0.0",
274
274
  "micromark-extension-gfm": "^3.0.0",
275
- "@jini-ai/ui-kit": "^0.6.17",
276
- "@jini-ai/platform": "^0.6.17",
277
- "@jini-ai/core": "^0.6.17",
278
- "@jini-ai/agentic": "^0.6.17"
275
+ "@jini-ai/core": "^0.6.22",
276
+ "@jini-ai/ui-kit": "^0.6.19",
277
+ "@jini-ai/agentic": "^0.6.19",
278
+ "@jini-ai/platform": "^0.6.22"
279
279
  },
280
280
  "peerDependencies": {
281
281
  "@tanstack/react-query": "^5.0.0",