@ultimat3/ui 12.0.0 → 13.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.
package/CLAUDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ultimat3/ui — agent notes
2
2
 
3
- Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was deleted). Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http`, `action`, `render`, `admin` — `render` is tier 4 too, which is what keeps the static bundle graph out of the design system.
3
+ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was deleted). Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**, which this line claimed until 2026-08-24 and `package.json` never declared. That absence is why `src/form/` re-declares Standard Schema's one member structurally (`FormSchema`) and copies `formatPath` as `formatFieldPath`: the tier table permits the edge, the manifest and the lockfile do not. Never `http`, `action`, `render`, `admin` — `render` is tier 4 too, which is what keeps the static bundle graph out of the design system.
4
4
 
5
5
  ## Boundary
6
6
 
@@ -42,6 +42,11 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
42
42
  - **A single-winner state attribute is decided by POSITION, never by a missing prop.** `Breadcrumb` gives `aria-current="page"` to the last item and to nothing else; an href-less ancestor renders as plain text with no `aria-current` at all. Reading "no href" as "is the current page" put two of them in one `<nav>`. `Tabs.tsx` is the same rule for `tabindex`, and `Breadcrumb.test.ts` proves it through `jsx-probe` rather than through the pure helper.
43
43
  - **Live semantics belong to the container that outlives the message.** `ToastRegion`'s `<ol>` carries `aria-live`; a `Toast` is a plain `<li>`. A region created with its content already inside it is not announced, and a `role="status"` on the `<li>` also strips its `listitem` semantics.
44
44
  - **`aria-checked` never mirrors a native `checked`.** ARIA outranks host state in the accessibility tree, and on the no-JS path this package supports there is nothing to rewrite the attribute after the user ticks the box. `Checkbox` writes only `'mixed'` (an IDL property with no attribute form, so ARIA is the only server-side lever); `Switch` writes none at all, over a `biome-ignore` that says why.
45
+ - **A form binds to an action; it never decides for one.** `useForm` (`src/form/`) is a binding over an existing `action`, not a ninth primitive and not a component — `submit` is REQUIRED and is the only producer of a `succeeded` state, and the client-side parse's **value** is discarded so that only its issues are read. A binding that submitted the locally-parsed value would let a browser choose what the server was asked to store; `FormSchema` has no output type for that reason.
46
+ - **One path grammar, both directions.** A control's `name` and a schema issue's path are the same string (`items[0].price`), which is what lets a rejection find its control with no per-form mapping table. `formatFieldPath` mirrors `@ultimat3/schema`'s `formatPath` — **keep it in sync**, because the server rendered the incoming path with THAT function — and `parseFieldPath` is its inverse, refusing `__proto__`, `items[]`, `items.0.price` and an index above `MAX_FIELD_INDEX`.
47
+ - **An issue that matches no declared field is SURFACED, never swallowed.** It goes to `formErrors`, which `<Form error>` announces and focuses; a near miss (`items` against a form holding `items[0].price`) goes there too, because an error rendered against the wrong input is a lie the user acts on. Two issues on one field both survive in the state — `Field`'s one slot renders the first, `messagesFor` has the rest.
48
+ - **The framework ships the mapping and no copy table.** `FormIssue.message` is a schema's diagnostic text and is never user-facing; `messageFor` is required and is the app's. There is deliberately no `ui.form.*` key: a built-in wording would be one app's convention shipped to every other. A `messageFor` that answers `''` or throws falls back to the diagnostic text — the loud-but-safe answer `t()` already gives with `⟦key⟧`, because the alternatives are a control marked `aria-invalid` with nothing to read, or a submit abandoned mid-flight holding the user's input.
49
+ - **The wire carries no structured issue list, `As of 2026-08-24`.** `InputInvalidError` renders the issues into `cause` and puts nothing in `meta`, and `toProblem` has no `issues` member, so `issuesFromRejection` reads `meta.issues` where there is one and parses the cause line where there is not. It binds a fragment to a field only when the fragment's head is a DECLARED path, so a mis-split degrades to a form-level error rather than to a wrong control. Delete the line parser the day the wire carries the list.
45
50
  - **Generated source is a CODE sink.** `build-icons.ts` writes modules every app EXECUTES at import, from data fetched over the network, so an attribute value goes through `JSON.stringify` and never `'${value}'` — and `SAFE_ATTR_VALUE` refuses anything that is not glyph geometry one layer earlier. `iconElements` guards tags and attribute NAMES; it has never guarded a value. Malformed upstream data is `X_UI_INVALID_VALUE`; only the generator's real environment faults (no network, no biome binary) are `X_UI_RUNTIME_MISSING`.
46
51
  - **The icon NAME is the third sink, and it has no escape.** The upstream map key becomes a filesystem path under `GLYPHS_DIR`, an exported identifier and a `//` banner, and `buildIcons` clears `GLYPHS_DIR` before it writes — so `../../index` was a delete of the glyph tree followed by an overwrite of a hand-written module, and a key carrying `;` produced a module that typechecked and ran. `SAFE_ICON_NAME` (`/^[a-z0-9]+(-[a-z0-9]+)*$/`) is checked in `parseIconNodes` BEFORE `out.set`, the same allowlist-over-a-sink shape as `SAFE_ATTR_VALUE` one layer down; all 1767 committed names pass it and `build-icons.test.ts` asserts that.
47
52
 
@@ -63,6 +68,13 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
63
68
  | `src/tokens/contrast.ts` | WCAG ratios over the channel tokens; `contrast.test.ts` gates AA in both themes |
64
69
  | `src/catalog/` | parses `components/*.tsx` into `CATALOG.md`; `bun run catalog` writes it, `catalog.test.ts` fails on drift |
65
70
  | `src/roving.ts` | the pure rules of a keyboard group: navigable set, tab stop, who keeps their own arrows |
