@kitnai/chat 0.6.0 → 0.8.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 (211) hide show
  1. package/README.md +9 -9
  2. package/dist/custom-elements.json +1676 -881
  3. package/dist/kitn-chat.es.js +36 -36
  4. package/dist/llms/llms-full.txt +316 -155
  5. package/dist/llms/llms.txt +18 -18
  6. package/dist/schemas/card-envelope.schema.json +14 -0
  7. package/dist/schemas/card-event.schema.json +12 -0
  8. package/dist/schemas/confirm.schema.json +65 -0
  9. package/dist/schemas/embed.schema.json +65 -0
  10. package/dist/schemas/form.result.schema.json +7 -0
  11. package/dist/schemas/form.schema.json +33 -0
  12. package/dist/schemas/link.schema.json +56 -0
  13. package/dist/schemas/task-list.result.schema.json +16 -0
  14. package/dist/schemas/task-list.schema.json +78 -0
  15. package/dist/theme.tokens.css +65 -65
  16. package/dist/tsx-B8rCNbgL.js +1 -0
  17. package/dist/typescript-RycA9KXf.js +1 -0
  18. package/frameworks/react/index.tsx +382 -193
  19. package/frameworks/react/runtime.tsx +2 -2
  20. package/llms-full.txt +316 -155
  21. package/llms.txt +18 -18
  22. package/package.json +5 -2
  23. package/src/components/artifact.stories.tsx +138 -0
  24. package/src/components/artifact.tsx +581 -0
  25. package/src/components/attachments.stories.tsx +7 -8
  26. package/src/components/attachments.tsx +2 -2
  27. package/src/components/card.tsx +110 -0
  28. package/src/components/chain-of-thought.stories.tsx +7 -8
  29. package/src/components/chat-container.stories.tsx +7 -8
  30. package/src/components/chat-container.tsx +4 -0
  31. package/src/components/checkpoint.stories.tsx +7 -8
  32. package/src/components/code-block.stories.tsx +8 -9
  33. package/src/components/component-meta.json +3411 -0
  34. package/src/components/confirm-card.stories.tsx +74 -0
  35. package/src/components/confirm-card.tsx +299 -0
  36. package/src/components/context.stories.tsx +7 -8
  37. package/src/components/conversation-item.stories.tsx +7 -8
  38. package/src/components/conversation-item.tsx +2 -2
  39. package/src/components/conversation-list.stories.tsx +7 -8
  40. package/src/components/conversation-list.tsx +1 -1
  41. package/src/components/embed.tsx +196 -0
  42. package/src/components/empty.stories.tsx +8 -9
  43. package/src/components/feedback-bar.stories.tsx +7 -8
  44. package/src/components/file-tree.stories.tsx +73 -0
  45. package/src/components/file-tree.tsx +383 -0
  46. package/src/components/file-upload.stories.tsx +7 -8
  47. package/src/components/form-widgets.tsx +461 -0
  48. package/src/components/form.tsx +796 -0
  49. package/src/components/image.stories.tsx +7 -8
  50. package/src/components/link-card.tsx +194 -0
  51. package/src/components/loader.stories.tsx +7 -8
  52. package/src/components/markdown.stories.tsx +7 -8
  53. package/src/components/message-narrow.stories.tsx +12 -13
  54. package/src/components/message-skills.stories.tsx +16 -17
  55. package/src/components/message.stories.tsx +17 -18
  56. package/src/components/model-switcher.stories.tsx +7 -8
  57. package/src/components/prompt-input.stories.tsx +8 -9
  58. package/src/components/prompt-suggestion.stories.tsx +7 -8
  59. package/src/components/prompt-suggestion.tsx +3 -3
  60. package/src/components/reasoning.stories.tsx +7 -8
  61. package/src/components/scroll-button.stories.tsx +7 -8
  62. package/src/components/slash-command.stories.tsx +8 -9
  63. package/src/components/slash-command.tsx +2 -2
  64. package/src/components/source.stories.tsx +7 -8
  65. package/src/components/source.tsx +1 -1
  66. package/src/components/task-list-card.stories.tsx +78 -0
  67. package/src/components/task-list-card.tsx +388 -0
  68. package/src/components/text-shimmer.stories.tsx +7 -8
  69. package/src/components/thinking-bar.stories.tsx +7 -8
  70. package/src/components/tool.stories.tsx +7 -8
  71. package/src/components/tool.tsx +2 -2
  72. package/src/components/voice-input.stories.tsx +7 -8
  73. package/src/elements/artifact.stories.tsx +291 -0
  74. package/src/elements/artifact.tsx +72 -0
  75. package/src/elements/{kitn-attachments.stories.tsx → attachments.stories.tsx} +11 -11
  76. package/src/elements/attachments.tsx +4 -4
  77. package/src/elements/card.stories.tsx +118 -0
  78. package/src/elements/card.tsx +40 -0
  79. package/src/elements/catalog.stories.tsx +491 -0
  80. package/src/elements/{kitn-chain-of-thought.stories.tsx → chain-of-thought.stories.tsx} +13 -13
  81. package/src/elements/chain-of-thought.tsx +3 -3
  82. package/src/elements/{kitn-chat-scope-picker.stories.tsx → chat-scope-picker.stories.tsx} +10 -10
  83. package/src/elements/chat-scope-picker.tsx +4 -4
  84. package/src/elements/{kitn-chat-workspace.stories.tsx → chat-workspace.stories.tsx} +71 -29
  85. package/src/elements/chat-workspace.tsx +29 -3
  86. package/src/elements/{kitn-chat.stories.tsx → chat.stories.tsx} +61 -16
  87. package/src/elements/chat.tsx +23 -2
  88. package/src/elements/{kitn-checkpoint.stories.tsx → checkpoint.stories.tsx} +11 -11
  89. package/src/elements/checkpoint.tsx +4 -4
  90. package/src/elements/{kitn-code-block.stories.tsx → code-block.stories.tsx} +10 -10
  91. package/src/elements/code-block.tsx +3 -3
  92. package/src/elements/compiled.css +1 -1
  93. package/src/elements/composed-shell.stories.tsx +316 -0
  94. package/src/elements/confirm-card.stories.tsx +186 -0
  95. package/src/elements/confirm-card.tsx +45 -0
  96. package/src/elements/{kitn-context-meter.stories.tsx → context-meter.stories.tsx} +10 -10
  97. package/src/elements/context-meter.tsx +3 -3
  98. package/src/elements/{kitn-conversation-list.stories.tsx → conversation-list.stories.tsx} +35 -22
  99. package/src/elements/conversation-list.tsx +11 -2
  100. package/src/elements/css.ts +1 -1
  101. package/src/elements/define.tsx +10 -10
  102. package/src/elements/element-meta.json +2649 -0
  103. package/src/elements/element-types.d.ts +251 -125
  104. package/src/elements/embed.stories.tsx +197 -0
  105. package/src/elements/embed.tsx +35 -0
  106. package/src/elements/{kitn-empty.stories.tsx → empty.stories.tsx} +12 -12
  107. package/src/elements/empty.tsx +3 -3
  108. package/src/elements/{kitn-feedback-bar.stories.tsx → feedback-bar.stories.tsx} +11 -11
  109. package/src/elements/feedback-bar.tsx +4 -4
  110. package/src/elements/file-tree.stories.tsx +133 -0
  111. package/src/elements/file-tree.tsx +52 -0
  112. package/src/elements/{kitn-file-upload.stories.tsx → file-upload.stories.tsx} +12 -12
  113. package/src/elements/file-upload.tsx +4 -4
  114. package/src/elements/form.stories.tsx +204 -0
  115. package/src/elements/form.tsx +37 -0
  116. package/src/elements/{kitn-image.stories.tsx → image.stories.tsx} +10 -10
  117. package/src/elements/image.tsx +3 -3
  118. package/src/elements/link-card.stories.tsx +193 -0
  119. package/src/elements/link-card.tsx +34 -0
  120. package/src/elements/{kitn-loader.stories.tsx → loader.stories.tsx} +11 -11
  121. package/src/elements/loader.tsx +3 -3
  122. package/src/elements/{kitn-markdown.stories.tsx → markdown.stories.tsx} +10 -10
  123. package/src/elements/markdown.tsx +3 -3
  124. package/src/elements/{kitn-message-skills.stories.tsx → message-skills.stories.tsx} +10 -10
  125. package/src/elements/message-skills.tsx +3 -3
  126. package/src/elements/{kitn-message.stories.tsx → message.stories.tsx} +12 -12
  127. package/src/elements/message.tsx +5 -5
  128. package/src/elements/{kitn-model-switcher.stories.tsx → model-switcher.stories.tsx} +10 -10
  129. package/src/elements/model-switcher.tsx +5 -5
  130. package/src/elements/{kitn-prompt-input.stories.tsx → prompt-input.stories.tsx} +41 -19
  131. package/src/elements/prompt-input.tsx +5 -5
  132. package/src/elements/{kitn-prompt-suggestions.stories.tsx → prompt-suggestions.stories.tsx} +13 -13
  133. package/src/elements/prompt-suggestions.tsx +4 -4
  134. package/src/elements/{kitn-reasoning.stories.tsx → reasoning.stories.tsx} +10 -10
  135. package/src/elements/reasoning.tsx +4 -4
  136. package/src/elements/register.ts +11 -1
  137. package/src/elements/resizable.stories.tsx +200 -0
  138. package/src/elements/resizable.tsx +264 -0
  139. package/src/elements/{kitn-response-stream.stories.tsx → response-stream.stories.tsx} +10 -10
  140. package/src/elements/response-stream.tsx +4 -4
  141. package/src/elements/{kitn-source-list.stories.tsx → source-list.stories.tsx} +11 -11
  142. package/src/elements/{kitn-source.stories.tsx → source.stories.tsx} +12 -12
  143. package/src/elements/source.tsx +5 -5
  144. package/src/elements/styles.css +140 -1
  145. package/src/elements/task-list-card.stories.tsx +194 -0
  146. package/src/elements/task-list-card.tsx +40 -0
  147. package/src/elements/{kitn-text-shimmer.stories.tsx → text-shimmer.stories.tsx} +10 -10
  148. package/src/elements/text-shimmer.tsx +3 -3
  149. package/src/elements/{kitn-thinking-bar.stories.tsx → thinking-bar.stories.tsx} +11 -11
  150. package/src/elements/thinking-bar.tsx +5 -5
  151. package/src/elements/{kitn-tool.stories.tsx → tool.stories.tsx} +10 -10
  152. package/src/elements/tool.tsx +3 -3
  153. package/src/elements/{kitn-voice-input.stories.tsx → voice-input.stories.tsx} +10 -10
  154. package/src/elements/voice-input.tsx +4 -4
  155. package/src/index.ts +94 -2
  156. package/src/primitives/card-contract.ts +60 -0
  157. package/src/primitives/card-host.tsx +35 -0
  158. package/src/primitives/card-routing.ts +79 -0
  159. package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
  160. package/src/primitives/card-schemas/card-event.schema.json +12 -0
  161. package/src/primitives/card-schemas/confirm.schema.json +65 -0
  162. package/src/primitives/card-schemas/embed.schema.json +65 -0
  163. package/src/primitives/card-schemas/form.result.schema.json +7 -0
  164. package/src/primitives/card-schemas/form.schema.json +33 -0
  165. package/src/primitives/card-schemas/link.schema.json +56 -0
  166. package/src/primitives/card-schemas/task-list.result.schema.json +16 -0
  167. package/src/primitives/card-schemas/task-list.schema.json +78 -0
  168. package/src/primitives/card-validate.ts +95 -0
  169. package/src/primitives/embed-providers.ts +254 -0
  170. package/src/primitives/highlighter.ts +4 -0
  171. package/src/primitives/link-preview.ts +87 -0
  172. package/src/primitives/pdf-preview.ts +121 -0
  173. package/src/stories/chat-panel-layout.stories.tsx +2 -1
  174. package/src/stories/chat-scene.tsx +22 -21
  175. package/src/stories/checkpoint-restore.stories.tsx +10 -10
  176. package/src/stories/conversation-with-reasoning.stories.tsx +4 -4
  177. package/src/stories/conversation-with-sources.stories.tsx +7 -7
  178. package/src/stories/docs/Accessibility.mdx +2 -2
  179. package/src/stories/docs/ForAIAgents.mdx +3 -3
  180. package/src/stories/docs/GettingStarted.mdx +2 -2
  181. package/src/stories/docs/Installation.mdx +2 -2
  182. package/src/stories/docs/Integrations.mdx +29 -29
  183. package/src/stories/docs/Introduction.mdx +3 -3
  184. package/src/stories/docs/Theming.mdx +2 -2
  185. package/src/stories/docs/element-controls.ts +60 -0
  186. package/src/stories/docs/theme-editor/theme-editor.tsx +1 -0
  187. package/src/stories/examples/ChoosingComponents.mdx +94 -0
  188. package/src/stories/examples/sample-data.ts +79 -0
  189. package/src/stories/message-actions.stories.tsx +13 -13
  190. package/src/stories/pattern-centered-conversation.stories.tsx +3 -3
  191. package/src/stories/pattern-docked-widget.stories.tsx +1 -1
  192. package/src/stories/pattern-empty-state.stories.tsx +3 -3
  193. package/src/stories/prompt-input-variants.stories.tsx +13 -13
  194. package/src/stories/streaming-response.stories.tsx +3 -3
  195. package/src/stories/typography.stories.tsx +4 -4
  196. package/src/ui/avatar.stories.tsx +7 -8
  197. package/src/ui/badge.stories.tsx +7 -8
  198. package/src/ui/button.stories.tsx +8 -9
  199. package/src/ui/button.tsx +1 -0
  200. package/src/ui/collapsible.stories.tsx +6 -7
  201. package/src/ui/dropdown.stories.tsx +6 -7
  202. package/src/ui/hover-card.stories.tsx +6 -7
  203. package/src/ui/resizable.stories.tsx +74 -9
  204. package/src/ui/resizable.tsx +351 -71
  205. package/src/ui/scroll-area.stories.tsx +6 -7
  206. package/src/ui/scroll-area.tsx +3 -1
  207. package/src/ui/separator.stories.tsx +7 -8
  208. package/src/ui/skeleton.stories.tsx +7 -8
  209. package/src/ui/textarea.stories.tsx +6 -7
  210. package/src/ui/tooltip.stories.tsx +8 -9
  211. package/theme.css +65 -65
