@elabs-ai/components-ai 4.0.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { MessageFeedback } from "./message-feedback";
5
+
6
+ describe("MessageFeedback", () => {
7
+ it("renders labelled thumbs up/down controls", () => {
8
+ render(<MessageFeedback />);
9
+ expect(screen.getByRole("button", { name: "Good response" })).toBeInTheDocument();
10
+ expect(screen.getByRole("button", { name: "Bad response" })).toBeInTheDocument();
11
+ });
12
+
13
+ it("emits onSubmit and marks the root submitted (uncontrolled)", async () => {
14
+ const user = userEvent.setup();
15
+ const onSubmit = vi.fn();
16
+ const { container } = render(<MessageFeedback onSubmit={onSubmit} />);
17
+ await user.click(screen.getByRole("button", { name: "Good response" }));
18
+ expect(onSubmit).toHaveBeenCalledWith({ type: "positive" });
19
+ const root = container.firstElementChild as HTMLElement;
20
+ expect(root).toHaveAttribute("data-submitted", "true");
21
+ expect(root).toHaveAttribute("data-feedback", "positive");
22
+ });
23
+
24
+ it("auto-disables both buttons after submit", async () => {
25
+ const user = userEvent.setup();
26
+ render(<MessageFeedback />);
27
+ await user.click(screen.getByRole("button", { name: "Bad response" }));
28
+ expect(screen.getByRole("button", { name: "Good response" })).toBeDisabled();
29
+ expect(screen.getByRole("button", { name: "Bad response" })).toBeDisabled();
30
+ expect(screen.getByRole("button", { name: "Bad response" })).toHaveAttribute(
31
+ "aria-pressed",
32
+ "true",
33
+ );
34
+ });
35
+
36
+ it("does not fire again once submitted", async () => {
37
+ const user = userEvent.setup();
38
+ const onSubmit = vi.fn();
39
+ render(<MessageFeedback onSubmit={onSubmit} />);
40
+ const up = screen.getByRole("button", { name: "Good response" });
41
+ await user.click(up);
42
+ await user.click(up); // disabled now — no-op
43
+ expect(onSubmit).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ it("reflects a controlled value", () => {
47
+ render(<MessageFeedback value="negative" />);
48
+ expect(screen.getByRole("button", { name: "Bad response" })).toHaveAttribute(
49
+ "aria-pressed",
50
+ "true",
51
+ );
52
+ expect(screen.getByRole("button", { name: "Good response" })).toHaveAttribute(
53
+ "aria-pressed",
54
+ "false",
55
+ );
56
+ });
57
+
58
+ it("respects the disabled prop", () => {
59
+ render(<MessageFeedback disabled />);
60
+ expect(screen.getByRole("button", { name: "Good response" })).toBeDisabled();
61
+ });
62
+ });
@@ -0,0 +1,117 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MessageFeedback — thumbs up / down for a single assistant message.
5
+ *
6
+ * Presentational only: it emits `onSubmit({ type })` and reflects the chosen
7
+ * state; it does NOT persist anything (the host owns storage). After a choice
8
+ * the buttons auto-disable (feedback is submit-once) and the root carries
9
+ * `data-submitted` + `data-feedback`. Controlled (`value`) or uncontrolled.
10
+ */
11
+
12
+ import { forwardRef, useState, type HTMLAttributes } from "react";
13
+ import { ThumbsDown, ThumbsUp } from "lucide-react";
14
+ import { cva, type VariantProps } from "class-variance-authority";
15
+ import { Button } from "@elabs-ai/components-ui";
16
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
17
+ import { useLocale } from "@elabs-ai/components-ui";
18
+
19
+ export type FeedbackType = "positive" | "negative";
20
+
21
+ export const messageFeedbackVariants = cva("inline-flex items-center", {
22
+ variants: {
23
+ compact: { true: "gap-0.5", false: "gap-1" },
24
+ },
25
+ defaultVariants: { compact: false },
26
+ });
27
+
28
+ export interface MessageFeedbackProps
29
+ extends
30
+ Omit<HTMLAttributes<HTMLDivElement>, "onSubmit" | "defaultValue">,
31
+ VariantProps<typeof messageFeedbackVariants> {
32
+ /** Controlled selected feedback. When set, `onSubmit` is the only updater. */
33
+ value?: FeedbackType | null;
34
+ /** Uncontrolled initial selection. @default null */
35
+ defaultValue?: FeedbackType | null;
36
+ /** Called once with the chosen feedback. No persistence — host-owned. */
37
+ onSubmit?: (feedback: { type: FeedbackType }) => void;
38
+ /** Force-disable both controls (e.g. while the message is still streaming). */
39
+ disabled?: boolean;
40
+ /** aria-label for the positive control. @default "Good response" */
41
+ positiveLabel?: string;
42
+ /** aria-label for the negative control. @default "Bad response" */
43
+ negativeLabel?: string;
44
+ }
45
+
46
+ /**
47
+ * Two icon buttons. The chosen one stays prominent after submit; the other
48
+ * recedes. `compact` tightens spacing/size for message toolbars.
49
+ */
50
+ export const MessageFeedback = forwardRef<HTMLDivElement, MessageFeedbackProps>(
51
+ function MessageFeedback(
52
+ {
53
+ value: valueProp,
54
+ defaultValue = null,
55
+ onSubmit,
56
+ disabled = false,
57
+ compact = false,
58
+ positiveLabel = "Good response",
59
+ negativeLabel = "Bad response",
60
+ className,
61
+ ...props
62
+ },
63
+ ref,
64
+ ) {
65
+ const { t } = useLocale();
66
+ const isControlled = valueProp !== undefined;
67
+ const [internal, setInternal] = useState<FeedbackType | null>(defaultValue);
68
+ const selected = isControlled ? (valueProp as FeedbackType | null) : internal;
69
+ const submitted = selected != null;
70
+
71
+ const choose = (type: FeedbackType) => {
72
+ if (submitted || disabled) return;
73
+ if (!isControlled) setInternal(type);
74
+ onSubmit?.({ type });
75
+ };
76
+
77
+ const compactBtn = compact ? "size-7 [&_svg]:size-3.5" : undefined;
78
+
79
+ const renderButton = (type: FeedbackType, label: string, Icon: typeof ThumbsUp) => {
80
+ const active = selected === type;
81
+ return (
82
+ <Button
83
+ type="button"
84
+ variant="ghost"
85
+ size="icon-sm"
86
+ aria-label={label}
87
+ aria-pressed={active}
88
+ // After submit both are inert; keep the chosen one at full opacity.
89
+ disabled={disabled || submitted}
90
+ onClick={() => choose(type)}
91
+ className={cn(
92
+ "text-muted-foreground hover:text-foreground",
93
+ active && "text-primary disabled:opacity-100 [&_svg]:fill-current",
94
+ compactBtn,
95
+ )}
96
+ >
97
+ <Icon aria-hidden="true" />
98
+ </Button>
99
+ );
100
+ };
101
+
102
+ return (
103
+ <div
104
+ ref={ref}
105
+ role="group"
106
+ aria-label={t("ai.message.feedback")}
107
+ data-submitted={submitted || undefined}
108
+ data-feedback={selected ?? undefined}
109
+ className={cn(messageFeedbackVariants({ compact }), className)}
110
+ {...props}
111
+ >
112
+ {renderButton("positive", positiveLabel, ThumbsUp)}
113
+ {renderButton("negative", negativeLabel, ThumbsDown)}
114
+ </div>
115
+ );
116
+ },
117
+ );
@@ -0,0 +1,349 @@
1
+ /**
2
+ * message-form-spec.ts — Serializable FormSpec for MessageForm.
3
+ *
4
+ * These are pure data types + zod schemas with no React dependency. The shape is
5
+ * what an LLM tool-call emits when it wants the user to fill in a form inside a
6
+ * chat message. It is deliberately compatible with MCP elicitation's
7
+ * `requestedSchema` (spec 2025-11-25): flat primitive fields (string / number /
8
+ * integer / boolean / single- and multi-select enum), each with a label,
9
+ * optional description, `required`, and a `default`.
10
+ *
11
+ * The exported zod schemas are the source of truth downstream catalogs compile
12
+ * prompts + validators from. Field types are intentionally limited to the safe
13
+ * set — there is NO file input and NO password/credential type/format, so a spec
14
+ * that asks for one fails validation (rejected, never rendered).
15
+ */
16
+
17
+ import { z } from "zod";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Enum options
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** A single enum choice: a plain value, or a `{ const, title }` pair. */
24
+ export const enumOptionSchema = z.union([
25
+ z.string(),
26
+ z.object({ const: z.string(), title: z.string().optional() }),
27
+ ]);
28
+ export type EnumOption = z.infer<typeof enumOptionSchema>;
29
+
30
+ /** The submitted value of an enum option. */
31
+ export function optionValue(option: EnumOption): string {
32
+ return typeof option === "string" ? option : option.const;
33
+ }
34
+
35
+ /** The display label of an enum option (falls back to its value). */
36
+ export function optionLabel(option: EnumOption): string {
37
+ return typeof option === "string" ? option : (option.title ?? option.const);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Fields (a discriminated union on `type`)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Fields shared by every field type. `name` is the key in the emitted values. */
45
+ const baseField = {
46
+ /** Value key in the emitted form state (the `values` object key). */
47
+ name: z.string(),
48
+ /** Human label. Falls back to a humanized `name` when omitted. */
49
+ label: z.string().optional(),
50
+ /** Helper text shown under the control. */
51
+ description: z.string().optional(),
52
+ /** Whether a value is required before the form can submit. */
53
+ required: z.boolean().optional(),
54
+ };
55
+
56
+ /**
57
+ * A single-line or multi-line text field.
58
+ * `format` narrows the input type + validation. Note there is NO `"password"`
59
+ * format — credential capture is deliberately unsupported.
60
+ */
61
+ export const stringFieldSchema = z.object({
62
+ type: z.literal("string"),
63
+ ...baseField,
64
+ default: z.string().optional(),
65
+ format: z.enum(["email", "uri", "date", "date-time"]).optional(),
66
+ minLength: z.number().int().nonnegative().optional(),
67
+ maxLength: z.number().int().nonnegative().optional(),
68
+ /** A serialized RegExp source string (client-enforced when present). */
69
+ pattern: z.string().optional(),
70
+ /** Render a textarea instead of a single-line input. */
71
+ multiline: z.boolean().optional(),
72
+ });
73
+ export type StringFieldSpec = z.infer<typeof stringFieldSchema>;
74
+
75
+ /** A floating-point number field. */
76
+ export const numberFieldSchema = z.object({
77
+ type: z.literal("number"),
78
+ ...baseField,
79
+ default: z.number().optional(),
80
+ min: z.number().optional(),
81
+ max: z.number().optional(),
82
+ });
83
+ export type NumberFieldSpec = z.infer<typeof numberFieldSchema>;
84
+
85
+ /** A whole-number field. */
86
+ export const integerFieldSchema = z.object({
87
+ type: z.literal("integer"),
88
+ ...baseField,
89
+ default: z.number().int().optional(),
90
+ min: z.number().optional(),
91
+ max: z.number().optional(),
92
+ });
93
+ export type IntegerFieldSpec = z.infer<typeof integerFieldSchema>;
94
+
95
+ /** A boolean checkbox field. */
96
+ export const booleanFieldSchema = z.object({
97
+ type: z.literal("boolean"),
98
+ ...baseField,
99
+ default: z.boolean().optional(),
100
+ });
101
+ export type BooleanFieldSpec = z.infer<typeof booleanFieldSchema>;
102
+
103
+ /** A single-select enum (one value from `options`). */
104
+ export const enumFieldSchema = z.object({
105
+ type: z.literal("enum"),
106
+ ...baseField,
107
+ options: z.array(enumOptionSchema),
108
+ default: z.string().optional(),
109
+ });
110
+ export type EnumFieldSpec = z.infer<typeof enumFieldSchema>;
111
+
112
+ /** A multi-select enum (zero or more values from `options`). */
113
+ export const multiEnumFieldSchema = z.object({
114
+ type: z.literal("multi-enum"),
115
+ ...baseField,
116
+ options: z.array(enumOptionSchema),
117
+ default: z.array(z.string()).optional(),
118
+ });
119
+ export type MultiEnumFieldSpec = z.infer<typeof multiEnumFieldSchema>;
120
+
121
+ /** Any one field in a FormSpec. */
122
+ export const fieldSpecSchema = z.discriminatedUnion("type", [
123
+ stringFieldSchema,
124
+ numberFieldSchema,
125
+ integerFieldSchema,
126
+ booleanFieldSchema,
127
+ enumFieldSchema,
128
+ multiEnumFieldSchema,
129
+ ]);
130
+ export type FieldSpec = z.infer<typeof fieldSpecSchema>;
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // FormSpec
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * The serializable form specification produced by an LLM tool-call.
138
+ * MessageForm reads this, renders the fields, and emits `{ formName, values }`.
139
+ *
140
+ * Export target for downstream catalogs: compile the prompt + a validator from
141
+ * `formSpecSchema`.
142
+ */
143
+ export const formSpecSchema = z.object({
144
+ /** Stable identifier echoed back in the submit payload. */
145
+ formName: z.string(),
146
+ /** Heading rendered above the fields (also the accessible form name). */
147
+ title: z.string().optional(),
148
+ /** Supplemental description under the title. */
149
+ description: z.string().optional(),
150
+ /** The ordered fields to render. */
151
+ fields: z.array(fieldSpecSchema),
152
+ /** Submit button label. @default "Submit" */
153
+ submitLabel: z.string().optional(),
154
+ });
155
+ export type FormSpec = z.infer<typeof formSpecSchema>;
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Values
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** A single field's value in the form state. */
162
+ export type FormValue = string | number | boolean | string[] | undefined;
163
+
164
+ /** The `values` object keyed by field `name`. */
165
+ export type FormValues = Record<string, FormValue>;
166
+
167
+ /** The payload passed to `onSubmit`. */
168
+ export interface FormSubmitState {
169
+ /** Echoes `spec.formName`. */
170
+ formName: string;
171
+ /** The current field values keyed by field name. */
172
+ values: FormValues;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Normalization (lenient — streaming-tolerant, never throws)
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** A validated, render-ready FormSpec. */
180
+ export interface NormalizedFormSpec {
181
+ formName: string;
182
+ title?: string;
183
+ description?: string;
184
+ submitLabel?: string;
185
+ fields: FieldSpec[];
186
+ }
187
+
188
+ /**
189
+ * Lenient parse for the render path. Mirrors AutoChart's guard philosophy:
190
+ * NEVER throws. Individually invalid or half-streamed fields are dropped (not
191
+ * fatal), so a partial spec still renders progressively. Returns `{ ok: false }`
192
+ * only when the whole spec is unusable (not an object) — the caller then shows
193
+ * `MessageFormFallback`.
194
+ */
195
+ export function normalizeFormSpec(
196
+ spec: unknown,
197
+ ): { ok: true; spec: NormalizedFormSpec } | { ok: false; reason: string } {
198
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
199
+ return { ok: false, reason: "Form specification is missing or malformed." };
200
+ }
201
+ const raw = spec as Record<string, unknown>;
202
+ const rawFields = Array.isArray(raw.fields) ? raw.fields : [];
203
+
204
+ const fields: FieldSpec[] = [];
205
+ const seen = new Set<string>();
206
+ for (const candidate of rawFields) {
207
+ const parsed = fieldSpecSchema.safeParse(candidate);
208
+ if (!parsed.success) continue; // drop invalid / still-streaming field
209
+ if (seen.has(parsed.data.name)) continue; // de-dupe by name (last-writer avoided)
210
+ seen.add(parsed.data.name);
211
+ fields.push(parsed.data);
212
+ }
213
+
214
+ return {
215
+ ok: true,
216
+ spec: {
217
+ formName: typeof raw.formName === "string" ? raw.formName : "",
218
+ title: typeof raw.title === "string" ? raw.title : undefined,
219
+ description: typeof raw.description === "string" ? raw.description : undefined,
220
+ submitLabel: typeof raw.submitLabel === "string" ? raw.submitLabel : undefined,
221
+ fields,
222
+ },
223
+ };
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Labels + initial values
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /** Turn a field name into a human label ("first_name" / "firstName" → "First name"). */
231
+ function humanize(name: string): string {
232
+ const spaced = name
233
+ .replace(/[_-]+/g, " ")
234
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
235
+ .trim();
236
+ if (!spaced) return name;
237
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
238
+ }
239
+
240
+ /** The label to show for a field (explicit `label`, else a humanized `name`). */
241
+ export function fieldLabel(field: FieldSpec): string {
242
+ return field.label && field.label.length > 0 ? field.label : humanize(field.name);
243
+ }
244
+
245
+ /** Seed the initial values from each field's `default` (+ sensible empties). */
246
+ export function initialFormValues(fields: FieldSpec[]): FormValues {
247
+ const values: FormValues = {};
248
+ for (const field of fields) {
249
+ if (field.default !== undefined) {
250
+ values[field.name] = field.default;
251
+ } else if (field.type === "boolean") {
252
+ values[field.name] = false;
253
+ } else if (field.type === "multi-enum") {
254
+ values[field.name] = [];
255
+ }
256
+ }
257
+ return values;
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Client-side validation (the declarative vocabulary)
262
+ // ---------------------------------------------------------------------------
263
+
264
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
265
+
266
+ function isEmptyValue(value: FormValue): boolean {
267
+ return value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
268
+ }
269
+
270
+ function isValidUrl(value: string): boolean {
271
+ try {
272
+ void new URL(value);
273
+ return true;
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Validate one field's value against its declarative constraints.
281
+ * Returns a short human error message, or `null` when the value is valid.
282
+ * The vocabulary: required · email · url · min · max · minLength · maxLength ·
283
+ * pattern · numeric (integer whole-number).
284
+ */
285
+ export function validateField(field: FieldSpec, value: FormValue): string | null {
286
+ const empty = isEmptyValue(value);
287
+
288
+ if (field.required) {
289
+ // A required boolean must be affirmatively true (e.g. "accept terms");
290
+ // `false` is otherwise a valid, non-empty value.
291
+ if (field.type === "boolean") {
292
+ if (value !== true) return "This field is required.";
293
+ } else if (empty) {
294
+ return "This field is required.";
295
+ }
296
+ }
297
+ if (empty) return null; // optional + empty → valid, skip further checks
298
+
299
+ switch (field.type) {
300
+ case "string": {
301
+ const text = String(value);
302
+ if (field.minLength !== undefined && text.length < field.minLength) {
303
+ return `Must be at least ${field.minLength} character${field.minLength === 1 ? "" : "s"}.`;
304
+ }
305
+ if (field.maxLength !== undefined && text.length > field.maxLength) {
306
+ return `Must be at most ${field.maxLength} character${field.maxLength === 1 ? "" : "s"}.`;
307
+ }
308
+ if (field.format === "email" && !EMAIL_RE.test(text)) {
309
+ return "Enter a valid email address.";
310
+ }
311
+ if (field.format === "uri" && !isValidUrl(text)) {
312
+ return "Enter a valid URL.";
313
+ }
314
+ if (field.pattern) {
315
+ let re: RegExp | null = null;
316
+ try {
317
+ re = new RegExp(field.pattern);
318
+ } catch {
319
+ re = null; // an invalid pattern from the model must not break validation
320
+ }
321
+ if (re && !re.test(text)) return "Value doesn't match the required format.";
322
+ }
323
+ return null;
324
+ }
325
+ case "number":
326
+ case "integer": {
327
+ const num = typeof value === "number" ? value : Number(value);
328
+ if (Number.isNaN(num)) return "Enter a number.";
329
+ if (field.type === "integer" && !Number.isInteger(num)) return "Enter a whole number.";
330
+ if (field.min !== undefined && num < field.min) return `Must be at least ${field.min}.`;
331
+ if (field.max !== undefined && num > field.max) return `Must be at most ${field.max}.`;
332
+ return null;
333
+ }
334
+ default:
335
+ return null;
336
+ }
337
+ }
338
+
339
+ /** Validate every field; returns a `{ [name]: error | null }` map. */
340
+ export function validateForm(
341
+ fields: FieldSpec[],
342
+ values: FormValues,
343
+ ): Record<string, string | null> {
344
+ const errors: Record<string, string | null> = {};
345
+ for (const field of fields) {
346
+ errors[field.name] = validateField(field, values[field.name]);
347
+ }
348
+ return errors;
349
+ }