71
+ | `src/form/field-path.ts` | the one path grammar, both directions — pure, and deliberately free of `../errors` so mapping an issue costs no chunk the error registry |
72
+ | `src/form/form-issue.ts` | the two readers of an issue: a local parse result, and whatever the server rejected with |
73
+ | `src/form/form-state.ts` | where an issue LANDS — the declared field, or the form |
74
+ | `src/form/form-binding.ts` | the submit state machine; the one place a `succeeded` exists |
75
+ | `src/form/use-form.ts` | the Solid shell: the same binding with its state in a signal |
76
+ | `src/form/form-values.ts` | `FormData` → the nested object the action's input schema declares |
77
+ | `src/form/field-binding.test.ts` | the WIRING, end to end: a rejection reaching `aria-invalid` and `aria-describedby` on the control it named |
66
78
  | `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
67
79
  | `src/jsx-probe.ts` | TEST-ONLY: a component's node tree, so a test can assert the props and call the handlers an element carries. `probe`/`unprobe` are the framework's ONE owner of `globalThis.React`, reached from `@ultimat3/admin` at `@ultimat3/ui/jsx-probe`; the walkers stay internal |
68
80
  | `src/barrel-bytes.test.ts` | the build error behind the two byte claims above: the setter's ceiling, and barrel-vs-deep-path parity |
package/README.md CHANGED
@@ -356,6 +356,72 @@ result before first paint, so there is no flash and screenshots are deterministi
356
356
  Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCspSource()
357
357
  ```
358
358
 
359
+ ## Forms bound to an action's input schema
360
+
361
+ `As of 2026-08-24`. Four things already existed and none were connected: the action's `input`
362
+ schema, the issue paths its parse produces, the rejection the server sends back, and `Field`'s
363
+ error slot. `useForm` is the binding — **not** a ninth primitive and not a component: a form is a
364
+ binding over an existing `action`, the way `llm()` is a factory over one.
365
+
366
+ ```tsx
367
+ import { t } from '@ultimat3/i18n';
368
+ import { Field, Form, type FormSchema, Input, useForm, valuesOfForm } from '@ultimat3/ui';
369
+
370
+ // In an app this is `InferInput<typeof createPost.input>` — an object type, not an interface,
371
+ // which is what lets `valuesOfForm`'s `Record<string, unknown>` be narrowed to it below.
372
+ type CreatePostInput = { readonly title: string };
373
+ interface Post {
374
+ readonly id: string;
375
+ }
376
+
377
+ // The action and its typed client, both the app's: `createPost.input` IS a `FormSchema`.
378
+ declare const createPost: { readonly input: FormSchema };
379
+ declare const api: { createPost(values: CreatePostInput): Promise<Post> };
380
+
381
+ export function CreatePostForm() {
382
+ const form = useForm<CreatePostInput, Post>({
383
+ fields: ['title', 'items[0].price'],
384
+ schema: createPost.input, // optional: latency only, never authority
385
+ submit: (values) => api.createPost(values),
386
+ // The app's wording, not the framework's: one `t()` key per thing a user can get wrong.
387
+ messageFor: (issue) => (issue.path === 'title' ? t('post.title.invalid') : t('post.invalid')),
388
+ });
389
+
390
+ return (
391
+ // `undefined` and never `''`: `error` being PRESENT is what makes Form render the summary
392
+ // and move focus to it, so an empty string is an error box on a form with nothing wrong.
393
+ <Form
394
+ error={form.state().formErrors.join(' ') || undefined}
395
+ onSubmit={(event) => {
396
+ event.preventDefault();
397
+ void form.submit(valuesOfForm(new FormData(event.currentTarget)) as CreatePostInput);
398
+ }}
399
+ >
400
+ <Field label={t('post.title')} error={form.errorFor('title')}>
401
+ {(control) => <Input {...control} name="title" />}
402
+ </Field>
403
+ </Form>
404
+ );
405
+ }
406
+ ```
407
+
408
+ | Rule | Why |
409
+ |---|---|
410
+ | **`submit` is required and is the only producer of `succeeded`** | a form that decides for itself that a value is acceptable is a security defect. The client-side parse is a latency optimisation; the action re-parses server-side on every path |
411
+ | the local parse's **value** is discarded — only its issues are read | otherwise the browser decides what the server was asked to store. `FormSchema` deliberately has no output type |
412
+ | a `name` attribute and an issue path are the **same string** (`items[0].price`) | one grammar, so a rejection finds its control with no per-form mapping table. `formatFieldPath` / `parseFieldPath` are the two directions; a name the grammar cannot read is `X_UI_FORM_PATH_INVALID`, refused where it is DECLARED |
413
+ | an issue whose path matches no declared field goes to **`formErrors`**, never to a neighbouring control | a form that silently drops "the server rejected this" is worse than one with no binding at all. A near miss (`items` against a form holding `items[0].price`) is one of these |
414
+ | every message is the app's, through `messageFor` | a schema issue (`expected number`) is diagnostic text, not a user-facing string. The framework ships the mapping and **no copy table** — there is no `ui.form.*` key to override. A translator that answers `''` or throws falls back to the diagnostic text, the way a missing catalog key renders `⟦key⟧` |
415
+ | a second `submit` while one is in flight **joins** it | a double click is not a second write |
416
+
417
+ **The wire carries no structured issue list yet.** A server-side validation failure arrives as
418
+ `X_INPUT_INVALID` with the issues rendered into `cause` (`title: too short; items[0].price:
419
+ expected number`) — `packages/action/src/errors.ts` puts nothing in `meta`, and `toProblem`
420
+ (`packages/http/src/error-facts.ts`) has no `issues` member — so `issuesFromRejection` reads
421
+ `meta.issues` where it exists and parses that line where it does not. It binds a fragment to a
422
+ field only when the head is a declared path, so a message holding `'; '` or `': '` degrades to a
423
+ form-level error rather than to a wrong control.
424
+
359
425
  ## Errors