@@ -0,0 +1,796 @@
1
+ import {
2
+ type JSX,
3
+ For,
4
+ Show,
5
+ Index,
6
+ splitProps,
7
+ mergeProps,
8
+ createSignal,
9
+ createMemo,
10
+ createEffect,
11
+ on,
12
+ ErrorBoundary,
13
+ } from 'solid-js';
14
+ import { createStore, produce, unwrap } from 'solid-js/store';
15
+ import { cn } from '../utils/cn';
16
+ import { Button } from '../ui/button';
17
+ import { Card } from './card';
18
+ import {
19
+ validateAgainstSchema,
20
+ type JsonSchema,
21
+ } from '../primitives/card-validate';
22
+ import type { CardEnvelope, CardEvent, CardHost } from '../primitives/card-contract';
23
+ import { emitCardEvent } from '../primitives/card-routing';
24
+ import { useCardHost } from '../primitives/card-host';
25
+ import {
26
+ TextWidget,
27
+ TextareaWidget,
28
+ NumberWidget,
29
+ SliderWidget,
30
+ RatingWidget,
31
+ SwitchWidget,
32
+ CheckboxWidget,
33
+ RadioGroupWidget,
34
+ SelectWidget,
35
+ CheckboxGroupWidget,
36
+ MultiSelectWidget,
37
+ TagListWidget,
38
+ } from './form-widgets';
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // Types (the JSON-Schema subset kc-form renders) — see form.schema.json.
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ /** A field definition (the JSON Schema subset kc-form renders). */
45
+ export interface FormField {
46
+ type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
47
+ title?: string;
48
+ description?: string;
49
+ default?: unknown;
50
+ enum?: unknown[];
51
+ format?: 'email' | 'uri' | 'url' | 'date' | 'date-time' | 'time';
52
+ minimum?: number;
53
+ maximum?: number;
54
+ minLength?: number;
55
+ maxLength?: number;
56
+ pattern?: string;
57
+ minItems?: number;
58
+ maxItems?: number;
59
+ items?: FormField | { enum: unknown[] };
60
+ properties?: Record<string, FormField>;
61
+ required?: string[];
62
+ readOnly?: boolean;
63
+ 'x-kc-widget'?:
64
+ | 'textarea'
65
+ | 'slider'
66
+ | 'rating'
67
+ | 'radio'
68
+ | 'select'
69
+ | 'checkbox'
70
+ | 'password'
71
+ | 'switch';
72
+ 'x-kc-placeholder'?: string;
73
+ 'x-kc-step'?: number;
74
+ }
75
+
76
+ /** The form definition = CardEnvelope.data for type:'form'. */
77
+ export interface FormDefinition {
78
+ type: 'object';
79
+ title?: string;
80
+ description?: string;
81
+ required?: string[];
82
+ properties: Record<string, FormField>;
83
+ 'x-kc-order'?: string[];
84
+ 'x-kc-inlineMax'?: number;
85
+ 'x-kc-submitLabel'?: string;
86
+ 'x-kc-dismissible'?: boolean;
87
+ 'x-kc-actions'?: { id: string; label: string; variant?: 'default' | 'ghost' | 'outline' }[];
88
+ }
89
+
90
+ export type FormCardEnvelope = CardEnvelope<'form', FormDefinition>;
91
+
92
+ /** The internal widget identifiers `widgetFor` resolves to. */
93
+ export type WidgetKind =
94
+ | 'text'
95
+ | 'textarea'
96
+ | 'password'
97
+ | 'email'
98
+ | 'url'
99
+ | 'date'
100
+ | 'datetime'
101
+ | 'time'
102
+ | 'number'
103
+ | 'slider'
104
+ | 'rating'
105
+ | 'switch'
106
+ | 'checkbox'
107
+ | 'radio'
108
+ | 'select'
109
+ | 'checkbox-group'
110
+ | 'multiselect'
111
+ | 'repeater'
112
+ | 'taglist'
113
+ | 'fieldset'
114
+ | 'unsupported';
115
+
116
+ export const DEFAULT_INLINE_MAX = 4;
117
+
118
+ const VALID_HINTS = new Set([
119
+ 'textarea',
120
+ 'slider',
121
+ 'rating',
122
+ 'radio',
123
+ 'select',
124
+ 'checkbox',
125
+ 'password',
126
+ 'switch',
127
+ ]);
128
+
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ // Pure mapping / validation / coercion helpers (unit-tested in isolation).
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ /** Resolve the widget for a field. An explicit valid `x-kc-widget` always wins;
134
+ * otherwise the type/format/enum/constraint combination selects the widget. */
135
+ export function widgetFor(field: FormField, inlineMax: number): WidgetKind {
136
+ const hint = field['x-kc-widget'];
137
+ if (hint && VALID_HINTS.has(hint)) {
138
+ switch (hint) {
139
+ case 'textarea':
140
+ return 'textarea';
141
+ case 'slider':
142
+ return 'slider';
143
+ case 'rating':
144
+ return 'rating';
145
+ case 'radio':
146
+ return 'radio';
147
+ case 'select':
148
+ return 'select';
149
+ case 'checkbox':
150
+ return 'checkbox';
151
+ case 'password':
152
+ return 'password';
153
+ case 'switch':
154
+ return 'switch';
155
+ }
156
+ }
157
+
158
+ switch (field.type) {
159
+ case 'string': {
160
+ if (Array.isArray(field.enum)) {
161
+ return field.enum.length <= inlineMax ? 'radio' : 'select';
162
+ }
163
+ switch (field.format) {
164
+ case 'email':
165
+ return 'email';
166
+ case 'uri':
167
+ case 'url':
168
+ return 'url';
169
+ case 'date':
170
+ return 'date';
171
+ case 'date-time':
172
+ return 'datetime';
173
+ case 'time':
174
+ return 'time';
175
+ }
176
+ if (field.maxLength !== undefined && field.maxLength > 120) return 'textarea';
177
+ return 'text';
178
+ }
179
+ case 'number':
180
+ case 'integer':
181
+ return 'number';
182
+ case 'boolean':
183
+ return 'switch';
184
+ case 'array': {
185
+ const items = field.items;
186
+ if (items && 'enum' in items && Array.isArray(items.enum)) {
187
+ return items.enum.length <= inlineMax ? 'checkbox-group' : 'multiselect';
188
+ }
189
+ if (items && 'type' in items && (items as FormField).type === 'object') return 'repeater';
190
+ if (items && 'type' in items && (items as FormField).type === 'string') return 'taglist';
191
+ return 'taglist';
192
+ }
193
+ case 'object':
194
+ return 'fieldset';
195
+ default:
196
+ return 'unsupported';
197
+ }
198
+ }
199
+
200
+ /** Humanize a camelCase / snake_case property key into a label. */
201
+ export function humanize(key: string): string {
202
+ const spaced = key
203
+ .replace(/[_-]+/g, ' ')
204
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
205
+ .trim();
206
+ return spaced.replace(/\b\w/g, (c) => c.toUpperCase());
207
+ }
208
+
209
+ /** Field order: `x-kc-order` (filtered to known keys, missing appended) if
210
+ * present, else `required` first then schema declaration order. */
211
+ export function orderedKeys(def: FormDefinition): string[] {
212
+ const all = Object.keys(def.properties ?? {});
213
+ const order = def['x-kc-order'];
214
+ if (Array.isArray(order)) {
215
+ const known = order.filter((k) => all.includes(k));
216
+ const rest = all.filter((k) => !known.includes(k));
217
+ return [...known, ...rest];
218
+ }
219
+ const required = (def.required ?? []).filter((k) => all.includes(k));
220
+ const rest = all.filter((k) => !required.includes(k));
221
+ return [...required, ...rest];
222
+ }
223
+
224
+ /** Coerce a raw control value to the field's JSON type. Empty number string →
225
+ * undefined; number/integer → Number; boolean → real boolean. */
226
+ export function coerceValue(field: FormField, raw: unknown): unknown {
227
+ if (field.type === 'number' || field.type === 'integer') {
228
+ if (raw === '' || raw === null || raw === undefined) return undefined;
229
+ const n = typeof raw === 'number' ? raw : Number(raw);
230
+ return Number.isNaN(n) ? raw : n;
231
+ }
232
+ if (field.type === 'boolean') return Boolean(raw);
233
+ return raw;
234
+ }
235
+
236
+ const EMAIL_RE = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$';
237
+
238
+ /** Translate a FormField into the lean-validator JsonSchema (incl. format→pattern). */
239
+ function toJsonSchema(field: FormField): JsonSchema {
240
+ const s: JsonSchema = { type: field.type };
241
+ if (field.enum) s.enum = field.enum;
242
+ if (field.minimum !== undefined) s.minimum = field.minimum;
243
+ if (field.maximum !== undefined) s.maximum = field.maximum;
244
+ if (field.minLength !== undefined) s.minLength = field.minLength;
245
+ if (field.maxLength !== undefined) s.maxLength = field.maxLength;
246
+ if (field.minItems !== undefined) s.minItems = field.minItems;
247
+ if (field.maxItems !== undefined) s.maxItems = field.maxItems;
248
+ if (field.pattern !== undefined) s.pattern = field.pattern;
249
+ else if (field.format === 'email') s.pattern = EMAIL_RE;
250
+ return s;
251
+ }
252
+
253
+ export interface FormValidation {
254
+ valid: boolean;
255
+ fieldErrors: Record<string, string>;
256
+ }
257
+
258
+ function isEmpty(v: unknown): boolean {
259
+ return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
260
+ }
261
+
262
+ /** Full client-side validation of `values` against the form definition. Returns a
263
+ * per-field error map (the contract validator subset, applied field-by-field so
264
+ * each field can show its own inline message). */
265
+ export function validateForm(def: FormDefinition, values: Record<string, unknown>): FormValidation {
266
+ const fieldErrors: Record<string, string> = {};
267
+ const required = new Set(def.required ?? []);
268
+
269
+ for (const [key, field] of Object.entries(def.properties ?? {})) {
270
+ const v = values[key];
271
+ if (required.has(key) && isEmpty(v)) {
272
+ fieldErrors[key] = `${field.title ?? humanize(key)} is required.`;
273
+ continue;
274
+ }
275
+ if (isEmpty(v)) continue; // optional + empty → skip per-field checks
276
+ const result = validateAgainstSchema(toJsonSchema(field), v);
277
+ if (!result.valid) {
278
+ fieldErrors[key] = friendlyError(field, key, result.errors[0]);
279
+ }
280
+ }
281
+
282
+ return { valid: Object.keys(fieldErrors).length === 0, fieldErrors };
283
+ }
284
+
285
+ function friendlyError(field: FormField, key: string, raw?: string): string {
286
+ const label = field.title ?? humanize(key);
287
+ if (!raw) return `${label} is invalid.`;
288
+ if (raw.includes('minimum')) return `${label} must be at least ${field.minimum}.`;
289
+ if (raw.includes('maximum')) return `${label} must be at most ${field.maximum}.`;
290
+ if (raw.includes('minLength')) return `${label} must be at least ${field.minLength} characters.`;
291
+ if (raw.includes('maxLength')) return `${label} must be at most ${field.maxLength} characters.`;
292
+ if (raw.includes('pattern')) {
293
+ return field.format === 'email'
294
+ ? `${label} must be a valid email address.`
295
+ : `${label} is not in the expected format.`;
296
+ }
297
+ if (raw.includes('one of')) return `${label} must be one of the allowed options.`;
298
+ if (raw.includes('expected integer')) return `${label} must be a whole number.`;
299
+ if (raw.includes('expected')) return `${label} is invalid.`;
300
+ if (raw.includes('minItems')) return `${label}: choose at least ${field.minItems}.`;
301
+ if (raw.includes('maxItems')) return `${label}: choose at most ${field.maxItems}.`;
302
+ return `${label} is invalid.`;
303
+ }
304
+
305
+ /** Build the result object: coerced values with empty optional fields omitted.
306
+ * `false` and `0` are kept (they are real values, not "empty"). */
307
+ export function buildResult(
308
+ def: FormDefinition,
309
+ values: Record<string, unknown>,
310
+ ): Record<string, unknown> {
311
+ const out: Record<string, unknown> = {};
312
+ for (const key of Object.keys(def.properties ?? {})) {
313
+ const field = def.properties[key];
314
+ const coerced = coerceValue(field, values[key]);
315
+ if (coerced === undefined || coerced === '' ) continue;
316
+ if (Array.isArray(coerced) && coerced.length === 0) continue;
317
+ out[key] = coerced;
318
+ }
319
+ return out;
320
+ }
321
+
322
+ // ─────────────────────────────────────────────────────────────────────────────
323
+ // The <Form> component.
324
+ // ─────────────────────────────────────────────────────────────────────────────
325
+
326
+ export interface FormProps {
327
+ /** The form definition (CardEnvelope.data). */
328
+ data?: FormDefinition;
329
+ /** The card id used to correlate every emitted CardEvent. */
330
+ cardId?: string;
331
+ /** The envelope title rendered in the card chrome. */
332
+ heading?: string;
333
+ /** Optional explicit CardHost (otherwise read from a CardProvider, otherwise the
334
+ * bubbling `kc-card` CustomEvent off `hostElement`). */
335
+ host?: CardHost;
336
+ /** The custom-element host node, for the bubbling `kc-card` fallback emit. */
337
+ hostElement?: HTMLElement;
338
+ class?: string;
339
+ }
340
+
341
+ const DEFAULT_FORM: FormDefinition = { type: 'object', properties: {} };
342
+
343
+ /**
344
+ * `Form` — renders a JSON-Schema form definition into themed, accessible widgets
345
+ * inside `Card` chrome, validates input against that schema, and emits the
346
+ * collected, coerced, validated object up the Card contract as `submit-data`.
347
+ * Reads context/emits via a `CardProvider` when present, else the bubbling
348
+ * `kc-card` CustomEvent.
349
+ */
350
+ export function Form(props: FormProps): JSX.Element {
351
+ const merged = mergeProps({ cardId: 'kc-form' }, props);
352
+ const [local] = splitProps(merged, ['data', 'cardId', 'heading', 'host', 'hostElement', 'class']);
353
+
354
+ const ctxHost = useCardHost();
355
+
356
+ const emit = (event: CardEvent): void => {
357
+ const h = local.host ?? ctxHost;
358
+ if (h) h.emit(event);
359
+ else if (local.hostElement) emitCardEvent(local.hostElement, event);
360
+ };
361
+
362
+ // Validate the incoming definition against form.schema.json's shape (the lean
363
+ // subset). A malformed definition → inline error + an `error` event.
364
+ const envelopeValid = createMemo(() => {
365
+ const d = local.data;
366
+ if (!d) return { ok: false, message: 'No form definition provided.' };
367
+ if (d.type !== 'object' || typeof d.properties !== 'object' || d.properties === null) {
368
+ return { ok: false, message: "This form couldn't be displayed." };
369
+ }
370
+ return { ok: true as const, message: '' };
371
+ });
372
+
373
+ const def = createMemo<FormDefinition>(() => (envelopeValid().ok ? local.data ?? DEFAULT_FORM : DEFAULT_FORM));
374
+ const inlineMax = () => def()['x-kc-inlineMax'] ?? DEFAULT_INLINE_MAX;
375
+ const keys = createMemo(() => orderedKeys(def()));
376
+
377
+ // The reactive values store, seeded from each field's `default`.
378
+ const [values, setValues] = createStore<Record<string, unknown>>({});
379
+ const [errors, setErrors] = createStore<Record<string, string>>({});
380
+ const [submitted, setSubmitted] = createSignal(false);
381
+
382
+ const seed = (d: FormDefinition): void => {
383
+ const next: Record<string, unknown> = {};
384
+ for (const [key, field] of Object.entries(d.properties ?? {})) {
385
+ if (field.default !== undefined) next[key] = field.default;
386
+ else if (field.type === 'array') next[key] = [];
387
+ }
388
+ setValues(produce((s) => {
389
+ for (const k of Object.keys(s)) delete s[k];
390
+ Object.assign(s, next);
391
+ }));
392
+ setErrors(produce((s) => {
393
+ for (const k of Object.keys(s)) delete s[k];
394
+ }));
395
+ setSubmitted(false);
396
+ };
397
+
398
+ // Reseed whenever a NEW valid definition arrives.
399
+ createEffect(on(() => local.data, () => { if (envelopeValid().ok) seed(def()); }));
400
+
401
+ // ready + error lifecycle emits.
402
+ createEffect(on(envelopeValid, (state) => {
403
+ if (state.ok) emit({ kind: 'ready', cardId: local.cardId });
404
+ else emit({ kind: 'error', cardId: local.cardId, message: state.message });
405
+ }));
406
+
407
+ const setField = (key: string, raw: unknown): void => {
408
+ setValues(key, raw);
409
+ if (errors[key]) setErrors(key, undefined as unknown as string);
410
+ };
411
+
412
+ const validateField = (key: string): void => {
413
+ const field = def().properties[key];
414
+ if (!field) return;
415
+ const single = validateForm(
416
+ { type: 'object', required: def().required, properties: { [key]: field } },
417
+ { [key]: values[key] },
418
+ );
419
+ setErrors(key, single.fieldErrors[key]);
420
+ };
421
+
422
+ const onSubmit = (e: Event): void => {
423
+ e.preventDefault();
424
+ // Capture the <form> synchronously — `e.currentTarget` is nulled out once the
425
+ // event has finished dispatching (so it can't be read in a later microtask).
426
+ const formEl = e.currentTarget as HTMLElement | null;
427
+ const snapshot = unwrap(values);
428
+ const result = validateForm(def(), snapshot as Record<string, unknown>);
429
+ setErrors(produce((s) => {
430
+ for (const k of Object.keys(s)) delete s[k];
431
+ Object.assign(s, result.fieldErrors);
432
+ }));
433
+ if (!result.valid) {
434
+ const firstBad = keys().find((k) => result.fieldErrors[k]);
435
+ if (firstBad && local.hostElement) {
436
+ // Focus the first invalid control (light-DOM query inside shadow root).
437
+ queueMicrotask(() => {
438
+ const root: ParentNode = formEl?.closest('form') ?? formEl ?? document;
439
+ root.querySelector<HTMLElement>(`[data-field="${cssEscape(firstBad)}"] [data-control]`)?.focus();
440
+ });
441
+ }
442
+ return;
443
+ }
444
+ const out = buildResult(def(), snapshot as Record<string, unknown>);
445
+ emit({ kind: 'submit-data', cardId: local.cardId, data: out });
446
+ setSubmitted(true);
447
+ };
448
+
449
+ const actions = createMemo(() => def()['x-kc-actions'] ?? []);
450
+ const submitLabel = () => def()['x-kc-submitLabel'] ?? 'Submit';
451
+ const dismissible = () => def()['x-kc-dismissible'] === true;
452
+
453
+ return (
454
+ <Show
455
+ when={envelopeValid().ok}
456
+ fallback={<Card heading={local.heading} errorMessage={envelopeValid().message} />}
457
+ >
458
+ <ErrorBoundary
459
+ fallback={() => {
460
+ emit({ kind: 'error', cardId: local.cardId, message: 'The form failed to render.' });
461
+ return <Card heading={local.heading} errorMessage="The form failed to render." />;
462
+ }}
463
+ >
464
+ <Card
465
+ heading={local.heading ?? def().title}
466
+ description={def().description}
467
+ actions={
468
+ <div class="flex w-full flex-wrap items-center justify-between gap-2">
469
+ <Show when={dismissible()}>
470
+ <Button
471
+ type="button"
472
+ variant="ghost"
473
+ onClick={() => emit({ kind: 'dismiss', cardId: local.cardId })}
474
+ >
475
+ Dismiss
476
+ </Button>
477
+ </Show>
478
+ <div class="ml-auto flex flex-wrap items-center gap-2">
479
+ <For each={actions()}>
480
+ {(action) => (
481
+ <Button
482
+ type="button"
483
+ variant={action.variant ?? 'ghost'}
484
+ onClick={() => emit({ kind: 'action', cardId: local.cardId, action: action.id })}
485
+ >
486
+ {action.label}
487
+ </Button>
488
+ )}
489
+ </For>
490
+ <Button type="submit" form={formId()} disabled={submitted()}>
491
+ {submitLabel()}
492
+ </Button>
493
+ </div>
494
+ </div>
495
+ }
496
+ >
497
+ <form
498
+ id={formId()}
499
+ class={cn('flex flex-col gap-3', local.class)}
500
+ novalidate
501
+ onSubmit={onSubmit}
502
+ >
503
+ <For each={keys()}>
504
+ {(key) => (
505
+ <FieldRow
506
+ fieldKey={key}
507
+ field={def().properties[key]}
508
+ required={(def().required ?? []).includes(key)}
509
+ inlineMax={inlineMax()}
510
+ value={() => values[key]}
511
+ error={() => errors[key]}
512
+ disabled={submitted()}
513
+ onInput={(v) => setField(key, v)}
514
+ onBlur={() => validateField(key)}
515
+ />
516
+ )}
517
+ </For>
518
+
519
+ <Show when={submitted()}>
520
+ <p role="status" class="text-sm text-muted-foreground">
521
+ Submitted. Thank you.
522
+ </p>
523
+ </Show>
524
+ </form>
525
+ </Card>
526
+ </ErrorBoundary>
527
+ </Show>
528
+ );
529
+ }
530
+
531
+ // A stable per-instance form id so the footer submit button can target the form.
532
+ let formIdCounter = 0;
533
+ const formIdValue = `kc-form-${++formIdCounter}`;
534
+ function formId(): string {
535
+ return formIdValue;
536
+ }
537
+
538
+ // ─────────────────────────────────────────────────────────────────────────────
539
+ // Per-field row: label + control + help + error, dispatching to the right widget.
540
+ // ─────────────────────────────────────────────────────────────────────────────
541
+
542
+ interface FieldRowProps {
543
+ fieldKey: string;
544
+ field: FormField;
545
+ required: boolean;
546
+ inlineMax: number;
547
+ value: () => unknown;
548
+ error: () => string | undefined;
549
+ disabled: boolean;
550
+ onInput: (value: unknown) => void;
551
+ onBlur: () => void;
552
+ }
553
+
554
+ function FieldRow(props: FieldRowProps): JSX.Element {
555
+ const id = `f-${props.fieldKey}-${Math.random().toString(36).slice(2, 8)}`;
556
+ const errorId = `${id}-err`;
557
+ const descId = `${id}-desc`;
558
+ const label = () => props.field.title ?? humanize(props.fieldKey);
559
+ const widget = createMemo(() => widgetFor(props.field, props.inlineMax));
560
+ const placeholder = () => props.field['x-kc-placeholder'];
561
+ const describedBy = () =>
562
+ [props.field.description ? descId : '', props.error() ? errorId : '']
563
+ .filter(Boolean)
564
+ .join(' ') || undefined;
565
+
566
+ const common = () => ({
567
+ id,
568
+ value: props.value(),
569
+ field: props.field,
570
+ disabled: props.disabled || props.field.readOnly === true,
571
+ placeholder: placeholder(),
572
+ required: props.required,
573
+ invalid: Boolean(props.error()),
574
+ describedBy: describedBy(),
575
+ label: label(),
576
+ onInput: props.onInput,
577
+ onBlur: props.onBlur,
578
+ });
579
+
580
+ // A nested fieldset / repeater / checkbox-group provide their own grouping
581
+ // label, so the row's <label> is only rendered for simple single controls.
582
+ const isGrouped = () =>
583
+ ['fieldset', 'repeater', 'checkbox-group', 'multiselect', 'radio', 'taglist'].includes(widget());
584
+
585
+ return (
586
+ <div class="flex flex-col gap-2 rounded-xl bg-muted/40 p-3.5" data-field={props.fieldKey}>
587
+ <Show when={!isGrouped()}>
588
+ <label for={id} class="text-sm font-medium text-foreground">
589
+ {label()}
590
+ <Show when={props.required}>
591
+ <span class="text-destructive dark:text-red-400" aria-hidden="true">{' *'}</span>
592
+ </Show>
593
+ </label>
594
+ </Show>
595
+
596
+ <Show when={props.field.description}>
597
+ <p id={descId} class="text-xs text-muted-foreground">
598
+ {props.field.description}
599
+ </p>
600
+ </Show>
601
+
602
+ <WidgetSwitch widget={widget()} common={common()} fieldKey={props.fieldKey} />
603
+
604
+ <Show when={props.error()}>
605
+ <p id={errorId} role="alert" class="text-xs text-destructive dark:text-red-400">
606
+ {props.error()}
607
+ </p>
608
+ </Show>
609
+ </div>
610
+ );
611
+ }
612
+
613
+ interface WidgetSwitchProps {
614
+ widget: WidgetKind;
615
+ fieldKey: string;
616
+ common: ReturnType<FieldRowCommon>;
617
+ }
618
+ type FieldRowCommon = () => {
619
+ id: string;
620
+ value: unknown;
621
+ field: FormField;
622
+ disabled: boolean;
623
+ placeholder?: string;
624
+ required: boolean;
625
+ invalid: boolean;
626
+ describedBy?: string;
627
+ label: string;
628
+ onInput: (v: unknown) => void;
629
+ onBlur: () => void;
630
+ };
631
+
632
+ /** Dispatch to the concrete widget for `widget`. */
633
+ function WidgetSwitch(props: WidgetSwitchProps): JSX.Element {
634
+ const w = () => props.widget;
635
+ const c = () => props.common;
636
+ return (
637
+ <>
638
+ <Show when={w() === 'text' || w() === 'email' || w() === 'url' || w() === 'date' || w() === 'datetime' || w() === 'time' || w() === 'password'}>
639
+ <TextWidget {...c()} variant={w() as 'text' | 'email' | 'url' | 'date' | 'datetime' | 'time' | 'password'} />
640
+ </Show>
641
+ <Show when={w() === 'textarea'}>
642
+ <TextareaWidget {...c()} />
643
+ </Show>
644
+ <Show when={w() === 'number'}>
645
+ <NumberWidget {...c()} />
646
+ </Show>
647
+ <Show when={w() === 'slider'}>
648
+ <SliderWidget {...c()} />
649
+ </Show>
650
+ <Show when={w() === 'rating'}>
651
+ <RatingWidget {...c()} />
652
+ </Show>
653
+ <Show when={w() === 'switch'}>
654
+ <SwitchWidget {...c()} />
655
+ </Show>
656
+ <Show when={w() === 'checkbox'}>
657
+ <CheckboxWidget {...c()} />
658
+ </Show>
659
+ <Show when={w() === 'radio'}>
660
+ <RadioGroupWidget {...c()} />
661
+ </Show>
662
+ <Show when={w() === 'select'}>
663
+ <SelectWidget {...c()} />
664
+ </Show>
665
+ <Show when={w() === 'checkbox-group'}>
666
+ <CheckboxGroupWidget {...c()} />
667
+ </Show>
668
+ <Show when={w() === 'multiselect'}>
669
+ <MultiSelectWidget {...c()} />
670
+ </Show>
671
+ <Show when={w() === 'taglist'}>
672
+ <TagListWidget {...c()} />
673
+ </Show>
674
+ <Show when={w() === 'repeater'}>
675
+ <RepeaterWidget {...c()} inlineMax={DEFAULT_INLINE_MAX} />
676
+ </Show>
677
+ <Show when={w() === 'fieldset'}>
678
+ <FieldsetWidget {...c()} inlineMax={DEFAULT_INLINE_MAX} />
679
+ </Show>
680
+ <Show when={w() === 'unsupported'}>
681
+ <p class="rounded-md border border-dashed border-border p-2 text-xs text-muted-foreground">
682
+ Unsupported field "{props.fieldKey}".
683
+ </p>
684
+ </Show>
685
+ </>
686
+ );
687
+ }
688
+
689
+ // ─────────────────────────────────────────────────────────────────────────────
690
+ // Composite widgets that need the FormField recursion (fieldset + repeater).
691
+ // They live here (not form-widgets) to reuse FieldRow/Switch + the helpers.
692
+ // ─────────────────────────────────────────────────────────────────────────────
693
+
694
+ interface CompositeProps {
695
+ id: string;
696
+ value: unknown;
697
+ field: FormField;
698
+ disabled: boolean;
699
+ required: boolean;
700
+ invalid: boolean;
701
+ describedBy?: string;
702
+ label: string;
703
+ inlineMax: number;
704
+ onInput: (v: unknown) => void;
705
+ onBlur: () => void;
706
+ }
707
+
708
+ function FieldsetWidget(props: CompositeProps): JSX.Element {
709
+ const subProps = () => props.field.properties ?? {};
710
+ const obj = () => (props.value && typeof props.value === 'object' ? (props.value as Record<string, unknown>) : {});
711
+ const setKey = (k: string, v: unknown): void => {
712
+ props.onInput({ ...obj(), [k]: v });
713
+ };
714
+ return (
715
+ <fieldset class="flex flex-col gap-3 rounded-lg border border-border p-3">
716
+ <legend class="px-1 text-sm font-medium text-foreground">{props.label}</legend>
717
+ <For each={Object.keys(subProps())}>
718
+ {(k) => (
719
+ <FieldRow
720
+ fieldKey={k}
721
+ field={subProps()[k]}
722
+ required={(props.field.required ?? []).includes(k)}
723
+ inlineMax={props.inlineMax}
724
+ value={() => obj()[k]}
725
+ error={() => undefined}
726
+ disabled={props.disabled}
727
+ onInput={(v) => setKey(k, v)}
728
+ onBlur={props.onBlur}
729
+ />
730
+ )}
731
+ </For>
732
+ </fieldset>
733
+ );
734
+ }
735
+
736
+ function RepeaterWidget(props: CompositeProps): JSX.Element {
737
+ const itemSchema = () => (props.field.items as FormField) ?? { type: 'object', properties: {} };
738
+ const rows = () => (Array.isArray(props.value) ? (props.value as unknown[]) : []);
739
+ const setRows = (next: unknown[]): void => props.onInput(next);
740
+ const addRow = (): void => setRows([...rows(), {}]);
741
+ const removeRow = (i: number): void => setRows(rows().filter((_, idx) => idx !== i));
742
+ const setRowKey = (i: number, k: string, v: unknown): void => {
743
+ const next = rows().slice();
744
+ next[i] = { ...(next[i] as Record<string, unknown>), [k]: v };
745
+ setRows(next);
746
+ };
747
+
748
+ return (
749
+ <fieldset class="flex flex-col gap-3 rounded-lg border border-border p-3" data-control>
750
+ <legend class="px-1 text-sm font-medium text-foreground">{props.label}</legend>
751
+ <Index each={rows()}>
752
+ {(row, i) => (
753
+ <div class="flex flex-col gap-2 rounded-md border border-border/60 p-2">
754
+ <div class="flex items-center justify-between">
755
+ <span class="text-xs text-muted-foreground">Item {i + 1}</span>
756
+ <Button
757
+ type="button"
758
+ size="icon-sm"
759
+ variant="ghost"
760
+ aria-label={`Remove row ${i + 1}`}
761
+ disabled={props.disabled}
762
+ onClick={() => removeRow(i)}
763
+ >
764
+
765
+ </Button>
766
+ </div>
767
+ <For each={Object.keys(itemSchema().properties ?? {})}>
768
+ {(k) => (
769
+ <FieldRow
770
+ fieldKey={k}
771
+ field={itemSchema().properties![k]}
772
+ required={(itemSchema().required ?? []).includes(k)}
773
+ inlineMax={props.inlineMax}
774
+ value={() => (row() as Record<string, unknown>)?.[k]}
775
+ error={() => undefined}
776
+ disabled={props.disabled}
777
+ onInput={(v) => setRowKey(i, k, v)}
778
+ onBlur={props.onBlur}
779
+ />
780
+ )}
781
+ </For>
782
+ </div>
783
+ )}
784
+ </Index>
785
+ <Button type="button" variant="outline" size="sm" disabled={props.disabled} onClick={addRow}>
786
+ Add item
787
+ </Button>
788
+ </fieldset>
789
+ );
790
+ }
791
+
792
+ /** Minimal CSS.escape fallback for attribute-selector building. */
793
+ function cssEscape(s: string): string {
794
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(s);
795
+ return s.replace(/["\\]/g, '\\$&');
796
+ }