@seed-ship/mcp-ui-solid 6.4.0 → 6.6.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/README.md +37 -0
  3. package/dist/adapters/connector.cjs +112 -0
  4. package/dist/adapters/connector.cjs.map +1 -0
  5. package/dist/adapters/connector.d.ts +71 -0
  6. package/dist/adapters/connector.d.ts.map +1 -0
  7. package/dist/adapters/connector.js +112 -0
  8. package/dist/adapters/connector.js.map +1 -0
  9. package/dist/adapters/index.d.ts +18 -0
  10. package/dist/adapters/index.d.ts.map +1 -0
  11. package/dist/adapters.cjs +6 -0
  12. package/dist/adapters.cjs.map +1 -0
  13. package/dist/adapters.d.cts +18 -0
  14. package/dist/adapters.d.ts +18 -0
  15. package/dist/adapters.js +6 -0
  16. package/dist/adapters.js.map +1 -0
  17. package/dist/components/ExpandableWrapper.cjs +24 -6
  18. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  19. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  20. package/dist/components/ExpandableWrapper.js +24 -6
  21. package/dist/components/ExpandableWrapper.js.map +1 -1
  22. package/dist/components/FeedbackInline.cjs +6 -2
  23. package/dist/components/FeedbackInline.cjs.map +1 -1
  24. package/dist/components/FeedbackInline.d.ts +2 -2
  25. package/dist/components/FeedbackInline.d.ts.map +1 -1
  26. package/dist/components/FeedbackInline.js +7 -3
  27. package/dist/components/FeedbackInline.js.map +1 -1
  28. package/dist/components/PresentationFeedback.cjs +207 -0
  29. package/dist/components/PresentationFeedback.cjs.map +1 -0
  30. package/dist/components/PresentationFeedback.d.ts +113 -0
  31. package/dist/components/PresentationFeedback.d.ts.map +1 -0
  32. package/dist/components/PresentationFeedback.js +207 -0
  33. package/dist/components/PresentationFeedback.js.map +1 -0
  34. package/dist/components/StreamingUIRenderer.cjs +82 -195
  35. package/dist/components/StreamingUIRenderer.cjs.map +1 -1
  36. package/dist/components/StreamingUIRenderer.d.ts +25 -5
  37. package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
  38. package/dist/components/StreamingUIRenderer.js +84 -197
  39. package/dist/components/StreamingUIRenderer.js.map +1 -1
  40. package/dist/components/UIResourceRenderer.cjs +40 -10
  41. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  42. package/dist/components/UIResourceRenderer.d.ts +20 -0
  43. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  44. package/dist/components/UIResourceRenderer.js +42 -12
  45. package/dist/components/UIResourceRenderer.js.map +1 -1
  46. package/dist/components/index.d.ts +2 -0
  47. package/dist/components/index.d.ts.map +1 -1
  48. package/dist/components.cjs +3 -0
  49. package/dist/components.cjs.map +1 -1
  50. package/dist/components.d.cts +2 -0
  51. package/dist/components.d.ts +2 -0
  52. package/dist/components.js +3 -0
  53. package/dist/components.js.map +1 -1
  54. package/dist/context/MCPUIStringsContext.cjs +38 -0
  55. package/dist/context/MCPUIStringsContext.cjs.map +1 -0
  56. package/dist/context/MCPUIStringsContext.d.ts +95 -0
  57. package/dist/context/MCPUIStringsContext.d.ts.map +1 -0
  58. package/dist/context/MCPUIStringsContext.js +38 -0
  59. package/dist/context/MCPUIStringsContext.js.map +1 -0
  60. package/dist/index.cjs +12 -0
  61. package/dist/index.cjs.map +1 -1
  62. package/dist/index.d.cts +8 -0
  63. package/dist/index.d.ts +8 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +12 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/mcp-ui-spec/dist/schemas.cjs +103 -0
  68. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  69. package/dist/mcp-ui-spec/dist/schemas.js +103 -0
  70. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  71. package/dist/utils/duplicate-mount-registry.cjs +27 -0
  72. package/dist/utils/duplicate-mount-registry.cjs.map +1 -0
  73. package/dist/utils/duplicate-mount-registry.d.ts +84 -0
  74. package/dist/utils/duplicate-mount-registry.d.ts.map +1 -0
  75. package/dist/utils/duplicate-mount-registry.js +27 -0
  76. package/dist/utils/duplicate-mount-registry.js.map +1 -0
  77. package/dist/utils/stable-key.cjs +41 -0
  78. package/dist/utils/stable-key.cjs.map +1 -0
  79. package/dist/utils/stable-key.d.ts +33 -0
  80. package/dist/utils/stable-key.d.ts.map +1 -0
  81. package/dist/utils/stable-key.js +41 -0
  82. package/dist/utils/stable-key.js.map +1 -0
  83. package/docs/briefs/ROADMAP-opendata-macro-mcpui.md +912 -0
  84. package/package.json +17 -5
  85. package/src/adapters/connector.test.ts +165 -0
  86. package/src/adapters/connector.ts +234 -0
  87. package/src/adapters/index.ts +24 -0
  88. package/src/components/ExpandableWrapper.test.tsx +5 -2
  89. package/src/components/ExpandableWrapper.tsx +8 -6
  90. package/src/components/FeedbackInline.test.tsx +6 -3
  91. package/src/components/FeedbackInline.tsx +8 -6
  92. package/src/components/PresentationFeedback.test.tsx +163 -0
  93. package/src/components/PresentationFeedback.tsx +326 -0
  94. package/src/components/StreamingUIRenderer.parity.test.tsx +158 -0
  95. package/src/components/StreamingUIRenderer.tsx +42 -166
  96. package/src/components/UIResourceRenderer.identity.test.tsx +161 -0
  97. package/src/components/UIResourceRenderer.tsx +63 -2
  98. package/src/components/index.ts +10 -0
  99. package/src/context/MCPUIStringsContext.test.tsx +116 -0
  100. package/src/context/MCPUIStringsContext.tsx +128 -0
  101. package/src/index.ts +35 -0
  102. package/src/utils/duplicate-mount-registry.test.ts +82 -0
  103. package/src/utils/duplicate-mount-registry.ts +113 -0
  104. package/src/utils/stable-key.test.ts +96 -0
  105. package/src/utils/stable-key.ts +91 -0
  106. package/tsconfig.tsbuildinfo +1 -1
  107. package/vite.config.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -73,6 +73,17 @@
