@miragon/mcp-toolkit-ui 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/app/builder/BuilderBanners.d.ts +7 -2
- package/dist/app/builder/BuilderBanners.d.ts.map +1 -1
- package/dist/app/builder/BuilderBanners.js +8 -3
- package/dist/app/builder/BuilderBanners.js.map +1 -1
- package/dist/app/builder/CanvasRow.d.ts.map +1 -1
- package/dist/app/builder/CanvasRow.js +12 -14
- package/dist/app/builder/CanvasRow.js.map +1 -1
- package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
- package/dist/app/builder/CatalogueSheet.js +6 -5
- package/dist/app/builder/CatalogueSheet.js.map +1 -1
- package/dist/app/builder/EmptyHint.d.ts +4 -0
- package/dist/app/builder/EmptyHint.d.ts.map +1 -1
- package/dist/app/builder/EmptyHint.js +6 -1
- package/dist/app/builder/EmptyHint.js.map +1 -1
- package/dist/app/builder/LayoutSurface.js +3 -3
- package/dist/app/builder/LayoutSurface.js.map +1 -1
- package/dist/app/builder/PipelineStrip.d.ts +3 -1
- package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
- package/dist/app/builder/PipelineStrip.js +11 -6
- package/dist/app/builder/PipelineStrip.js.map +1 -1
- package/dist/app/builder/SaveDialog.d.ts.map +1 -1
- package/dist/app/builder/SaveDialog.js +2 -2
- package/dist/app/builder/SaveDialog.js.map +1 -1
- package/dist/app/builder/Toolbar.d.ts +3 -1
- package/dist/app/builder/Toolbar.d.ts.map +1 -1
- package/dist/app/builder/Toolbar.js +9 -3
- package/dist/app/builder/Toolbar.js.map +1 -1
- package/dist/app/builder/Workspace.js +2 -2
- package/dist/app/builder/Workspace.js.map +1 -1
- package/dist/app/builder/builder-model.d.ts +3 -2
- package/dist/app/builder/builder-model.d.ts.map +1 -1
- package/dist/app/builder/builder-model.js +5 -4
- package/dist/app/builder/builder-model.js.map +1 -1
- package/dist/app/builder/labels.d.ts +13 -62
- package/dist/app/builder/labels.d.ts.map +1 -1
- package/dist/app/builder/labels.js +9 -58
- package/dist/app/builder/labels.js.map +1 -1
- package/dist/app/builder/status-chip.d.ts.map +1 -1
- package/dist/app/builder/status-chip.js +3 -2
- package/dist/app/builder/status-chip.js.map +1 -1
- package/dist/app/builder/use-builder-catalogue.d.ts +1 -0
- package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
- package/dist/app/builder/use-builder-catalogue.js +6 -0
- package/dist/app/builder/use-builder-catalogue.js.map +1 -1
- package/dist/app/builder/use-save-dashboard.d.ts +3 -1
- package/dist/app/builder/use-save-dashboard.d.ts.map +1 -1
- package/dist/app/builder/use-save-dashboard.js +3 -1
- package/dist/app/builder/use-save-dashboard.js.map +1 -1
- package/dist/app/index.d.ts +1 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js.map +1 -1
- package/dist/app/layout-builder.d.ts +7 -2
- package/dist/app/layout-builder.d.ts.map +1 -1
- package/dist/app/layout-builder.js +17 -5
- package/dist/app/layout-builder.js.map +1 -1
- package/dist/app/mcp-app-view-chrome.d.ts +94 -20
- package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
- package/dist/app/mcp-app-view-chrome.js +109 -40
- package/dist/app/mcp-app-view-chrome.js.map +1 -1
- package/dist/app/mcp-app-view-states.d.ts +15 -0
- package/dist/app/mcp-app-view-states.d.ts.map +1 -0
- package/dist/app/mcp-app-view-states.js +34 -0
- package/dist/app/mcp-app-view-states.js.map +1 -0
- package/dist/app/mcp-app-view.d.ts +40 -5
- package/dist/app/mcp-app-view.d.ts.map +1 -1
- package/dist/app/mcp-app-view.js +174 -128
- package/dist/app/mcp-app-view.js.map +1 -1
- package/dist/app/mcp-toolkit-app.d.ts +2 -1
- package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
- package/dist/app/mcp-toolkit-app.js +25 -3
- package/dist/app/mcp-toolkit-app.js.map +1 -1
- package/dist/app/use-view-lifecycle.d.ts +38 -0
- package/dist/app/use-view-lifecycle.d.ts.map +1 -0
- package/dist/app/use-view-lifecycle.js +79 -0
- package/dist/app/use-view-lifecycle.js.map +1 -0
- package/dist/app/view-lifecycle.d.ts +109 -0
- package/dist/app/view-lifecycle.d.ts.map +1 -0
- package/dist/app/view-lifecycle.js +120 -0
- package/dist/app/view-lifecycle.js.map +1 -0
- package/dist/app/widget-props-sheet.d.ts +3 -9
- package/dist/app/widget-props-sheet.d.ts.map +1 -1
- package/dist/app/widget-props-sheet.js +6 -13
- package/dist/app/widget-props-sheet.js.map +1 -1
- package/dist/app/widget-renderer.d.ts +41 -1
- package/dist/app/widget-renderer.d.ts.map +1 -1
- package/dist/app/widget-renderer.js +82 -31
- package/dist/app/widget-renderer.js.map +1 -1
- package/dist/components/DrillButton.d.ts.map +1 -1
- package/dist/components/DrillButton.js +2 -1
- package/dist/components/DrillButton.js.map +1 -1
- package/dist/components/EmptyState.d.ts +33 -0
- package/dist/components/EmptyState.d.ts.map +1 -0
- package/dist/components/EmptyState.js +17 -0
- package/dist/components/EmptyState.js.map +1 -0
- package/dist/components/ErrorState.d.ts +50 -0
- package/dist/components/ErrorState.d.ts.map +1 -0
- package/dist/components/ErrorState.js +60 -0
- package/dist/components/ErrorState.js.map +1 -0
- package/dist/components/FilterBar.d.ts +1 -1
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterBar.js +7 -4
- package/dist/components/FilterBar.js.map +1 -1
- package/dist/components/GroupCard.d.ts.map +1 -1
- package/dist/components/GroupCard.js +2 -1
- package/dist/components/GroupCard.js.map +1 -1
- package/dist/components/KpiGrid.d.ts +10 -3
- package/dist/components/KpiGrid.d.ts.map +1 -1
- package/dist/components/KpiGrid.js +31 -9
- package/dist/components/KpiGrid.js.map +1 -1
- package/dist/components/ListFooter.d.ts +3 -2
- package/dist/components/ListFooter.d.ts.map +1 -1
- package/dist/components/ListFooter.js +14 -4
- package/dist/components/ListFooter.js.map +1 -1
- package/dist/components/Pills.d.ts +10 -6
- package/dist/components/Pills.d.ts.map +1 -1
- package/dist/components/Pills.js +12 -6
- package/dist/components/Pills.js.map +1 -1
- package/dist/components/Toast.d.ts +37 -0
- package/dist/components/Toast.d.ts.map +1 -0
- package/dist/components/Toast.js +91 -0
- package/dist/components/Toast.js.map +1 -0
- package/dist/components/WidgetHeader.js +1 -1
- package/dist/hooks/use-tool-result-recovery.d.ts +23 -1
- package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
- package/dist/hooks/use-tool-result-recovery.js +21 -7
- package/dist/hooks/use-tool-result-recovery.js.map +1 -1
- package/dist/hooks/use-view-data.d.ts +6 -0
- package/dist/hooks/use-view-data.d.ts.map +1 -1
- package/dist/hooks/use-view-data.js +6 -1
- package/dist/hooks/use-view-data.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/focus-ring.d.ts +18 -0
- package/dist/lib/focus-ring.d.ts.map +1 -0
- package/dist/lib/focus-ring.js +18 -0
- package/dist/lib/focus-ring.js.map +1 -0
- package/dist/lib/icon-stroke.d.ts +12 -0
- package/dist/lib/icon-stroke.d.ts.map +1 -0
- package/dist/lib/icon-stroke.js +12 -0
- package/dist/lib/icon-stroke.js.map +1 -0
- package/dist/lib/toast-model.d.ts +46 -0
- package/dist/lib/toast-model.d.ts.map +1 -0
- package/dist/lib/toast-model.js +36 -0
- package/dist/lib/toast-model.js.map +1 -0
- package/dist/lib/tone-utils.d.ts +43 -9
- package/dist/lib/tone-utils.d.ts.map +1 -1
- package/dist/lib/tone-utils.js +36 -7
- package/dist/lib/tone-utils.js.map +1 -1
- package/dist/locale/index.d.ts +3 -1
- package/dist/locale/index.d.ts.map +1 -1
- package/dist/locale/index.js +2 -1
- package/dist/locale/index.js.map +1 -1
- package/dist/locale/locale-provider.d.ts +42 -1
- package/dist/locale/locale-provider.d.ts.map +1 -1
- package/dist/locale/locale-provider.js +44 -3
- package/dist/locale/locale-provider.js.map +1 -1
- package/dist/locale/message-types.d.ts +223 -0
- package/dist/locale/message-types.d.ts.map +1 -0
- package/dist/locale/message-types.js +7 -0
- package/dist/locale/message-types.js.map +1 -0
- package/dist/locale/messages-de.d.ts +9 -0
- package/dist/locale/messages-de.d.ts.map +1 -0
- package/dist/locale/messages-de.js +151 -0
- package/dist/locale/messages-de.js.map +1 -0
- package/dist/locale/messages-en.d.ts +8 -0
- package/dist/locale/messages-en.d.ts.map +1 -0
- package/dist/locale/messages-en.js +150 -0
- package/dist/locale/messages-en.js.map +1 -0
- package/dist/locale/toolkit-messages.d.ts +39 -0
- package/dist/locale/toolkit-messages.d.ts.map +1 -0
- package/dist/locale/toolkit-messages.js +50 -0
- package/dist/locale/toolkit-messages.js.map +1 -0
- package/dist/primitives/alert.d.ts.map +1 -1
- package/dist/primitives/alert.js +4 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/badge.d.ts +1 -1
- package/dist/primitives/badge.d.ts.map +1 -1
- package/dist/primitives/badge.js +14 -6
- package/dist/primitives/badge.js.map +1 -1
- package/dist/primitives/button.d.ts.map +1 -1
- package/dist/primitives/button.js +12 -8
- package/dist/primitives/button.js.map +1 -1
- package/dist/primitives/card.js +4 -4
- package/dist/primitives/card.js.map +1 -1
- package/dist/primitives/dialog.d.ts +7 -3
- package/dist/primitives/dialog.d.ts.map +1 -1
- package/dist/primitives/dialog.js +14 -7
- package/dist/primitives/dialog.js.map +1 -1
- package/dist/primitives/input.d.ts.map +1 -1
- package/dist/primitives/input.js +4 -1
- package/dist/primitives/input.js.map +1 -1
- package/dist/primitives/scroll-area.js +1 -1
- package/dist/primitives/scroll-area.js.map +1 -1
- package/dist/primitives/select.d.ts.map +1 -1
- package/dist/primitives/select.js +5 -2
- package/dist/primitives/select.js.map +1 -1
- package/dist/primitives/sheet.d.ts +3 -1
- package/dist/primitives/sheet.d.ts.map +1 -1
- package/dist/primitives/sheet.js +12 -6
- package/dist/primitives/sheet.js.map +1 -1
- package/dist/primitives/skeleton.js +1 -1
- package/dist/primitives/skeleton.js.map +1 -1
- package/dist/primitives/switch.d.ts.map +1 -1
- package/dist/primitives/switch.js +2 -1
- package/dist/primitives/switch.js.map +1 -1
- package/dist/primitives/tabs.d.ts.map +1 -1
- package/dist/primitives/tabs.js +5 -3
- package/dist/primitives/tabs.js.map +1 -1
- package/dist/render-cases.d.ts.map +1 -1
- package/dist/render-cases.js +20 -2
- package/dist/render-cases.js.map +1 -1
- package/dist/render-scan.d.ts +25 -0
- package/dist/render-scan.d.ts.map +1 -0
- package/dist/render-scan.js +66 -0
- package/dist/render-scan.js.map +1 -0
- package/dist/theme/cd-tokens.d.ts +66 -0
- package/dist/theme/cd-tokens.d.ts.map +1 -0
- package/dist/theme/cd-tokens.js +64 -0
- package/dist/theme/cd-tokens.js.map +1 -0
- package/dist/theme/create-theme.d.ts +196 -39
- package/dist/theme/create-theme.d.ts.map +1 -1
- package/dist/theme/create-theme.js +189 -13
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/fonts.d.ts +38 -0
- package/dist/theme/fonts.d.ts.map +1 -0
- package/dist/theme/fonts.js +56 -0
- package/dist/theme/fonts.js.map +1 -0
- package/dist/theme/host-fonts.d.ts +39 -0
- package/dist/theme/host-fonts.d.ts.map +1 -0
- package/dist/theme/host-fonts.js +44 -0
- package/dist/theme/host-fonts.js.map +1 -0
- package/dist/theme/index.d.ts +8 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +5 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/portal-container.d.ts +16 -0
- package/dist/theme/portal-container.d.ts.map +1 -0
- package/dist/theme/portal-container.js +19 -0
- package/dist/theme/portal-container.js.map +1 -0
- package/dist/theme/presets.d.ts +24 -9
- package/dist/theme/presets.d.ts.map +1 -1
- package/dist/theme/presets.js +44 -11
- package/dist/theme/presets.js.map +1 -1
- package/dist/theme/theme-provider.d.ts +27 -9
- package/dist/theme/theme-provider.d.ts.map +1 -1
- package/dist/theme/theme-provider.js +42 -20
- package/dist/theme/theme-provider.js.map +1 -1
- package/package.json +9 -4
- package/src/app/builder/BuilderBanners.tsx +26 -13
- package/src/app/builder/CanvasRow.tsx +22 -21
- package/src/app/builder/CatalogueSheet.tsx +16 -14
- package/src/app/builder/EmptyHint.tsx +16 -11
- package/src/app/builder/LayoutSurface.tsx +7 -7
- package/src/app/builder/PipelineStrip.tsx +26 -17
- package/src/app/builder/SaveDialog.tsx +11 -12
- package/src/app/builder/Toolbar.tsx +19 -7
- package/src/app/builder/Workspace.tsx +5 -3
- package/src/app/builder/builder-feedback.test.tsx +75 -0
- package/src/app/builder/builder-model.test.ts +5 -0
- package/src/app/builder/builder-model.ts +5 -4
- package/src/app/builder/labels.ts +15 -121
- package/src/app/builder/status-chip.tsx +8 -4
- package/src/app/builder/use-builder-catalogue.ts +6 -0
- package/src/app/builder/use-save-dashboard.ts +5 -0
- package/src/app/index.ts +8 -1
- package/src/app/layout-builder.tsx +23 -3
- package/src/app/mcp-app-view-chrome.test.tsx +347 -14
- package/src/app/mcp-app-view-chrome.tsx +278 -100
- package/src/app/mcp-app-view-states.tsx +100 -0
- package/src/app/mcp-app-view.test.tsx +258 -0
- package/src/app/mcp-app-view.tsx +292 -185
- package/src/app/mcp-toolkit-app.tsx +31 -7
- package/src/app/use-view-lifecycle.ts +116 -0
- package/src/app/view-lifecycle.test.ts +227 -0
- package/src/app/view-lifecycle.ts +174 -0
- package/src/app/widget-props-sheet.tsx +13 -26
- package/src/app/widget-renderer.render.test.tsx +131 -0
- package/src/app/widget-renderer.tsx +136 -38
- package/src/components/DrillButton.tsx +2 -1
- package/src/components/EmptyState.tsx +81 -0
- package/src/components/ErrorState.tsx +182 -0
- package/src/components/FilterBar.tsx +9 -6
- package/src/components/GroupCard.tsx +3 -2
- package/src/components/KpiGrid.tsx +75 -44
- package/src/components/ListFooter.tsx +17 -6
- package/src/components/Pills.tsx +20 -11
- package/src/components/Toast.tsx +188 -0
- package/src/components/WidgetHeader.tsx +1 -1
- package/src/components/feedback-states.test.tsx +225 -0
- package/src/components/tone-figures.test.tsx +54 -0
- package/src/fonts.css +19 -0
- package/src/globals.css +9 -165
- package/src/hooks/use-tool-result-recovery.test.ts +25 -1
- package/src/hooks/use-tool-result-recovery.ts +34 -6
- package/src/hooks/use-view-data.test.tsx +60 -0
- package/src/hooks/use-view-data.ts +12 -1
- package/src/i18n-coverage.test.tsx +323 -0
- package/src/index.ts +57 -5
- package/src/lib/focus-ring.ts +20 -0
- package/src/lib/icon-stroke.test.ts +59 -0
- package/src/lib/icon-stroke.ts +13 -0
- package/src/lib/toast-model.test.ts +68 -0
- package/src/lib/toast-model.ts +63 -0
- package/src/lib/tone-utils.test.ts +47 -14
- package/src/lib/tone-utils.ts +61 -13
- package/src/locale/index.ts +13 -1
- package/src/locale/locale-provider.test.tsx +87 -0
- package/src/locale/locale-provider.tsx +90 -3
- package/src/locale/message-types.ts +221 -0
- package/src/locale/messages-de.ts +159 -0
- package/src/locale/messages-en.ts +157 -0
- package/src/locale/toolkit-messages.test.ts +216 -0
- package/src/locale/toolkit-messages.ts +82 -0
- package/src/no-hardcoded-copy.test.ts +432 -0
- package/src/primitives/alert.tsx +4 -2
- package/src/primitives/badge.tsx +14 -7
- package/src/primitives/button.tsx +13 -8
- package/src/primitives/card.tsx +4 -4
- package/src/primitives/dialog.tsx +32 -7
- package/src/primitives/input.tsx +5 -3
- package/src/primitives/scroll-area.tsx +1 -1
- package/src/primitives/select.tsx +6 -3
- package/src/primitives/sheet.tsx +26 -6
- package/src/primitives/skeleton.tsx +1 -1
- package/src/primitives/switch.tsx +4 -2
- package/src/primitives/tabs.tsx +9 -6
- package/src/render-cases.tsx +45 -1
- package/src/render-scan.ts +75 -0
- package/src/theme/cd-tokens.generated.css +48 -0
- package/src/theme/cd-tokens.test.ts +107 -0
- package/src/theme/cd-tokens.ts +67 -0
- package/src/theme/create-theme.test.ts +243 -5
- package/src/theme/create-theme.ts +313 -41
- package/src/theme/fonts.test.ts +85 -0
- package/src/theme/fonts.ts +72 -0
- package/src/theme/host-fonts.test.ts +135 -0
- package/src/theme/host-fonts.ts +52 -0
- package/src/theme/index.ts +15 -1
- package/src/theme/portal-container.ts +20 -0
- package/src/theme/presets.ts +44 -11
- package/src/theme/theme-css.test.ts +568 -0
- package/src/theme/theme-provider.test.tsx +82 -0
- package/src/theme/theme-provider.tsx +60 -24
- package/src/theme.css +314 -0
- package/ui-catalog.json +344 -17
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
3
|
+
import { CircleCheck } from "lucide-react"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { LocaleProvider } from "../locale/locale-provider.js"
|
|
6
|
+
import { TOOLKIT_MESSAGES } from "../locale/toolkit-messages.js"
|
|
7
|
+
import { FOCUS_RING } from "../lib/focus-ring.js"
|
|
8
|
+
import { createToast } from "../lib/toast-model.js"
|
|
9
|
+
import { TONE_BORDER, TONE_ICON, TONE_SOFT } from "../lib/tone-utils.js"
|
|
10
|
+
import { attribute, openingTags } from "../render-scan.js"
|
|
11
|
+
import { EmptyState } from "./EmptyState.js"
|
|
12
|
+
import { ErrorState } from "./ErrorState.js"
|
|
13
|
+
import { ToastProvider, ToastViewport, useToast, type ToastApi } from "./Toast.js"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Accessibility contract of the feedback primitives, pinned on their SSR
|
|
17
|
+
* markup (the house render path — no jsdom): roles, live regions, accessible
|
|
18
|
+
* names in the active locale, native focusable controls, nothing that steals
|
|
19
|
+
* focus.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const de = TOOLKIT_MESSAGES.de
|
|
23
|
+
const inGerman = (node: ReactNode) =>
|
|
24
|
+
renderToStaticMarkup(<LocaleProvider locale="de-DE">{node}</LocaleProvider>)
|
|
25
|
+
const noop = (): void => {}
|
|
26
|
+
|
|
27
|
+
/** No positive tabindex and no autofocus anywhere — feedback never steals focus. */
|
|
28
|
+
function expectNoFocusTheft(html: string) {
|
|
29
|
+
expect(html).not.toMatch(/autofocus/i)
|
|
30
|
+
for (const value of html.match(/tabindex="(-?\d+)"/gi) ?? []) {
|
|
31
|
+
expect(Number(/"(-?\d+)"/.exec(value)?.[1])).toBeLessThanOrEqual(0)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("Toast", () => {
|
|
36
|
+
const toasts = [
|
|
37
|
+
createToast(1, { tone: "success", title: "Job retried." }),
|
|
38
|
+
createToast(2, { tone: "danger", title: "Retry failed.", description: "Engine down (503)" }),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
it("renders ONE persistent live region: role=status, aria-live=polite, a localized name", () => {
|
|
42
|
+
const html = inGerman(<ToastViewport toasts={toasts} onDismiss={noop} />)
|
|
43
|
+
const [region, ...others] = openingTags(html, "div").filter((t) => /role="status"/.test(t))
|
|
44
|
+
expect(others).toHaveLength(0)
|
|
45
|
+
expect(attribute(region ?? "", "aria-live")).toBe("polite")
|
|
46
|
+
expect(attribute(region ?? "", "aria-label")).toBe(de.common.notifications)
|
|
47
|
+
// An empty stack keeps the region mounted, so the first toast is announced.
|
|
48
|
+
const empty = inGerman(<ToastViewport toasts={[]} onDismiss={noop} />)
|
|
49
|
+
expect(empty).toContain('role="status"')
|
|
50
|
+
expect(empty).toContain('aria-live="polite"')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("shows the status as edge + icon next to plain text, never as coloured text", () => {
|
|
54
|
+
const html = inGerman(<ToastViewport toasts={toasts} onDismiss={noop} />)
|
|
55
|
+
const cards = openingTags(html, "div").filter((t) => /data-slot="toast"/.test(t))
|
|
56
|
+
expect(cards.map((t) => attribute(t, "data-tone"))).toEqual(["success", "danger"])
|
|
57
|
+
expect(cards[0]).toMatch(/border-l-success/)
|
|
58
|
+
expect(cards[1]).toMatch(/border-l-danger/)
|
|
59
|
+
for (const card of cards) expect(card).not.toMatch(/\btext-(success|danger|warning|info)\b/)
|
|
60
|
+
// The edge survives the dark 3:1 rim (a later dark: rule would paint over it).
|
|
61
|
+
expect(cards[0]).toMatch(/dark:border-input/)
|
|
62
|
+
expect(cards[0]).toMatch(/dark:border-l-success/)
|
|
63
|
+
// Icons are decorative and drawn in the tone colour; the text carries the message.
|
|
64
|
+
const svgs = openingTags(html, "svg")
|
|
65
|
+
for (const svg of svgs) expect(attribute(svg, "aria-hidden")).toBe("true")
|
|
66
|
+
expect(attribute(svgs[0] ?? "", "class")).toContain(TONE_ICON.success)
|
|
67
|
+
expect(attribute(svgs[2] ?? "", "class")).toContain(TONE_ICON.danger)
|
|
68
|
+
expect(html).toContain("Job retried.")
|
|
69
|
+
expect(html).toContain("Engine down (503)")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("gives every toast a native close button named in the locale, and steals no focus", () => {
|
|
73
|
+
const html = inGerman(<ToastViewport toasts={toasts} onDismiss={noop} />)
|
|
74
|
+
const buttons = openingTags(html, "button")
|
|
75
|
+
expect(buttons).toHaveLength(2)
|
|
76
|
+
for (const b of buttons) {
|
|
77
|
+
expect(attribute(b, "type")).toBe("button")
|
|
78
|
+
expect(attribute(b, "aria-label")).toBe(de.common.dismiss)
|
|
79
|
+
expect(attribute(b, "class")).toContain(FOCUS_RING)
|
|
80
|
+
}
|
|
81
|
+
expectNoFocusTheft(html)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("nests idempotently: one provider, one viewport", () => {
|
|
85
|
+
const html = renderToStaticMarkup(
|
|
86
|
+
<ToastProvider>
|
|
87
|
+
<ToastProvider>
|
|
88
|
+
<p>body</p>
|
|
89
|
+
</ToastProvider>
|
|
90
|
+
</ToastProvider>,
|
|
91
|
+
)
|
|
92
|
+
expect(html.match(/data-slot="toast-viewport"/g)).toHaveLength(1)
|
|
93
|
+
expect(html).toContain("<p>body</p>")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("useToast outside a provider is a safe no-op", () => {
|
|
97
|
+
let api: ToastApi | undefined
|
|
98
|
+
function Probe() {
|
|
99
|
+
api = useToast()
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
renderToStaticMarkup(<Probe />)
|
|
103
|
+
expect(api?.show({ title: "x" })).toBe(-1)
|
|
104
|
+
expect(() => api?.dismiss(1)).not.toThrow()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe("EmptyState", () => {
|
|
109
|
+
it("is static (no live region), titles with h3 by default, hides its icon from AT", () => {
|
|
110
|
+
const html = inGerman(
|
|
111
|
+
<EmptyState
|
|
112
|
+
icon={<CircleCheck />}
|
|
113
|
+
tone="success"
|
|
114
|
+
title="Keine offenen Incidents"
|
|
115
|
+
description="Alles erledigt."
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
expect(html).not.toMatch(/role="(status|alert)"|aria-live/)
|
|
119
|
+
expect(html).toMatch(/<h3[^>]*>Keine offenen Incidents<\/h3>/)
|
|
120
|
+
const iconSlot = openingTags(html, "div").find((t) => /aria-hidden="true"/.test(t))
|
|
121
|
+
// The success tone on the icon (TONE_ICON), never on the words.
|
|
122
|
+
expect(iconSlot).toContain(TONE_ICON.success)
|
|
123
|
+
expect(html).not.toMatch(/text-success-ink/)
|
|
124
|
+
expect(html).toContain("Alles erledigt.")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("sizes an icon to 24 px but lets a mark keep its own size-* class (the Komet needs room)", () => {
|
|
128
|
+
const html = renderToStaticMarkup(
|
|
129
|
+
<EmptyState icon={<svg className="size-12" aria-label="mark" />} title="Willkommen" />,
|
|
130
|
+
)
|
|
131
|
+
const slot = openingTags(html, "div").find((t) => /aria-hidden="true"/.test(t)) ?? ""
|
|
132
|
+
const slotClass = attribute(slot, "class") ?? ""
|
|
133
|
+
// The default applies only to an SVG without its own size class…
|
|
134
|
+
expect(slotClass).toContain("[&_svg:not([class*='size-'])]:size-6")
|
|
135
|
+
// …and never as a clamp that outranks the mark's class (0,1,1 beats 0,1,0).
|
|
136
|
+
expect(slotClass).not.toMatch(/\[&_svg\]:size-/)
|
|
137
|
+
expect(html).toContain('<svg class="size-12"')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("takes the heading level of its surface and at most two actions", () => {
|
|
141
|
+
const html = renderToStaticMarkup(
|
|
142
|
+
<EmptyState
|
|
143
|
+
titleAs="h2"
|
|
144
|
+
title="Nothing here"
|
|
145
|
+
action={<button type="button">First</button>}
|
|
146
|
+
secondaryAction={<button type="button">Second</button>}
|
|
147
|
+
/>,
|
|
148
|
+
)
|
|
149
|
+
expect(html).toMatch(/<h2[^>]*>Nothing here<\/h2>/)
|
|
150
|
+
expect(openingTags(html, "button")).toHaveLength(2)
|
|
151
|
+
expect(renderToStaticMarkup(<EmptyState title="Bare" />)).not.toContain("<button")
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe("ErrorState", () => {
|
|
156
|
+
it("announces itself (role=alert) with a heading, the hint, details and a retry in the locale", () => {
|
|
157
|
+
const html = inGerman(
|
|
158
|
+
<ErrorState
|
|
159
|
+
title="Die Ansicht konnte nicht geladen werden"
|
|
160
|
+
description="Versuch es noch mal."
|
|
161
|
+
details="[503 ProcessEngineException] Engine is shutting down"
|
|
162
|
+
onRetry={noop}
|
|
163
|
+
/>,
|
|
164
|
+
)
|
|
165
|
+
const root = openingTags(html, "div")[0] ?? ""
|
|
166
|
+
expect(attribute(root, "role")).toBe("alert")
|
|
167
|
+
expect(attribute(root, "data-variant")).toBe("block")
|
|
168
|
+
expect(html).toMatch(/<h2[^>]*>Die Ansicht konnte nicht geladen werden<\/h2>/)
|
|
169
|
+
// The raw reason sits collapsed in a native disclosure (keyboard-ready).
|
|
170
|
+
expect(html).toMatch(/<details[^>]*><summary[^>]*>Details<\/summary>/)
|
|
171
|
+
expect(html).not.toMatch(/<details[^>]*\sopen/)
|
|
172
|
+
const [retry] = openingTags(html, "button")
|
|
173
|
+
expect(attribute(retry ?? "", "type")).toBe("button")
|
|
174
|
+
expect(html).toContain(`${de.common.retry}</button>`)
|
|
175
|
+
expectNoFocusTheft(html)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("supports a quieter status role, no role at all, and the inline alert form", () => {
|
|
179
|
+
const status = renderToStaticMarkup(<ErrorState title="Notice" role="status" tone="info" />)
|
|
180
|
+
expect(attribute(openingTags(status, "div")[0] ?? "", "role")).toBe("status")
|
|
181
|
+
const none = renderToStaticMarkup(<ErrorState title="Static" role="none" />)
|
|
182
|
+
expect(attribute(openingTags(none, "div")[0] ?? "", "role")).toBeUndefined()
|
|
183
|
+
const inline = renderToStaticMarkup(
|
|
184
|
+
<ErrorState variant="inline" title="Partial" tone="warning" />,
|
|
185
|
+
)
|
|
186
|
+
expect(inline).toMatch(/<p[^>]*>Partial<\/p>/)
|
|
187
|
+
expect(attribute(openingTags(inline, "div")[0] ?? "", "data-tone")).toBe("warning")
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it.each(["danger", "warning", "info"] as const)(
|
|
191
|
+
"shows the %s tone as rim, tint and icon; every word on the tint is the foreground (CI §3.3)",
|
|
192
|
+
(tone) => {
|
|
193
|
+
const html = renderToStaticMarkup(
|
|
194
|
+
<ErrorState title="x" description="what to do" details="raw" tone={tone} onRetry={noop} />,
|
|
195
|
+
)
|
|
196
|
+
const rootClass = attribute(openingTags(html, "div")[0] ?? "", "class") ?? ""
|
|
197
|
+
expect(rootClass).toContain(TONE_SOFT[tone])
|
|
198
|
+
expect(rootClass).toContain(TONE_BORDER[tone])
|
|
199
|
+
expect(attribute(openingTags(html, "svg")[0] ?? "", "class")).toContain(TONE_ICON[tone])
|
|
200
|
+
// No quiet text, no ink and no status colour as text anywhere on the tint
|
|
201
|
+
// (the icons, which carry the tone colour, set aside).
|
|
202
|
+
const words = html.replace(/<svg[\s\S]*?<\/svg>/g, "")
|
|
203
|
+
expect(words).not.toMatch(/text-muted-foreground|text-\w+-ink|text-(danger|warning|info)\b/)
|
|
204
|
+
// The disclosure is a kit control: the kit's opaque focus ring.
|
|
205
|
+
expect(attribute(openingTags(html, "summary")[0] ?? "", "class")).toContain(FOCUS_RING)
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
it("has no retry without onRetry, and takes custom labels", () => {
|
|
210
|
+
expect(renderToStaticMarkup(<ErrorState title="x" />)).not.toContain("<button")
|
|
211
|
+
const html = renderToStaticMarkup(
|
|
212
|
+
<ErrorState
|
|
213
|
+
title="x"
|
|
214
|
+
details="raw"
|
|
215
|
+
detailsLabel="Technical"
|
|
216
|
+
onRetry={noop}
|
|
217
|
+
retryLabel="Reload"
|
|
218
|
+
actions={<a href="#help">Help</a>}
|
|
219
|
+
/>,
|
|
220
|
+
)
|
|
221
|
+
expect(html).toContain("Technical</summary>")
|
|
222
|
+
expect(html).toContain("Reload</button>")
|
|
223
|
+
expect(html).toContain('<a href="#help">Help</a>')
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import type { ToneVariant } from "../lib/tone-utils.js"
|
|
4
|
+
import { KpiGrid } from "./KpiGrid.js"
|
|
5
|
+
import { CountPill, LivePill } from "./Pills.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Status model (owner decision 2e-f, CI §3.3): coloured digits are retired.
|
|
9
|
+
* A figure — a KPI value, a trend, a count — stays in the foreground; the tone
|
|
10
|
+
* shows as a dot, a border or a tint next to it. These components carry
|
|
11
|
+
* figures, so no tone colour may reach their text (SSR, the house render path).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const STATUS = ["info", "success", "warning", "danger"] as const
|
|
15
|
+
const TONES: ToneVariant[] = ["neutral", ...STATUS]
|
|
16
|
+
/** Any tone used as a TEXT colour: the raw status colour or its -ink role. */
|
|
17
|
+
const TONE_AS_TEXT = /\btext-(?:success|warning|danger|info)(?:-ink)?\b/
|
|
18
|
+
|
|
19
|
+
describe("figures never take a tone colour", () => {
|
|
20
|
+
it("KpiGrid: trend lines and toned cells keep their text in the foreground", () => {
|
|
21
|
+
const html = renderToStaticMarkup(
|
|
22
|
+
<KpiGrid
|
|
23
|
+
cells={[
|
|
24
|
+
{ label: "Up", value: "12", trend: "+12% vs last week", trendDirection: "up" },
|
|
25
|
+
{ label: "Down", value: "3", trend: "-4% vs last week", trendDirection: "down" },
|
|
26
|
+
{ label: "Flat", value: "7", trend: "±0", trendDirection: "flat" },
|
|
27
|
+
...STATUS.map((tone) => ({ label: tone, value: "1", tone })),
|
|
28
|
+
]}
|
|
29
|
+
/>,
|
|
30
|
+
)
|
|
31
|
+
expect(html).not.toMatch(TONE_AS_TEXT)
|
|
32
|
+
// The tone is still there — as a dot.
|
|
33
|
+
expect(html).toMatch(/bg-danger\b/)
|
|
34
|
+
expect(html).toMatch(/bg-success\b/)
|
|
35
|
+
// A toned trend reads in the foreground, an untoned one stays quiet.
|
|
36
|
+
expect(html).toMatch(/text-foreground[^"]*"><span aria-hidden="true"[^>]*bg-danger/)
|
|
37
|
+
expect(html).toMatch(/text-muted-foreground">±0/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it.each(TONES)(
|
|
41
|
+
"CountPill and LivePill (%s): number and label in the foreground, tone on the rim",
|
|
42
|
+
(tone) => {
|
|
43
|
+
const count = renderToStaticMarkup(<CountPill tone={tone}>42</CountPill>)
|
|
44
|
+
const live = renderToStaticMarkup(<LivePill tone={tone}>Live</LivePill>)
|
|
45
|
+
for (const html of [count, live]) {
|
|
46
|
+
expect(html).not.toMatch(TONE_AS_TEXT)
|
|
47
|
+
if (tone !== "neutral") {
|
|
48
|
+
expect(html).toContain("text-foreground")
|
|
49
|
+
expect(html).toContain(`border-${tone}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
})
|
package/src/fonts.css
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @miragon/mcp-toolkit-ui/fonts.css — OPT-IN: Geist and Geist Mono, self-hosted
|
|
3
|
+
* through your bundler (no CDN). Import it next to globals.css/theme.css:
|
|
4
|
+
*
|
|
5
|
+
* @import "@miragon/mcp-toolkit-ui/globals.css";
|
|
6
|
+
* @import "@miragon/mcp-toolkit-ui/fonts.css";
|
|
7
|
+
*
|
|
8
|
+
* Cost: a bundler that inlines assets (vite-plugin-singlefile, MCP App views)
|
|
9
|
+
* embeds the woff2 files as base64 in every view. When a server can serve the
|
|
10
|
+
* files, prefer `fontFaces({ baseUrl })`, which references them by URL (and
|
|
11
|
+
* allow that origin in the view's CSP resourceDomains). The token stacks
|
|
12
|
+
* (--font-sans, --font-mono) name both families first, so either path works.
|
|
13
|
+
* A host's own font stays the fallback behind them: McpToolkitApp moves it
|
|
14
|
+
* to --host-font-sans/--host-font-mono (adoptHostFontVariables); an app with
|
|
15
|
+
* its own host-document layer calls that helper after applying the host's
|
|
16
|
+
* style variables, or the host's --font-sans replaces the whole stack.
|
|
17
|
+
*/
|
|
18
|
+
@import "@fontsource-variable/geist/wght.css";
|
|
19
|
+
@import "@fontsource-variable/geist-mono/wght.css";
|
package/src/globals.css
CHANGED
|
@@ -1,168 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @miragon/mcp-toolkit-ui/globals.css — the one-line stylesheet for an app
|
|
3
|
+
* entry: Tailwind, the toolkit's design tokens (./theme.css, Miragon by
|
|
4
|
+
* default) and the toolkit's own class sources. Fonts are opt-in: add
|
|
5
|
+
* `@import "@miragon/mcp-toolkit-ui/fonts.css"` or serve them with
|
|
6
|
+
* `fontFaces()`. A brand overrides the role variables after this import —
|
|
7
|
+
* see docs/guides/white-labeling.md.
|
|
8
|
+
*/
|
|
1
9
|
@import "tailwindcss";
|
|
2
|
-
@import "
|
|
3
|
-
@import "@fontsource-variable/geist";
|
|
10
|
+
@import "./theme.css";
|
|
4
11
|
|
|
5
12
|
@source "../**/*.{ts,tsx}";
|
|
6
|
-
|
|
7
|
-
@custom-variant dark (&:is(.dark *));
|
|
8
|
-
|
|
9
|
-
@theme inline {
|
|
10
|
-
--font-heading: var(--font-sans);
|
|
11
|
-
--font-sans: "Geist Variable", sans-serif;
|
|
12
|
-
--color-sidebar-ring: var(--sidebar-ring);
|
|
13
|
-
--color-sidebar-border: var(--sidebar-border);
|
|
14
|
-
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
15
|
-
--color-sidebar-accent: var(--sidebar-accent);
|
|
16
|
-
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
17
|
-
--color-sidebar-primary: var(--sidebar-primary);
|
|
18
|
-
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
19
|
-
--color-sidebar: var(--sidebar);
|
|
20
|
-
--color-chart-5: var(--chart-5);
|
|
21
|
-
--color-chart-4: var(--chart-4);
|
|
22
|
-
--color-chart-3: var(--chart-3);
|
|
23
|
-
--color-chart-2: var(--chart-2);
|
|
24
|
-
--color-chart-1: var(--chart-1);
|
|
25
|
-
--color-ring: var(--ring);
|
|
26
|
-
--color-input: var(--input);
|
|
27
|
-
--color-border: var(--border);
|
|
28
|
-
--color-destructive: var(--destructive);
|
|
29
|
-
--color-accent-foreground: var(--accent-foreground);
|
|
30
|
-
--color-accent: var(--accent);
|
|
31
|
-
--color-muted-foreground: var(--muted-foreground);
|
|
32
|
-
--color-muted: var(--muted);
|
|
33
|
-
--color-secondary-foreground: var(--secondary-foreground);
|
|
34
|
-
--color-secondary: var(--secondary);
|
|
35
|
-
--color-primary-foreground: var(--primary-foreground);
|
|
36
|
-
--color-primary: var(--primary);
|
|
37
|
-
--color-popover-foreground: var(--popover-foreground);
|
|
38
|
-
--color-popover: var(--popover);
|
|
39
|
-
--color-card-foreground: var(--card-foreground);
|
|
40
|
-
--color-card: var(--card);
|
|
41
|
-
--color-foreground: var(--foreground);
|
|
42
|
-
--color-background: var(--background);
|
|
43
|
-
/* Semantic status tones — the token surface behind ToneVariant/TONE_* maps.
|
|
44
|
-
Each tone exposes a readable foreground (`text-danger`) and a tinted
|
|
45
|
-
surface (`bg-danger-soft`); both carry light + dark values so tone-based
|
|
46
|
-
components (Pills, KpiGrid, WidgetHeader, FilterBar) re-theme automatically. */
|
|
47
|
-
--color-danger: var(--danger);
|
|
48
|
-
--color-danger-soft: var(--danger-soft);
|
|
49
|
-
--color-warning: var(--warning);
|
|
50
|
-
--color-warning-soft: var(--warning-soft);
|
|
51
|
-
--color-success: var(--success);
|
|
52
|
-
--color-success-soft: var(--success-soft);
|
|
53
|
-
--color-info: var(--info);
|
|
54
|
-
--color-info-soft: var(--info-soft);
|
|
55
|
-
--radius-sm: calc(var(--radius) * 0.6);
|
|
56
|
-
--radius-md: calc(var(--radius) * 0.8);
|
|
57
|
-
--radius-lg: var(--radius);
|
|
58
|
-
--radius-xl: calc(var(--radius) * 1.4);
|
|
59
|
-
--radius-2xl: calc(var(--radius) * 1.8);
|
|
60
|
-
--radius-3xl: calc(var(--radius) * 2.2);
|
|
61
|
-
--radius-4xl: calc(var(--radius) * 2.6);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
:root {
|
|
65
|
-
--background: oklch(1 0 0);
|
|
66
|
-
--foreground: oklch(0.145 0 0);
|
|
67
|
-
--card: oklch(1 0 0);
|
|
68
|
-
--card-foreground: oklch(0.145 0 0);
|
|
69
|
-
--popover: oklch(1 0 0);
|
|
70
|
-
--popover-foreground: oklch(0.145 0 0);
|
|
71
|
-
--primary: oklch(0.205 0 0);
|
|
72
|
-
--primary-foreground: oklch(0.985 0 0);
|
|
73
|
-
--secondary: oklch(0.97 0 0);
|
|
74
|
-
--secondary-foreground: oklch(0.205 0 0);
|
|
75
|
-
--muted: oklch(0.97 0 0);
|
|
76
|
-
--muted-foreground: oklch(0.556 0 0);
|
|
77
|
-
--accent: oklch(0.97 0 0);
|
|
78
|
-
--accent-foreground: oklch(0.205 0 0);
|
|
79
|
-
--destructive: oklch(0.577 0.245 27.325);
|
|
80
|
-
--border: oklch(0.922 0 0);
|
|
81
|
-
--input: oklch(0.922 0 0);
|
|
82
|
-
--ring: oklch(0.708 0 0);
|
|
83
|
-
--chart-1: oklch(0.87 0 0);
|
|
84
|
-
--chart-2: oklch(0.556 0 0);
|
|
85
|
-
--chart-3: oklch(0.439 0 0);
|
|
86
|
-
--chart-4: oklch(0.371 0 0);
|
|
87
|
-
--chart-5: oklch(0.269 0 0);
|
|
88
|
-
--radius: 0.625rem;
|
|
89
|
-
--sidebar: oklch(0.985 0 0);
|
|
90
|
-
--sidebar-foreground: oklch(0.145 0 0);
|
|
91
|
-
--sidebar-primary: oklch(0.205 0 0);
|
|
92
|
-
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
93
|
-
--sidebar-accent: oklch(0.97 0 0);
|
|
94
|
-
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
95
|
-
--sidebar-border: oklch(0.922 0 0);
|
|
96
|
-
--sidebar-ring: oklch(0.708 0 0);
|
|
97
|
-
/* Semantic status tones (light). Foreground = readable on a card; *-soft =
|
|
98
|
-
a tinted surface for badges/dots/icon tiles. Neutral tone reuses the
|
|
99
|
-
muted ramp and needs no dedicated token. */
|
|
100
|
-
--danger: oklch(0.55 0.21 27.3);
|
|
101
|
-
--danger-soft: oklch(0.95 0.04 27.3);
|
|
102
|
-
--warning: oklch(0.62 0.16 70);
|
|
103
|
-
--warning-soft: oklch(0.95 0.05 80);
|
|
104
|
-
--success: oklch(0.58 0.15 152);
|
|
105
|
-
--success-soft: oklch(0.95 0.05 152);
|
|
106
|
-
--info: oklch(0.55 0.2 255);
|
|
107
|
-
--info-soft: oklch(0.95 0.04 255);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
.dark {
|
|
111
|
-
--background: oklch(0.145 0 0);
|
|
112
|
-
--foreground: oklch(0.985 0 0);
|
|
113
|
-
--card: oklch(0.205 0 0);
|
|
114
|
-
--card-foreground: oklch(0.985 0 0);
|
|
115
|
-
--popover: oklch(0.205 0 0);
|
|
116
|
-
--popover-foreground: oklch(0.985 0 0);
|
|
117
|
-
--primary: oklch(0.922 0 0);
|
|
118
|
-
--primary-foreground: oklch(0.205 0 0);
|
|
119
|
-
--secondary: oklch(0.269 0 0);
|
|
120
|
-
--secondary-foreground: oklch(0.985 0 0);
|
|
121
|
-
--muted: oklch(0.269 0 0);
|
|
122
|
-
--muted-foreground: oklch(0.708 0 0);
|
|
123
|
-
--accent: oklch(0.269 0 0);
|
|
124
|
-
--accent-foreground: oklch(0.985 0 0);
|
|
125
|
-
--destructive: oklch(0.704 0.191 22.216);
|
|
126
|
-
--border: oklch(1 0 0 / 10%);
|
|
127
|
-
--input: oklch(1 0 0 / 15%);
|
|
128
|
-
--ring: oklch(0.556 0 0);
|
|
129
|
-
--chart-1: oklch(0.87 0 0);
|
|
130
|
-
--chart-2: oklch(0.556 0 0);
|
|
131
|
-
--chart-3: oklch(0.439 0 0);
|
|
132
|
-
--chart-4: oklch(0.371 0 0);
|
|
133
|
-
--chart-5: oklch(0.269 0 0);
|
|
134
|
-
--sidebar: oklch(0.205 0 0);
|
|
135
|
-
--sidebar-foreground: oklch(0.985 0 0);
|
|
136
|
-
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
137
|
-
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
138
|
-
--sidebar-accent: oklch(0.269 0 0);
|
|
139
|
-
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
140
|
-
--sidebar-border: oklch(1 0 0 / 10%);
|
|
141
|
-
--sidebar-ring: oklch(0.556 0 0);
|
|
142
|
-
/* Semantic status tones (dark). Brighter foregrounds for contrast on the
|
|
143
|
-
dark surface; *-soft is a low-alpha tint of the same hue. */
|
|
144
|
-
--danger: oklch(0.7 0.19 22.2);
|
|
145
|
-
--danger-soft: oklch(0.7 0.19 22.2 / 18%);
|
|
146
|
-
--warning: oklch(0.78 0.15 75);
|
|
147
|
-
--warning-soft: oklch(0.78 0.15 75 / 18%);
|
|
148
|
-
--success: oklch(0.72 0.17 152);
|
|
149
|
-
--success-soft: oklch(0.72 0.17 152 / 18%);
|
|
150
|
-
--info: oklch(0.7 0.16 255);
|
|
151
|
-
--info-soft: oklch(0.7 0.16 255 / 18%);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
* {
|
|
155
|
-
font-family: "Geist Variable", ui-sans-serif, system-ui, sans-serif !important;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
@layer base {
|
|
159
|
-
*,
|
|
160
|
-
*::before,
|
|
161
|
-
*::after {
|
|
162
|
-
@apply border-border outline-ring/50;
|
|
163
|
-
}
|
|
164
|
-
html,
|
|
165
|
-
body {
|
|
166
|
-
@apply bg-background text-foreground;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
decodeRecoveredResult,
|
|
4
|
+
isRecoveryReady,
|
|
5
|
+
resolveRecoveryToolName,
|
|
6
|
+
} from "./use-tool-result-recovery.js"
|
|
3
7
|
|
|
4
8
|
// The hook's effect wiring (single-flight, StrictMode re-run, host-payload
|
|
5
9
|
// precedence) is exercised end-to-end by the consumer's Playwright host
|
|
@@ -63,3 +67,23 @@ describe("decodeRecoveredResult", () => {
|
|
|
63
67
|
expect(decodeRecoveredResult({ content: [] }, isView)).toBeNull()
|
|
64
68
|
})
|
|
65
69
|
})
|
|
70
|
+
|
|
71
|
+
describe("isRecoveryReady (toolkit#176)", () => {
|
|
72
|
+
const base = { resultReady: false, assumedReady: false, cancelled: false, errored: false }
|
|
73
|
+
|
|
74
|
+
it("fires once a result latched or the stripped-result grace period passed", () => {
|
|
75
|
+
expect(isRecoveryReady(base)).toBe(false)
|
|
76
|
+
expect(isRecoveryReady({ ...base, resultReady: true })).toBe(true)
|
|
77
|
+
expect(isRecoveryReady({ ...base, assumedReady: true })).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("never re-executes a call that failed (K14: isError renders the error instead)", () => {
|
|
81
|
+
expect(isRecoveryReady({ ...base, resultReady: true, errored: true })).toBe(false)
|
|
82
|
+
expect(isRecoveryReady({ ...base, assumedReady: true, errored: true })).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("never re-executes a call the host cancelled", () => {
|
|
86
|
+
expect(isRecoveryReady({ ...base, resultReady: true, cancelled: true })).toBe(false)
|
|
87
|
+
expect(isRecoveryReady({ ...base, assumedReady: true, cancelled: true })).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -23,7 +23,10 @@ import { parseToolResult } from "../lib/parse-tool-result.js"
|
|
|
23
23
|
* A call the host cancelled is never recovered: since mcp-use 2.7.1 the
|
|
24
24
|
* cancellation latches as `status: "error"` (a `ToolCancelledError`), which
|
|
25
25
|
* would otherwise count as a ready-but-invalid result — re-executing it would
|
|
26
|
-
* override the user's decision to stop the call.
|
|
26
|
+
* override the user's decision to stop the call. Neither is a call that
|
|
27
|
+
* failed (`isError` or a transport error, {@link ToolResultRecoveryOptions.errored}):
|
|
28
|
+
* re-running it only repeats the failure, so the shell renders the error and
|
|
29
|
+
* offers an explicit retry instead (toolkit#176).
|
|
27
30
|
*
|
|
28
31
|
* Assumes the originating tool is a read-only render pipeline (true for all
|
|
29
32
|
* toolkit `render-view`/`show_*` tools) — the re-execution must be safe to
|
|
@@ -53,6 +56,14 @@ export interface ToolResultRecoveryOptions {
|
|
|
53
56
|
* attempt already in flight is dropped. Defaults to `false`.
|
|
54
57
|
*/
|
|
55
58
|
cancelled?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* The originating call latched a tool error (`useToolContext().status ===
|
|
61
|
+
* "error"` without a cancellation): an `isError` result or a failed call.
|
|
62
|
+
* Suppresses the recovery exactly like {@link cancelled} — the shell shows
|
|
63
|
+
* the error instead of silently re-running a call that just failed
|
|
64
|
+
* (toolkit#176). Defaults to `false`.
|
|
65
|
+
*/
|
|
66
|
+
errored?: boolean
|
|
56
67
|
/** The payload the host delivered (`useToolContext().toolOutput`). */
|
|
57
68
|
props: unknown
|
|
58
69
|
/**
|
|
@@ -124,12 +135,28 @@ export function decodeRecoveredResult<T>(
|
|
|
124
135
|
return decoded !== null && isValid(decoded) ? (decoded as T) : null
|
|
125
136
|
}
|
|
126
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Whether the recovery may fire: the result latched (or is assumed stripped)
|
|
140
|
+
* and the call was neither cancelled nor failed. Exported for tests — this is
|
|
141
|
+
* the #176 rule "never recover on an error status".
|
|
142
|
+
*/
|
|
143
|
+
export function isRecoveryReady(state: {
|
|
144
|
+
resultReady: boolean
|
|
145
|
+
assumedReady: boolean
|
|
146
|
+
cancelled: boolean
|
|
147
|
+
errored: boolean
|
|
148
|
+
}): boolean {
|
|
149
|
+
if (state.cancelled || state.errored) return false
|
|
150
|
+
return state.resultReady || state.assumedReady
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
export function useToolResultRecovery<T>(
|
|
128
154
|
options: ToolResultRecoveryOptions,
|
|
129
155
|
): ToolResultRecoveryState<T> {
|
|
130
156
|
const {
|
|
131
157
|
resultReady,
|
|
132
158
|
cancelled = false,
|
|
159
|
+
errored = false,
|
|
133
160
|
props,
|
|
134
161
|
isValid,
|
|
135
162
|
toolInput,
|
|
@@ -160,15 +187,16 @@ export function useToolResultRecovery<T>(
|
|
|
160
187
|
setPrevTimerInput(toolInput)
|
|
161
188
|
if (assumedReady) setAssumedReady(false)
|
|
162
189
|
}
|
|
190
|
+
const suppressed = cancelled || errored
|
|
163
191
|
useEffect(() => {
|
|
164
|
-
if (resultReady ||
|
|
192
|
+
if (resultReady || suppressed || toolName === undefined) return
|
|
165
193
|
const timer = setTimeout(() => setAssumedReady(true), assumeStrippedAfterMs)
|
|
166
194
|
return () => clearTimeout(timer)
|
|
167
|
-
}, [resultReady,
|
|
195
|
+
}, [resultReady, suppressed, toolName, toolInput, assumeStrippedAfterMs])
|
|
168
196
|
|
|
169
|
-
// A cancelled call never counts as ready, so the effect below
|
|
170
|
-
// any in-flight attempt the same way it does for a superseded run.
|
|
171
|
-
const ready =
|
|
197
|
+
// A cancelled or failed call never counts as ready, so the effect below
|
|
198
|
+
// invalidates any in-flight attempt the same way it does for a superseded run.
|
|
199
|
+
const ready = isRecoveryReady({ resultReady, assumedReady, cancelled, errored })
|
|
172
200
|
|
|
173
201
|
useEffect(() => {
|
|
174
202
|
// A new tool run (result no longer ready, or fresh tool-input identity)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
2
|
+
import { describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { AppQueryProvider } from "../providers/query-provider.js"
|
|
4
|
+
import { useViewData, type ViewDataResult } from "./use-view-data.js"
|
|
5
|
+
|
|
6
|
+
type Demo = { n: number }
|
|
7
|
+
type CallTool = (name: string, args: object) => Promise<unknown>
|
|
8
|
+
|
|
9
|
+
/** Renders a probe once (SSR, the house path) and hands back the hook's result. */
|
|
10
|
+
function probe(
|
|
11
|
+
initialData: Demo | null,
|
|
12
|
+
ready: boolean,
|
|
13
|
+
callTool: CallTool,
|
|
14
|
+
key: string,
|
|
15
|
+
): ViewDataResult<Demo> {
|
|
16
|
+
let result: ViewDataResult<Demo> | undefined
|
|
17
|
+
function Probe() {
|
|
18
|
+
result = useViewData<Demo>(
|
|
19
|
+
initialData,
|
|
20
|
+
["use-view-data.test", key],
|
|
21
|
+
"demo_data",
|
|
22
|
+
{ id: 1 },
|
|
23
|
+
ready,
|
|
24
|
+
)
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
renderToStaticMarkup(
|
|
28
|
+
<AppQueryProvider callTool={callTool}>
|
|
29
|
+
<Probe />
|
|
30
|
+
</AppQueryProvider>,
|
|
31
|
+
)
|
|
32
|
+
if (!result) throw new Error("probe did not render")
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("useViewData", () => {
|
|
37
|
+
it("returns host-pushed data verbatim without fetching", () => {
|
|
38
|
+
const callTool = vi.fn<CallTool>(() => Promise.resolve({}))
|
|
39
|
+
const result = probe({ n: 1 }, true, callTool, "pushed")
|
|
40
|
+
expect(result.data).toEqual({ n: 1 })
|
|
41
|
+
expect(result.loading).toBe(false)
|
|
42
|
+
expect(result.error).toBeNull()
|
|
43
|
+
expect(callTool).not.toHaveBeenCalled()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("is not loading before it is ready", () => {
|
|
47
|
+
const result = probe(null, false, vi.fn<CallTool>(), "not-ready")
|
|
48
|
+
expect(result).toMatchObject({ data: null, loading: false, error: null })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("refetch calls the tool again: the retry an error state offers", async () => {
|
|
52
|
+
const callTool = vi.fn<CallTool>(() => Promise.resolve({ structuredContent: { n: 2 } }))
|
|
53
|
+
const result = probe(null, false, callTool, "refetch")
|
|
54
|
+
expect(callTool).not.toHaveBeenCalled()
|
|
55
|
+
result.refetch()
|
|
56
|
+
await vi.waitFor(() => {
|
|
57
|
+
expect(callTool).toHaveBeenCalledWith("demo_data", { id: 1 })
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useCallback } from "react"
|
|
1
2
|
import { useToolQuery } from "./use-tool-query.js"
|
|
2
3
|
|
|
3
4
|
export interface ViewDataResult<T> {
|
|
@@ -5,6 +6,12 @@ export interface ViewDataResult<T> {
|
|
|
5
6
|
/** True while self-fetching with no data yet — the first paint of a cockpit cell. */
|
|
6
7
|
loading: boolean
|
|
7
8
|
error: Error | null
|
|
9
|
+
/**
|
|
10
|
+
* Fetch `tool` again: the retry of an error state
|
|
11
|
+
* (`<ErrorState onRetry={refetch} … />`). Host-pushed data, when present,
|
|
12
|
+
* stays what `data` returns.
|
|
13
|
+
*/
|
|
14
|
+
refetch: () => void
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
/**
|
|
@@ -27,5 +34,9 @@ export function useViewData<T>(
|
|
|
27
34
|
): ViewDataResult<T> {
|
|
28
35
|
const query = useToolQuery<T>([...key], tool, args, { enabled: !initialData && ready })
|
|
29
36
|
const data = initialData ?? query.data ?? null
|
|
30
|
-
|
|
37
|
+
const { refetch: refetchQuery } = query
|
|
38
|
+
const refetch = useCallback(() => {
|
|
39
|
+
void refetchQuery()
|
|
40
|
+
}, [refetchQuery])
|
|
41
|
+
return { data, loading: !data && ready && !query.isError, error: query.error ?? null, refetch }
|
|
31
42
|
}
|