360
426
 
361
427
  | Code | When |
@@ -363,6 +429,7 @@ Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCsp
363
429
  | `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define — including a `defineTheme()` override of a role, radius or font slot that is not in the scale |
364
430
  | `X_THEME_INVALID` | a theme other than `light` / `dark` |
365
431
  | `X_UI_RUNTIME_MISSING` | a DOM render with no registered Solid runtime, `<UiProvider>` on the server, or `browserThemeEnv()` off-DOM. A server render with no runtime is **not** one of them — it gets `INERT_SOLID_RUNTIME` |
432
+ | `X_UI_FORM_PATH_INVALID` | a form field or control name the path grammar cannot read (`items.0.price`, `items[]`, `__proto__`), or two control names describing different shapes for one path (`user` beside `user.name`) |
366
433
  | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry) |
367
434
 
368
435
  ## Commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "12.0.0",
3
+ "version": "13.0.0",
4
4
  "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,10 +44,10 @@
44
44
  "icons": "bun run src/icons/build-icons.ts"
45
45
  },
46
46
  "dependencies": {
47
- "@ultimat3/core": "12.0.0",
48
- "@ultimat3/i18n": "12.0.0",
49
- "@ultimat3/money": "12.0.0",
50
- "@ultimat3/time": "12.0.0"
47
+ "@ultimat3/core": "13.0.0",
48
+ "@ultimat3/i18n": "13.0.0",
49
+ "@ultimat3/money": "13.0.0",
50
+ "@ultimat3/time": "13.0.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "solid-js": "^1.9.0"
package/src/errors.ts CHANGED
@@ -8,11 +8,12 @@ export const UI_ERROR_CODES = {
8
8
  themeInvalid: 'X_THEME_INVALID',
9
9
  runtimeMissing: 'X_UI_RUNTIME_MISSING',
10
10
  invalidValue: 'X_UI_INVALID_VALUE',
11
+ formPathInvalid: 'X_UI_FORM_PATH_INVALID',
11
12
  } as const;
12
13
 
13
14
  export type UiErrorCode = (typeof UI_ERROR_CODES)[keyof typeof UI_ERROR_CODES];
14
15
 
15
- // Unconditional like every other package: all four codes are ui's own, and a second package
16
+ // Unconditional like every other package: every code here is ui's own, and a second package
16
17
  // claiming one has to throw X_ERROR_CODE_DUPLICATE at import. Taking the process down there is the
17
18
  // point — the alternative is two packages shipping two meanings for one code, decided by load order.
18
19
  registerErrorCodes({
@@ -20,6 +21,7 @@ registerErrorCodes({
20
21
  X_THEME_INVALID: { title: 'theme is not "light" or "dark"' },
21
22
  X_UI_RUNTIME_MISSING: { title: 'a host capability @ultimat3/ui needs is absent' },
22
23
  X_UI_INVALID_VALUE: { title: 'a formatting component received an unrenderable value' },
24
+ X_UI_FORM_PATH_INVALID: { title: 'a form control name is not a usable field path' },
23
25
  });
24
26
 
25
27
  export class UiError extends UltimateError {
@@ -147,3 +149,33 @@ export function invalidIconDataError(found: string, fix: string): UiError {
147
149
  fix,
148
150
  });
149
151
  }
152
+
153
+ /** The one grammar both a `name` attribute and a schema issue path are written in. */
154
+ const FIELD_PATH_GRAMMAR =
155
+ 'a segment, then `.segment` or `[index]` — "title", "items[0].price", never "items.0.price" or "items[]"';
156
+
157
+ /**
158
+ * A form named a field the path grammar cannot read. Refused where it is DECLARED rather than
159
+ * where a rejection arrives: a path no issue can ever equal is a field whose server error lands at
160
+ * the top of the form forever, and that is indistinguishable from an app with no bug at all.
161
+ */
162
+ export function invalidFieldPathError(subject: string, name: string): UiError {
163
+ return new UiError({
164
+ code: UI_ERROR_CODES.formPathInvalid,
165
+ cause: `${subject} "${name}" is not a field path, so no schema issue can address it`,
166
+ fix: `rename it to ${FIELD_PATH_GRAMMAR}`,
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Two controls whose names describe different shapes for one path (`user` beside `user.name`).
172
+ * Refused rather than resolved: either answer silently drops one control's value, and the value
173
+ * dropped is something the user typed.
174
+ */
175
+ export function conflictingFieldNameError(name: string, at: string): UiError {
176
+ return new UiError({
177
+ code: UI_ERROR_CODES.formPathInvalid,
178
+ cause: `form control "${name}" cannot be read: "${at}" already holds a value of another shape`,
179
+ fix: `rename one of the two controls — a path segment holds a value or a container, never both`,
180
+ });
181
+ }
@@ -0,0 +1,91 @@
1
+ // The ONE path grammar a form speaks, in both directions: the dotted, bracketed string a schema
2
+ // issue carries (`items[2].price`) and the `name` attribute the control renders. One grammar is
3
+ // what lets a rejection find its control at all — two would be a hand-written mapping table per
4
+ // form, which is the 40-forms-40-mappings cost this binding exists to delete.
5
+
6
+ /** A Standard Schema issue segment. Wrapped or bare — conforming libraries send both. */
7
+ export interface IssuePathSegment {
8
+ readonly key: PropertyKey;
9
+ }
10
+
11
+ export type FieldPathSegment = string | number;
12
+
13
+ /**
14
+ * The largest index the grammar accepts. A form does not have ten thousand rows; a larger index is
15
+ * a crafted `name`, and `valuesOfForm` would allocate the array it describes.
16
+ */
17
+ export const MAX_FIELD_INDEX = 10_000;
18
+
19
+ /**
20
+ * Structural copy of `@ultimat3/schema`'s `formatPath`. **Keep in sync**: an issue that arrives
21
+ * from the server was rendered by THAT function, so a divergence here is an error that lands on no
22
+ * field. Copied rather than imported because `@ultimat3/ui` holds no dependency edge on
23
+ * `@ultimat3/schema` — the tier table permits one, the manifest and the lockfile do not — the same
24
+ * trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down.
25
+ */
26
+ export function formatFieldPath(
27
+ path: readonly (PropertyKey | IssuePathSegment)[] | undefined,
28
+ ): string {
29
+ if (path === undefined || path.length === 0) return '';
30
+ let out = '';
31
+ for (const segment of path) {
32
+ const key = typeof segment === 'object' ? segment.key : segment;
33
+ if (typeof key === 'number') {
34
+ out += `[${key}]`;
35
+ } else {
36
+ out += out === '' ? String(key) : `.${String(key)}`;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ const KEY = /^[A-Za-z_$][A-Za-z0-9_$]*/;
43
+ /** `[0]`, never `[01]`: an index that does not round-trip through `formatFieldPath` is not one. */
44
+ const INDEX = /^\[(0|[1-9][0-9]*)\]/;
45
+
46
+ /**
47
+ * Keys that are not fields on any object an app owns. A control named `__proto__` reaching
48
+ * `valuesOfForm` writes through the prototype of every object in the process — the same sink
49
+ * `bun run proto-index` guards on the read side.
50
+ */
51
+ const UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'prototype', 'constructor']);
52
+
53
+ /**
54
+ * The reverse of `formatFieldPath`, or `null` for anything that is not a field path. Total and
55
+ * silent by design: the caller decides whether an unusable name is a refusal (`valuesOfForm`, which
56
+ * is building an object) or simply a path that matches no field (`distributeIssues`, which must
57
+ * surface it and carry on).
58
+ */
59
+ export function parseFieldPath(name: string): readonly FieldPathSegment[] | null {
60
+ const segments: FieldPathSegment[] = [];
61
+ let rest = name;
62
+ let expectKey = true;
63
+
64
+ while (rest.length > 0) {
65
+ if (expectKey) {
66
+ const match = KEY.exec(rest);
67
+ if (match === null) return null;
68
+ const key = match[0];
69
+ if (UNSAFE_KEYS.has(key)) return null;
70
+ segments.push(key);
71
+ rest = rest.slice(key.length);
72
+ expectKey = false;
73
+ continue;
74
+ }
75
+ if (rest.startsWith('.')) {
76
+ rest = rest.slice(1);
77
+ expectKey = true;
78
+ continue;
79
+ }
80
+ const match = INDEX.exec(rest);
81
+ const digits = match?.[1];
82
+ if (match === undefined || match === null || digits === undefined) return null;
83
+ const index = Number(digits);
84
+ if (index > MAX_FIELD_INDEX) return null;
85
+ segments.push(index);
86
+ rest = rest.slice(match[0].length);
87
+ }
88
+
89
+ // `expectKey` still set means the name ended on a `.` or was empty — both are half a path.
90
+ return expectKey ? null : segments;
91
+ }
@@ -0,0 +1,128 @@
1
+ // The binding itself: an action's input schema on one side, `Field`'s error slot on the other, and
2
+ // a submit that only the SERVER can turn into a success.
3
+ //
4
+ // Server authority is structural here, not documented: `submit` is required, `succeeded` is
5
+ // produced in exactly one place — after the caller's `submit` resolves — and the local parse's
6
+ // VALUE is discarded, so a client cannot decide either that a form is valid or what it said.
7
+
8
+ import { invalidFieldPathError } from '../errors';
9
+ import { parseFieldPath } from './field-path';
10
+ import {
11
+ type FormIssue,
12
+ type FormSchema,
13
+ issuesFromRejection,
14
+ issuesFromValidation,
15
+ } from './form-issue';
16
+ import {
17
+ distributeIssues,
18
+ errorOf,
19
+ type FormState,
20
+ IDLE_FORM_STATE,
21
+ messagesOf,
22
+ NO_FORM_ERRORS,
23
+ } from './form-state';
24
+
25
+ export interface FormBindingOptions<TValues, TResult> {
26
+ /**
27
+ * The server call — `action.client()`, `rpc(...).createPost`, or a `fetch` that throws on a
28
+ * refusal. REQUIRED: it is the only thing in this file that can produce a success.
29
+ */
30
+ readonly submit: (values: TValues) => Promise<TResult>;
31
+ /** The paths this form renders a control for. An issue matching none surfaces at the form. */
32
+ readonly fields: readonly string[];
33
+ /** The app's wording for one issue. The framework ships the mapping, never the copy. */
34
+ readonly messageFor: (issue: FormIssue) => string;
35
+ /**
36
+ * The action's `input`. Optional, and a LATENCY optimisation only: the action re-parses on the
37
+ * server on every path, so omitting this changes when the user hears about a bad value, never
38
+ * whether it is rejected.
39
+ */
40
+ readonly schema?: FormSchema | undefined;
41
+ /** Called on every transition — how a reactive shell mirrors the state into a signal. */
42
+ readonly onState?: ((state: FormState<TResult>) => void) | undefined;
43
+ }
44
+
45
+ export interface FormBinding<TValues, TResult> {
46
+ readonly state: () => FormState<TResult>;
47
+ readonly submit: (values: TValues) => Promise<FormState<TResult>>;
48
+ /** The message `Field`'s single error slot renders for one path. */
49
+ readonly errorFor: (path: string) => string | undefined;
50
+ /** Every message bound to one path, when a form renders more than one. */
51
+ readonly messagesFor: (path: string) => readonly string[];
52
+ readonly reset: () => void;
53
+ }
54
+
55
+ /**
56
+ * Refused at DECLARATION, which is the only place it can be caught: a field named `items.0.price`
57
+ * is never equal to the `items[0].price` an issue carries, so its server errors would pile up at
58
+ * the top of the form and look exactly like an app with nothing wrong.
59
+ */
60
+ function declaredFields(fields: readonly string[]): ReadonlySet<string> {
61
+ const declared = new Set<string>();
62
+ for (const name of fields) {
63
+ if (parseFieldPath(name) === null) throw invalidFieldPathError('form field', name);
64
+ declared.add(name);
65
+ }
66
+ return declared;
67
+ }
68
+
69
+ export function createFormBinding<TValues, TResult>(
70
+ options: FormBindingOptions<TValues, TResult>,
71
+ ): FormBinding<TValues, TResult> {
72
+ const fields = declaredFields(options.fields);
73
+ let state: FormState<TResult> = IDLE_FORM_STATE;
74
+ let inFlight: Promise<FormState<TResult>> | null = null;
75
+
76
+ const publish = (next: FormState<TResult>): FormState<TResult> => {
77
+ state = next;
78
+ options.onState?.(next);
79
+ return next;
80
+ };
81
+
82
+ const failed = (issues: readonly FormIssue[]): FormState<TResult> =>
83
+ publish({
84
+ status: 'failed',
85
+ ...distributeIssues(issues, fields, options.messageFor),
86
+ result: undefined,
87
+ issues,
88
+ });
89
+
90
+ const run = async (values: TValues): Promise<FormState<TResult>> => {
91
+ // Cleared, never carried: a stale message would mark a control invalid for a value the user
92
+ // has already changed, on the one screen where the user is watching for exactly that.
93
+ publish({ status: 'submitting', ...NO_FORM_ERRORS, result: undefined, issues: [] });
94
+
95
+ const schema = options.schema;
96
+ if (schema !== undefined) {
97
+ // The result's `value` is read by nothing. Deliberately: the parse below is the browser's
98
+ // opinion, and the only thing this file wants from it is which paths to complain about.
99
+ const local = issuesFromValidation(await schema['~standard'].validate(values));
100
+ if (local.length > 0) return failed(local);
101
+ }
102
+
103
+ try {
104
+ const result = await options.submit(values);
105
+ return publish({ status: 'succeeded', ...NO_FORM_ERRORS, result, issues: [] });
106
+ } catch (rejection) {
107
+ return failed(issuesFromRejection(rejection));
108
+ }
109
+ };
110
+
111
+ return {
112
+ state: () => state,
113
+ /** A second submit JOINS the first rather than starting one: a double click is not two writes. */
114
+ submit: (values) => {
115
+ if (inFlight !== null) return inFlight;
116
+ const flight = run(values).finally(() => {
117
+ inFlight = null;
118
+ });
119
+ inFlight = flight;
120
+ return flight;
121
+ },
122
+ errorFor: (path) => errorOf(state, path),
123
+ messagesFor: (path) => messagesOf(state, path),
124
+ reset: () => {
125
+ publish(IDLE_FORM_STATE);
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,138 @@
1
+ // One issue shape, and the two readers that produce it: a local parse result (the latency half)
2
+ // and a rejection off the server (the authoritative half). Nothing here decides what a user reads
3
+ // — `FormIssue.message` is a schema's diagnostic text, never a translated string.
4
+
5
+ import { renderThrowable, stringField } from '@ultimat3/core';
6
+ import { formatFieldPath, type IssuePathSegment, parseFieldPath } from './field-path';
7
+
8
+ /**
9
+ * One rejected value, addressed by the path the schema gave it.
10
+ *
11
+ * `message` is UNTRANSLATED and is not user-facing: it is whatever the schema library or the
12
+ * server wrote (`expected number`). The app turns it into words through `messageFor` — the
13
+ * framework ships no copy table, because the wording is the app's and the mapping is not.
14
+ */
15
+ export interface FormIssue {
16
+ /** Canonical dotted path, `''` when the issue addresses the form rather than a field. */
17
+ readonly path: string;
18
+ readonly message: string;
19
+ /** The framework code that carried it, when a rejection named one. */
20
+ readonly code: string | undefined;
21
+ }
22
+
23
+ /** One issue as a conforming schema library reports it. */
24
+ export interface FormValidationIssue {
25
+ readonly message: string;
26
+ readonly path?: readonly (PropertyKey | IssuePathSegment)[] | undefined;
27
+ }
28
+
29
+ /**
30
+ * Standard Schema's result: `issues` present is the failure, absent is the pass.
31
+ *
32
+ * The success member holds `value: unknown` and nothing reads it — the type says what the runtime
33
+ * does. A client-side parse is a latency optimisation, so its OUTPUT is not something the server
34
+ * agreed to, and typing it would invite a caller to submit it.
35
+ */
36
+ export type FormValidationResult =
37
+ | { readonly value: unknown; readonly issues?: undefined }
38
+ | { readonly issues: readonly FormValidationIssue[] };
39
+
40
+ /**
41
+ * The minimum of an input schema this package needs — Standard Schema's one member, declared
42
+ * structurally so `@ultimat3/ui` needs no dependency edge on `@ultimat3/schema`. An `action()`'s
43
+ * own `input` satisfies it as written.
44
+ *
45
+ * The OUTPUT type is deliberately absent: a client-side parse is a latency optimisation, its value
46
+ * is thrown away, and typing it would invite a caller to submit it.
47
+ */
48
+ export interface FormSchema {
49
+ readonly '~standard': {
50
+ readonly validate: (value: unknown) => FormValidationResult | Promise<FormValidationResult>;
51
+ };
52
+ }
53
+
54
+ /** A local parse's issues, addressed by path. A result with none answers `[]`. */
55
+ export function issuesFromValidation(result: FormValidationResult): readonly FormIssue[] {
56
+ const issues = result.issues;
57
+ if (issues === undefined) return [];
58
+ return issues.map((issue) => ({
59
+ path: formatFieldPath(issue.path),
60
+ message: issue.message,
61
+ code: undefined,
62
+ }));
63
+ }
64
+
65
+ /** The codes whose `cause` is a rendered issue list rather than a sentence. */
66
+ const VALIDATION_CODES: ReadonlySet<string> = new Set(['X_INPUT_INVALID', 'X_VALIDATION_FAILED']);
67
+
68
+ /** `@ultimat3/action`'s `InputInvalidError` prefixes the list with the action it refused for. */
69
+ const LIST_PREFIX = 'failed validation: ';
70
+
71
+ /**
72
+ * `formatIssues()`'s separator, on both sides of the wire. A message that itself contains `'; '`
73
+ * splits wrongly here — and degrades VISIBLY, because a fragment whose head is not a field path
74
+ * lands at the form rather than on a control. That is the cost of the wire carrying no structured
75
+ * issue list; see this file's note in `README.md`.
76
+ */
77
+ const ISSUE_SEPARATOR = '; ';
78
+
79
+ function issuesFromCauseLine(cause: string, code: string): readonly FormIssue[] {
80
+ const prefix = cause.lastIndexOf(LIST_PREFIX);
81
+ const list = prefix === -1 ? cause : cause.slice(prefix + LIST_PREFIX.length);
82
+ return list
83
+ .split(ISSUE_SEPARATOR)
84
+ .map((part) => part.trim())
85
+ .filter((part) => part.length > 0)
86
+ .map((part) => {
87
+ const at = part.indexOf(': ');
88
+ const head = at === -1 ? '' : part.slice(0, at);
89
+ // Only a head that PARSES becomes a path. Otherwise the fragment is a sentence holding a
90
+ // colon, and splitting it would mint a field name the form never declared.
91
+ if (head === '' || parseFieldPath(head) === null) return { path: '', message: part, code };
92
+ return { path: head, message: part.slice(at + 2), code };
93
+ });
94
+ }
95
+
96
+ /** `meta.issues`, when the rejection survived with its structure intact. */
97
+ function issuesFromMeta(rejection: object, code: string | undefined): readonly FormIssue[] | null {
98
+ let held: unknown;
99
+ try {
100
+ const meta: unknown = (rejection as Record<string, unknown>)['meta'];
101
+ if (typeof meta !== 'object' || meta === null) return null;
102
+ held = (meta as Record<string, unknown>)['issues'];
103
+ } catch {
104
+ // A getter or a Proxy trap fought the read; the cause line below is still there.
105
+ return null;
106
+ }
107
+ if (!Array.isArray(held)) return null;
108
+ const issues: FormIssue[] = [];
109
+ for (const entry of held as readonly unknown[]) {
110
+ const message = stringField(entry, 'message');
111
+ if (message === undefined) return null;
112
+ issues.push({ path: stringField(entry, 'path') ?? '', message, code });
113
+ }
114
+ return issues.length === 0 ? null : issues;
115
+ }
116
+
117
+ /**
118
+ * Whatever the server rejected with, as issues. Total over `unknown`: an `UltimateError`, a plain
119
+ * object off a worker or a WebSocket, a parsed `application/problem+json` body (`code` and `cause`
120
+ * are members of that document) and a `TypeError` from a fetch that never left the browser all
121
+ * answer here, and every one of them answers with at least one issue.
122
+ *
123
+ * Never empty. A rejection that produced no issue would be a form that submitted, failed, and
124
+ * showed nothing — which is worse than having no binding at all.
125
+ */
126
+ export function issuesFromRejection(rejection: unknown): readonly FormIssue[] {
127
+ const code = stringField(rejection, 'code');
128
+ if (typeof rejection === 'object' && rejection !== null) {
129
+ const structured = issuesFromMeta(rejection, code);
130
+ if (structured !== null) return structured;
131
+ }
132
+ const cause = stringField(rejection, 'cause') ?? stringField(rejection, 'detail');
133
+ if (code !== undefined && cause !== undefined && VALIDATION_CODES.has(code)) {
134
+ const parsed = issuesFromCauseLine(cause, code);
135
+ if (parsed.length > 0) return parsed;
136
+ }
137
+ return [{ path: '', message: cause ?? renderThrowable(rejection), code }];
138
+ }
@@ -0,0 +1,92 @@
1
+ // Where an issue LANDS. One rule — a path that matches a declared field goes to that field, and
2
+ // everything else goes to the form — because the alternative is an error the user cannot see on a
3
+ // control the app forgot to declare, which is worse than having no binding at all.
4
+
5
+ import type { FormIssue } from './form-issue';
6
+
7
+ export type FormStatus = 'idle' | 'submitting' | 'succeeded' | 'failed';
8
+
9
+ /** Already-translated messages, addressed the way `Field` and `Form` consume them. */
10
+ export interface FormErrors {
11
+ readonly fieldErrors: ReadonlyMap<string, readonly string[]>;
12
+ readonly formErrors: readonly string[];
13
+ }
14
+
15
+ export interface FormState<TResult> extends FormErrors {
16
+ readonly status: FormStatus;
17
+ /** The server's answer. Only ever set from a resolved `submit`. */
18
+ readonly result: TResult | undefined;
19
+ /** The untranslated issues behind the messages above — nothing is dropped on the way in. */
20
+ readonly issues: readonly FormIssue[];
21
+ }
22
+
23
+ const NO_FIELD_ERRORS: ReadonlyMap<string, readonly string[]> = new Map();
24
+
25
+ /** The empty pair, shared: every member is readonly and neither is ever mutated in place. */
26
+ export const NO_FORM_ERRORS: FormErrors = Object.freeze({
27
+ fieldErrors: NO_FIELD_ERRORS,
28
+ formErrors: Object.freeze([]),
29
+ });
30
+
31
+ /** `FormState<never>` is assignable to every `FormState<T>`: every member is readonly. */
32
+ export const IDLE_FORM_STATE: FormState<never> = Object.freeze({
33
+ status: 'idle',
34
+ fieldErrors: NO_FIELD_ERRORS,
35
+ formErrors: [],
36
+ result: undefined,
37
+ issues: [],
38
+ });
39
+
40
+ /**
41
+ * A translator that answers nothing, or that throws, degrades to the schema's own diagnostic text
42
+ * — the same choice `t()` makes with `⟦key⟧`. The two alternatives are worse in the two ways that
43
+ * matter: a blank message leaves a control marked `aria-invalid` with nothing to read, and letting
44
+ * the throw out abandons the submit mid-flight, so the form spins forever holding the user's input.
45
+ */
46
+ function translate(issue: FormIssue, messageFor: (issue: FormIssue) => string): string {
47
+ let rendered: string;
48
+ try {
49
+ rendered = messageFor(issue);
50
+ } catch {
51
+ // The app's translator is not the framework's to report on, and this frame is the one that
52
+ // tells the user their submit failed.
53
+ return issue.message;
54
+ }
55
+ return rendered.trim() === '' ? issue.message : rendered;
56
+ }
57
+
58
+ /**
59
+ * Issues to slots. A path is bound only where the form DECLARED that exact field: a near miss
60
+ * (`items` against a form holding `items[0].price`) surfaces at the form, never on a neighbouring
61
+ * control, because an error rendered against the wrong input is a lie the user acts on.
62
+ */
63
+ export function distributeIssues(
64
+ issues: readonly FormIssue[],
65
+ fields: ReadonlySet<string>,
66
+ messageFor: (issue: FormIssue) => string,
67
+ ): FormErrors {
68
+ const fieldErrors = new Map<string, readonly string[]>();
69
+ const formErrors: string[] = [];
70
+ for (const issue of issues) {
71
+ const message = translate(issue, messageFor);
72
+ if (issue.path !== '' && fields.has(issue.path)) {
73
+ fieldErrors.set(issue.path, [...(fieldErrors.get(issue.path) ?? []), message]);
74
+ continue;
75
+ }
76
+ formErrors.push(message);
77
+ }
78
+ return { fieldErrors, formErrors };
79
+ }
80
+
81
+ /** Every message bound to one field, in the order the issues arrived. */
82
+ export function messagesOf(state: FormErrors, path: string): readonly string[] {
83
+ return state.fieldErrors.get(path) ?? [];
84
+ }
85
+
86
+ /**
87
+ * The one message `Field`'s single error slot renders. The rest stay reachable through
88
+ * `messagesOf` — one slot is a rendering decision, so nothing is discarded from the state.
89
+ */
90
+ export function errorOf(state: FormErrors, path: string): string | undefined {
91
+ return messagesOf(state, path)[0];
92
+ }
@@ -0,0 +1,100 @@
1
+ // What the browser submitted, shaped the way the action's input schema declares it. The `name`
2
+ // attribute and the issue path are the SAME string, which is what makes a rejection findable: a
3
+ // control named `items[0].price` is the value at `items[0].price` and the error on it.
4
+ //
5
+ // A form is user input reaching an object graph, so a name is refused, never repaired.
6
+
7
+ import { conflictingFieldNameError, invalidFieldPathError } from '../errors';
8
+ import { type FieldPathSegment, formatFieldPath, parseFieldPath } from './field-path';
9
+
10
+ type Container = Record<string, unknown> | readonly unknown[];
11
+
12
+ function asContainer(value: unknown): Container | null {
13
+ if (Array.isArray(value)) return value as readonly unknown[];
14
+ if (typeof value === 'object' && value !== null) return value as Record<string, unknown>;
15
+ return null;
16
+ }
17
+
18
+ /** `Object.hasOwn` rather than the bare read: the key is a `name` attribute, which is data. */
19
+ function childOf(container: Container, segment: FieldPathSegment): unknown {
20
+ if (Array.isArray(container)) {
21
+ return typeof segment === 'number' ? container[segment] : undefined;
22
+ }
23
+ const record = container as Record<string, unknown>;
24
+ return typeof segment === 'string' && Object.hasOwn(record, segment)
25
+ ? record[segment]
26
+ : undefined;
27
+ }
28
+
29
+ function writeChild(container: Container, segment: FieldPathSegment, value: unknown): boolean {
30
+ if (Array.isArray(container)) {
31
+ if (typeof segment !== 'number') return false;
32
+ (container as unknown[])[segment] = value;
33
+ return true;
34
+ }
35
+ if (typeof segment !== 'string') return false;
36
+ (container as Record<string, unknown>)[segment] = value;
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * One entry into the tree. Every branch that cannot continue throws: a name that describes an
42
+ * object where another name already put a string is two controls fighting over one path, and
43
+ * either winner silently discards something the user typed.
44
+ */
45
+ function place(
46
+ container: Container,
47
+ segments: readonly FieldPathSegment[],
48
+ value: unknown,
49
+ name: string,
50
+ prefix: readonly FieldPathSegment[],
51
+ ): void {
52
+ const [head, ...rest] = segments;
53
+ // `parseFieldPath` never answers an empty list, so this is the type's obligation, not a case.
54
+ if (head === undefined) return;
55
+ const at = (): string => formatFieldPath([...prefix, head]);
56
+ const existing = childOf(container, head);
57
+ const next = rest[0];
58
+
59
+ if (next === undefined) {
60
+ if (existing !== undefined) throw conflictingFieldNameError(name, at());
61
+ if (!writeChild(container, head, value)) throw conflictingFieldNameError(name, at());
62
+ return;
63
+ }
64
+
65
+ const wantsArray = typeof next === 'number';
66
+ const child = existing === undefined ? (wantsArray ? [] : {}) : asContainer(existing);
67
+ if (child === null || Array.isArray(child) !== wantsArray) {
68
+ throw conflictingFieldNameError(name, at());
69
+ }
70
+ if (existing === undefined && !writeChild(container, head, child)) {
71
+ throw conflictingFieldNameError(name, at());
72
+ }
73
+ place(child, rest, value, name, [...prefix, head]);
74
+ }
75
+
76
+ /**
77
+ * `new FormData(event.currentTarget)` as the object the typed client posts.
78
+ *
79
+ * A name that appears more than once collects into an array in document order — a `<select
80
+ * multiple>` and a checkbox group have one name and many values, and no index to write. A field
81
+ * that must ALWAYS be an array is named with explicit indexes (`tags[0]`, `tags[1]`), because one
82
+ * checked box out of a group is otherwise a single value, which is what HTML submits.
83
+ */
84
+ export function valuesOfForm(data: FormData): Record<string, unknown> {
85
+ const grouped = new Map<string, unknown[]>();
86
+ for (const [name, value] of data) {
87
+ const held = grouped.get(name);
88
+ if (held === undefined) grouped.set(name, [value]);
89
+ else held.push(value);
90
+ }
91
+
92
+ const root: Record<string, unknown> = {};
93
+ for (const [name, values] of grouped) {
94
+ const segments = parseFieldPath(name);
95
+ if (segments === null) throw invalidFieldPathError('form control', name);
96
+ const [only] = values;
97
+ place(root, segments, values.length === 1 && only !== undefined ? only : values, name, []);
98
+ }
99
+ return root;
100
+ }
@@ -0,0 +1,38 @@
1
+ // The reactive shell over `createFormBinding`, and the whole of what it adds: the state lands in a
2
+ // signal, so a `<Field error={form.errorFor('title')}>` re-renders when a submit answers.
3
+ //
4
+ // Reactivity goes through `solid()` like every other read in this package — a component that
5
+ // imported solid-js directly would make @ultimat3/ui depend on a runtime it deliberately does not.
6
+
7
+ import { solid } from '../theme/solid-adapter';
8
+ import { createFormBinding, type FormBinding, type FormBindingOptions } from './form-binding';
9
+ import { errorOf, type FormState, IDLE_FORM_STATE, messagesOf } from './form-state';
10
+
11
+ /**
12
+ * Call it in a component body. On the server there is no registered runtime and the inert one
13
+ * answers: the state holds, no effect runs, and the form renders in its idle state — which is what
14
+ * a form that nobody has submitted yet IS.
15
+ */
16
+ export function useForm<TValues, TResult>(
17
+ options: FormBindingOptions<TValues, TResult>,
18
+ ): FormBinding<TValues, TResult> {
19
+ const runtime = solid();
20
+ const [state, setState] = runtime.createSignal<FormState<TResult>>(IDLE_FORM_STATE);
21
+ const binding = createFormBinding<TValues, TResult>({
22
+ ...options,
23
+ onState: (next) => {
24
+ options.onState?.(next);
25
+ setState(next);
26
+ },
27
+ });
28
+
29
+ // Re-derived from the SIGNAL, never from the binding's own snapshot: `binding.errorFor` reads a
30
+ // closed-over variable, which is a value a reactive scope cannot subscribe to — a form bound to
31
+ // it would show the first answer forever.
32
+ return {
33
+ ...binding,
34
+ state,
35
+ errorFor: (path) => errorOf(state(), path),
36
+ messagesFor: (path) => messagesOf(state(), path),
37
+ };
38
+ }
package/src/index.ts CHANGED
@@ -178,7 +178,9 @@ export type { Debounced } from './debounce';
178
178
  export { DEBOUNCE_DEFAULT_MS, debounce } from './debounce';
179
179
  export type { UiErrorCode } from './errors';
180
180
  export {
181
+ conflictingFieldNameError,
181
182
  invalidBrandTokenError,
183
+ invalidFieldPathError,
182
184
  invalidGlyphError,
183
185
  invalidIconDataError,
184
186
  invalidThemeError,
@@ -189,6 +191,28 @@ export {
189
191
  UiError,
190
192
  unknownTokenError,
191
193
  } from './errors';
194
+ // --- forms: the binding between an action's input schema and Field's error slot ---------------
195
+ export type { FieldPathSegment, IssuePathSegment } from './form/field-path';
196
+ export { formatFieldPath, MAX_FIELD_INDEX, parseFieldPath } from './form/field-path';
197
+ export type { FormBinding, FormBindingOptions } from './form/form-binding';
198
+ export { createFormBinding } from './form/form-binding';
199
+ export type {
200
+ FormIssue,
201
+ FormSchema,
202
+ FormValidationIssue,
203
+ FormValidationResult,
204
+ } from './form/form-issue';
205
+ export { issuesFromRejection, issuesFromValidation } from './form/form-issue';
206
+ export type { FormErrors, FormState, FormStatus } from './form/form-state';
207
+ export {
208
+ distributeIssues,
209
+ errorOf,
210
+ IDLE_FORM_STATE,
211
+ messagesOf,
212
+ NO_FORM_ERRORS,
213
+ } from './form/form-state';
214
+ export { valuesOfForm } from './form/form-values';
215
+ export { useForm } from './form/use-form';
192
216
  export type { UiKey } from './i18n-keys';
193
217
  export { UI_KEYS } from './i18n-keys';
194
218
  export type { ArrowKeyElement, RovingItem } from './roving';