73
73
  "default": "./dist/types-export.cjs"
74
74
  }
75
75
  },
76
+ "./adapters": {
77
+ "solid": "./src/adapters/index.ts",
78
+ "import": {
79
+ "types": "./dist/adapters.d.ts",
80
+ "default": "./dist/adapters.js"
81
+ },
82
+ "require": {
83
+ "types": "./dist/adapters.d.cts",
84
+ "default": "./dist/adapters.cjs"
85
+ }
86
+ },
76
87
  "./plugins/duckdb": {
77
88
  "solid": "./src/plugins/duckdb.ts",
78
89
  "import": {
@@ -86,12 +97,13 @@
86
97
  },
87
98
  "./src/*": "./src/*"
88
99
  },
100
+ "_size-limit-note": "size-limit is a regression guardrail, not a hard cap — it flags accidental bundle bloat. It measures the pre-built dist/index.js and sums the FULL graph reachable from each entry, including lazy import() chunks (leaflet, @antv/g6, chart.js) a consumer fetches on demand, NOT at import time — so the figures are worst-case totals, not eager load cost. Since v6.6.0 the 'Streaming renderer' entry delegates to UIResourceRenderer (Gap 1 parity), so its graph equals the full renderer set; its budget is set to 1 MB (generous headroom — the real eager cost is far lower). The 'Hooks only' and 'Full bundle' limits are pre-existing and unchanged by v6.6.0. size-limit is informational only — it does not gate CI.",
89
101
  "size-limit": [
90
102
  {
91
103
  "name": "Streaming renderer",
92
104
  "path": "dist/index.js",
93
105
  "import": "{ StreamingUIRenderer }",
94
- "limit": "30 KB"
106
+ "limit": "1 MB"
95
107
  },
96
108
  {
97
109
  "name": "Hooks only",
@@ -143,7 +155,7 @@
143
155
  }
144
156
  },
145
157
  "dependencies": {
146
- "@seed-ship/mcp-ui-spec": "^5.0.5",
158
+ "@seed-ship/mcp-ui-spec": "^5.1.0",
147
159
  "@types/dompurify": "^3.0.5",
148
160
  "dompurify": "^3.4.1",
149
161
  "marked": "^16.3.0",
@@ -195,8 +207,8 @@
195
207
  "scripts": {
196
208
  "build": "vite build && pnpm build:types",
197
209
  "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist --composite false && pnpm build:types:copy",
198
- "build:types:copy": "cp dist/components/index.d.ts dist/components.d.ts 2>/dev/null || true && cp dist/hooks/index.d.ts dist/hooks.d.ts 2>/dev/null || true && cp dist/types/index.d.ts dist/types.d.ts 2>/dev/null || true && pnpm build:types:cts",
199
- "build:types:cts": "cp dist/index.d.ts dist/index.d.cts && cp dist/components.d.ts dist/components.d.cts 2>/dev/null || true && cp dist/hooks.d.ts dist/hooks.d.cts 2>/dev/null || true && cp dist/types.d.ts dist/types.d.cts 2>/dev/null || true && cp dist/validation.d.ts dist/validation.d.cts 2>/dev/null || true && cp dist/types-export.d.ts dist/types-export.d.cts 2>/dev/null || true",
210
+ "build:types:copy": "cp dist/components/index.d.ts dist/components.d.ts 2>/dev/null || true && cp dist/hooks/index.d.ts dist/hooks.d.ts 2>/dev/null || true && cp dist/types/index.d.ts dist/types.d.ts 2>/dev/null || true && cp dist/adapters/index.d.ts dist/adapters.d.ts 2>/dev/null || true && pnpm build:types:cts",
211
+ "build:types:cts": "cp dist/index.d.ts dist/index.d.cts && cp dist/components.d.ts dist/components.d.cts 2>/dev/null || true && cp dist/hooks.d.ts dist/hooks.d.cts 2>/dev/null || true && cp dist/types.d.ts dist/types.d.cts 2>/dev/null || true && cp dist/validation.d.ts dist/validation.d.cts 2>/dev/null || true && cp dist/types-export.d.ts dist/types-export.d.cts 2>/dev/null || true && cp dist/adapters.d.ts dist/adapters.d.cts 2>/dev/null || true",
200
212
  "dev": "vite build --watch",
201
213
  "test": "vitest run",
202
214
  "test:watch": "vitest",
@@ -0,0 +1,165 @@
1
+ /**
2
+ * v6.6.0 — connector adapters (D5 / D6 of ROADMAP-opendata-macro-mcpui).
3
+ *
4
+ * Coverage:
5
+ * 1. connectorActionsToActionGroup wraps actions into an action-group
6
+ * 2. connectorResultToUILayout assembles primary + supplemental + actions
7
+ * 3. primary that is itself a layout has its components spread in
8
+ * 4. unknown schemaVersion + usable envelope → degraded-but-rendered (R2)
9
+ * 5. unreadable payload → explicit error layout, never throws (R2)
10
+ * 6. purity — same input yields a deep-equal output
11
+ * 7. every shipped spec fixture assembles without a degraded layout
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest'
15
+ import { readFileSync, readdirSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import {
18
+ connectorResultToUILayout,
19
+ connectorActionsToActionGroup,
20
+ } from './connector'
21
+ import { CONNECTOR_DYNAMIC_RESULT_V1 } from '@seed-ship/mcp-ui-spec'
22
+
23
+ const VALID = {
24
+ schemaVersion: CONNECTOR_DYNAMIC_RESULT_V1,
25
+ connectorId: 'datagouv',
26
+ toolName: 'datagouv.search',
27
+ query: 'immobilier toulouse',
28
+ queryHash: 'abc123',
29
+ primary: {
30
+ id: 'tbl',
31
+ type: 'table',
32
+ position: { colStart: 1, colSpan: 12 },
33
+ params: { columns: [{ key: 'a', label: 'A' }], rows: [] },
34
+ },
35
+ supplemental: [
36
+ { id: 'mtr', type: 'metric', position: { colStart: 1, colSpan: 4 }, params: { title: 'M', value: 1 } },
37
+ ],
38
+ actions: [
39
+ { label: 'Compare', action: 'tool-call', toolName: 'datagouv.search', params: { query: 'x' } },
40
+ ],
41
+ }
42
+
43
+ describe('connectorActionsToActionGroup (v6.6.0)', () => {
44
+ it('wraps actions into an action-group UIComponent', () => {
45
+ const ag = connectorActionsToActionGroup(VALID.actions as never)
46
+ expect(ag.type).toBe('action-group')
47
+ expect((ag.params as { actions: unknown[] }).actions).toHaveLength(1)
48
+ expect(ag.position).toEqual({ colStart: 1, colSpan: 12 })
49
+ })
50
+
51
+ it('honors id / title / layout / gap options', () => {
52
+ const ag = connectorActionsToActionGroup(VALID.actions as never, {
53
+ id: 'my-actions',
54
+ title: 'Suite',
55
+ layout: 'end',
56
+ gap: 'sm',
57
+ })
58
+ expect(ag.id).toBe('my-actions')
59
+ const params = ag.params as { title: string; layout: string; gap: string }
60
+ expect(params.title).toBe('Suite')
61
+ expect(params.layout).toBe('end')
62
+ expect(params.gap).toBe('sm')
63
+ })
64
+ })
65
+
66
+ describe('connectorResultToUILayout (v6.6.0)', () => {
67
+ it('assembles primary + supplemental + actions into one UILayout', () => {
68
+ const layout = connectorResultToUILayout(VALID)
69
+ // primary (1) + supplemental (1) + action-group (1)
70
+ expect(layout.components).toHaveLength(3)
71
+ expect(layout.components[0].id).toBe('tbl')
72
+ expect(layout.components[1].id).toBe('mtr')
73
+ expect(layout.components[2].type).toBe('action-group')
74
+ expect(layout.grid.columns).toBe(12)
75
+ })
76
+
77
+ it('derives a stable layout id from connectorId + queryHash', () => {
78
+ const layout = connectorResultToUILayout(VALID)
79
+ expect(layout.id).toBe('connector-datagouv-abc123')
80
+ })
81
+
82
+ it('omits the action-group when there are no actions', () => {
83
+ const { actions, ...noActions } = VALID
84
+ void actions
85
+ const layout = connectorResultToUILayout(noActions)
86
+ expect(layout.components.every((c) => c.type !== 'action-group')).toBe(true)
87
+ })
88
+
89
+ it('spreads the components of a primary that is itself a layout', () => {
90
+ const withLayoutPrimary = {
91
+ ...VALID,
92
+ primary: {
93
+ id: 'inner',
94
+ components: [
95
+ { id: 'p1', type: 'metric', position: { colStart: 1, colSpan: 6 }, params: {} },
96
+ { id: 'p2', type: 'metric', position: { colStart: 7, colSpan: 6 }, params: {} },
97
+ ],
98
+ grid: { columns: 12, gap: '1rem' },
99
+ },
100
+ }
101
+ const layout = connectorResultToUILayout(withLayoutPrimary)
102
+ const ids = layout.components.map((c) => c.id)
103
+ expect(ids).toContain('p1')
104
+ expect(ids).toContain('p2')
105
+ })
106
+
107
+ it('fills a missing grid position with full-width', () => {
108
+ const noPos = {
109
+ ...VALID,
110
+ supplemental: undefined,
111
+ actions: undefined,
112
+ primary: { id: 'np', type: 'text', params: { content: 'x' } },
113
+ }
114
+ const layout = connectorResultToUILayout(noPos)
115
+ expect(layout.components[0].position).toEqual({ colStart: 1, colSpan: 12 })
116
+ })
117
+
118
+ // ── R2 — unknown schemaVersion, never throw ───────────────
119
+ it('renders a degraded-but-usable layout for an unknown schemaVersion', () => {
120
+ const futureVersion = { ...VALID, schemaVersion: 'connector-dynamic-result/v2' }
121
+ const layout = connectorResultToUILayout(futureVersion)
122
+ expect(layout.id).toMatch(/^connector-degraded/)
123
+ // First component is the version warning, the real components follow.
124
+ expect(layout.components[0].id).toBe('connector-version-warning')
125
+ expect(layout.components.some((c) => c.id === 'tbl')).toBe(true)
126
+ })
127
+
128
+ it('renders an explicit error layout for an unreadable payload — never throws', () => {
129
+ for (const bad of [null, undefined, 42, 'nope', {}, { foo: 'bar' }]) {
130
+ let layout!: ReturnType<typeof connectorResultToUILayout>
131
+ expect(() => {
132
+ layout = connectorResultToUILayout(bad)
133
+ }).not.toThrow()
134
+ expect(layout.id).toBe('connector-degraded')
135
+ expect(layout.components[0].id).toBe('connector-degraded-notice')
136
+ expect(layout.components[0].type).toBe('text')
137
+ }
138
+ })
139
+
140
+ it('is pure — same input yields a deep-equal output', () => {
141
+ expect(connectorResultToUILayout(VALID)).toEqual(connectorResultToUILayout(VALID))
142
+ })
143
+ })
144
+
145
+ describe('connector adapters — spec fixtures', () => {
146
+ const FIXTURE_DIR = join(
147
+ __dirname,
148
+ '..',
149
+ '..',
150
+ '..',
151
+ 'mcp-ui-spec',
152
+ 'examples',
153
+ 'connector'
154
+ )
155
+ const fixtures = readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.json'))
156
+
157
+ for (const file of fixtures) {
158
+ it(`fixture ${file} assembles into a non-degraded layout`, () => {
159
+ const raw = JSON.parse(readFileSync(join(FIXTURE_DIR, file), 'utf8'))
160
+ const layout = connectorResultToUILayout(raw)
161
+ expect(layout.id).not.toMatch(/^connector-degraded/)
162
+ expect(layout.components.length).toBeGreaterThan(0)
163
+ })
164
+ }
165
+ })
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Connector adapters — `ConnectorDynamicResultV1` → MCP-UI render structures.
3
+ *
4
+ * @since v6.6.0 (D5 / D6 of ROADMAP-opendata-macro-mcpui)
5
+ *
6
+ * ## Opt-in, pure
7
+ *
8
+ * These adapters are published under the dedicated subpath
9
+ * `@seed-ship/mcp-ui-solid/adapters` — they are NEVER imported by the core
10
+ * renderer path, so a consumer that does not emit connector results pays
11
+ * nothing for them.
12
+ *
13
+ * Every adapter here is a **pure function** (D5) : same input → same
14
+ * output, no `fetch`, no `localStorage`, no global state, no clock, no
15
+ * randomness. This is what lets a host re-run an adapter deterministically
16
+ * after presentation feedback (D1) and replay fixtures in tests.
17
+ *
18
+ * ## Unknown schema version — never throw (R2)
19
+ *
20
+ * `connectorResultToUILayout()` never throws on the runtime render path.
21
+ * A payload it cannot read becomes an explicit degraded `UILayout` (a
22
+ * visible notice), never a silent disappearance and never an exception.
23
+ */
24
+
25
+ import type { UIComponent, UILayout, GridPosition } from '../types'
26
+ import {
27
+ ConnectorDynamicResultV1Schema,
28
+ CONNECTOR_DYNAMIC_RESULT_V1,
29
+ type ConnectorAction,
30
+ } from '@seed-ship/mcp-ui-spec'
31
+ import { z } from 'zod'
32
+
33
+ // ─────────────────────────────────────────────────────────────
34
+ // connectorActionsToActionGroup
35
+ // ─────────────────────────────────────────────────────────────
36
+
37
+ export interface ConnectorActionsToActionGroupOptions {
38
+ /** Component id. Default `'connector-actions'`. */
39
+ id?: string
40
+ /** Optional heading shown above the buttons. */
41
+ title?: string
42
+ /** Button row layout. Default `'horizontal'`. */
43
+ layout?: 'horizontal' | 'vertical' | 'space-between' | 'end' | 'center'
44
+ /** Gap between buttons. Default `'md'`. */
45
+ gap?: 'none' | 'sm' | 'md' | 'lg'
46
+ /** Grid position. Default full-width. */
47
+ position?: GridPosition
48
+ }
49
+
50
+ /**
51
+ * Wraps a connector's `actions` into an `action-group` `UIComponent`.
52
+ *
53
+ * `ConnectorAction` is the exact `action-group` action shape, so this is a
54
+ * thin, pure envelope — no transformation of the actions themselves.
55
+ */
56
+ export function connectorActionsToActionGroup(
57
+ actions: ConnectorAction[],
58
+ options: ConnectorActionsToActionGroupOptions = {}
59
+ ): UIComponent {
60
+ return {
61
+ id: options.id ?? 'connector-actions',
62
+ type: 'action-group',
63
+ position: options.position ?? { colStart: 1, colSpan: 12 },
64
+ params: {
65
+ actions,
66
+ ...(options.title ? { title: options.title } : {}),
67
+ layout: options.layout ?? 'horizontal',
68
+ gap: options.gap ?? 'md',
69
+ },
70
+ } as UIComponent
71
+ }
72
+
73
+ // ─────────────────────────────────────────────────────────────
74
+ // connectorResultToUILayout
75
+ // ─────────────────────────────────────────────────────────────
76
+
77
+ export interface ConnectorResultToUILayoutOptions {
78
+ /** Layout id. Default derived from `connectorId` + `queryHash` / `toolName`. */
79
+ id?: string
80
+ /** Heading for the actions `action-group`. */
81
+ actionsTitle?: string
82
+ }
83
+
84
+ /**
85
+ * Lenient mirror of `ConnectorDynamicResultV1Schema` — `schemaVersion`
86
+ * relaxed to any string. Used to tell apart "unknown version but otherwise
87
+ * a usable envelope" (→ render with a warning, R2) from "truly unreadable"
88
+ * (→ explicit error state).
89
+ */
90
+ const LenientResultSchema = z.object({
91
+ schemaVersion: z.string(),
92
+ connectorId: z.string().min(1),
93
+ toolName: z.string().min(1),
94
+ query: z.string(),
95
+ queryHash: z.string().optional(),
96
+ intent: z.string().optional(),
97
+ primary: z.record(z.unknown()),
98
+ supplemental: z.array(z.record(z.unknown())).optional(),
99
+ actions: z.array(z.record(z.unknown())).optional(),
100
+ })
101
+
102
+ const DEFAULT_GRID = { columns: 12, gap: '1rem' } as const
103
+
104
+ function withPosition(component: UIComponent): UIComponent {
105
+ if (component && component.position) return component
106
+ return { ...component, position: { colStart: 1, colSpan: 12 } }
107
+ }
108
+
109
+ /** A component is a UILayout when it carries a `components` array. */
110
+ function isLayoutShape(value: Record<string, unknown>): boolean {
111
+ return Array.isArray((value as { components?: unknown }).components)
112
+ }
113
+
114
+ function degradedTextComponent(id: string, message: string): UIComponent {
115
+ return {
116
+ id,
117
+ type: 'text',
118
+ position: { colStart: 1, colSpan: 12 },
119
+ params: { markdown: true, content: message },
120
+ } as UIComponent
121
+ }
122
+
123
+ /**
124
+ * Assembles a `ConnectorDynamicResultV1` into a single `UILayout` :
125
+ * `primary` + `supplemental[]` + (`actions` → an `action-group`).
126
+ *
127
+ * - When `primary` is itself a layout, its components are spread in.
128
+ * - Raw data is never sacrificed — every supplied component is kept.
129
+ * - Pure : no side effects, deterministic.
130
+ *
131
+ * ### Degraded behavior (R2)
132
+ *
133
+ * - Valid v1 payload → normal assembled layout.
134
+ * - Unknown `schemaVersion` but an otherwise-usable envelope → the
135
+ * components are still rendered, prefixed with a visible warning notice.
136
+ * - Unreadable payload → an explicit error `UILayout` (a single `text`
137
+ * notice). Never throws, never returns an empty layout silently.
138
+ *
139
+ * A degraded layout always has an `id` starting with `connector-degraded`,
140
+ * so a host can detect it (e.g. for telemetry) without inspecting content.
141
+ */
142
+ export function connectorResultToUILayout(
143
+ result: unknown,
144
+ options: ConnectorResultToUILayoutOptions = {}
145
+ ): UILayout {
146
+ const strict = ConnectorDynamicResultV1Schema.safeParse(result)
147
+
148
+ // ── Tier 3 : unreadable ───────────────────────────────────
149
+ if (!strict.success) {
150
+ const lenient = LenientResultSchema.safeParse(result)
151
+ if (!lenient.success) {
152
+ const version =
153
+ result && typeof result === 'object'
154
+ ? (result as { schemaVersion?: unknown }).schemaVersion
155
+ : undefined
156
+ return {
157
+ id: 'connector-degraded',
158
+ components: [
159
+ degradedTextComponent(
160
+ 'connector-degraded-notice',
161
+ `### Résultat non rendu\n\nLe résultat du connecteur n'a pas pu être interprété${
162
+ typeof version === 'string' ? ` (schéma : \`${version}\`)` : ''
163
+ }. Cet état explicite remplace une disparition silencieuse du rendu.`
164
+ ),
165
+ ],
166
+ grid: { ...DEFAULT_GRID },
167
+ }
168
+ }
169
+ // ── Tier 2 : usable envelope, unknown version ───────────
170
+ const r = lenient.data
171
+ const components: UIComponent[] = [
172
+ degradedTextComponent(
173
+ 'connector-version-warning',
174
+ `> ⚠ Schéma connecteur non reconnu (\`${r.schemaVersion}\`, attendu \`${CONNECTOR_DYNAMIC_RESULT_V1}\`). Le rendu ci-dessous est en mode dégradé.`
175
+ ),
176
+ ...collectComponents(r),
177
+ ]
178
+ return {
179
+ id: options.id ?? `connector-degraded-${r.connectorId}`,
180
+ components: components.map(withPosition),
181
+ grid: { ...DEFAULT_GRID },
182
+ }
183
+ }
184
+
185
+ // ── Tier 1 : valid v1 ─────────────────────────────────────
186
+ const r = strict.data
187
+ const components = collectComponents(r, options.actionsTitle).map(withPosition)
188
+ return {
189
+ id: options.id ?? layoutId(r.connectorId, r.queryHash ?? r.toolName),
190
+ components,
191
+ grid: { ...DEFAULT_GRID },
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Flattens `primary` + `supplemental` + `actions` into a component list.
197
+ * Shared by the valid and degraded-but-usable paths.
198
+ */
199
+ function collectComponents(
200
+ r: {
201
+ primary: Record<string, unknown>
202
+ supplemental?: Record<string, unknown>[]
203
+ actions?: unknown[]
204
+ },
205
+ actionsTitle?: string
206
+ ): UIComponent[] {
207
+ const components: UIComponent[] = []
208
+
209
+ if (isLayoutShape(r.primary)) {
210
+ const inner = (r.primary as { components?: UIComponent[] }).components ?? []
211
+ components.push(...inner)
212
+ } else {
213
+ components.push(r.primary as unknown as UIComponent)
214
+ }
215
+
216
+ if (r.supplemental) {
217
+ components.push(...(r.supplemental as unknown as UIComponent[]))
218
+ }
219
+
220
+ if (Array.isArray(r.actions) && r.actions.length > 0) {
221
+ components.push(
222
+ connectorActionsToActionGroup(r.actions as ConnectorAction[], {
223
+ id: 'connector-actions',
224
+ title: actionsTitle,
225
+ })
226
+ )
227
+ }
228
+
229
+ return components
230
+ }
231
+
232
+ function layoutId(connectorId: string, suffix: string): string {
233
+ return `connector-${connectorId}-${suffix}`
234
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @seed-ship/mcp-ui-solid/adapters
3
+ *
4
+ * Opt-in, pure adapters that turn connector / macro contracts into MCP-UI
5
+ * render structures. Published as a dedicated subpath so the core renderer
6
+ * path never depends on them.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { connectorResultToUILayout } from '@seed-ship/mcp-ui-solid/adapters'
11
+ *
12
+ * const layout = connectorResultToUILayout(connectorResult)
13
+ * // → <UIResourceRenderer content={layout} />
14
+ * ```
15
+ */
16
+
17
+ export {
18
+ connectorResultToUILayout,
19
+ connectorActionsToActionGroup,
20
+ } from './connector'
21
+ export type {
22
+ ConnectorResultToUILayoutOptions,
23
+ ConnectorActionsToActionGroupOptions,
24
+ } from './connector'
@@ -143,7 +143,7 @@ describe('ExpandableWrapper', () => {
143
143
  expect(writeText).toHaveBeenCalledWith(testData)
144
144
  })
145
145
 
146
- it('uses default title "Expanded View" when no title provided', async () => {
146
+ it('uses default title "Expanded view" when no title provided', async () => {
147
147
  const { getByLabelText, getByText } = render(() => (
148
148
  <ExpandableWrapper>
149
149
  <div>Content</div>
@@ -152,7 +152,10 @@ describe('ExpandableWrapper', () => {
152
152
 
153
153
  fireEvent.click(getByLabelText('Expand to fullscreen'))
154
154
 
155
- expect(getByText('Expanded View')).toBeDefined()
155
+ // v6.6.0: default heading comes from MCPUIStrings.expandedView (D2).
156
+ // Also unified to a single casing — the pre-v6.6.0 code had
157
+ // 'Expanded View' as the heading but 'Expanded view' as the aria-label.
158
+ expect(getByText('Expanded view')).toBeDefined()
156
159
  })
157
160
 
158
161
  it('expanded content area is scrollable (overflow-auto)', async () => {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { Component, Show, createSignal, createEffect, onCleanup, JSX, createContext, useContext, Accessor } from 'solid-js'
10
10
  import { Portal } from 'solid-js/web'
11
+ import { useMCPUIStrings } from '../context/MCPUIStringsContext'
11
12
 
12
13
  /** Context for child components to know if they're in expanded/fullscreen view */
13
14
  const ExpandedContext = createContext<Accessor<boolean>>(() => false)
@@ -50,6 +51,7 @@ export interface ExpandableWrapperProps {
50
51
  export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
51
52
  const [isExpanded, setIsExpanded] = createSignal(false)
52
53
  const [copied, setCopied] = createSignal(false)
54
+ const strings = useMCPUIStrings()
53
55
  let dialogRef: HTMLDivElement | undefined
54
56
  let contentRef: HTMLDivElement | undefined
55
57
  let inlineSlotRef: HTMLDivElement | undefined
@@ -133,7 +135,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
133
135
  ? 'opacity-60 hover:opacity-100'
134
136
  : 'opacity-0 group-hover:opacity-70 hover:!opacity-100'
135
137
  } p-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm`}
136
- title="Expand"
138
+ title={strings.expand}
137
139
  aria-label="Expand to fullscreen"
138
140
  >
139
141
  <svg class="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -150,7 +152,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
150
152
  onClick={handleBackdropClick}
151
153
  role="dialog"
152
154
  aria-modal="true"
153
- aria-label={props.title || 'Expanded view'}
155
+ aria-label={props.title || strings.expandedView}
154
156
  tabIndex={-1}
155
157
  ref={dialogRef}
156
158
  >
@@ -163,7 +165,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
163
165
  {/* Header */}
164
166
  <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
165
167
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white truncate">
166
- {props.title || 'Expanded View'}
168
+ {props.title || strings.expandedView}
167
169
  </h2>
168
170
  <div class="flex items-center gap-2">
169
171
  {/* Copy button */}
@@ -171,8 +173,8 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
171
173
  <button
172
174
  onClick={handleCopy}
173
175
  class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
174
- title={props.copyLabel || 'Copy to clipboard'}
175
- aria-label={props.copyLabel || 'Copy to clipboard'}
176
+ title={props.copyLabel || strings.copyToClipboard}
177
+ aria-label={props.copyLabel || strings.copyToClipboard}
176
178
  >
177
179
  <Show
178
180
  when={!copied()}
@@ -192,7 +194,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
192
194
  <button
193
195
  onClick={handleClose}
194
196
  class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
195
- aria-label="Close expanded view"
197
+ aria-label={strings.closeExpandedView}
196
198
  >
197
199
  <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
198
200
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -37,7 +37,8 @@ describe('FeedbackInline — v5.2.0', () => {
37
37
  intent: 'search',
38
38
  confidenceBand: 'high',
39
39
  })
40
- expect(getByText('Merci !')).toBeDefined()
40
+ // v6.6.0: default ack is now EN (MCPUIStrings.feedbackPositiveAck) — R4.
41
+ expect(getByText('Thanks!')).toBeDefined()
41
42
  })
42
43
 
43
44
  it('click thumb-down calls onSubmit with negative and shows negative ack', () => {
@@ -49,7 +50,8 @@ describe('FeedbackInline — v5.2.0', () => {
49
50
  fireEvent.click(negative)
50
51
 
51
52
  expect(onSubmit).toHaveBeenCalledWith('negative', undefined)
52
- expect(getByText("Noté, on s'améliore")).toBeDefined()
53
+ // v6.6.0: default ack is now EN (MCPUIStrings.feedbackNegativeAck) R4.
54
+ expect(getByText("Noted — we'll improve")).toBeDefined()
53
55
  })
54
56
 
55
57
  it('second click after rating is a no-op (final state)', () => {
@@ -102,7 +104,8 @@ describe('FeedbackInline — v5.2.0', () => {
102
104
  ) as HTMLElement
103
105
  // Should not throw even though onSubmit rejects
104
106
  expect(() => fireEvent.click(positive)).not.toThrow()
105
- expect(getByText('Merci !')).toBeDefined()
107
+ // v6.6.0: default ack is now EN (MCPUIStrings.feedbackPositiveAck) — R4.
108
+ expect(getByText('Thanks!')).toBeDefined()
106
109
  })
107
110
 
108
111
  it('works without messageHash or context', () => {
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import { Component, Show, createSignal } from 'solid-js'
44
+ import { useMCPUIStrings } from '../context/MCPUIStringsContext'
44
45
 
45
46
  export interface FeedbackInlineContext {
46
47
  intent?: string
@@ -59,9 +60,9 @@ export interface FeedbackInlineProps {
59
60
  onSubmit: (rating: 'positive' | 'negative', context?: FeedbackInlineContext) => void | Promise<void>
60
61
  /** Extra context forwarded to `onSubmit`. */
61
62
  context?: FeedbackInlineContext
62
- /** Ack text shown after positive rating. Default: 'Merci !' */
63
+ /** Ack text shown after positive rating. Defaults to `MCPUIStrings.feedbackPositiveAck` ('Thanks!' in EN). */
63
64
  positiveAck?: string
64
- /** Ack text shown after negative rating. Default: "Noté, on s'améliore" */
65
+ /** Ack text shown after negative rating. Defaults to `MCPUIStrings.feedbackNegativeAck`. */
65
66
  negativeAck?: string
66
67
  /** Extra Tailwind classes on the container. */
67
68
  class?: string
@@ -73,6 +74,7 @@ export interface FeedbackInlineProps {
73
74
  */
74
75
  export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
75
76
  const [rating, setRating] = createSignal<'positive' | 'negative' | null>(null)
77
+ const strings = useMCPUIStrings()
76
78
 
77
79
  const handle = (value: 'positive' | 'negative') => {
78
80
  if (rating() !== null) return // already submitted, final state
@@ -98,8 +100,8 @@ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
98
100
  fallback={
99
101
  <span class="text-[11px] text-deposium-slate-500">
100
102
  {rating() === 'positive'
101
- ? (props.positiveAck ?? 'Merci !')
102
- : (props.negativeAck ?? "Noté, on s'améliore")}
103
+ ? (props.positiveAck ?? strings.feedbackPositiveAck)
104
+ : (props.negativeAck ?? strings.feedbackNegativeAck)}
103
105
  </span>
104
106
  }
105
107
  >
@@ -107,7 +109,7 @@ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
107
109
  type="button"
108
110
  onClick={() => handle('positive')}
109
111
  class="p-1 rounded hover:bg-green-500/10 text-deposium-slate-500 hover:text-green-500 transition-colors"
110
- title="Utile"
112
+ title={strings.feedbackUseful}
111
113
  aria-label="Mark response as useful"
112
114
  data-feedback-inline-rating="positive"
113
115
  >
@@ -124,7 +126,7 @@ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
124
126
  type="button"
125
127
  onClick={() => handle('negative')}
126
128
  class="p-1 rounded hover:bg-red-500/10 text-deposium-slate-500 hover:text-red-500 transition-colors"
127
- title="Pas utile"
129
+ title={strings.feedbackNotUseful}
128
130
  aria-label="Mark response as not useful"
129
131
  data-feedback-inline-rating="negative"
130
132
  >