first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

34
docs/ui/README.md Normal file
View File

@@ -0,0 +1,34 @@
# `ui`
React components, forms, and user-interface primitives.
<!-- begin:auto-docs -->
## Subcategories
2 subcategories, 11 packages.
### `forms`
Form builders, validation, and React form components.
| Package | Description |
|---|---|
| [`@suaveplan/form-builder`](./form-builder/README.md) | Universal dynamic form schema engine. Renderer-agnostic — no DOM, no React, no Node-specific globals. Drives every dynamic form surface in the Suaveplan. |
| [`@suaveplan/react-forms`](./react-forms/README.md) | React forms library for the Suaveplan ecosystem. Wraps react-hook-form + Zod with a primitives + factory + recipes architecture: every user-facing string is ty… |
### `react`
React hooks and components for audio, auth, and UI surfaces.
| Package | Description |
|---|---|
| [`@suaveplan/react-audio`](./react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
| [`@suaveplan/react-audio-fingerprint`](./react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
| [`@suaveplan/react-audio-player`](./react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
| [`@suaveplan/react-audio-stream`](./react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
| [`@suaveplan/react-auth`](./react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
| [`@suaveplan/react-auth-whitelabel`](./react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
| [`@suaveplan/react-control-surface`](./react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
| [`@suaveplan/react-midi`](./react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
| [`@suaveplan/react-ui`](./react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
<!-- end:auto-docs -->

3
docs/ui/_intro.md Normal file
View File

@@ -0,0 +1,3 @@
# `ui`
React components, forms, and user-interface primitives.

14
docs/ui/forms/README.md Normal file
View File

@@ -0,0 +1,14 @@
# `forms`
Form builders, validation, and React form components.
<!-- begin:auto-docs -->
## Packages in `forms`
2 packages.
| Package | Description |
|---|---|
| [`@suaveplan/form-builder`](./form-builder/README.md) | Universal dynamic form schema engine. Renderer-agnostic — no DOM, no React, no Node-specific globals. Drives every dynamic form surface in the Suaveplan. |
| [`@suaveplan/react-forms`](./react-forms/README.md) | React forms library for the Suaveplan ecosystem. Wraps react-hook-form + Zod with a primitives + factory + recipes architecture: every user-facing string is ty… |
<!-- end:auto-docs -->

1
docs/ui/forms/_intro.md Normal file
View File

@@ -0,0 +1 @@
Form builders, validation, and React form components.

View File

@@ -0,0 +1,128 @@
# @suaveplan/form-builder - Universal dynamic form schema engine with conditional logic, async validation, dependency graph, undo/redo, Zod-schema generation, OTel instrumentation, and pluggable field-type registry.
Universal dynamic form schema engine. Renderer-agnostic — no DOM, no
React, no Node-specific globals. Drives every dynamic form surface in the
Suaveplan.
<!-- begin:auto-docs -->
## Documentation tree
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
- [form-builder](./src/builder/form-builder/form-builder.md) — `form-builder.ts` exposes the top-level `FormBuilder` class — the public entry point for `@suaveplan/form-builder`. The…
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
- [conditional](./src/conditional/conditional/conditional.md) — `conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`. It supports nine leaf operators (`eq`…
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
- [dependency-graph](./src/conditional/dependency-graph/dependency-graph.md) — `dependency-graph.ts` builds a directed acyclic graph of field-id dependencies derived from `FieldDefinition.conditiona…
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
- [form-error](./src/errors/form-error/form-error.md) — `form-error.ts` owns the stable error-code registry and the typed `FormError` class for the entire `@suaveplan/form-bui…
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
- [field-registry](./src/fields/field-registry/field-registry.md) — `field-registry.ts` is the extensible registry of field-type descriptors used by `FormBuilder`. It ships seventeen buil…
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
- [instruments](./src/otel/instruments/instruments.md) — `instruments.ts` lazily resolves the OpenTelemetry meter and the five instruments emitted across the package: three cou…
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
- [schema](./src/schema/schema/schema.md) — `schema.ts` is the Zod boundary for the public `FormSchema`, `FieldDefinition`, and `FormSection` types. It exposes par…
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
- [serialise](./src/serialise/serialise/serialise.md) — `serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is the bridge between the in-memory schema engin…
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
- [form-state](./src/state/form-state/form-state.md) — `form-state.ts` is the mutable holder for the runtime state of a form instance: the current value map, the most recent…
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
- [undo-redo](./src/state/undo-redo/undo-redo.md) — `undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is the substrate for `FormBuilder.enableHistory`…
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
- [submission](./src/submission/submission/submission.md) — `submission.ts` is the orchestration layer that runs the canonical `prepare → validate → transform → submit` pipeline f…
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
- [validation](./src/validation/validation/validation.md) — `validation.ts` is the iteration layer that runs every rule attached to a field or form and aggregates the resulting `V…
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
- [validators](./src/validation/validators/validators.md) — `validators.ts` provides the eleven built-in rule runners used by the validation engine. Each runner takes the candidat…
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
- [zod-schema](./src/zod-schema/zod-schema/zod-schema.md) — `zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a `FormSchema` into a `z.ZodObject` whose `safeP…
<!-- end:auto-docs -->
## Features
- 17 built-in field types (`text`, `number`, `email`, `url`, `tel`,
`password`, `select`, `multiselect`, `checkbox`, `radio`, `textarea`,
`date`, `file`, `range`, `hidden`, `group`, `array`) via an extensible
`FieldRegistry`.
- 11 built-in validation rules (`required`, `minLength`, `maxLength`, `min`,
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`).
Async-friendly via `Promise<boolean>` predicates.
- Conditional logic with 9 leaf operators (`eq`, `neq`, `in`, `notIn`,
`gt`, `lt`, `gte`, `lte`, `contains`) and boolean composition
(`and` / `or` / `not`).
- Field-level dependency graph with topological evaluation and cycle
detection (`FORM_BUILDER.CYCLIC_DEPENDENCY`).
- Bounded undo / redo on schema mutations.
- Typed event bus via `EventBus<FormEventMap>` from `@suaveplan/events`.
- Versioned JSON serialise / deserialise round-trip.
- `toZodSchema(form)` generator for `react-hook-form` consumers.
- Submission pipeline: `prepare → validate → transform → submit`.
- Six stable `FORM_BUILDER.*` error codes via `defineCodes`.
- Three OTel counters and two histograms emitted on every code path.
- Injectable `Clock` + `IdGenerator` for deterministic tests.
## Install
```bash
bun add @suaveplan/form-builder
```
## Usage
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const builder = new FormBuilder().enableHistory();
builder.addField({
id: "name",
name: "name",
label: "Name",
type: "text",
validations: [{ type: "required" }],
});
builder.addField({
id: "subscribe",
name: "subscribe",
label: "Subscribe to newsletter",
type: "checkbox",
});
builder.addField({
id: "frequency",
name: "frequency",
label: "Frequency",
type: "select",
options: [
{ label: "Weekly", value: "weekly" },
{ label: "Monthly", value: "monthly" },
],
conditional: { field: "subscribe", operator: "eq", value: true },
});
builder.bus.on("values.changed", (values) =>
console.log("changed", values)
);
const schema = builder.createSchema("signup", "Sign-up form");
```
## Submission
```ts
import { submit } from "@suaveplan/form-builder";
await submit({
fields: schema.fields,
values: { name: "alice", subscribe: true, frequency: "weekly" },
submitter: async (values, signal) =>
fetch("/api/forms", {
method: "POST",
signal,
body: JSON.stringify(values),
}),
});
```
## License
MIT.

View File

@@ -0,0 +1,80 @@
# form-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-builder.ts` exposes the top-level `FormBuilder` class — the public
entry point for `@suaveplan/form-builder`. The class aggregates every
subsystem (schema, state, validation, conditional logic, dependency
graph, undo/redo, event bus, OTel) behind a renderer-agnostic, universal-
tier façade. It is the only class downstream packages need to instantiate
to model a dynamic form end-to-end.
The builder emits typed events through an `EventBus<FormEventMap>` from
`@suaveplan/events` so consumers (React adapters, debug overlays, audit
logs) can subscribe to lifecycle changes without polling. Optional
injectable `Clock` and `IdGenerator` make the package deterministic for
snapshot tests and golden-master fixtures.
## Features
- Full upstream public surface preserved (`createSchema`, `addField`,
`removeField`, `updateField`, `addSection`, `getField`, `getSchema`,
`validateField`, `validateForm`, `getVisibleFields`, `setValues`,
`resetForm`, `getState`, `toJSON`, `getSectionById`).
- New: `removeSection`, `enableHistory`, `undo`, `redo`, `newId`.
- `EventBus<FormEventMap>` emission for every mutation + lifecycle event.
- Dependency-graph driven conditional evaluation.
- Optional bounded undo/redo with linear history semantics.
- Injectable `Clock` + `IdGenerator` for determinism.
## Usage: Basic
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const builder = new FormBuilder();
builder.addField({ id: "email", name: "email", label: "Email", type: "email", validations: [{ type: "required" }] });
const schema = builder.createSchema("contact", "Contact form");
```
## Usage: Advanced
```ts
import { FormBuilder } from "@suaveplan/form-builder";
const clock = () => 1700_000_000_000;
const builder = new FormBuilder({ clock }).enableHistory(50);
builder.addField({ id: "a", name: "a", label: "A", type: "text" });
builder.bus.on("field.added", (field) => console.log("added", field.id));
builder.undo(); // removes the field, fires field.removed
builder.redo(); // re-adds it
```
## API Reference
- `new FormBuilder(options?)` — `{ clock?, idGenerator?, bus? }`.
- `bus: EventBus<FormEventMap>` — public event bus.
- `createSchema(id, name, options?): FormSchema`.
- `addField(field)`, `removeField(id)`, `updateField(id, patch)`.
- `addSection(section)`, `removeSection(id)`, `getSectionById(id)`.
- `getField(id)`, `getSchema()`.
- `validateField(id, value, signal?)`, `validateForm(values, signal?)`.
- `getVisibleFields(values)`.
- `setValues(values, signal?)`, `resetForm()`, `getState()`.
- `toJSON()`, `newId()`.
- `enableHistory(capacity?)`, `undo()`, `redo()`.
## Implementation Notes
`getVisibleFields` builds a fresh `DependencyGraph` on each call rather
than caching, because field additions/removals invalidate the cache and
the graph build is O(V + E) — measurably under 1 ms for forms of any
realistic size. Undo ops are paired symmetrically: `addField`'s reverse-op
removes the field, whose reverse-op re-adds it. Pushing onto history while
history is disabled is a no-op so the class is safe to use with or without
history enabled.

View File

@@ -0,0 +1,70 @@
# conditional
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`conditional.ts` is the rule-evaluation engine for `FieldDefinition.conditional`.
It supports nine leaf operators (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`,
`gte`, `lte`, `contains`) and three boolean combinators (`and`, `or`,
`not`). It also normalises the legacy upstream shape
(`{ field, operator, value }`) into the tagged-leaf representation so
schemas authored against the original `form-builder` continue to work
unchanged.
The same file exposes `fieldsReferencedBy`, a recursive walker that returns
every field id referenced by a conditional rule. The dependency-graph
builder consumes that walker to derive evaluation order for chained
conditionals.
## Features
- Nine leaf operators.
- Boolean combinators with short-circuit semantics.
- Legacy upstream shape accepted (no migration step).
- Pure: no side effects, no IO, deterministic per `(rule, values)` pair.
## Usage: Basic
```ts
import { evaluateConditional } from "@suaveplan/form-builder";
evaluateConditional(
{ field: "country", operator: "eq", value: "US" },
{ country: "US" }
); // true
```
## Usage: Advanced
```ts
import { evaluateConditional } from "@suaveplan/form-builder";
const rule = {
kind: "and" as const,
rules: [
{ kind: "leaf" as const, field: "age", operator: "gte" as const, value: 18 },
{ kind: "or" as const, rules: [
{ kind: "leaf" as const, field: "country", operator: "in" as const, value: ["US", "CA"] },
{ kind: "not" as const, rule: { kind: "leaf" as const, field: "blocked", operator: "eq" as const, value: true } },
]},
],
};
evaluateConditional(rule, { age: 21, country: "GB", blocked: false }); // true
```
## API Reference
- `evaluateConditional(rule, values): boolean`.
- `fieldsReferencedBy(rule): readonly string[]` — unique field ids.
## Implementation Notes
`and` returns `false` on the first false child; `or` returns `true` on the
first true child. Numeric operators (`gt`, `lt`, `gte`, `lte`) return
`false` when either side is not a `number`. `contains` works for both
strings (substring) and arrays (membership). The function is recursive but
form schemas in practice have shallow conditional trees, so the recursion
depth is bounded.

View File

@@ -0,0 +1,82 @@
# dependency-graph
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`dependency-graph.ts` builds a directed acyclic graph of field-id
dependencies derived from `FieldDefinition.conditional` and `equalsField`
validators. The graph is the substrate for `FormBuilder.getVisibleFields`,
which evaluates conditional fields in topological order so chained
dependencies (`a → b → c`) settle in a single pass instead of converging
over multiple iterations.
`topoSort()` runs Kahn's algorithm and emits a stable ordering; if it
detects a cycle, it throws a `FormError` carrying
`FORM_BUILDER.CYCLIC_DEPENDENCY` and the unresolved set of field ids.
Forms with cyclic visibility rules are by definition undefined, so the
package treats them as a configuration error rather than silently
diverging.
## Features
- Builds edges from both conditional rules and `equalsField` validators.
- Kahn's topological sort with deterministic insertion order.
- `cyclicDependency` factory raises `FORM_BUILDER.CYCLIC_DEPENDENCY` with
the unresolved id list in `context.cycle`.
- O(V + E) time, O(V) space.
## Usage: Basic
```ts
import { DependencyGraph } from "@suaveplan/form-builder";
const graph = new DependencyGraph([
{ id: "a", name: "a", label: "A", type: "text" },
{
id: "b",
name: "b",
label: "B",
type: "text",
conditional: { field: "a", operator: "eq", value: "yes" },
},
]);
graph.topoSort(); // ["a", "b"]
```
## Usage: Advanced
```ts
import { DependencyGraph } from "@suaveplan/form-builder";
const graph = new DependencyGraph();
graph.addField({ id: "first", name: "first", label: "First", type: "text" });
graph.addField({
id: "confirm",
name: "confirm",
label: "Confirm",
type: "text",
validations: [{ type: "equalsField", value: "first" }],
});
graph.dependents("first"); // ["confirm"]
```
## API Reference
- `new DependencyGraph(fields?)` — optionally seeded.
- `addField(field)` — register a node and its edges.
- `nodes(): readonly string[]`.
- `dependents(from): readonly string[]`.
- `topoSort(): readonly string[]` — may throw `FormError`.
## Implementation Notes
Cycle detection compares the sorted order's length to the registered node
count; any divergence implies a cycle, and the remaining nodes form the
reported cycle context. The graph stores edges in
`Map<string, Set<string>>` to keep edge insertion O(1) and dedup automatic.
Nodes referenced only from `equalsField` (e.g. the password field
referenced by a confirm field) are added as bare nodes so the topo sort
can place them before their dependents.

View File

@@ -0,0 +1,84 @@
# form-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-error.ts` owns the stable error-code registry and the typed `FormError`
class for the entire `@suaveplan/form-builder` package. Every failure mode
exposed by the public API surfaces as a `FormError` carrying one of six
literal codes registered through `defineCodes("FORM_BUILDER", …)`, so
downstream consumers (loggers, problem+json responders, retry wrappers, i18n
adapters) can pattern-match on `error.code` without re-implementing the
mapping.
The module also exports six factory helpers — `fieldNotFound`,
`sectionNotFound`, `duplicateId`, `configInvalid`, `validationFailed`, and
`cyclicDependency` — that build pre-shaped errors with the appropriate
context payload (field id, section id, cycle path, aggregated validation
errors, etc.). These are the only constructors used inside the package.
## Features
- Codes defined via `@suaveplan/error`'s `defineCodes`, giving fully
qualified strings such as `"FORM_BUILDER.FIELD_NOT_FOUND"`.
- `FormError extends SuaveplanError<FormBuilderCode>` so `instanceof
FormError` narrows the union.
- Strongly-typed `FormErrorContext` with optional `fieldId`, `sectionId`,
`schemaId`, `cycle`, and aggregated `errors`.
- Factory helpers keep error construction consistent and ensure every
registered code is reachable from at least one production call site.
## Usage: Basic
```ts
import { fieldNotFound, FORM_BUILDER_CODES } from "@suaveplan/form-builder";
try {
throw fieldNotFound("email");
} catch (err) {
if (err instanceof Error && "code" in err) {
// err.code === FORM_BUILDER_CODES.FIELD_NOT_FOUND
}
}
```
## Usage: Advanced
```ts
import { cyclicDependency, FormError, validationFailed } from "@suaveplan/form-builder";
function safeTopo(cycle: readonly string[]): never {
throw cyclicDependency(cycle);
}
const aggregated = validationFailed([
{ fieldId: "name", rule: "required", message: "Name is required" },
]);
if (aggregated instanceof FormError) {
// aggregated.context?.errors contains the per-field details
}
```
## API Reference
- `FORM_BUILDER_CODES` — frozen code table; six string members.
- `FormBuilderCode` — union of every registered code value.
- `FormError(code, message, options?)` — base error class.
- `fieldNotFound(fieldId)` — `FORM_BUILDER.FIELD_NOT_FOUND`.
- `sectionNotFound(sectionId)` — `FORM_BUILDER.SECTION_NOT_FOUND`.
- `duplicateId(kind, id)` — `FORM_BUILDER.DUPLICATE_ID`.
- `configInvalid(message, cause?)` — `FORM_BUILDER.CONFIG_INVALID`.
- `validationFailed(errors)` — `FORM_BUILDER.VALIDATION_FAILED`.
- `cyclicDependency(cycle)` — `FORM_BUILDER.CYCLIC_DEPENDENCY`.
## Implementation Notes
`SuaveplanError` is an abstract class. `FormError` declares a public
constructor so callers may throw new instances directly without subclassing
further. The factory helpers are pure functions and do not allocate event
buses or otel handles, so they are safe to call from hot validation loops.
The code registry is intentionally closed; adding a code requires an
OpenSpec change that mirrors the bump in this file.

View File

@@ -0,0 +1,69 @@
# field-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`field-registry.ts` is the extensible registry of field-type descriptors
used by `FormBuilder`. It ships seventeen built-in descriptors (`text`,
`number`, `email`, `url`, `tel`, `password`, `select`, `multiselect`,
`checkbox`, `radio`, `textarea`, `date`, `file`, `range`, `hidden`, `group`,
`array`) and exposes `register`, `unregister`, `has`, `get`, and `list` so
downstream packages can contribute additional field types without forking
the form-builder.
Descriptors carry a human-readable label, a default value, a `hasOptions`
flag (used by select-family fields), and an optional `coerce` function that
normalises raw text input (e.g. `"42"` → `42`) into the canonical runtime
representation.
## Features
- 17 built-in field types registered out of the box.
- Pluggable: third-party packages can `register("rating", { defaultValue:
0 })` to add custom types.
- Coercers normalise external string inputs into the runtime shape.
- A singleton `defaultFieldRegistry` is exported for callers who only need
the built-ins.
## Usage: Basic
```ts
import { defaultFieldRegistry } from "@suaveplan/form-builder";
const desc = defaultFieldRegistry.get("number");
desc?.coerce?.("42"); // → 42
```
## Usage: Advanced
```ts
import { FieldRegistry } from "@suaveplan/form-builder";
const registry = new FieldRegistry();
registry.register("rating", { label: "Rating", defaultValue: 0, hasOptions: false });
registry.has("rating"); // true
```
## API Reference
- `FieldRegistry` — registry class.
- `FieldRegistry#register(type, descriptor)` — chainable.
- `FieldRegistry#unregister(type)`.
- `FieldRegistry#has(type)`.
- `FieldRegistry#get(type)`.
- `FieldRegistry#list()` — snapshot of registered ids.
- `FieldTypeDescriptor` — `{ label, defaultValue, hasOptions, coerce? }`.
- `defaultFieldRegistry` — singleton with the 17 built-ins.
## Implementation Notes
The registry stores descriptors in a `Map<string, FieldTypeDescriptor>` and
the built-in identifiers come from the `FIELD_TYPES` constant so the union
type, the Zod schema, and the registry stay in sync. Custom types may
overwrite built-ins (useful for theming the default `text` field). The
registry deliberately has no opinions about rendering — `defaultValue`
exists so consumers can derive `resetForm` defaults when a field declares
no explicit `defaultValue`.

View File

@@ -0,0 +1,65 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` lazily resolves the OpenTelemetry meter and the five
instruments emitted across the package: three counters
(`form_builder.field.mutated_total`, `form_builder.validation.run_total`,
`form_builder.submission.total`) and two histograms
(`form_builder.validation.duration_ms`, `form_builder.submission.duration_ms`).
Lazy resolution lets test environments install a custom `MeterProvider`
via `metrics.setGlobalMeterProvider` *after* the module is imported and
still observe production emissions.
A test-only `_resetInstrumentsForTests` is exported so the lazy cache can
be cleared between tests, ensuring each test fixture starts from a clean
slate. The leading underscore signals "do not call from production code".
## Features
- Lazy meter + instrument resolution.
- Stable instrument names that double as OTel collector route keys.
- Test-only reset hook for fixture isolation.
- No imports outside `@opentelemetry/api`.
## Usage: Basic
```ts
import { fieldMutatedCounter } from "@suaveplan/form-builder";
fieldMutatedCounter().add(1, { op: "add" });
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { _resetInstrumentsForTests, validationRunCounter } from "@suaveplan/form-builder";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
_resetInstrumentsForTests();
validationRunCounter().add(1, { scope: "field", result: "ok" });
```
## API Reference
- `fieldMutatedCounter(): Counter`.
- `validationRunCounter(): Counter`.
- `submissionTotalCounter(): Counter`.
- `validationDurationHistogram(): Histogram`.
- `submissionDurationHistogram(): Histogram`.
- `_resetInstrumentsForTests()` — test-only.
## Implementation Notes
The module-level `_meter` cache is the only piece of mutable state in the
file. Multiple producers (the validation engine, the builder, the
submission pipeline) call into these helpers from independent code paths;
sharing the cache avoids creating duplicate instruments per call site and
preserves correct accumulation in the OTel SDK.

View File

@@ -0,0 +1,67 @@
# schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schema.ts` is the Zod boundary for the public `FormSchema`,
`FieldDefinition`, and `FormSection` types. It exposes parse functions that
validate untrusted input (JSON from disk, network requests, persisted
storage) and produces the canonical runtime shape used by the rest of the
package. The schema is the single source of truth for what a "valid form"
looks like; every other module assumes the values it receives have already
passed through this layer.
Parse failures uniformly raise `FormError` with code
`FORM_BUILDER.CONFIG_INVALID`, so consumers do not have to special-case Zod
issue trees. Field-id uniqueness is checked on top of the structural Zod
parse because Zod arrays do not natively support cross-element invariants
without `refine`, and emitting a typed `FormError` is friendlier to
problem+json responders than a generic `ZodError`.
## Features
- Strict Zod schemas (`.strict()` blocks unknown keys at every level).
- Recursive support for nested `FieldDefinition.fields` (group/array types).
- Lazy conditional-rule schema covering the legacy upstream shape, the
tagged `leaf`, and boolean composition (`and` / `or` / `not`).
- Re-exports `schemas` namespace for callers that want to compose.
## Usage: Basic
```ts
import { parseFormSchema } from "@suaveplan/form-builder";
const form = parseFormSchema(JSON.parse(rawJsonFromDisk));
```
## Usage: Advanced
```ts
import { schemas } from "@suaveplan/form-builder";
import z from "zod";
const FormWithMetadata = schemas.formSchema.extend({
tenant: z.string().uuid(),
});
type FormWithMetadata = z.infer<typeof FormWithMetadata>;
```
## API Reference
- `parseFormSchema(raw): FormSchema` — full validation incl. unique ids.
- `parseFieldDefinition(raw): FieldDefinition`.
- `parseFormSection(raw): FormSection`.
- `schemas` — namespace with all underlying `z.ZodType`s.
## Implementation Notes
The conditional-rule schema is `z.lazy` because the type is self-recursive
(`and` / `or` / `not` carry sub-rules). `isoDateString` is a lightweight
refinement around `Date.parse`; we deliberately do not import a heavyweight
date library because the package is universal with zero browser/Node
APIs assumed in callers. Boundary errors are thrown — never returned —
because every caller in this package raises rather than returns, and the
schema layer should match.

View File

@@ -0,0 +1,68 @@
# serialise
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`serialise.ts` provides versioned JSON round-trip for `FormSchema`. It is
the bridge between the in-memory schema engine and persisted forms in a
database, blob store, or message bus. The wire format is a small envelope
carrying a `$version` discriminator and the schema body; bumping the
discriminator in a future change lets consumers migrate forward without
ambiguity.
`deserialiseForm` performs three checks in order: JSON parse,
envelope-shape match (including `$version` equality), and full
`parseFormSchema` validation (which itself enforces Zod conformance plus
field-id uniqueness). Every failure mode raises `FormError` carrying
`FORM_BUILDER.CONFIG_INVALID`, so callers can rely on a single typed
exception path.
## Features
- Versioned wire format (`$version: 1`).
- Symmetric: `deserialiseForm(serialiseForm(s))` returns a value
structurally equal to `s`.
- Single error code (`FORM_BUILDER.CONFIG_INVALID`) for every failure.
- Pure: deterministic; no IO.
## Usage: Basic
```ts
import { serialiseForm, deserialiseForm } from "@suaveplan/form-builder";
const wire = serialiseForm(schema);
const restored = deserialiseForm(wire);
```
## Usage: Advanced
```ts
import { deserialiseForm, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
try {
deserialiseForm("{not json");
} catch (err) {
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.CONFIG_INVALID) {
// record the cause + telemetry
}
}
```
## API Reference
- `serialiseForm(schema): string`.
- `deserialiseForm(json): FormSchema`.
- `SERIALISE_VERSION = 1` — wire version constant.
## Implementation Notes
The envelope wraps the schema rather than annotating it inline because that
keeps the schema body untouched and lets the version discriminator be a
plain object key rather than a magic field embedded in the schema. The
implementation deliberately performs the version check *before* delegating
to `parseFormSchema`, so callers see a clear "wrong version" error rather
than a flood of Zod-issue noise when the inner shape is for a future
release.

View File

@@ -0,0 +1,67 @@
# form-state
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`form-state.ts` is the mutable holder for the runtime state of a form
instance: the current value map, the most recent validation errors, the
set of touched field ids, the dirty flag, and the valid flag. It is the
structure returned from `FormBuilder.getState` and emitted on the
`values.changed` event. The state machine is intentionally minimal — it
owns no validation logic of its own; the builder asks `runFormValidation`
for errors and forwards them via `setErrors`.
The state is treated as a write-through cache: every mutation produces a
new value map (via spread) so consumers that snapshot via `getState` always
get an independent copy and cannot mutate internal state by reference.
## Features
- `setValue(id, value)` — single-field update, marks dirty + touched.
- `setValues(values)` — bulk replace, marks every key touched.
- `touch(id)` — record visit without changing values.
- `reset(defaults?)` — restore defaults, clear dirty + touched.
- `setErrors(errors)` — update errors and recompute `valid`.
- `getState()` — immutable snapshot.
## Usage: Basic
```ts
import { FormStateMachine } from "@suaveplan/form-builder";
const state = new FormStateMachine({ name: "" });
state.setValue("name", "alice");
state.getState().dirty; // true
```
## Usage: Advanced
```ts
import { FormStateMachine } from "@suaveplan/form-builder";
const state = new FormStateMachine();
state.setErrors([
{ fieldId: "email", rule: "email", message: "Not a valid email" },
]);
state.getState().valid; // false
state.reset({ email: "" });
state.getState().valid; // true
state.getState().errors.length; // 0
```
## API Reference
- `FormStateMachine(initial?)`.
- `setValue(fieldId, value)`, `setValues(values)`, `touch(fieldId)`.
- `reset(defaults?)`, `setErrors(errors)`.
- `getState(): FormState`.
## Implementation Notes
`getState` returns a fresh object on every call, including a new `Set`
copy of touched ids, so consumers cannot influence subsequent mutations
by holding a reference. The class is intentionally not an `EventEmitter`
— event emission lives on `FormBuilder` where the public bus is wired up.

View File

@@ -0,0 +1,70 @@
# undo-redo
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`undo-redo.ts` is a bounded LIFO of reverse-ops with redo support. It is
the substrate for `FormBuilder.enableHistory` — when history is enabled,
every schema-mutation method (`addField`, `removeField`, `updateField`,
`addSection`, `removeSection`) pushes a paired `UndoOp` whose `apply`
reverses the operation and whose `inverse` re-applies it. Calling `undo`
pops the most recent reverse-op, applies it, and pushes its inverse onto
the redo stack; calling `redo` reverses the process. Any new mutation
clears the redo stack to preserve linear history.
The capacity bound is enforced by dropping the oldest undo entry when a
push exceeds the limit, mirroring the canonical "infinite-undo with
ceiling" behaviour users expect from desktop applications. The default
capacity is 100, configured via `DEFAULT_UNDO_CAPACITY`.
## Features
- Bounded LIFO with FIFO-eviction on overflow.
- Redo stack cleared on new mutation (linear-history semantics).
- `canUndo` / `canRedo` introspection.
- `clear()` resets both stacks.
## Usage: Basic
```ts
import { UndoRedoStack } from "@suaveplan/form-builder";
const stack = new UndoRedoStack(2);
let value = 0;
const inc: import("@suaveplan/form-builder").UndoOp = {
apply: () => { value -= 1; },
inverse: undefined as any,
};
// (set up the pairing yourself for non-builder code)
```
## Usage: Advanced
```ts
import { UndoRedoStack } from "@suaveplan/form-builder";
const stack = new UndoRedoStack();
stack.push(myUndoOp);
stack.undo();
stack.redo();
stack.canUndo(); // true again
```
## API Reference
- `new UndoRedoStack(capacity = 100)`.
- `push(op)`, `undo()`, `redo()`.
- `canUndo()`, `canRedo()`, `clear()`.
- `UndoOp = { apply: () => void; inverse: UndoOp }`.
## Implementation Notes
The capacity is validated at construction time and a `RangeError` is raised
if it is not a positive integer. The implementation uses native arrays and
`shift` / `pop` because the expected capacity ceiling (≤ 100 by default)
makes the amortised cost negligible. Switching to a ring buffer would only
matter if capacities reached the tens of thousands, which is not a
supported use case.

View File

@@ -0,0 +1,79 @@
# submission
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`submission.ts` is the orchestration layer that runs the canonical
`prepare → validate → transform → submit` pipeline for a fully-prepared
form. It validates the values against every rule, refuses to call the
submitter if validation produces errors (raising `FORM_BUILDER.VALIDATION_FAILED`),
optionally rewrites the payload via a pure-function `Transformer`, and
forwards the resulting map and the caller-supplied `AbortSignal` to the
async `Submitter` function.
Every code path emits two OpenTelemetry instruments:
`form_builder.submission.total` (counter) and
`form_builder.submission.duration_ms` (histogram) tagged with
`result: "ok" | "invalid" | "failed"`. That lets dashboards split error
budgets by terminal state.
## Features
- Strict gating: invalid values short-circuit before the submitter runs.
- Optional pure-function `Transformer` between validate and submit.
- `AbortSignal` threaded into the submitter.
- OTel counter + histogram on every terminal state.
## Usage: Basic
```ts
import { submit } from "@suaveplan/form-builder";
await submit({
fields,
values: { email: "a@example.com" },
submitter: async (values) => fetch("/api/forms", {
method: "POST",
body: JSON.stringify(values),
}),
});
```
## Usage: Advanced
```ts
import { submit, FORM_BUILDER_CODES, FormError } from "@suaveplan/form-builder";
try {
await submit({
fields,
values,
transform: (v) => ({ ...v, normalisedEmail: String(v.email).trim().toLowerCase() }),
submitter: postSubmission,
signal,
});
} catch (err) {
if (err instanceof FormError && err.code === FORM_BUILDER_CODES.VALIDATION_FAILED) {
// err.context?.errors is the aggregated per-field error list
}
}
```
## API Reference
- `submit<TResult = void>(options): Promise<TResult>`.
- `Submitter<TResult> = (values, signal?) => Promise<TResult>`.
- `Transformer = (values) => FormValues`.
- `SubmitOptions<TResult>` — `{ fields, values, submitter, transform?, signal?, clock? }`.
## Implementation Notes
`submit` clamps elapsed durations at zero to defend against fake clocks
producing negative deltas. Errors thrown by the submitter are re-thrown
verbatim after recording the `result: "failed"` counter — the submission
layer makes no attempt to translate them, because submitters are an
extensibility seam and downstream code chooses how to surface its own
errors.

View File

@@ -0,0 +1,69 @@
# validation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validation.ts` is the iteration layer that runs every rule attached to a
field or form and aggregates the resulting `ValidationError` array. It
honours `AbortSignal` for cancellation (so callers can race a slow async
custom rule against a debounce timer) and emits two OpenTelemetry
instruments — `form_builder.validation.run_total` counter and
`form_builder.validation.duration_ms` histogram — on every invocation so
operations dashboards can watch validation latency and error rates.
The engine deliberately knows nothing about the rule semantics; it
delegates to `runRule` from `../validators/validators.ts`. That keeps the
two layers independently testable: the validators cover rule semantics, the
engine covers iteration, cancellation, and observability.
## Features
- `runFieldValidation(field, value, values, signal?)` — single field.
- `runFormValidation(fields, values, signal?)` — every field.
- `AbortSignal` short-circuits both functions and returns `[]`.
- Async-safe: awaits any promise returned by `runRule`.
- OTel counter with `{ scope, result }` attributes and histogram with
`{ scope }`.
## Usage: Basic
```ts
import { runFieldValidation } from "@suaveplan/form-builder";
const errors = await runFieldValidation(
{ id: "name", name: "name", label: "Name", type: "text", validations: [{ type: "required" }] },
"",
{ name: "" }
);
// errors[0]?.rule === "required"
```
## Usage: Advanced
```ts
import { runFormValidation } from "@suaveplan/form-builder";
const controller = new AbortController();
const promise = runFormValidation(fields, values, controller.signal);
controller.abort();
const errors = await promise; // []
```
## API Reference
- `runFieldValidation(field, value, values, signal?, clock?): Promise<readonly ValidationError[]>`.
- `runFormValidation(fields, values, signal?, clock?): Promise<readonly ValidationError[]>`.
- `Clock = () => number` — injectable for deterministic histograms.
## Implementation Notes
Both functions check the signal before every rule (not just at the start)
so a long-running form aborts promptly. Histogram durations clamp at zero
to defend against monotonic-clock skew under fake clocks. Counters use the
`{ scope: "field"|"form", result: "ok"|"errors" }` attribute pair so
dashboards can split error rate by scope. We intentionally do not throw
on individual rule failures — validation failures are data, not
exceptions.

View File

@@ -0,0 +1,75 @@
# validators
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validators.ts` provides the eleven built-in rule runners used by the
validation engine. Each runner takes the candidate value, the surrounding
`FormValues` (so `equalsField` can cross-reference), and the originating
field id; it returns either `null` (passed) or a `ValidationError` shaped
`{ fieldId, rule, message }`. `custom` rules may return a
`Promise<boolean>`, in which case `runRule` also returns a promise — the
upstream engine awaits the result transparently.
This file is the only place where rule semantics live. Adding a new
built-in rule means adding a case here, extending `BUILT_IN_RULES`, and
updating the Zod schema; everything else (the engine, the Zod generator,
the builder) consumes the rule kind opaquely.
## Features
- Eleven built-in rules: `required`, `minLength`, `maxLength`, `min`,
`max`, `pattern`, `email`, `url`, `oneOf`, `equalsField`, `custom`.
- `required` treats `null`, `undefined`, empty string, and empty array as
absent.
- `pattern` accepts a string source; constructed into `RegExp` per call.
- `url` validates via the standard `URL` constructor.
- `custom` supports synchronous boolean predicates and async predicates.
## Usage: Basic
```ts
import { runRule } from "@suaveplan/form-builder";
const error = runRule(
{ type: "required" },
"",
{},
"name"
);
// error.rule === "required"
```
## Usage: Advanced
```ts
import { runRule } from "@suaveplan/form-builder";
const asyncCustom = runRule(
{
type: "custom",
value: async (v: unknown) => v !== "blocked",
},
"blocked",
{},
"username"
);
// asyncCustom is a Promise<ValidationError | null>
```
## API Reference
- `runRule(rule, value, values, fieldId): ValidationError | null |
Promise<ValidationError | null>`.
- `CustomRulePredicate = (value, values) => boolean | Promise<boolean>`.
## Implementation Notes
Unknown rule kinds return `null` — the engine treats unknown rules as
no-ops so a registry mismatch never crashes a hot loop. `equalsField`
compares with `!==` (strict equality), which is the right behaviour for
the canonical "password confirmation" use case. `url` constructs a `URL`
inside a try/catch; this is cheap and avoids regex traps for IPv6 hosts.

View File

@@ -0,0 +1,60 @@
# zod-schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`zod-schema.ts` exposes `toZodSchema(form)` — a function that compiles a
`FormSchema` into a `z.ZodObject` whose `safeParse` behaviour matches the
package's own `runFormValidation` for the eleven built-in rule kinds.
Downstream consumers using `react-hook-form` with
`@hookform/resolvers/zod` get identical validation semantics in the
browser without re-implementing the rule engine in two places.
Conditional required rules are honoured: the generated schema uses
`superRefine` to skip the "required" check when a field's conditional rule
evaluates to false. Cross-field `equalsField` rules are emitted as
`superRefine` issues so the Zod path-based error trees line up with the
field-id keys used elsewhere in the package.
## Features
- Generates a `ZodObject` from any `FormSchema`.
- Respects conditional `required` — optional when invisible.
- Maps `min`, `max`, `minLength`, `maxLength`, `pattern`, `email`, `url`,
and `oneOf` into native Zod constraints where possible.
- Cross-field `equalsField` reported via `superRefine`.
## Usage: Basic
```ts
import { toZodSchema } from "@suaveplan/form-builder";
const schema = toZodSchema(form);
const result = schema.safeParse({ name: "" });
// result.success === false (name is required)
```
## Usage: Advanced
```ts
import { toZodSchema } from "@suaveplan/form-builder";
// const resolver = zodResolver(toZodSchema(form));
// const { register, handleSubmit } = useForm({ resolver });
```
## API Reference
- `toZodSchema(form): z.ZodType<FormValues>`.
## Implementation Notes
Every field is wrapped in `.optional()` so the generated schema accepts
partially-filled drafts; the `superRefine` step is the canonical layer
that decides whether a value is structurally required. This means the
schema validates against the *current* values being parsed, which is what
`react-hook-form` consumers expect for conditional UIs. We deliberately
avoid `z.discriminatedUnion` over field types because field types are an
attribute of the schema, not the value space.

View File

@@ -0,0 +1,533 @@
# @suaveplan/react-forms - React forms library — ApiClient/Auth contexts, ValidationEngine, useFormValidation/useDomainForm with field rendering, createDomainForm factory (with three escape hatches), four recipes (wizard, repeating-fieldset, conditional, async-search), MoneyInput + Field re-exports, and a TranslationKey-typed validator suite (sync + async + composers).
## Summary
React forms library for the Suaveplan ecosystem. Wraps
react-hook-form + Zod with a primitives + factory + recipes
architecture: every user-facing string is typed `TranslationKey`,
every error code is registered with `@suaveplan/error`, every
lifecycle event emits OTel telemetry.
Wave 5b adds the field-rendering layer, the `createDomainForm`
factory (with three escape hatches), four recipes (wizard,
repeating-fieldset, conditional, async-search), and the
`MoneyInput` primitive on top of Wave 5a's contexts + validation
engine + validator suite.
<!-- begin:auto-docs -->
## Documentation tree
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-api-client — React hook that reads the active `…
- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_- or — OR-semantics composer for the validator suite.…
- [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-client-context](./src/core/api-client-context/api-client-context.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-api-client](./src/core/api-client-context/use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-context](./src/core/auth-context/auth-context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-auth](./src/core/auth-context/use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-domain-form](./src/core/use-domain-form/use-domain-form.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-form-validation](./src/core/use-form-validation/use-form-validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cross-field-rule](./src/core/validation-engine/cross-field-rule.md) — Convenience factory + helper for declaring `CrossFieldRule` values. Cross-field rules are the second pipeline phase ins…
- [server-error-mapper](./src/core/validation-engine/server-error-mapper.md) — Bridges server-side validation envelopes into the engine's per-field `ValidatorFailure` shape. When a form's `submit.on…
- [validation-engine](./src/core/validation-engine/validation-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — Registers the thirteen stable error codes used by `@suaveplan/react-forms` under the `REACT_FORMS` namespace via `defin…
- [react-forms-error](./src/errors/react-forms-error.md) — Defines the base `ReactFormsError` class shared by every error path in `@suaveplan/react-forms`. Extending the project…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [create-domain-form](./src/factory/create-domain-form/create-domain-form.md) — Declarative typed-form factory. Wraps `useDomainForm` + `<RHF FormProvider>` + the layout dispatcher into a single comp…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [field-config](./src/factory/field-config/field-config.md) — `FieldConfig` discriminated union, per-kind Zod schemas, and the `validateFieldConfig` / `validateFieldConfigMap` helpe…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [render-field](./src/factory/render-field/render-field.md) — Single-field renderer used by `useDomainForm` and `createDomainForm` to turn a typed `FieldConfig` into a `<Field>` cha…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [augmentation](./src/locales/augmentation.md) — Side-effect-only TypeScript module that augments `@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with…
- [locales](./src/locales/locales.md) — Bundled translation strings shipped with `@suaveplan/react-forms`.
- [translation-keys](./src/locales/translation-keys.md) — `translation-keys.ts` is the codegen-emitted string-literal union of every translation key contributed by `@suaveplan/r…
- [observability](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [react-forms-metrics](./src/observability/react-forms-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/primitives/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [money-input](./src/primitives/money-input/money-input.md) — Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [async-search](./src/recipes/async-search/async-search.md) — Async-prefilled form recipe. Resolves an entity by `id` via the supplied `load(id, signal)` callback, then mounts the u…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [conditional](./src/recipes/conditional/conditional.md) — Show / hide form fields based on a predicate over the parent form's values. When the gate flips closed the recipe unreg…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [repeating-fieldset](./src/recipes/repeating-fieldset/repeating-fieldset.md) — Dynamic-array fieldset recipe backed by react-hook-form's `useFieldArray`. Renders one row per array entry; add / remov…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [wizard](./src/recipes/wizard/wizard.md) — Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed field-config map; navigating forward validates th…
- [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_ - [async-email-unique](./src/validation/async/async-email-unique/async-email-unique.md) — <!-- begin:auto-docs --> _(no submodules)_- [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_ - [async-username-available](./src/validation/async/async-username-available/async-username-available.md) — <!-- begin:auto-docs --> _(no submodules)_- [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compose](./src/validation/compose/compose.md) — <!-- begin:auto-docs --> _(no submodules)_ - [or](./src/validation/compose/or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./src/validation/compose/sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./src/validation/compose/shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bic](./src/validation/validators/bic/bic.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_ - [credit-card](./src/validation/validators/credit-card/credit-card.md) — <!-- begin:auto-docs --> _(no submodules)_- [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_ - [custom](./src/validation/validators/custom/custom.md) — <!-- begin:auto-docs --> _(no submodules)_- [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email](./src/validation/validators/email/email.md) — <!-- begin:auto-docs --> _(no submodules)_- [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_ - [iban](./src/validation/validators/iban/iban.md) — <!-- begin:auto-docs --> _(no submodules)_- [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_ - [max-length](./src/validation/validators/max-length/max-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_ - [min-length](./src/validation/validators/min-length/min-length.md) — <!-- begin:auto-docs --> _(no submodules)_- [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_ - [password-strength](./src/validation/validators/password-strength/password-strength.md) — <!-- begin:auto-docs --> _(no submodules)_- [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pattern](./src/validation/validators/pattern/pattern.md) — <!-- begin:auto-docs --> _(no submodules)_- [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_ - [phone](./src/validation/validators/phone/phone.md) — <!-- begin:auto-docs --> _(no submodules)_- [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_ - [postcode](./src/validation/validators/postcode/postcode.md) — <!-- begin:auto-docs --> _(no submodules)_- [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_ - [required](./src/validation/validators/required/required.md) — <!-- begin:auto-docs --> _(no submodules)_- [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url](./src/validation/validators/url/url.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/react-forms
```
Peer deps: `react ^18.0.0 || ^19.0.0`, `@suaveplan/react-ui` (must be
provided by the consumer; this package re-exports `<Field>` and
imports `<Input>` / `<Button>` / `<Skeleton>` / etc. from it).
Workspace deps: `@suaveplan/react-i18n` (`TranslationKey`),
`@suaveplan/react-ui` (component primitives), `@suaveplan/error`
(`ReactFormsError`), `@suaveplan/timing` (async-validator debounce),
`@suaveplan/api-schemas`, `@suaveplan/i18n` (currency / date
formatters).
External catalog deps: `react-hook-form`, `@hookform/resolvers`,
`zod`, `libphonenumber-js`, `@zxcvbn-ts/core`, `@opentelemetry/api`.
## Quick start — `createDomainForm`
The factory is the 80%-case ergonomics layer. Declare a Zod schema +
field config + submit handler:
```tsx
import { createDomainForm } from "@suaveplan/react-forms";
import { RegisterInput } from "@suaveplan/api-schemas";
const RegisterForm = createDomainForm({
formId: "register",
schema: RegisterInput,
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: {
kind: "password",
label: "auth.password.label" as never,
strengthMeter: true,
},
name: { kind: "text", label: "auth.name.label" as never },
},
defaultValues: { email: "", password: "", name: "" },
submit: {
label: "auth.register.submit" as never,
onSubmit: async (values) => {
await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(values),
});
},
},
});
export function RegisterPage(): JSX.Element {
return <RegisterForm />;
}
```
Three escape hatches keep RHF visible:
```tsx
// 1. Render-prop children for full custom shells:
<RegisterForm>
{({ form, fields, submit, isSubmitting }) => (
<CustomLayout>{Object.values(fields)}</CustomLayout>
)}
</RegisterForm>
// 2. Static <Form.Field name=...> for partial layouts:
<div>
<RegisterForm.Field name="email" />
<p>Then choose a password.</p>
<RegisterForm.Field name="password" />
</div>
// 3. The standalone useDomainForm hook, exported separately:
const { form, fields, submit } = useDomainForm({ ...config });
```
## Recipes
### Wizard — multi-step factory
```tsx
import { createWizardForm } from "@suaveplan/react-forms";
const SignUpWizard = createWizardForm({
formId: "signup",
steps: [
{
id: "credentials",
title: "auth.steps.credentials" as never,
schema: z.object({ email: z.string().email(), password: z.string().min(8) }),
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: { kind: "password", label: "auth.password.label" as never },
},
},
{
id: "profile",
title: "auth.steps.profile" as never,
schema: z.object({ name: z.string().min(1) }),
fields: {
name: { kind: "text", label: "auth.name.label" as never },
},
},
],
onComplete: async (values) => api.auth.register(values),
});
```
### Repeating fieldset — dynamic-array rows
```tsx
import { createRepeatingFieldset } from "@suaveplan/react-forms";
const Contacts = createRepeatingFieldset<{ name: string; email: string }>({
formId: "checkout",
name: "contacts",
minRows: 1,
maxRows: 5,
defaultRow: { name: "", email: "" },
fieldset: {
name: { kind: "text", label: "checkout.contact.name" as never },
email: { kind: "email", label: "checkout.contact.email" as never },
},
});
```
### Conditional — show/hide fields
```tsx
import { ConditionalField } from "@suaveplan/react-forms";
<ConditionalField<CheckoutValues>
when={(v) => v.paymentMethod === "transfer"}
names={["bankAccount"]}
watch={["paymentMethod"]}
>
<RegisterForm.Field name="bankAccount" />
</ConditionalField>
```
When the gate flips closed the recipe unregisters `bankAccount`
from the form so it disappears from the submit payload.
### Async search — load-then-edit
```tsx
import { createAsyncSearchForm } from "@suaveplan/react-forms";
const EditUserForm = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: { kind: "email", label: "user.email.label" as never },
name: { kind: "text", label: "user.name.label" as never },
},
load: async (id, signal) => {
const res = await fetch(`/api/users/${id}`, { signal });
return (await res.json()) as { email: string; name: string };
},
submit: {
label: "user.save" as never,
onSubmit: async (values) => {
await fetch(`/api/users/${id}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
},
});
<EditUserForm id="alice" />;
```
## Validation library — sync + async + composers
```ts
import {
compose,
email,
minLength,
passwordStrength,
required,
sequence,
} from "@suaveplan/react-forms";
const validateEmail = sequence(required(), email());
const validatePassword = compose(
required(),
minLength(8),
passwordStrength({ minScore: 3 })
);
validateEmail("alice@example.com"); // → { valid: true }
validatePassword("hunter2"); // → { valid: false, messageKey, messageArgs }
```
Async validators receive an `AbortSignal`:
```ts
import { asyncEmailUnique } from "@suaveplan/react-forms";
const checkEmailUnique = asyncEmailUnique({
check: async (email, signal) => {
const res = await fetch(
`/api/users/email-available?email=${encodeURIComponent(email)}`,
{ signal }
);
return res.ok;
},
debounceMs: 300,
});
```
## Standalone hooks
`useDomainForm` is exported separately for consumers building bespoke
shells:
```tsx
import { useDomainForm } from "@suaveplan/react-forms";
const { form, fields, submit, isSubmitting, serverError } = useDomainForm({
formId: "login",
schema,
fields, // optional — when omitted, `fields` is null
onSubmit: async (values) => api.auth.login(values),
});
```
## Field kinds shipped in Wave 5b
`text`, `email`, `password` (with optional `strengthMeter`),
`number`, `currency` (uses `MoneyInput`), `checkbox`, `toggle`,
`radio`, `textarea`, `custom` (consumer render-prop).
## Deferred primitives — Wave 5c roadmap
The following primitives and field kinds are NOT in Wave 5b. They
ship in Wave 5c once the underlying react-ui components land:
| Primitive | Underlying component | Wave |
|---|---|---|
| `accessible-date-picker` | `Calendar` (react-ui) | Wave 5c after react-ui Wave 3h |
| `date-time-picker` | `Calendar` + `TimeInput` | Wave 5c after react-ui Wave 3h |
| `searchable-select` | `Combobox` (react-ui) | Wave 5c after react-ui Wave 3h |
| `tag-input` | `Badge` (react-ui) | Wave 5c after react-ui Wave 3d |
| `code-input` | `InputOTP` (react-ui) | Wave 5c after react-ui Wave 3h |
| `file-upload-field` | `@suaveplan/react-file-upload` | Wave 5c after react-file-upload ships |
| `rich-text-field` | `@suaveplan/react-rich-text` | Wave 5c after react-rich-text ships |
Field kinds excluded from the Wave 5b enum: `date`, `dateTime`,
`select`, `multi-select`, `combobox`, `file`, `rich-text`, `otp`.
Passing one of these to `createDomainForm` today fails the
discriminator and throws `REACT_FORMS.FIELD_KIND_INVALID` at
module-eval time.
## Modules
- [`src/errors/error-codes.md`](src/errors/error-codes.md) — REACT_FORMS error code registry.
- [`src/errors/react-forms-error.md`](src/errors/react-forms-error.md) — `ReactFormsError` base class.
- [`src/observability/react-forms-metrics.md`](src/observability/react-forms-metrics.md) — OTel instruments and in-memory ring.
- [`src/locales/locales.md`](src/locales/locales.md) — bundled recipe-default strings.
- [`src/locales/translation-keys.md`](src/locales/translation-keys.md) — codegen union of contributed keys.
- [`src/locales/augmentation.md`](src/locales/augmentation.md) — `ContributedTranslationKeys` registration.
- [`src/core/api-client-context/api-client-context.md`](src/core/api-client-context/api-client-context.md) — `<ApiClientProvider>` + `useApiClient`.
- [`src/core/api-client-context/use-api-client.md`](src/core/api-client-context/use-api-client.md) — hook details + throw semantics.
- [`src/core/auth-context/auth-context.md`](src/core/auth-context/auth-context.md) — `<AuthProvider>` + `useAuth`.
- [`src/core/auth-context/use-auth.md`](src/core/auth-context/use-auth.md) — hook details + throw semantics.
- [`src/core/validation-engine/validation-engine.md`](src/core/validation-engine/validation-engine.md) — four-phase pipeline.
- [`src/core/validation-engine/cross-field-rule.md`](src/core/validation-engine/cross-field-rule.md) — `defineCrossFieldRule` + `violate`.
- [`src/core/validation-engine/server-error-mapper.md`](src/core/validation-engine/server-error-mapper.md) — server envelope unwrap + map.
- [`src/core/use-form-validation/use-form-validation.md`](src/core/use-form-validation/use-form-validation.md) — RHF resolver bridge.
- [`src/core/use-domain-form/use-domain-form.md`](src/core/use-domain-form/use-domain-form.md) — standalone domain-form hook (now with field rendering).
- [`src/primitives/field/field.md`](src/primitives/field/field.md) — single-source re-export of react-ui's Field family.
- [`src/primitives/money-input/money-input.md`](src/primitives/money-input/money-input.md) — locale-aware currency input.
- [`src/factory/field-config/field-config.md`](src/factory/field-config/field-config.md) — discriminated-union FieldConfig + Zod schemas.
- [`src/factory/render-field/render-field.md`](src/factory/render-field/render-field.md) — single-field renderer.
- [`src/factory/create-domain-form/create-domain-form.md`](src/factory/create-domain-form/create-domain-form.md) — declarative factory + escape hatches.
- [`src/recipes/wizard/wizard.md`](src/recipes/wizard/wizard.md) — multi-step factory.
- [`src/recipes/repeating-fieldset/repeating-fieldset.md`](src/recipes/repeating-fieldset/repeating-fieldset.md) — dynamic-array fieldset.
- [`src/recipes/conditional/conditional.md`](src/recipes/conditional/conditional.md) — show/hide field gate.
- [`src/recipes/async-search/async-search.md`](src/recipes/async-search/async-search.md) — load-then-edit recipe.
- [`src/validation/validators/required/required.md`](src/validation/validators/required/required.md) — presence validator.
- [`src/validation/validators/min-length/min-length.md`](src/validation/validators/min-length/min-length.md) — minimum length.
- [`src/validation/validators/max-length/max-length.md`](src/validation/validators/max-length/max-length.md) — maximum length.
- [`src/validation/validators/email/email.md`](src/validation/validators/email/email.md) — RFC 5322 subset email.
- [`src/validation/validators/url/url.md`](src/validation/validators/url/url.md) — WHATWG URL.
- [`src/validation/validators/pattern/pattern.md`](src/validation/validators/pattern/pattern.md) — regex match.
- [`src/validation/validators/phone/phone.md`](src/validation/validators/phone/phone.md) — libphonenumber-js phone.
- [`src/validation/validators/postcode/postcode.md`](src/validation/validators/postcode/postcode.md) — per-country postcode.
- [`src/validation/validators/credit-card/credit-card.md`](src/validation/validators/credit-card/credit-card.md) — Luhn checksum.
- [`src/validation/validators/password-strength/password-strength.md`](src/validation/validators/password-strength/password-strength.md) — zxcvbn-backed strength.
- [`src/validation/validators/iban/iban.md`](src/validation/validators/iban/iban.md) — ISO 13616 IBAN.
- [`src/validation/validators/bic/bic.md`](src/validation/validators/bic/bic.md) — ISO 9362 BIC.
- [`src/validation/validators/custom/custom.md`](src/validation/validators/custom/custom.md) — predicate adapter.
- [`src/validation/async/async-email-unique/async-email-unique.md`](src/validation/async/async-email-unique/async-email-unique.md) — debounced email-uniqueness.
- [`src/validation/async/async-username-available/async-username-available.md`](src/validation/async/async-username-available/async-username-available.md) — debounced username-availability.
- [`src/validation/compose/compose.md`](src/validation/compose/compose.md) — AND composer.
- [`src/validation/compose/or.md`](src/validation/compose/or.md) — OR composer.
- [`src/validation/compose/sequence.md`](src/validation/compose/sequence.md) — ordered AND composer.

View File

@@ -0,0 +1,103 @@
# `api-client-context.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- use-api-client — React hook that reads the active `…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
- [use-api-client](./use-api-client.md) — React hook that reads the active `ApiClient` from the surrounding `<ApiClientProvider>` and returns it typed as the con…
## Purpose
Mounts the consumer's `ApiClient` instance into a React context so
form submit handlers, domain hooks, and any descendant component can
reach it via `useApiClient()` without prop drilling. The provider is
intentionally a thin wrapper over `React.createContext` — no
validation, no transformation — because the forms package never
inspects the client's methods, only forwards the value to the
consumer's `submit.onSubmit` callback.
The marker `ApiClient` interface (in `api-client.types.ts`) keeps the
context value typed as a nominal handle rather than `unknown` so DI
containers, stack traces, and TypeScript inlay hints display a
meaningful name.
## Features
- `<ApiClientProvider value={...}>` — generic over `TApi extends
ApiClient`; the provider's prop type pins the consumer's concrete
client type at the mount site.
- `ApiClientContext` — exported as the raw React context for advanced
consumers (testing harnesses, server-side rendering glue) that need
to mutate the value imperatively.
- `displayName` set to `"ApiClientContext"` so React DevTools shows a
meaningful name instead of `Context.Provider`.
## Usage: Basic Example
```tsx
import { ApiClientProvider } from "@suaveplan/react-forms/core/api-client-context";
import { createApiClient } from "./api/client";
const api = createApiClient({ baseUrl: "/api" });
export function App({ children }: { children: ReactNode }) {
return (
<ApiClientProvider value={api}>{children}</ApiClientProvider>
);
}
```
## Usage: Advanced Example
```tsx
import {
ApiClientContext,
ApiClientProvider,
} from "@suaveplan/react-forms/core/api-client-context";
// Mock-server rendering: swap the API client for a different value
// per route.
function RouteScopedApi({ route, children }) {
const api = useMemo(() => buildClientForRoute(route), [route]);
return <ApiClientProvider value={api}>{children}</ApiClientProvider>;
}
// Direct context read — when you want explicit control over the
// hook's throw-when-absent semantics.
function MaybeApiBadge() {
const api = useContext(ApiClientContext);
return api === null ? <span>offline</span> : <span>{api.baseUrl}</span>;
}
```
## API Reference
- `ApiClientContext: Context<ApiClient | null>` — raw React context.
`null` default lets `useApiClient` discriminate "no provider".
- `ApiClientProvider<TApi extends ApiClient>(props: { value: TApi;
children: ReactNode }): ReactNode` — provider component.
## Implementation Notes
- The default value is `null` (not `undefined`) so the hook can use a
cheap `value === null` guard. Both forms work, but `null` is
explicit about "this is intentional" rather than the ambient default.
- The provider does not memoise its value — when the consumer's
`value` prop reference changes, every descendant re-renders. If the
consumer wants a stable reference they should memoise upstream
(e.g. `useMemo(() => createApi(), [])`).
- The generic `TApi` flows from the provider's prop to the hook
through TypeScript inference; consumers writing
`useApiClient<MyApi>()` get the precise type even though the context
value itself is typed only as the marker.

View File

@@ -0,0 +1,84 @@
# `use-api-client.ts`
## Purpose
React hook that reads the active `ApiClient` from the surrounding
`<ApiClientProvider>` and returns it typed as the consumer's concrete
client type. Throws `ReactFormsError(REACT_FORMS.API_CLIENT_NOT_FOUND)`
when called outside a provider — the throw is preferred over silent
`undefined`-return because every caller depends on the value being
present and a downstream "Cannot read properties of undefined" is much
harder to debug than a structured error with `context.hook ===
"useApiClient"`.
## Features
- Generic over `TApi extends ApiClient` so callers narrow the marker
context value to their concrete client type at the call site:
`const api = useApiClient<MyApi>();`.
- Structured throw with stable error code on no-provider — easy to
catch in error boundaries or assert against in tests.
- Zero allocation on the hot path — the body is a single `useContext`
call plus a null check.
## Usage: Basic Example
```ts
import { useApiClient } from "@suaveplan/react-forms/core/api-client-context";
function CreateUserButton() {
const api = useApiClient<MyApi>();
return (
<button onClick={() => api.users.create({ name: "Alice" })}>
Create
</button>
);
}
```
## Usage: Advanced Example
```ts
import { useApiClient } from "@suaveplan/react-forms/core/api-client-context";
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
function safelyUseApi<T extends ApiClient>(): T | undefined {
try {
return useApiClient<T>();
} catch (cause) {
if (
cause instanceof ReactFormsError &&
cause.code === REACT_FORMS_CODES.API_CLIENT_NOT_FOUND
) {
return undefined;
}
throw cause;
}
}
```
## API Reference
- `useApiClient<TApi extends ApiClient>(): TApi` — read the active API
client.
- Throws `ReactFormsError` with
`code === "REACT_FORMS.API_CLIENT_NOT_FOUND"` when the hook is
invoked outside an `<ApiClientProvider>` ancestor. The thrown
error carries `context.hook === "useApiClient"` so downstream
error handlers can disambiguate against
`useAuth`'s sibling `AUTH_CONTEXT_NOT_FOUND` failure.
## Implementation Notes
- Throwing during render is the React-idiomatic signal for "this
component is misconfigured" — the nearest error boundary catches it
and renders a fallback, which is exactly the right developer UX.
- The hook does NOT memoise the returned value — `useContext` already
returns a stable reference per provider, so wrapping in `useMemo`
would be redundant and would only add overhead.
- The generic `TApi` is a pure type assertion; the runtime value is
whatever the provider published. Consumers that need runtime type
guards should wrap the returned value in their own validation layer.

View File

@@ -0,0 +1,93 @@
# `auth-context.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- use-auth — React hook that reads the active `AuthCo…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
- [use-auth](./use-auth.md) — React hook that reads the active `AuthContextValue` from the surrounding `<AuthProvider>` and returns it typed as `Auth…
## Purpose
Mounts the consumer's `AuthContextValue` (current user, signIn,
signOut, optional permissions) into a React context so registration
forms, sign-in forms, and any descendant component can reach the auth
implementation via `useAuth()` without prop drilling. Mirrors the
`ApiClientContext` shape — thin provider, generic-over-TUser hook,
typed throw-when-absent for the no-provider case.
## Features
- `<AuthProvider value={...}>` — generic over `TUser`; the consumer
pins the concrete user shape at the mount site.
- `AuthContext` — exported as the raw React context for advanced
consumers (testing harnesses, SSR glue) that need imperative
access.
- `displayName` set to `"AuthContext"` so React DevTools shows a
meaningful name.
## Usage: Basic Example
```tsx
import { AuthProvider } from "@suaveplan/react-forms/core/auth-context";
import { useMyAuth } from "./auth/use-my-auth";
export function App({ children }: { children: ReactNode }) {
const auth = useMyAuth();
return <AuthProvider value={auth}>{children}</AuthProvider>;
}
```
## Usage: Advanced Example
```tsx
import {
AuthContext,
AuthProvider,
} from "@suaveplan/react-forms/core/auth-context";
// Test harness: stub the auth context with a deterministic value.
function TestHarness({ children }) {
const stub = useMemo(
() => ({
currentUser: { id: "test" },
isAuthenticated: true,
signIn: async () => ({ id: "test" }),
signOut: async () => undefined,
permissions: new Set<string>(["read", "write"]),
}),
[]
);
return <AuthProvider value={stub}>{children}</AuthProvider>;
}
```
## API Reference
- `AuthContext: Context<AuthContextValue | null>` — raw React context.
`null` default lets `useAuth` discriminate "no provider".
- `AuthProvider<TUser>(props: { value: AuthContextValue<TUser>;
children: ReactNode }): ReactNode` — provider component.
## Implementation Notes
- The default value is `null` (not `undefined`) — same rationale as
`ApiClientContext`: explicit nullability is easier to reason about
than the ambient default.
- The provider does not memoise — when the consumer's `value` prop
reference changes, descendants re-render. Memoise upstream when the
auth implementation has stable identity.
- The generic `TUser` flows from the provider's prop to the hook
through TypeScript inference; consumers writing
`useAuth<MyUser>()` get the precise type back even though the
context value itself is typed only as `AuthContextValue<unknown>`.

View File

@@ -0,0 +1,79 @@
# `use-auth.ts`
## Purpose
React hook that reads the active `AuthContextValue` from the
surrounding `<AuthProvider>` and returns it typed as
`AuthContextValue<TUser>`. Throws `ReactFormsError(REACT_FORMS.AUTH_CONTEXT_NOT_FOUND)`
when called outside a provider — same throw-vs-undefined rationale as
`useApiClient`.
## Features
- Generic over `TUser` so callers narrow the marker context value to
their concrete user shape: `const { currentUser } = useAuth<MyUser>();`.
- Structured throw with stable error code on no-provider.
- Zero allocation on the hot path.
## Usage: Basic Example
```ts
import { useAuth } from "@suaveplan/react-forms/core/auth-context";
function GreetUser() {
const { currentUser } = useAuth<MyUser>();
if (currentUser === null) return <a href="/login">Sign in</a>;
return <span>Hello, {currentUser.displayName}</span>;
}
```
## Usage: Advanced Example
```ts
import { useAuth } from "@suaveplan/react-forms/core/auth-context";
function PermissionGate({
permission,
children,
}: {
permission: string;
children: ReactNode;
}) {
const { permissions } = useAuth();
if (permissions === undefined || !permissions.has(permission)) {
return null;
}
return <>{children}</>;
}
```
## API Reference
- `useAuth<TUser = unknown>(): AuthContextValue<TUser>` — read the
active auth context.
- Throws `ReactFormsError` with
`code === "REACT_FORMS.AUTH_CONTEXT_NOT_FOUND"` when invoked
outside an `<AuthProvider>` ancestor. The thrown error carries
`context.hook === "useAuth"` so downstream handlers can
disambiguate against `useApiClient`'s sibling failure.
## Implementation Notes
- Throwing during render is the React-idiomatic signal for "this
component is misconfigured" — the nearest error boundary catches it
and renders a fallback. The structured `ReactFormsError` carries
enough context (`tier`, `hook`, `code`) for downstream telemetry
pipelines to disambiguate forms-tier failures from other error
classes without parsing the message.
- The hook does NOT memoise the returned value — `useContext` already
returns a stable reference per provider, so wrapping in `useMemo`
would only add overhead. Consumers needing reference-stable derived
values should compose `useMemo` themselves at the call site.
- `TUser` is a pure type assertion; the runtime value is whatever the
provider published. Consumers wanting runtime type guards should
wrap the returned value in their own validation layer (e.g. a Zod
schema parse) before treating fields like `currentUser.id` as
strings.
- Mirror of `useApiClient` — the two hooks share their structure
intentionally so consumer apps that wire both contexts present a
uniform error-handling surface to downstream components.

View File

@@ -0,0 +1,161 @@
# `use-domain-form.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Standalone domain-form hook that wraps react-hook-form's `useForm`
with the package's `ValidationEngine` resolver, a typed submit
pipeline, server-error mapping, and OTel telemetry. The same hook
will also be wrapped by `createDomainForm` (Wave 5b) to deliver the
factory-driven ergonomic shell.
The hook is fully usable today for consumers building custom shells —
the returned `form` is the complete RHF context, and `submit` is the
hardened submit function with telemetry + server-error mapping wired
in. The only thing that does NOT ship in Wave 5a is the
field-rendering layer (`fields`), which depends on
`@suaveplan/react-ui`'s `FieldGroup` and `Field` primitives. `fields`
returns `null` until Wave 5b lands.
## Features
- RHF integration via the package's `useFormValidation` resolver.
- Submit pipeline that catches `onSubmit` failures and routes them
through `unwrapServerEnvelope` + `mapServerErrors` so server-side
field errors land on the correct RHF fields.
- `isSubmitting` boolean and `serverError` envelope surfaced for
top-of-form error rendering.
- OTel `react_forms.submit.total{form,outcome}` and
`react_forms.submit.duration_ms{form}` emitted per submit.
- Submit re-entry guarded by a `useRef` flag so double-clicking
doesn't fire two submits.
- `formId` is required for stable telemetry attribution.
## Usage: Basic Example
```tsx
import { useDomainForm } from "@suaveplan/react-forms/core/use-domain-form";
import z from "zod";
const schema = z.object({
email: z.string().email(),
});
function LoginShell() {
const { form, submit, isSubmitting, serverError } = useDomainForm({
formId: "login",
schema,
onSubmit: async (values) => {
await api.auth.login(values);
},
});
return (
<form onSubmit={submit}>
<input {...form.register("email")} />
{form.formState.errors.email && (
<span>{form.formState.errors.email.message}</span>
)}
<button disabled={isSubmitting}>Sign in</button>
{serverError.hasError && <p>Submit failed</p>}
</form>
);
}
```
## Usage: Advanced Example
```tsx
import {
asyncEmailUnique,
} from "@suaveplan/react-forms/validation/async";
import {
defineCrossFieldRule,
useDomainForm,
violate,
} from "@suaveplan/react-forms/core";
function RegisterShell() {
const { form, submit, isSubmitting } = useDomainForm({
formId: "register",
schema: registerSchema,
crossFieldRules: [passwordsMatch],
asyncValidators: [
{
field: "email",
validator: asyncEmailUnique({
check: (email, signal) =>
api.auth.checkEmail(email, signal),
}),
},
],
defaultValues: {
email: "",
password: "",
passwordConfirm: "",
},
onSubmit: async (values) => {
await api.auth.register(values);
},
});
return (
<form onSubmit={submit}>{/* ... */}</form>
);
}
```
## API Reference
- `useDomainForm<TValues>(options: UseDomainFormOptions<TValues>):
UseDomainFormReturn<TValues>` — the hook.
- `interface UseDomainFormOptions<TValues>` — extends
`ValidationEngineConfig<TValues>` with:
- `formId: string` — telemetry attribution.
- `defaultValues?: Partial<TValues>` — initial values for RHF.
- `onSubmit: (values: TValues) => Promise<unknown>` — submit
handler; throws are routed through the server-error mapper.
- `interface UseDomainFormReturn<TValues>`:
- `form: UseFormReturn<TValues>` — full RHF context.
- `submit: (event?: BaseSyntheticEvent) => Promise<void>` — wired
submit handler.
- `isSubmitting: boolean` — true while a submit is in flight.
- `serverError: ServerErrorState` — `{ hasError, cause? }`.
- `fields: null` — placeholder; renderer ships in Wave 5b.
- `children: undefined` — placeholder; render-prop slot ships in
Wave 5b.
## Implementation Notes
### Wave 5a vs Wave 5b
This is the Wave 5a deliverable. The hook is fully functional for
custom-shell consumers — register fields directly via
`form.register("name")` and lay them out yourself. Wave 5b will:
1. Add the `<FieldGroup>`/`<Field>` rendering layer that populates
the `fields: null` slot with React elements keyed by name.
2. Wrap this hook in the `createDomainForm(config)` factory that
returns a `ComponentType<FormProps>` plus the `.Field` static
accessor and `.useFormContext` re-export.
3. Add the `recipes/wizard`, `recipes/repeating-fieldset`,
`recipes/conditional`, and `recipes/async-search` factories on top.
### Submit pipeline
- The submit guard uses BOTH `useState` (for re-render) and `useRef`
(for synchronous re-entry detection). The ref check happens before
the state update so a synchronous double-call does not start two
submits even before React schedules a re-render.
- `mapServerErrors` is called inside the `handleSubmit` callback so
`form.setError` lands during a render cycle RHF understands. Setting
errors outside `handleSubmit`'s callback can race RHF's internal
state and not surface in `formState.errors`.
- `unwrapServerEnvelope` returns `undefined` for non-envelope failures;
the catch arm wraps those in `ReactFormsError(SUBMIT_FAILED)` so
consumer error boundaries get a stable shape.
- `recordSubmitOutcome` and `recordSubmitDuration` always fire from
the `finally` arm so a thrown error still contributes to the
metrics.

View File

@@ -0,0 +1,122 @@
# `use-form-validation.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
React hook that bridges a `ValidationEngine` instance to react-hook-
form's `Resolver` contract. Constructs the engine once per render-
cycle (memoised on the supplied config), wires its
`engine.resolve(values)` output into RHF's `ResolverResult` shape, and
cancels in-flight async validators on unmount via `engine.cancel()`.
The hook is the layer where the package's `messageKey` discriminated-
union failure shape gets translated into RHF's
`{ type, message, types }` field-error shape. RHF's `message` becomes
the consumer's `TranslationKey`; `types.messageArgs` carries the ICU
args bag for downstream `t(messageKey, messageArgs)` resolution.
## Features
- Memoised engine — stable engine identity across re-renders unless
the config changes.
- Auto-cancel on unmount — `useEffect` registers a cleanup that calls
`engine.cancel()` so async validators do not leak after the form
goes away.
- Caller-supplied engine override — pass `options.engine` to reuse an
existing engine instance (useful for tests asserting engine state).
- Stable `Resolver` identity per engine — RHF re-creates internal
state when the resolver reference changes, so memoisation matters.
## Usage: Basic Example
```ts
import { useForm } from "react-hook-form";
import z from "zod";
import { useFormValidation } from "@suaveplan/react-forms/core/use-form-validation";
const schema = z.object({
email: z.string().email(),
});
function LoginForm() {
const resolver = useFormValidation({ schema });
const form = useForm({ resolver });
return <form onSubmit={form.handleSubmit(...)}>...</form>;
}
```
## Usage: Advanced Example
```ts
import { useMemo } from "react";
import {
asyncEmailUnique,
} from "@suaveplan/react-forms/validation/async";
import {
defineCrossFieldRule,
useFormValidation,
violate,
} from "@suaveplan/react-forms/core";
function RegisterForm() {
const config = useMemo(
() => ({
schema: registerSchema,
crossFieldRules: [passwordsMatch],
asyncValidators: [
{
field: "email",
validator: asyncEmailUnique({
check: async (email, signal) => {
const r = await fetch(`/api/email/check?e=${email}`, {
signal,
});
return r.status === 404;
},
}),
},
],
}),
[]
);
const resolver = useFormValidation(config);
const form = useForm({ resolver, mode: "onBlur" });
return <form onSubmit={form.handleSubmit(submitFn)}>...</form>;
}
```
## API Reference
- `useFormValidation<TValues>(options:
UseFormValidationOptions<TValues>): Resolver<TValues>` — hook.
- `interface UseFormValidationOptions<TValues> extends
ValidationEngineConfig<TValues> {`
- `engine?: ValidationEngine<TValues>` — caller-supplied engine
override.
- `}`
- `type FormSchema<T extends FieldValuesMap> = z.ZodType<T>` — alias
for the Zod schema type accepted by the engine.
## Implementation Notes
- The memo dep array includes the four config fields — schema,
crossFieldRules, asyncValidators, debounceMs — so the engine is
rebuilt only when any of these change. Consumers passing a fresh
array literal each render would defeat the memoisation; the
Advanced Example above wraps the config in `useMemo` precisely to
avoid that.
- The cleanup `useEffect` runs only on unmount (empty dep array). It
pulls `engine.cancel()` through a `ref` so the closure captures the
latest engine even after re-renders.
- The translation from `EngineFieldErrors` to `ResolverResult` uses
RHF's `type: "validate"` discriminator, which is RHF's standard
catch-all for custom-validation errors. Consumers reading the
resolver result will see `formState.errors.<field>.message ===
"<messageKey>"` and can pass that into their `useTranslation()` t
function for rendering.
- The `messageArgs` go into `types.messageArgs` because RHF's
`FieldError` has no first-class slot for ICU args. Consumers
rendering errors with ICU support extract them via
`formState.errors.<field>.types?.messageArgs`.

View File

@@ -0,0 +1,104 @@
# `cross-field-rule.ts`
## Purpose
Convenience factory + helper for declaring `CrossFieldRule` values.
Cross-field rules are the second pipeline phase inside
`ValidationEngine` — they run after the Zod schema passes and have
read access to the entire form values object, so they can express
constraints like "passwordConfirm matches password" or "endDate must
be after startDate" that no per-field validator can.
## Features
- `defineCrossFieldRule(opts)` — pass-through factory that gives
consumers a typed call site without having to import the interface
and write the shape literal.
- `violate(field, messageKey, messageArgs?)` — tiny helper that
constructs a single `CrossFieldViolation` so rule bodies can stay
one-liner-friendly.
## Usage: Basic Example
```ts
import {
defineCrossFieldRule,
violate,
} from "@suaveplan/react-forms/core/validation-engine";
import type { TranslationKey } from "@suaveplan/react-i18n";
const passwordsMatch = defineCrossFieldRule<{
password: string;
passwordConfirm: string;
}>({
name: "passwords-match",
fields: ["password", "passwordConfirm"],
validate: ({ password, passwordConfirm }) =>
password === passwordConfirm
? null
: [
violate(
"passwordConfirm",
"auth.password.mismatch" as TranslationKey
),
],
});
```
## Usage: Advanced Example
```ts
import {
defineCrossFieldRule,
violate,
} from "@suaveplan/react-forms/core/validation-engine";
const dateRangeOrdered = defineCrossFieldRule<{
startDate: Date;
endDate: Date;
}>({
name: "date-range-ordered",
fields: ["startDate", "endDate"],
validate: ({ startDate, endDate }) => {
const violations = [];
if (endDate.getTime() <= startDate.getTime()) {
violations.push(
violate(
"endDate",
"form.date.before_start" as TranslationKey,
{ startDate: startDate.toISOString() }
)
);
}
return violations.length > 0 ? violations : null;
},
});
```
## API Reference
- `defineCrossFieldRule<TValues>(opts: CrossFieldRuleOptions<TValues>):
CrossFieldRule<TValues>` — factory.
- `interface CrossFieldRuleOptions<TValues> {`
- `name: string` — stable rule name.
- `fields: readonly string[]` — field paths the rule depends on.
- `validate: (values: TValues) => CrossFieldViolation[] | null` —
pure validator.
- `}`
- `violate(field: string, messageKey: TranslationKey, messageArgs?:
Readonly<Record<string, unknown>>): CrossFieldViolation` — single-
violation helper.
## Implementation Notes
- The factory is a pass-through (literally `return opts`) so the type
parameters propagate cleanly into the consumer's call site without
needing a generic adapter layer. The cost of the function call is
zero after the JIT inlines it.
- The `fields` declaration is a documentation handle today — Wave 5b's
engine wiring uses it to skip rule re-execution when none of the
declared fields changed since last run. Even before Wave 5b lands,
the field list is a useful audit signal.
- `violate`'s conditional branch on `messageArgs === undefined`
avoids emitting an explicit `undefined` member into the resulting
object, which keeps JSON serialisation of violations lean.

View File

@@ -0,0 +1,98 @@
# `server-error-mapper.ts`
## Purpose
Bridges server-side validation envelopes into the engine's per-field
`ValidatorFailure` shape. When a form's `submit.onSubmit` callback
returns or throws a `ServerErrorEnvelope` (or a `ReactFormsError`
whose `context.fieldErrors` carries the same shape), this module
unwraps the envelope, maps each entry into the engine's collection
shape, and increments the
`react_forms.validation.error_total{kind="server"}` counter.
## Features
- `mapServerErrors(envelope)` — convert a `ServerErrorEnvelope` into
the engine's `EngineFieldErrors` map.
- `unwrapServerEnvelope(cause)` — duck-type a thrown value into a
`ServerErrorEnvelope` if it carries the right shape; returns
`undefined` for anything else so the caller can re-throw.
- `throwServerErrorMapped(envelope, cause?)` — wrap an envelope in a
structured `ReactFormsError(REACT_FORMS.SERVER_ERROR_MAPPED)` for
cases where the engine wants to surface the mapping outcome to an
error boundary.
- OTel telemetry emitted per mapped field error.
## Usage: Basic Example
```ts
import {
mapServerErrors,
unwrapServerEnvelope,
} from "@suaveplan/react-forms/core/validation-engine";
try {
await api.users.create(values);
} catch (cause) {
const envelope = unwrapServerEnvelope(cause);
if (envelope) {
const errors = mapServerErrors(envelope);
// errors === { email: { valid: false, messageKey: "auth.email.taken" } }
} else {
throw cause;
}
}
```
## Usage: Advanced Example
```ts
import {
mapServerErrors,
throwServerErrorMapped,
unwrapServerEnvelope,
} from "@suaveplan/react-forms/core/validation-engine";
async function submit(values) {
try {
return await api.users.register(values);
} catch (cause) {
const envelope = unwrapServerEnvelope(cause);
if (envelope === undefined) throw cause;
// Map for in-form display
const errors = mapServerErrors(envelope);
if (Object.keys(errors).length > 0) {
// also surface to error boundary
throwServerErrorMapped(envelope, cause);
}
}
}
```
## API Reference
- `mapServerErrors(envelope: ServerErrorEnvelope): EngineFieldErrors`
— convert envelope into the engine's per-field failure map.
- `unwrapServerEnvelope(cause: unknown): ServerErrorEnvelope |
undefined` — duck-type a thrown value.
- `throwServerErrorMapped(envelope: ServerErrorEnvelope, cause?:
unknown): never` — throw a structured `ReactFormsError`.
- `unsafeAssertTranslationKey(value: string): TranslationKey` — test-
only escape hatch for the brand check; real callers should always
go through the consumer's translation augmentation.
## Implementation Notes
- The mapper uses a positive duck-type check (`Array.isArray` plus
per-entry type guards) rather than a Zod parse to keep the hot
path zero-allocation and runtime-dependency-free. Zod's parse
would add ~3 µs per envelope and the engine runs this on every
submit.
- Each mapped entry increments the OTel counter; consumers wanting
per-form attribution can add `form` to the counter via a wrapping
layer in `useDomainForm`.
- The `unsafeAssertTranslationKey` helper exists so spec-conformance
tests can construct branded keys without going through the
consumer's full augmentation path. It is exported from the
validation-engine module — NOT from the package barrel — so
application code never sees it.

View File

@@ -0,0 +1,131 @@
# `validation-engine.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Coordinates the four-phase validation pipeline that turns a Zod schema
plus optional cross-field rules and async validators into a single
`ValidationResult` consumable by react-hook-form's resolver. The
engine is the synchronisation point that ensures phases run in the
right order, that schema errors short-circuit cross-field rules, that
schema or cross-field errors short-circuit async validators (no
network calls when the form is structurally invalid), and that an
in-flight pipeline run is cancelled before a newer one starts so
async validators do not race to set stale errors.
## Features
- Four-phase pipeline: Zod schema → cross-field rules → async
validators → server-error mapping (the last phase is invoked
separately by `useDomainForm`).
- AbortSignal cancellation between runs — calling `resolve(values)` a
second time aborts the first.
- Strict precedence — earlier-phase failures take priority per field;
later phases never overwrite an earlier failure.
- OTel telemetry — `react_forms.validation.duration_ms` per run,
`react_forms.validation.error_total{kind}` per failure.
- Throws `ReactFormsError(REACT_FORMS.SCHEMA_INVALID)` from the
constructor when the supplied schema is not a Zod schema (no
`.safeParse` member).
## Usage: Basic Example
```ts
import z from "zod";
import { ValidationEngine } from "@suaveplan/react-forms/core/validation-engine";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const engine = new ValidationEngine({ schema });
const result = await engine.resolve({ email: "x", password: "short" });
// result.values === undefined
// result.errors.email && result.errors.password
```
## Usage: Advanced Example
```ts
import z from "zod";
import {
asyncEmailUnique,
} from "@suaveplan/react-forms/validation/async";
import {
defineCrossFieldRule,
ValidationEngine,
violate,
} from "@suaveplan/react-forms/core/validation-engine";
import type { TranslationKey } from "@suaveplan/react-i18n";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
passwordConfirm: z.string(),
});
const passwordsMatch = defineCrossFieldRule({
name: "passwords-match",
fields: ["password", "passwordConfirm"],
validate: ({ password, passwordConfirm }) =>
password === passwordConfirm
? null
: [
violate(
"passwordConfirm",
"auth.password.mismatch" as TranslationKey
),
],
});
const checkUnique = asyncEmailUnique({
check: async (email, signal) => {
const r = await fetch(`/api/email/check?e=${email}`, {
signal,
});
return r.status === 404;
},
});
const engine = new ValidationEngine({
schema,
crossFieldRules: [passwordsMatch],
asyncValidators: [{ field: "email", validator: checkUnique }],
});
```
## API Reference
- `class ValidationEngine<TValues>` — pipeline coordinator.
- `new ValidationEngine(config: ValidationEngineConfig<TValues>)` —
throws `REACT_FORMS.SCHEMA_INVALID` on a non-Zod schema.
- `engine.resolve(values: TValues): Promise<ValidationResult<TValues>>`
— run the pipeline; cancels the previous run.
- `engine.cancel(): void` — cancel any in-flight async validators
(call on form unmount).
## Implementation Notes
- The engine carries its own `AbortController` instance and replaces
it on every `resolve()` call. The previous controller is aborted
first so async validators that observe their `signal` can short-
circuit; validators that don't observe the signal will still
resolve, but the engine guards via `runId` checks so their results
are dropped.
- Zod errors are mapped to a stable namespaced key
(`react_forms.validation.zod.<issue.code>`) so consumer catalogs
can register one entry per Zod issue family rather than per error.
The original Zod message is forwarded via `messageArgs.message` so
the catalog template can interpolate it when human copy is
desired.
- The engine deliberately does NOT debounce `resolve()` calls — that
is the resolver's responsibility (RHF's `mode: "onBlur" |
"onChange"` controls how often `resolve` is invoked). Debouncing
is layered on top of the engine, not inside it.
- The pipeline cancels in-flight pipelines via the `runId` discriminator
so a slow async validator from the previous run cannot overwrite
the current run's errors. The `runId !== this.currentRunId` check
is what gives the engine its "stale-result safe" guarantee.

View File

@@ -0,0 +1,90 @@
# `error-codes.ts`
## Purpose
Registers the thirteen stable error codes used by
`@suaveplan/react-forms` under the `REACT_FORMS` namespace via
`defineCodes` from `@suaveplan/error`. These codes are the single
source of truth for error identity across the React forms surface —
every `ReactFormsError` carries one of them as its `.code`, every
spec-conformance test asserts against them, and every observability
sink (OTel attributes, structured log fields, error-wire JSON) uses the
fully-qualified `REACT_FORMS.<reason>` string.
The registry must run at module-import time so the codes are present in
the global registry before any hook, component, or validator throws. The
barrel imports the file as a side-effect-only entry to guarantee the
registration sequence regardless of which subpath the consumer imports
first.
## Features
- Frozen `REACT_FORMS_CODES` record with thirteen entries —
added/removed only through an OpenSpec change.
- `ReactFormsCode` literal-union type derived from the registry, used as
the `TCode` parameter of `ReactFormsError`.
- Side-effect-free constant declaration: no logger calls, no global
mutation beyond what `defineCodes` performs internally on the shared
registry.
## Usage: Basic Example
```ts
import { REACT_FORMS_CODES } from "@suaveplan/react-forms/errors";
console.log(REACT_FORMS_CODES.VALIDATION_FAILED);
// → "REACT_FORMS.VALIDATION_FAILED"
```
## Usage: Advanced Example
```ts
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
function unwrapApi(api: unknown): unknown {
if (api === undefined) {
throw new ReactFormsError(
REACT_FORMS_CODES.API_CLIENT_NOT_FOUND,
"react_forms.error.api_client_not_found",
{ context: { hook: "useApiClient" } }
);
}
return api;
}
```
## API Reference
- `REACT_FORMS_CODES` — frozen `Record<Reason, "REACT_FORMS.<Reason>">`
produced by `defineCodes("REACT_FORMS", [...])`. Reasons:
`VALIDATION_FAILED`, `SUBMIT_FAILED`, `ASYNC_VALIDATOR_TIMEOUT`,
`ASYNC_VALIDATOR_ABORTED`, `SERVER_ERROR_MAPPED`,
`API_CLIENT_NOT_FOUND`, `AUTH_CONTEXT_NOT_FOUND`,
`FIELD_KIND_INVALID`, `SCHEMA_INVALID`,
`HARDCODED_STRING_DETECTED`, `CONTEXT_NOT_PROVIDED` (recipe needs
a missing provider), `REPEATING_ROW_LIMIT` (`add()` past `maxRows`
or `remove()` past `minRows`), `ASYNC_SEARCH_LOAD_FAILED`
(async-search recipe's `load(id, signal)` rejected).
- `ReactFormsCode` — `(typeof REACT_FORMS_CODES)[keyof typeof
REACT_FORMS_CODES]`. Use as the `TCode` parameter of
`SuaveplanError<TCode>` extensions.
## Implementation Notes
- The file is intentionally tiny — its only responsibility is to call
`defineCodes(...)` and re-export the resulting record + code union.
Keeping it side-effect-free past the registration call lets bundlers
prove a no-mutation tree and lets test harnesses import it without
perturbing other modules' state.
- `as const` on the reasons array preserves the literal types so
`ReactFormsCode` becomes a precise discriminated union rather than a
widened `string`.
- The package barrel imports this file as a side-effect entry
(`import "./errors/error-codes.js";`) so the codes register before the
consumer's first throw site is reached.
- New codes require an OpenSpec change — adding a code is a public-API
change because consumers may switch on it. Removing a code is a
breaking change.

View File

@@ -0,0 +1,90 @@
# `react-forms-error.ts`
## Purpose
Defines the base `ReactFormsError` class shared by every error path in
`@suaveplan/react-forms`. Extending the project-wide `SuaveplanError`
plugs the forms tier into the cause-chain traversal, redaction-aware
JSON wire format, OTel span-event adapter, and i18n message-resolver
hook implemented by `@suaveplan/error`.
The constructor stamps `context.tier === "react-forms"` on every
instance so observability sinks can filter forms-layer failures without
inspecting `error.constructor.name` (which is rewritten by minifiers).
## Features
- One concrete class for every error code in the registry — caller code
always throws `new ReactFormsError(code, messageKey, options)`.
- Auto-injected `context.tier === "react-forms"` for observability.
- Generic-typed `code` field constrained to `ReactFormsCode` so
`instanceof` narrows the union for TypeScript-aware consumers.
- Forwards `cause`, `traceId`, `clock`, and any caller-supplied context
unchanged through `SuaveplanError`'s wire format.
## Usage: Basic Example
```ts
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
throw new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed"
);
```
## Usage: Advanced Example
```ts
import {
REACT_FORMS_CODES,
ReactFormsError,
} from "@suaveplan/react-forms/errors";
try {
await submit(values);
} catch (cause) {
throw new ReactFormsError(
REACT_FORMS_CODES.SUBMIT_FAILED,
"react_forms.error.submit_failed",
{
cause,
context: { formId: "register", attempt: 2 },
traceId: spanContext.traceId,
}
);
}
```
## API Reference
- `class ReactFormsError extends SuaveplanError<ReactFormsCode>` — the
base error class.
- `new ReactFormsError(code, messageKey, options?)` — constructs an
error.
- `code: ReactFormsCode` — one of the values in `REACT_FORMS_CODES`.
- `messageKey: string` — i18n catalog key resolved at render time by
the consumer's `useErrorMessage` / `t(key)` pipeline.
- `options?: SuaveplanErrorOptions` — optional `cause`, `context`,
`clock`, `traceId`. The constructor merges
`context = { tier: "react-forms", ...options.context }` so the tier
cannot be overwritten by accident.
## Implementation Notes
- The class is intentionally minimal — any forms-specific behaviour that
needs to live on the error (e.g. `error.context.field`,
`error.context.fieldErrors`) is supplied by the caller through the
`context` option, not via subclassing. One class plus N codes keeps
the surface auditable.
- `messageKey` is intentionally typed as `string` rather than
`TranslationKey`. The `TranslationKey` brand is enforced at the public
prop boundary (validators, FieldConfig); errors thrown from internal
defensive guards may use raw catalog keys outside the branded
namespace.
- The `tier: "react-forms"` stamp is the same convention used by every
other React-tier error class in the project (e.g.
`ReactI18nError`'s `tier: "react-i18n"`).

View File

@@ -0,0 +1,187 @@
# `createDomainForm`
Declarative typed-form factory. Wraps `useDomainForm` + `<RHF
FormProvider>` + the layout dispatcher into a single component while
preserving direct access to react-hook-form via three escape
hatches.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createDomainForm` is the factory consumers reach for to build a form
in three lines: declare a Zod schema, declare a typed field-config
map, declare a submit handler. The factory returns a working
component plus a `.Field` static accessor for partial layouts. The
underlying RHF context is never hidden — the render-prop children
escape hatch yields the raw `useFormReturn`, the standalone
`useDomainForm` hook is exported for consumers who need a fully
custom shell, and `<RegisterForm.Field name="email" />` lets the
consumer mix factory-rendered fields into their own JSX.
Field-config validation runs at module-evaluation time via Zod —
misconfigurations (`select` without `options`, `currency` without
an ISO code) throw `REACT_FORMS.FIELD_KIND_INVALID` before the
component ever renders, so a typo never reaches a user-facing
surface.
## Features
- Three escape hatches: default render, render-prop children, static
`Form.Field` accessor.
- Built-in layouts: `vertical` (default), `horizontal`, `grid`. Plus
a `LayoutRenderFn` escape hatch for custom JSX.
- Auto-rendered submit footer with translated label and optional
cancel button.
- Wraps RHF's `<FormProvider>` automatically so consumers using
`useFormContext()` deep in the tree work without extra wiring.
- Passes through every option to the underlying `useDomainForm`:
`crossFieldRules`, `asyncValidators`, `debounceMs`, `defaultValues`.
- `data-form-id` on the rendered form so OTel-instrumented dashboards
can correlate DOM events to telemetry counters.
## Usage: Basic Example
```tsx
import { createDomainForm } from "@suaveplan/react-forms/factory/create-domain-form";
import { RegisterInput } from "@suaveplan/api-schemas/auth/register";
const RegisterForm = createDomainForm({
formId: "register",
schema: RegisterInput,
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: {
kind: "password",
label: "auth.password.label" as never,
strengthMeter: true,
},
name: { kind: "text", label: "auth.name.label" as never },
},
defaultValues: { email: "", password: "", name: "" },
submit: {
label: "auth.register.submit" as never,
onSubmit: async (values) => {
await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(values),
});
},
},
});
export function RegisterPage(): JSX.Element {
return <RegisterForm />;
}
```
## Usage: Advanced Example
Drop down to render-prop children for a custom shell (e.g. a two-pane
layout with a sidebar of additional context):
```tsx
import { Button } from "@suaveplan/react-ui";
export function RegisterPageWithSidebar(): JSX.Element {
return (
<RegisterForm>
{({ form, fields, submit, isSubmitting }) => (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
<div>
{fields.email}
{fields.password}
{fields.name}
<Button
type="button"
onClick={() => submit()}
disabled={isSubmitting}
>
Sign up
</Button>
</div>
<aside data-form-pristine={String(!form.formState.isDirty)}>
<p>By signing up you agree to the terms.</p>
</aside>
</div>
)}
</RegisterForm>
);
}
```
Or use the `Form.Field` static accessor for a partial layout where
the consumer wants the factory's chrome on some fields but renders
others themselves:
```tsx
export function RegisterMixed(): JSX.Element {
return (
<div>
<RegisterForm.Field name="email" />
<RegisterForm.Field name="password" />
<p>Show your name in your public profile?</p>
<RegisterForm.Field name="name" />
</div>
);
}
```
## API Reference
### `createDomainForm(config)`
Returns a `DomainFormComponent<TValues>` — a React component plus a
`.Field` static accessor.
Config (`CreateDomainFormConfig<TValues>`):
- `formId: string` — stable identifier used in OTel attributes.
- `schema: ZodType<TValues>` — Zod schema spine; defines `TValues`.
- `fields: FieldConfigMap<TValues>` — per-field config map.
- `layout?: DomainFormLayout` — `"vertical"` (default), `"horizontal"`,
`"grid"`, or a `LayoutRenderFn`.
- `defaultValues?: Partial<TValues>` — initial form values.
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
configuration.
- `asyncValidators?: ReadonlyArray<AsyncValidatorRegistration>` —
per-field async validators.
- `crossFieldRules?: ReadonlyArray<CrossFieldRule<TValues>>` —
cross-field rules.
- `debounceMs?: number` — async-validator debounce window.
### `<DomainForm>` props (the returned component)
- `children?: DomainFormChildren` — render-prop callback. When
supplied, the factory yields control and the default footer is
NOT rendered.
- `className?: string` — class applied to the underlying `<form>`.
### `<DomainForm.Field name>` static accessor
Renders a single field by name with the factory chrome. Throws
`REACT_FORMS.FIELD_KIND_INVALID` when the name is not in the config
map.
## Implementation Notes
- The `validateFieldConfigMap` call lives at module-evaluation time
inside the `createDomainForm` body so misconfigurations fail at
import time, not on first render.
- The `<FormProvider>` wrapper ensures hooks like `useFormContext` and
`useWatch` work for descendants without consumers having to wire it
themselves.
- The factory's component does NOT memoise `useDomainForm`'s returned
`fields` between the default render and the children render-prop
branch — the same pre-rendered elements are passed through both
paths via the same memoised reference held inside the hook.
- When children are supplied, the default submit footer is omitted —
the consumer controls the entire sub-tree below the `<form>` tag,
including any submit button.
- `Form.Field` calls `useDomainForm` independently to render its
single field. This means a parent that uses both `<RegisterForm />`
and `<RegisterForm.Field name="email" />` will instantiate two
hook trees with independent state. For combined usage, prefer the
render-prop children API which shares one hook instance.

View File

@@ -0,0 +1,120 @@
# `field-config`
`FieldConfig` discriminated union, per-kind Zod schemas, and the
`validateFieldConfig` / `validateFieldConfigMap` helpers used by the
factory at module-evaluation time.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Every form built via `createDomainForm` declares a typed
`FieldConfigMap<TValues>` mapping field name → kind-specific config.
The map drives label rendering, input selection, validation wiring,
and submit payload construction. Misconfigurations (`select` without
`options`, `currency` without an ISO code, `custom` without a
`render` callback) MUST fail at factory-define time so a bug never
reaches the user-facing render path. The `FieldConfigSchema` Zod
discriminated union enforces those invariants for every kind shipped
in Wave 5b.
The supported kinds in Wave 5b are: `text`, `email`, `password`,
`number`, `currency`, `checkbox`, `toggle`, `radio`, `textarea`,
`custom`. Deferred kinds (`date`, `dateTime`, `select`, `multi-select`,
`combobox`, `file`, `rich-text`, `otp`) are NOT in the discriminator
yet — they land in Wave 5c when the underlying react-ui primitives
ship. Passing a deferred kind today fails the discriminator and
throws `REACT_FORMS.FIELD_KIND_INVALID` with the offending field name
on the error context.
## Features
- Discriminated union over every kind so TypeScript narrows per kind.
- Per-kind Zod schema validating the runtime shape (currency code is
ISO 4217 length 3, radio has at least one option, custom has a
function `render`).
- `validateFieldConfig` throws `REACT_FORMS.FIELD_KIND_INVALID` with
the offending field name and the structured Zod issues on the
error context.
- `validateFieldConfigMap` walks every entry of the map so the factory
can fail-fast at define time.
- `SUPPORTED_FIELD_KINDS` exposes the in-Wave kind allowlist for
tooling / introspection.
## Usage: Basic Example
```ts
import { validateFieldConfig } from "@suaveplan/react-forms/factory/field-config";
const config = validateFieldConfig("email", {
kind: "email",
label: "auth.email.label",
});
console.log(config.kind); // "email"
```
## Usage: Advanced Example
```ts
import {
validateFieldConfigMap,
type FieldConfigMap,
} from "@suaveplan/react-forms/factory/field-config";
interface RegisterValues {
readonly email: string;
readonly password: string;
readonly name: string;
}
const fields: FieldConfigMap<RegisterValues> = {
email: { kind: "email", label: "auth.email.label" as never },
password: {
kind: "password",
label: "auth.password.label" as never,
strengthMeter: true,
},
name: { kind: "text", label: "auth.name.label" as never },
};
const validated = validateFieldConfigMap(fields);
// On failure: throws ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)
// carrying { field, issues }
```
## API Reference
- `FieldConfigSchema` — Zod discriminated union over every kind.
Use this for ad-hoc parsing if not running through the helper
functions.
- `validateFieldConfig(fieldName, config)` — parse a single config.
Throws `ReactFormsError(REACT_FORMS.FIELD_KIND_INVALID)` on failure.
Returns the parsed `FieldConfig` on success.
- `validateFieldConfigMap(fields)` — parse every entry of a map.
Returns the same map on success; throws on the first invalid entry.
- `isSupportedFieldKind(kind)` — boolean predicate over the in-Wave
kind allowlist. Useful for runtime guards in custom factory wrappers.
- `FieldConfig`, `FieldConfigMap`, `SUPPORTED_FIELD_KINDS`,
`SupportedFieldKind`, plus the per-kind config interfaces — declared
in `field-config.types.ts` and re-exported via the index barrel.
## Implementation Notes
- The Zod schemas accept plain `string` for every label / placeholder /
helperText property because the `TranslationKey` brand exists only
in TypeScript's type space — the brand cannot be observed at
runtime. The compile-time check is the gate; the runtime check
exists to catch shape misuse (missing required fields, wrong types)
rather than translation-key registration errors.
- The discriminator is `kind`, matching the master-design §9.5 surface
exactly. Wave 5c will widen the discriminator without breaking
consumers — the existing entries stay valid.
- `custom` accepts any function as `render` because the factory only
invokes it inside a Controller with the correct argument shape.
Type-narrowing the function signature inside Zod produces unhelpful
unknown-arg errors; the `CustomFieldConfig` TypeScript type is the
consumer-facing contract.
- The supported-kind allowlist is exported as a frozen tuple
(`as const`) so the literal types stay narrowed.

View File

@@ -0,0 +1,162 @@
# `RenderField`
Single-field renderer used by `useDomainForm` and `createDomainForm` to
turn a typed `FieldConfig` into a `<Field>` chassis container paired
with the appropriate input control.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maps a `FieldConfig<TValues>` discriminated-union entry to a complete
`<Field>` subtree (label / control / description / error) wired to a
react-hook-form `UseFormReturn`. Drives the auto-render pathway used
by both the standalone `useDomainForm` hook and the higher-level
`createDomainForm` factory. Consumers should rarely import this
directly — the factory is the public surface — but it is exposed via
the `factory/render-field` subpath for advanced cases that compose
their own layout while still wanting per-field auto-rendering.
The renderer wires `id`, `htmlFor`, `aria-describedby`, `aria-invalid`,
and `data-invalid` consistently across every kind so the output passes
WCAG 2.2 AA without consumer wiring. It uses RHF's `register` for
plain inputs (text, email, password, number, textarea) and
`Controller` for inputs with non-DOM event shapes (currency,
checkbox, toggle, radio, custom).
## Features
- One JSX subtree per Wave 5b field kind: text, email, password,
number, currency, checkbox, toggle, radio, textarea, custom.
- Consistent ARIA wiring: `htmlFor`, `aria-describedby`, `aria-invalid`,
`data-invalid`, `data-disabled`, `role="alert"` on errors.
- Inline `<Translated>` helper resolves every `TranslationKey` against
the active i18n at render time so no consumer has to thread `t`
through the renderer.
- Honours `config.disabled` everywhere — register-bound inputs receive
`{ disabled: true }`, controller-bound inputs forward the prop.
- `data-strength-meter="true"` written on password fields whose config
declares `strengthMeter: true` so a sibling visualiser can subscribe
to the value via `useFormContext()`.
## Usage: Basic Example
```tsx
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
import { useForm } from "react-hook-form";
interface Values {
readonly email: string;
}
function EmailFieldShell(): JSX.Element {
const form = useForm<Values>({ defaultValues: { email: "" } });
return (
<RenderField
name="email"
config={{ kind: "email", label: "auth.email.label" as never }}
form={form}
/>
);
}
```
## Usage: Advanced Example
Drive a multi-section layout by selecting which fields to render
inline vs which to render in a sidebar:
```tsx
import { RenderField } from "@suaveplan/react-forms/factory/create-domain-form";
import { FieldGroup } from "@suaveplan/react-ui";
import { useForm } from "react-hook-form";
interface Values {
readonly email: string;
readonly password: string;
readonly captcha: string;
}
const fields = {
email: { kind: "email" as const, label: "auth.email.label" as never },
password: {
kind: "password" as const,
label: "auth.password.label" as never,
strengthMeter: true,
},
captcha: {
kind: "custom" as const,
label: "auth.captcha.label" as never,
render: ({ field }: { readonly field: { readonly name: string } }) => (
<div data-captcha={field.name} />
),
},
};
export function SplitLayout(): JSX.Element {
const form = useForm<Values>({
defaultValues: { email: "", password: "", captcha: "" },
});
return (
<div style={{ display: "flex", gap: "1rem" }}>
<FieldGroup style={{ flex: 1 }}>
<RenderField name="email" config={fields.email} form={form} />
<RenderField
name="password"
config={fields.password}
form={form}
/>
</FieldGroup>
<aside>
<RenderField
name="captcha"
config={fields.captcha}
form={form}
/>
</aside>
</div>
);
}
```
## API Reference
### `<RenderField>` (component)
Props:
- `name: string` — field name (must match a key in `form`'s value
shape).
- `config: FieldConfig<TValues>` — discriminated-union field config.
- `form: UseFormReturn<TValues>` — RHF context returned by
`useDomainForm` or `useForm`.
The component subscribes to `form.formState` via
`form.getFieldState(name, formState)` so error messages and
`data-invalid` flags re-render on the same React commit cycle as the
underlying state change.
## Implementation Notes
- The renderer uses RHF's `register` for plain inputs whose native
`change` events flow through normally (text, email, password,
number, textarea). `Controller` is used for inputs with non-DOM
event shapes: currency (number-shaped value), checkbox / toggle
(boolean), radio (string), and custom (consumer-defined).
- The internal `<Translated>` helper is co-located rather than
factored out so the renderer file remains self-contained and the
`useTranslation()` subscription is established once per field.
- `aria-describedby` chains description and error ids when both are
present (`"field-x-description field-x-error"`); when only one is
active, the chain collapses to that single id. This matches the
WAI-ARIA APG recommendation for grouped helper text + alerts.
- The renderer never reads `config.errorMessage` directly — RHF's
`fieldState.error?.message` is the source of truth for the
rendered error text. The validator suite produces `messageKey`s
that flow through unchanged.
- The custom renderer cast on `render({ field, fieldState })` returns
`ReactNode` cast to `ReactElement` because `Controller`'s
`RenderFunction` requires `ReactElement`. Consumer functions are
free to return any `ReactNode`; the wrapper coerces.

View File

@@ -0,0 +1,72 @@
# `augmentation` — `ContributedTranslationKeys` registration
## Purpose
Side-effect-only TypeScript module that augments
`@suaveplan/react-i18n`'s `ContributedTranslationKeys` interface with
every key shipped by `@suaveplan/react-forms`. The augmentation runs
purely in TypeScript's type space — there is no runtime value to
import — but the file is included as a side-effect import from
`src/locales/locales.ts` so consumers' `tsconfig` picks up the
declaration when they `import "@suaveplan/react-forms"`.
The augmentation widens the branded `TranslationKey` union so a
recipe's `next` button label is statically known to be a registered
key. Misuse (`labels: { next: "typo" }`) is a TypeScript error before
the form ever mounts.
## Features
- Pure type-space augmentation — zero bytes of JavaScript runtime cost.
- Mirrors the keys in the generated `augmentation.d.ts` exactly so
side-effect import paths and declaration paths stay consistent.
- Imported as a side effect from `src/locales/locales.ts` so the
package barrel carries the augmentation transitively.
- Empty runtime export keeps tooling that strips type-only files from
side-effect chains honest.
## Usage: Basic Example
The augmentation is automatically applied when the package is imported.
No explicit usage is required.
```ts
import "@suaveplan/react-forms";
import type { TranslationKey } from "@suaveplan/react-i18n";
const k: TranslationKey = "react-forms.wizard.next"; // ✅ typechecks
```
## Usage: Advanced Example
If the consumer is building a server-side helper that does not import
the package barrel but still wants the augmentation widened, pull the
augmentation alone:
```ts
import "@suaveplan/react-forms/locales/augmentation";
import type { TranslationKey } from "@suaveplan/react-i18n";
declare const fromConfig: string;
const key = fromConfig as TranslationKey;
```
## API Reference
This module exports a single empty object literal (`export {}`) so the
TypeScript compiler treats it as a module and applies the
`declare module` augmentation. There are no runtime exports.
## Implementation Notes
- The file MUST stay in sync with `augmentation.d.ts`; both are
generated from `en-GB.json` by `bun
packages/react/react-i18n/scripts/generate-translation-types.ts
packages/react/react-forms --package-name @suaveplan/react-forms`.
- Two files exist (one `.ts`, one `.d.ts`) so consumers' bundlers do
not strip the runtime side-effect even when declaration-only files
are filtered out by aggressive tree-shaking.
- The unique-symbol brand on `ContributedTranslationKeys` ensures the
augmentation surface stays mergeable across packages — never declare
a `type ContributedTranslationKeys = { ... }`; it MUST stay an
`interface`.

View File

@@ -0,0 +1,105 @@
# Locales
Bundled translation strings shipped with `@suaveplan/react-forms`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package owns a small set of user-facing strings used by the recipes
(`Next`, `Back`, `Submit`, `Add`, `Remove`, `Loading…`, `Failed to
load`). The strings ship as flat-key JSON bundles in five locales
(`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) and are merged into the
consumer's `I18nInstance.catalog` at provider mount.
Consumers MAY override any key via `<TranslationProvider>`'s catalog
merging or via the recipe factory's per-recipe `labels` prop.
## Features
- Flat-key bundles addressable by `react-forms.<recipe>.<key>`
- Lazy loaders (one dynamic import per locale) so only the active
locale's JSON ships in the consumer's main bundle
- Type-narrowed via the generated `augmentation.d.ts` so misuse fails
at compile time
- Sorted key list (`REACT_FORMS_BUNDLE_KEYS`) for parity tests
- One source of truth (`en-GB.json`); other locales are kept parity-
complete by `scripts/check-locale-completeness.ts`
## Usage: Basic Example
```ts
import {
REACT_FORMS_BUNDLE_KEYS,
reactFormsBundles,
} from "@suaveplan/react-forms";
const en = await reactFormsBundles["en-GB"]();
console.log(Object.keys(en).length); // 7
console.log(REACT_FORMS_BUNDLE_KEYS[0]); // "react-forms.asyncSearch.error"
```
## Usage: Advanced Example
Wire the bundles into the active `I18nInstance` so recipe defaults
resolve correctly. The pattern mirrors `react-ui`'s `ReactUiProvider`
flat-to-nested merging step:
```ts
import { reactFormsBundles } from "@suaveplan/react-forms";
import type { I18nInstance } from "@suaveplan/i18n/plugin";
function nestFlat(bag: Record<string, string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(bag)) {
const parts = k.split(".");
let cur: Record<string, unknown> = out;
for (let i = 0; i < parts.length - 1; i += 1) {
const seg = parts[i] as string;
if (typeof cur[seg] !== "object" || cur[seg] === null) {
cur[seg] = {};
}
cur = cur[seg] as Record<string, unknown>;
}
cur[parts[parts.length - 1] as string] = v;
}
return out;
}
export async function registerReactFormsLocales(
i18n: I18nInstance
): Promise<void> {
for (const [locale, loader] of Object.entries(reactFormsBundles)) {
const bundle = await loader();
i18n.catalog.mergeMessages(
locale,
nestFlat(bundle as Record<string, string>)
);
}
}
```
## API Reference
- `SupportedFormsLocale` — `"en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU"`. The five locales the package ships translations for.
- `reactFormsBundles` — `Readonly<Record<SupportedFormsLocale, () => Promise<FormsLocaleBundle>>>`. Frozen map of locale → async loader returning the flat key/value bag for that locale. Loaders are lazy so consumers only chunk the active locale.
- `FormsLocaleBundle` — `Readonly<Record<string, string>>`. Shape of a single locale's bundle.
- `REACT_FORMS_BUNDLE_KEYS` — `ReadonlyArray<string>`. Sorted list of every key in `en-GB.json` for parity tests.
## Implementation Notes
- The bundles are imported via dynamic JSON imports so the bundler can
tree-shake unused locales when consumers split per language route.
- The augmentation lives in two files (`augmentation.ts` + the
generated `augmentation.d.ts`) so a side-effect runtime import
carries the augmentation through the package barrel even when
declaration-only files are pruned by aggressive bundlers.
- `Object.freeze` plus `Readonly<...>` annotations prevent accidental
mutation by consumers — the bundles MUST be treated as immutable.
- Adding a key requires updating ALL FIVE locale bundles + re-running
the codegen (`bun packages/react/react-i18n/scripts/generate-translation-types.ts packages/react/react-forms --package-name @suaveplan/react-forms`)
- duplicating the entry into `augmentation.ts`. The
`check-locale-completeness.ts` CI gate fails the build if any locale
drifts.

View File

@@ -0,0 +1,76 @@
# `translation-keys` — codegen union of contributed keys
## Purpose
`translation-keys.ts` is the codegen-emitted string-literal union of
every translation key contributed by `@suaveplan/react-forms`. It is
regenerated from `src/locales/en-GB.json` by `bun
packages/react/react-i18n/scripts/generate-translation-types.ts
packages/react/react-forms --package-name @suaveplan/react-forms` and
committed verbatim so consumers and CI can statically reference the
union without re-running the codegen.
The union is useful for non-React consumers (universal helpers,
server-side validators) that want a typed reference to the
forms-contributed keys without taking on the full
`@suaveplan/react-i18n` `ContributedTranslationKeys` augmentation
pathway. React consumers should prefer the branded `TranslationKey`
type from `@suaveplan/react-i18n` instead — that type widens
automatically as more packages augment the interface and is the type
the recipe and factory props use.
## Features
- Single-purpose: re-declares every key in `en-GB.json` as a string-
literal union.
- Sorted alphabetically via `localeCompare("en")` for diff stability.
- Generated comment header documents the regeneration command so the
file does not drift on hand-edit.
- Side-effect-free — no runtime code, no imports from runtime packages.
## Usage: Basic Example
```ts
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
declare const key: ReactFormsTranslationKey;
```
## Usage: Advanced Example
```ts
import type { ReactFormsTranslationKey } from "@suaveplan/react-forms";
const RECIPE_LABELS: Record<string, ReactFormsTranslationKey> = {
next: "react-forms.wizard.next",
back: "react-forms.wizard.back",
submit: "react-forms.wizard.submit",
add: "react-forms.repeating.add",
remove: "react-forms.repeating.remove",
loading: "react-forms.asyncSearch.loading",
error: "react-forms.asyncSearch.error",
};
```
## API Reference
### `ReactFormsTranslationKey`
String-literal union of every (flattened) key in
`src/locales/en-GB.json`. Sorted alphabetically; deterministic across
regeneration runs.
## Implementation Notes
- The file is regenerated by codegen — never hand-edit. Re-run `bun
packages/react/react-i18n/scripts/generate-translation-types.ts
packages/react/react-forms --package-name @suaveplan/react-forms`
after every change to `en-GB.json` and commit the regenerated file.
- The sibling `augmentation.d.ts` (also generated by the same codegen)
extends the runtime-typed `ContributedTranslationKeys` interface that
`TranslationKey` reads. The two files always carry the same set of
keys; the union here is a convenience export for non-React consumers,
the augmentation is the default type integration.
- The output ordering uses `localeCompare("en")` so the union is byte-
identical for identical input regardless of `en-GB.json`'s in-file
key insertion order.

View File

@@ -0,0 +1,115 @@
# `react-forms-metrics.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument set for `@suaveplan/react-forms`. Six
instruments — three counters and three histograms — cover every form
lifecycle event the engine, the async-validator queue, and the submit
pipeline emit. Built lazily so the package pays zero overhead when no
`MeterProvider` is registered, but always emits through the OTel API
when one is.
The module also keeps a small in-memory state ring so spec-conformance
tests can assert exact attribute payloads without standing up a full
SDK pipeline. The ring is process-local; consumers running multiple
isolated forms in the same process should call
`resetReactFormsMetricsState()` between cases.
## Features
- Lazy meter — `metrics.getMeter("suaveplan.react-forms")` is invoked
on first use, then cached.
- Counters: `react_forms.submit.total`,
`react_forms.async_validator.total`,
`react_forms.validation.error_total`.
- Histograms: `react_forms.submit.duration_ms`,
`react_forms.async_validator.duration_ms`,
`react_forms.validation.duration_ms`.
- In-memory ring per instrument so tests can assert attributes without
an SDK exporter.
- Stable contract — instrument names + attribute keys are part of the
package's public surface.
## Usage: Basic Example
```ts
import {
recordSubmitDuration,
recordSubmitOutcome,
} from "@suaveplan/react-forms/observability";
const start = performance.now();
try {
await submitForm(values);
recordSubmitOutcome({ form: "register", outcome: "success" });
} catch (cause) {
recordSubmitOutcome({ form: "register", outcome: "error" });
throw cause;
} finally {
recordSubmitDuration(performance.now() - start, { form: "register" });
}
```
## Usage: Advanced Example
```ts
import {
getReactFormsMetricsState,
recordAsyncValidatorDuration,
recordAsyncValidatorOutcome,
recordValidationError,
resetReactFormsMetricsState,
} from "@suaveplan/react-forms/observability";
resetReactFormsMetricsState();
recordAsyncValidatorOutcome({
validator: "asyncEmailUnique",
outcome: "aborted",
});
recordAsyncValidatorDuration(42, { validator: "asyncEmailUnique" });
recordValidationError({ kind: "async" });
const state = getReactFormsMetricsState();
state.asyncValidatorTotal[0]?.attributes; // { validator, outcome }
state.validationErrorTotal[0]?.attributes; // { kind: "async" }
```
## API Reference
- `getReactFormsMetrics(): CachedInstruments` — return cached counters
- histograms; build them on first call.
- `recordSubmitOutcome({ form, outcome })` — increment the
`react_forms.submit.total` counter and push a sample.
- `recordSubmitDuration(durationMs, { form })` — record on
`react_forms.submit.duration_ms`.
- `recordAsyncValidatorOutcome({ validator, outcome })` — increment
`react_forms.async_validator.total`.
- `recordAsyncValidatorDuration(durationMs, { validator })` — record
on `react_forms.async_validator.duration_ms`.
- `recordValidationError({ kind })` — increment
`react_forms.validation.error_total` with `kind` in
`{schema, async, cross-field, server}`.
- `recordValidationDuration(durationMs)` — record on
`react_forms.validation.duration_ms`.
- `getReactFormsMetricsState(): ReactFormsMetricsState` — read the
in-memory ring.
- `resetReactFormsMetricsState(): void` — clear the ring.
- `resetReactFormsMetricsCache(): void` — drop the cached meter so a
fresh one is built (test-only).
## Implementation Notes
- The cache uses a closure-scoped `let cached` rather than a module
constant so the test-only `resetReactFormsMetricsCache()` can invalidate
it. Production code never calls the reset path.
- Attribute keys are flat — `form`, `outcome`, `validator`, `kind` — to
match dashboard-friendly conventions and avoid nested attributes that
many OTel exporters serialise inconsistently.
- Outcome unions (`SubmitOutcome`, `AsyncValidatorOutcome`) and
`ValidationErrorKind` are exported so consumers wiring custom dashboards
can typecheck against the same allowlist the package uses.
- Renaming an instrument is a breaking change because consumer dashboards
query by name. Per AGENTS.md §13a a migration note is required.

View File

@@ -0,0 +1,121 @@
# `field` — single-source re-export of react-ui's Field family
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`primitives/field` re-exports the `<Field>`, `<FieldLabel>`,
`<FieldDescription>`, `<FieldError>`, `<FieldGroup>`, `<FieldSet>`, and
`<FieldLegend>` primitives from `@suaveplan/react-ui` so consumers
building forms have a single, semantically-named import path. The
underlying components are owned and tested inside `react-ui`; this
module only forwards them so the forms package never duplicates the
chassis surface.
The contract is identity-equality: `field.test.ts` asserts that every
re-exported symbol is reference-equal to the upstream symbol. Adding a
local wrapper would break the contract and silently fork the
underlying behaviour — never wrap, always re-export.
## Features
- Re-exports six React component references plus their TypeScript
prop types, identity-equal to the `react-ui` originals.
- Zero runtime cost — pure barrel export, no JS additions.
- Maintains a single upgrade vector: bumping the workspace `react-ui`
dep updates the field surface here automatically.
- Sibling `.test.ts` enforces the identity-equality contract so an
accidental wrapper rewrite fails the suite.
## Usage: Basic Example
```tsx
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@suaveplan/react-forms/primitives/field";
import { Input } from "@suaveplan/react-ui";
export function EmailField(): JSX.Element {
return (
<Field invalid={false}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
<FieldDescription>We never share your address.</FieldDescription>
</Field>
);
}
```
## Usage: Advanced Example
Wire a fieldset of grouped fields using the `<FieldSet>` and
`<FieldLegend>` primitives so the rendered tree carries the correct
semantic structure for assistive technologies:
```tsx
import {
Field,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@suaveplan/react-forms/primitives/field";
import { Input } from "@suaveplan/react-ui";
export function AddressFieldset(): JSX.Element {
return (
<FieldSet>
<FieldLegend>Postal address</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel htmlFor="line1">Address line 1</FieldLabel>
<Input id="line1" />
</Field>
<Field>
<FieldLabel htmlFor="line2">Address line 2</FieldLabel>
<Input id="line2" />
</Field>
<Field>
<FieldLabel htmlFor="city">City</FieldLabel>
<Input id="city" />
</Field>
</FieldGroup>
</FieldSet>
);
}
```
## API Reference
Every export is a re-export of the same-named symbol from
`@suaveplan/react-ui`. Refer to the upstream documentation for the
canonical prop tables; this section enumerates only the available
identifiers.
- `Field` — component. Container with `data-invalid` /
`data-disabled` attributes.
- `FieldLabel` — component. `htmlFor`-bound label primitive.
- `FieldDescription` — component. Muted helper text rendered as `<p>`.
- `FieldError` — component. Destructive helper with `role="alert"`.
- `FieldGroup` — component. Vertical stack container.
- `FieldSet` — component. Semantic `<fieldset>` wrapper.
- `FieldLegend` — component. Semantic `<legend>` wrapper.
- `FieldProps`, `FieldLabelProps`, `FieldDescriptionProps`,
`FieldErrorProps`, `FieldGroupProps`, `FieldSetProps`,
`FieldLegendProps` — TypeScript types matching each component.
## Implementation Notes
- The module never adds local wrappers. If a future feature requires
a forms-only `Field` variant, ship it under a new feature directory
(`primitives/<variant>/`) rather than modifying this re-export.
- Identity equality is asserted in the sibling `.test.ts` so any drift
(e.g. accidentally wrapping `Field` with a memoised proxy) fails CI
before publish.
- The barrel `index.ts` exports the same symbols so consumers can pick
either subpath (`@suaveplan/react-forms/primitives/field`) or the
package barrel (`@suaveplan/react-forms`).

View File

@@ -0,0 +1,128 @@
# `MoneyInput`
Locale-aware currency input wrapping `@suaveplan/react-ui`'s `<Input>`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Render a numeric input that displays its value as locale-formatted
currency (`£12,345.67`, `12 345,67 €`, `¥12,345`) while keeping the
public API a plain `number | null`. The component reads the active
i18n locale from `useLocale()` so locale switches re-render the input
in the new locale automatically. Parsing on blur (or per-keystroke,
configurable) converts the user's typed text back into a JavaScript
number for the consumer's `onChange` callback.
The component is fully controlled — the consumer owns the value. No
hidden state is held across renders other than a transient editing
buffer used between focus and blur so the user can type freely
without the formatter rewriting their caret position.
## Features
- Locale-aware formatting via `@suaveplan/i18n`'s `formatCurrency`.
- ISO 4217 currency code drives both display and the precision of the
value reported back to `onChange`.
- Two commit modes: `keystroke` (default, fires on every change) and
`blur` (fires only when the input loses focus).
- Subscribed to `useLocale()` so a `setLocale()` in any ancestor
re-renders the displayed text without remounting.
- Fully type-safe — `placeholder` is typed as `TranslationKey` so a
hardcoded English literal is a compile error.
- Accessible — `inputMode="decimal"` triggers the numeric keyboard on
mobile; the underlying `<Input>` carries the chassis a11y wiring.
## Usage: Basic Example
```tsx
import { useState } from "react";
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
function PriceInput(): JSX.Element {
const [price, setPrice] = useState<number | null>(null);
return <MoneyInput value={price} currency="GBP" onChange={setPrice} />;
}
```
## Usage: Advanced Example
Defer commits until blur, pin the locale to French, and surface the
parsed value back to a react-hook-form `Controller`:
```tsx
import { Controller, type Control } from "react-hook-form";
import { MoneyInput } from "@suaveplan/react-forms/primitives/money-input";
interface OrderForm {
readonly total: number | null;
}
export function OrderTotalField(props: {
readonly control: Control<OrderForm>;
}): JSX.Element {
return (
<Controller
control={props.control}
name="total"
render={({ field }) => (
<MoneyInput
value={field.value ?? null}
currency="EUR"
locale="fr-FR"
commitMode="blur"
onChange={field.onChange}
/>
)}
/>
);
}
```
## API Reference
### `<MoneyInput>` (component)
Props (extends `InputProps` minus `value`, `defaultValue`, `onChange`,
`type`, `inputMode`, `placeholder`):
- `value: number | null` — current amount (`null` is empty).
- `currency: string` — ISO 4217 code (`"GBP"`, `"USD"`, ...).
- `locale?: string` — BCP-47 override; defaults to the active i18n
locale resolved via `useLocale()`.
- `placeholder?: TranslationKey` — empty-state placeholder, typed.
- `onChange: (value: number | null) => void` — committed value
callback. `null` when the user clears the field.
- `commitMode?: "keystroke" | "blur"` — when `onChange` fires.
Defaults to `"keystroke"`.
### Behaviour
- Non-finite values (`NaN`, `Infinity`) supplied via `value` are
coerced to `null` at render time.
- The component never overwrites the user's typing while focused —
prop changes during edit are deferred until blur.
- Parsing tolerates locale-specific decimal separators (`.`, `,`, `٫`)
and grouping separators (space, NBSP, apostrophe, `.`, `,`).
- Sign markers `-`, `+`, and the Unicode minus `−` are all parsed
correctly.
## Implementation Notes
- The editing buffer is rendered as a plain `<input type="text"
inputMode="decimal">` rather than `<input type="number">` because
the latter rejects locale separators in many browsers.
- `data-currency={currency}` is written onto the rendered element so
consumers can style by currency without re-passing the prop.
- The decimal-separator detector caches `Intl.NumberFormat` lookups
per-locale so a re-rendering form does not re-instantiate the
formatter on every keystroke.
- The component is `forwardRef`-bound so consumers can focus / select
it from the parent (e.g. `useEffect(() => ref.current?.focus(), [])`
on mount).
- Currency display formatting always uses the i18n `Intl.NumberFormat`
defaults — to override fraction digits, wrap the component or use
the underlying `formatCurrency` utility directly inside a custom
primitive.

View File

@@ -0,0 +1,150 @@
# `createAsyncSearchForm`
Async-prefilled form recipe. Resolves an entity by `id` via the
supplied `load(id, signal)` callback, then mounts the underlying
`createDomainForm` with the resolved values as `defaultValues`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The canonical "edit by id" pattern: given a stable identifier (URL
slug, list-row click), fetch the underlying entity, then render a
form prefilled with the entity's current state. The recipe handles
the three-phase lifecycle (`loading`, `error`, `ready`) so consumers
do not have to wire `useEffect` + `AbortController` + `useState`
themselves on every page.
The `signal` passed to `load` is aborted on unmount AND when the
`id` prop changes mid-load, so a fast list of entity-edit pages
does not race stale fetches against the active form. The error
phase renders a translated `role="alert"` banner so screen readers
announce the failure.
## Features
- Three-phase lifecycle (`loading` → `ready` OR `loading` → `error`).
- AbortSignal threaded through `load(id, signal)`; auto-aborted on
unmount + id change.
- Default loading shell uses `react-ui`'s `<Skeleton>` so the
placeholder looks the same as every other loading region in the
app.
- Error region renders as `role="alert"` with translated text so
assistive tech announces the failure.
- Loading + error labels are TranslationKey-typed and overridable.
- Built on `createDomainForm` so the inner form inherits every
factory feature (escape hatches, layout, OTel, server-error
mapping).
## Usage: Basic Example
```tsx
import { createAsyncSearchForm } from "@suaveplan/react-forms/recipes/async-search";
import z from "zod";
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
const EditUserForm = createAsyncSearchForm({
formId: "edit-user",
schema,
fields: {
email: { kind: "email", label: "user.email.label" as never },
name: { kind: "text", label: "user.name.label" as never },
},
load: async (id, signal) => {
const res = await fetch(`/api/users/${id}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as { email: string; name: string };
},
submit: {
label: "user.save" as never,
onSubmit: async (values) => {
await fetch(`/api/users/${id}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
},
});
export function UserEditPage(props: { readonly id: string }): JSX.Element {
return <EditUserForm id={props.id} />;
}
```
## Usage: Advanced Example
Provide a custom loading skeleton sized to the form's layout:
```tsx
import { Skeleton } from "@suaveplan/react-ui";
export function UserEditPageWithCustomLoader(props: {
readonly id: string;
}): JSX.Element {
return (
<EditUserForm
id={props.id}
loadingFallback={
<div style={{ display: "grid", gap: "1rem" }}>
<Skeleton style={{ height: "3rem" }} />
<Skeleton style={{ height: "3rem" }} />
<Skeleton style={{ height: "2rem", width: "30%" }} />
</div>
}
/>
);
}
```
## API Reference
### `createAsyncSearchForm(config)`
- `formId: string` — stable id for OTel attributes.
- `schema: ZodType<TValues>` — Zod schema spine for the inner form.
- `fields: FieldConfigMap<TValues>` — per-field config map.
- `load: (id: string, signal: AbortSignal) => Promise<TValues>` —
async loader; receives an AbortSignal that is aborted on unmount
- id change.
- `submit: { label: TranslationKey; cancelLabel?: TranslationKey; onSubmit: (values: TValues) => Promise<unknown> }` — submit
configuration mirroring `createDomainForm`'s shape.
- `labels?: { loading?: TranslationKey; error?: TranslationKey }` —
override the bundled defaults.
### `<AsyncSearchForm>` props
- `id: string` — entity identifier passed to `load`.
- `loadingFallback?: ReactNode` — replaces the default Skeleton shell.
- `className?: string` — class on the rendered container.
### `AsyncSearchPhase`
`"loading" | "error" | "ready"` — exported for tooling that wants
to observe the lifecycle.
## Implementation Notes
- The recipe constructs the inner `createDomainForm` lazily inside
`useMemo`, gated by `phase === "ready"`. This means the inner
form's hooks only mount once the data is available — so RHF's
`defaultValues` is the source of truth (it does not flicker
between empty and populated).
- An `AbortController` is created per id-prop change and stored in
the effect's cleanup so unmount + id change both abort the
signal cleanly. The `requestRef` guards against late-resolving
fetches that should not update state for a superseded id.
- The error region intentionally does not surface the underlying
error message — only a translated label. Internal failures
(`cause`) flow through error-tracking middleware (OTel + the
package's `recordSubmitError` helpers) so consumers debug via
observability sinks, not via user-facing strings.
- The recipe re-creates the inner form factory on every successful
load. This is OK because `createDomainForm` is cheap (it returns
a function component); the alternative — a single factory with
controlled defaultValues — fights RHF's `defaultValues` semantics.

View File

@@ -0,0 +1,122 @@
# `ConditionalField`
Show / hide form fields based on a predicate over the parent form's
values. When the gate flips closed the recipe unregisters the named
fields from RHF so their values disappear from the submit payload
and any prior errors are cleared.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conditional fields are the canonical pattern for "depending on the
selected payment method, show the bank account section" — fields
whose presence is a function of other field values. The recipe
subscribes to the parent form via RHF's `useWatch` and renders the
children only when the supplied `when(values)` predicate returns
true. When the predicate flips from `true` to `false` the named
fields are explicitly `unregister`ed so they do not pollute the
submitted payload AND any errors set on them are cleared so they do
not block submission.
The recipe MUST be rendered inside a `<FormProvider>` ancestor.
Missing-provider use cases throw `REACT_FORMS.CONTEXT_NOT_PROVIDED`.
## Features
- Predicate-driven conditional rendering with `useWatch`-based
subscription.
- Explicit `unregister` + `clearErrors` on close so the form state
matches the visible UI exactly.
- Optional `watch` array as a performance hint — the recipe only
re-evaluates the predicate when one of the listed fields changes.
- Children may be a static node OR a render-prop receiving the
current form values (for cheap derived UI).
- Throws on missing provider so misuse surfaces immediately.
## Usage: Basic Example
```tsx
import { ConditionalField } from "@suaveplan/react-forms/recipes/conditional";
interface CheckoutValues {
paymentMethod: "card" | "transfer";
bankAccount?: string;
cardNumber?: string;
}
function ConditionalSection(): JSX.Element {
return (
<ConditionalField<CheckoutValues>
when={(v) => v.paymentMethod === "transfer"}
names={["bankAccount"]}
watch={["paymentMethod"]}
>
<input name="bankAccount" />
</ConditionalField>
);
}
```
## Usage: Advanced Example
Use the render-prop form to show derived UI based on the current
form values:
```tsx
function PaymentSummary(): JSX.Element {
return (
<ConditionalField<CheckoutValues>
when={(v) => v.paymentMethod === "card"}
names={["cardNumber"]}
>
{(values) => (
<div data-card-summary>
Card ending in
{values.cardNumber?.slice(-4) ?? "----"}
</div>
)}
</ConditionalField>
);
}
```
## API Reference
### `<ConditionalField<TValues>>`
Props:
- `when: (values: TValues) => boolean` — predicate. Children render
only when this returns truthy.
- `names: ReadonlyArray<string>` — the names of the conditional
fields. Unregistered + cleared on close.
- `watch?: ReadonlyArray<string>` — fields to subscribe to. Performance
hint — when omitted, the recipe subscribes to the entire form.
- `children: ReactNode | ((values: TValues) => ReactNode)` — node or
render-prop callback.
## Implementation Notes
- The recipe uses `useWatch` to subscribe and `getValues()` to read
the current snapshot. `useWatch`'s subscription is what triggers
re-renders when a watched field changes; `getValues()` returns the
full current values bag so the predicate sees the up-to-date
picture.
- The recipe tracks the previous visibility via a `useRef` so the
unregister-on-close branch only fires on the actual transition,
not on every re-render while hidden.
- `unregister` is called per name to avoid the case where RHF batches
unregisters and one name's error survives. `clearErrors` is called
separately for the same reason.
- The recipe never registers fields itself — that is the consumer's
job (typically via `form.register(name)` inside the child node or
via a sibling `RenderField`). The recipe is purely an
unregister-on-close lifecycle wrapper.
- Children are wrapped in a Fragment in both branches (static and
render-prop) so the recipe contributes zero DOM nodes.
- The throw on missing `<FormProvider>` is intentional: silently
no-oping would let consumers ship forms whose conditional fields
never re-render. Surfacing the error at first mount is safer.

View File

@@ -0,0 +1,173 @@
# `createRepeatingFieldset`
Dynamic-array fieldset recipe backed by react-hook-form's
`useFieldArray`. Renders one row per array entry; add / remove
buttons throw `REACT_FORMS.REPEATING_ROW_LIMIT` when the operation
would breach the configured `minRows` / `maxRows` bounds.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Repeating fieldsets are the canonical pattern for any form whose
shape is "0..N entries of the same record" — contacts, line items,
addresses, attendees. The recipe wires `useFieldArray` against a
named array property on the parent form (the form created via
`createDomainForm`, `createWizardForm`, or a hand-rolled
`<FormProvider>`) and renders one labelled row per entry. Each row
is a small `FieldConfigMap` so all the supported field kinds are
available inside a row.
The recipe MUST be rendered inside a `<FormProvider>`. The factory's
`<DomainForm>` already wraps one; consumers using bespoke shells
must do the same. When the provider is missing the recipe throws
`REACT_FORMS.CONTEXT_NOT_PROVIDED` so the bug surfaces immediately
rather than producing a confusing null-context error inside RHF.
## Features
- Bound to RHF's `useFieldArray` for stable row keys, ordered
insertion / deletion, and integration with the parent form's
validation pass.
- `minRows` / `maxRows` bounds enforced at the call site —
disabled buttons are the soft gate, the throw is the hard gate.
- Semantic `<ul>` / `<li>` elements (with implicit `list` /
`listitem` roles) so screen readers announce the rows as a list.
- Children render-prop yields the rows array (with per-row context
including a `remove` callback) so consumers can build custom row
chrome (drag handles, summary panels, ...).
- Translated `Add` / `Remove` button labels with overrides via the
`labels` config.
## Usage: Basic Example
```tsx
import { createRepeatingFieldset } from "@suaveplan/react-forms/recipes/repeating-fieldset";
interface ContactRow {
readonly name: string;
readonly email: string;
}
const Contacts = createRepeatingFieldset<ContactRow>({
formId: "checkout",
name: "contacts",
minRows: 1,
maxRows: 5,
defaultRow: { name: "", email: "" },
fieldset: {
name: { kind: "text", label: "checkout.contact.name" as never },
email: {
kind: "email",
label: "checkout.contact.email" as never,
},
},
});
export function ContactsSection(): JSX.Element {
// Must be rendered inside a <FormProvider> ancestor.
return <Contacts />;
}
```
## Usage: Advanced Example
Render rows inside a custom drag-and-drop shell with a per-row
summary panel:
```tsx
export function CustomContacts(): JSX.Element {
return (
<Contacts>
{({ rows, add, canAdd }) => (
<div>
{rows.map((row) => (
<article
key={row.id}
data-draggable
data-row-index={row.index}
>
<header>Contact #{row.index + 1}</header>
{Object.values(row.fields)}
<button
type="button"
onClick={row.remove}
disabled={!row.canRemove}
>
Remove
</button>
</article>
))}
<button
type="button"
onClick={add}
disabled={!canAdd}
>
Add contact
</button>
</div>
)}
</Contacts>
);
}
```
## API Reference
### `createRepeatingFieldset(config)`
- `formId: string` — stable id for OTel attributes / DOM inspection.
- `name: string` — array field name on the parent form.
- `minRows?: number` — minimum row count enforced by `remove()`.
Defaults to `0`.
- `maxRows?: number` — maximum row count enforced by `add()`.
Defaults to `Infinity`.
- `defaultRow: TRow` — default values for a newly-added row.
- `fieldset: FieldConfigMap<TRow>` — per-row field config map.
- `labels?: { add?: TranslationKey; remove?: TranslationKey }` —
override the bundled defaults.
### `<RepeatingFieldset>` props
- `children?: (ctx) => ReactNode` — render-prop receiving
`{ rows, add, canAdd, fieldArray }`. When supplied, the default
shell is NOT rendered.
- `className?: string` — class on the rendered `<div>`.
### `RepeatingChildrenContext<TRow>` (passed to children)
- `rows: ReadonlyArray<RepeatingRowContext<TRow>>` — one entry per
row, each containing:
- `index: number`
- `id: string` — RHF's stable row id (use as React key).
- `fields: Readonly<Record<string, ReactElement>>` — pre-rendered
field elements for the row.
- `remove: () => void` — removes this row (throws on min-row
breach).
- `canRemove: boolean` — whether the soft gate would allow remove.
- `add: () => void` — appends a new `defaultRow` (throws on
max-row breach).
- `canAdd: boolean` — whether the soft gate would allow add.
- `fieldArray: UseFieldArrayReturn<...>` — raw RHF field-array
context for advanced operations (`move`, `swap`, `insert`,
`replace`).
## Implementation Notes
- The recipe uses RHF's `useFieldArray` with a stringly-typed name —
the upstream API is intentionally `string`-typed, so the recipe
passes through. TypeScript narrows the row shape at the consumer
level via the `<TRow>` generic.
- Throwing on `add()` past `maxRows` AND disabling the button is
intentional belt-and-braces: the disabled button is the affordance
for the typical user; the throw catches consumers who bypass the
affordance via the children render-prop.
- The default shell renders the add button inside the same `<ul>` as
the rows so the button shares the list semantics. Custom children
can render the button anywhere.
- Field configs are validated at module-eval time so a misconfigured
row never reaches a render.
- `data-row-index` and `data-row-action` attributes are stable DOM
contracts useful for E2E tests and CSS targeting.

View File

@@ -0,0 +1,176 @@
# `createWizardForm`
Multi-step wizard recipe. Each step owns a Zod sub-schema and a typed
field-config map; navigating forward validates the current step
before advancing; navigating backward preserves entered data;
`onComplete` runs after the final step's validation succeeds.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wizards are the canonical pattern for forms that exceed a single
viewport — registration, checkout, multi-page surveys — where the
user benefits from chunked progress. The recipe wraps `useForm` from
react-hook-form with a small step-state machine; field validation
runs against the current step's sub-schema, and only the last step's
button is wired to `onComplete`. The merged values across all steps
flow into the `onComplete` callback as a single object so the
consumer never has to stitch step state together.
The recipe ships with a default chrome (Back / Next / Submit
buttons, vertical field stack), all wired to the bundled translation
keys (`react-forms.wizard.next` / `back` / `submit`). Consumers
override individual labels via the `labels` config option or replace
the entire chrome via the children render-prop.
## Features
- Per-step Zod schema enforced before forward navigation.
- Backward navigation preserves entered data — `useForm` state
spans every step.
- Default chrome with translated Back / Next / Submit buttons; final
step's button auto-flips from `Next` to `Submit`.
- `WizardState` exposed via the children render-prop for custom
progress UIs (`currentIndex`, `totalSteps`, `canGoBack`,
`canGoNext`, `isComplete`).
- `data-wizard-step` attribute on the rendered `<form>` so DOM
inspectors can identify the active step without subscribing to
React state.
- Per-step field-config validation at module-eval time — a
misconfigured step throws `REACT_FORMS.FIELD_KIND_INVALID` before
any render.
## Usage: Basic Example
```tsx
import { createWizardForm } from "@suaveplan/react-forms/recipes/wizard";
import z from "zod";
const SignUpWizard = createWizardForm({
formId: "signup",
steps: [
{
id: "credentials",
title: "auth.steps.credentials" as never,
schema: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
fields: {
email: { kind: "email", label: "auth.email.label" as never },
password: { kind: "password", label: "auth.password.label" as never },
},
},
{
id: "profile",
title: "auth.steps.profile" as never,
schema: z.object({ name: z.string().min(1) }),
fields: {
name: { kind: "text", label: "auth.name.label" as never },
},
},
],
defaultValues: { email: "", password: "", name: "" },
onComplete: async (values) => {
await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(values),
});
},
});
export function SignUpPage(): JSX.Element {
return <SignUpWizard />;
}
```
## Usage: Advanced Example
Replace the default chrome with a custom progress UI showing the
total step count and a localised label per step:
```tsx
export function SignUpPageWithProgress(): JSX.Element {
return (
<SignUpWizard>
{({ state, fields, goNext, goPrev, isSubmitting }) => (
<div>
<ol data-progress>
<li>
Step {state.currentIndex + 1} of {state.totalSteps}
</li>
</ol>
{Object.values(fields)}
<button
type="button"
onClick={goPrev}
disabled={!state.canGoBack || isSubmitting}
>
Back
</button>
<button
type="button"
onClick={() => void goNext()}
disabled={isSubmitting}
>
{state.canGoNext ? "Next" : "Submit"}
</button>
</div>
)}
</SignUpWizard>
);
}
```
## API Reference
### `createWizardForm(config)`
- `formId: string` — stable id for OTel attributes.
- `steps: WizardStep<TValues>[]` — at least one. Each:
- `id: string` — stable step identifier.
- `title: TranslationKey` — heading translation key.
- `schema: ZodType<Partial<TValues>>` — sub-schema for the step.
- `fields: FieldConfigMap<TValues>` — per-step field configs.
- `defaultValues?: Partial<TValues>` — initial values across steps.
- `labels?: { next?: TranslationKey; back?: TranslationKey; submit?: TranslationKey }` —
override the bundled defaults.
- `onComplete: (values: TValues) => Promise<unknown>` — invoked when
the final step validates successfully.
### `<WizardForm>` props
- `children?: (ctx) => ReactNode` — render-prop receiving
`{ form, fields, state, goNext, goPrev, isSubmitting }`.
- `className?: string` — class on the rendered `<form>`.
### `WizardState` (from the render-prop ctx)
- `currentIndex: number`
- `currentStep: { id: string }`
- `totalSteps: number`
- `canGoBack: boolean`
- `canGoNext: boolean`
- `isComplete: boolean`
## Implementation Notes
- The wizard validates ONLY the current step's slice of values — the
later steps are skipped. This means `onComplete` may be called
with values that fail a downstream sub-schema if a later step is
edited, then the user navigates back. Consumers should prefer
schemas where every step's sub-schema is a strict subset of the
full Zod schema.
- The merged values passed to `onComplete` are read via
`form.getValues()` — the RHF state is the single source of truth.
- The default chrome renders both buttons inside a
`<div data-wizard-nav>` so styling layers can target the navigation
block independently of the field stack.
- Throwing on zero steps is a hard contract — there is no defaultable
"empty wizard" UI worth shipping.
- Field-config validation runs once per step at module-eval time;
consumers cannot accidentally smuggle a deferred kind (`select`,
`date`, ...) into a step's config.

View File

@@ -0,0 +1,106 @@
# `async-email-unique.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Async validator factory that resolves to `{ valid: true }` when a
caller-supplied `check(email, signal)` predicate returns `true`
("the email is unique"), and to `{ valid: false, messageKey }` when
the predicate returns `false` ("the email is already registered").
The validator is the canonical pattern for "async field validation
that hits the network" without baking a transport into the package.
The factory wraps the supplied `check` in a `@suaveplan/timing.debounce`
window so a user typing characters fires at most one network call per
window (default 300 ms), and observes its `AbortSignal` argument so
in-flight checks for stale values do not overwrite the response for
the latest value.
## Features
- Caller-injected `check(email, signal): Promise<boolean>` — the
validator owns no transport, no fetch, no network primitive. Tests
pass a real function (no `vi.mock`).
- `AbortSignal` observation — when the signal aborts, the validator
resolves to a structured failure with
`messageKey === "react_forms.validation.async_aborted"`.
- Debounce window via `@suaveplan/timing.debounce` (`leading: false,
trailing: true`).
- In-flight dedup — when the same value is checked twice in quick
succession, the second call returns the in-flight promise rather
than starting a fresh debounce.
- OTel telemetry — every invocation emits
`react_forms.async_validator.duration_ms` and
`react_forms.async_validator.total{validator,outcome}`.
## Usage: Basic Example
```ts
import { asyncEmailUnique } from "@suaveplan/react-forms/validation/async";
const validateUnique = asyncEmailUnique({
check: async (email, signal) => {
const res = await fetch(`/api/users?email=${encodeURIComponent(email)}`, {
signal,
});
return res.status === 404; // 404 = not found = unique
},
});
await validateUnique("alice@example.com", new AbortController().signal);
// → { valid: true } if the email is unique
```
## Usage: Advanced Example
```ts
import { asyncEmailUnique } from "@suaveplan/react-forms/validation/async";
import type { TranslationKey } from "@suaveplan/react-i18n";
const cache = new Map<string, boolean>();
const validateUnique = asyncEmailUnique({
debounceMs: 500,
messageKey: "auth.email.taken" as TranslationKey,
check: async (email, signal) => {
const cached = cache.get(email);
if (cached !== undefined) return cached;
const res = await fetch(`/api/email/check?email=${email}`, {
signal,
});
const fresh = res.status === 404;
cache.set(email, fresh);
return fresh;
},
});
```
## API Reference
- `asyncEmailUnique(opts: AsyncEmailUniqueOptions):
AsyncValidator<string>` — factory.
- `interface AsyncEmailUniqueOptions {`
- `check: (email: string, signal: AbortSignal) => Promise<boolean>`
- `debounceMs?: number` — default `300`.
- `messageKey?: TranslationKey` — default
`react_forms.validation.email_taken`.
- `}`
## Implementation Notes
- The debounce uses `@suaveplan/timing.debounce` rather than `setTimeout`
directly because the convention is that all timing utilities
flow through `@suaveplan/timing` for dependency-injected `Clock`
testability.
- The `check` function receives the abort signal — implementations
that hit `fetch` should pass it through as `fetch(url, { signal })`
so the underlying request is cancelled on abort.
- The validator surfaces `AbortError` (the `DOMException` thrown by
`fetch` on abort) as a structured "aborted" failure rather than
re-throwing. Re-throwing would force the engine to try/catch every
validator call, which obscures normal-failure code paths.
- The `check` predicate can be sync-throwing or async-rejecting;
both paths converge on `{ valid: false, messageKey }` so the
validator never crashes the form.

View File

@@ -0,0 +1,89 @@
# `async-username-available.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Async validator factory that resolves to `{ valid: true }` when a
caller-supplied `check(username, signal)` predicate returns `true`
("the username is available"), and to
`{ valid: false, messageKey }` when it returns `false` ("the username
is already taken"). Mirrors `asyncEmailUnique` in shape — the split
exists so the failure key, OTel `validator` attribute, and dedup
namespace stay distinct between the two validators.
## Features
- Caller-injected `check(username, signal): Promise<boolean>` — no
baked-in transport.
- `AbortSignal` observation — aborts surface as
`react_forms.validation.async_aborted`.
- Debounce window via `@suaveplan/timing.debounce`
(`leading: false, trailing: true`, default 300 ms).
- In-flight dedup keyed on the username value.
- OTel telemetry —
`react_forms.async_validator.duration_ms{validator="asyncUsernameAvailable"}`
histogram and
`react_forms.async_validator.total{validator,outcome}` counter.
## Usage: Basic Example
```ts
import { asyncUsernameAvailable } from "@suaveplan/react-forms/validation/async";
const validate = asyncUsernameAvailable({
check: async (username, signal) => {
const res = await fetch(
`/api/usernames/${encodeURIComponent(username)}`,
{ signal }
);
return res.status === 404; // 404 = available
},
});
await validate("alice", new AbortController().signal);
// → { valid: true }
```
## Usage: Advanced Example
```ts
import { asyncUsernameAvailable } from "@suaveplan/react-forms/validation/async";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validate = asyncUsernameAvailable({
debounceMs: 500,
messageKey: "auth.username.taken" as TranslationKey,
check: async (username, signal) => {
if (username.length < 3) return false;
return await fetch(`/api/usernames/${username}`, {
signal,
}).then((r) => r.status === 404);
},
});
```
## API Reference
- `asyncUsernameAvailable(opts: AsyncUsernameAvailableOptions):
AsyncValidator<string>` — factory.
- `interface AsyncUsernameAvailableOptions {`
- `check: (username: string, signal: AbortSignal) => Promise<boolean>`
- `debounceMs?: number` — default `300`.
- `messageKey?: TranslationKey` — default
`react_forms.validation.username_taken`.
- `}`
## Implementation Notes
- The validator deliberately namespaces its dedup key (`username::<v>`)
so it can co-exist with `asyncEmailUnique` (which uses `email::<v>`)
in the same form's validation engine without one stomping on the
other.
- The debounce uses `@suaveplan/timing.debounce` for the same reasons
as `asyncEmailUnique` — clock injectability via the timing
primitive.
- The factory does NOT enforce a minimum username length; layer
`minLength` via `sequence` if you want the check to skip until the
user has typed enough characters.

View File

@@ -0,0 +1,117 @@
# `compose.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- or — OR-semantics composer for the validator suite.…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
- [or](./or.md) — OR-semantics composer for the validator suite. Returns a validator that passes when any of the input validators passes.…
- [sequence](./sequence.md) — Short-circuit AND composer with explicit ordering semantics. Identical to `compose` in result — every validator must pa…
- [shared](./shared.md) — Tiny internal helper module shared by every composer in `validation/compose`. Centralises the `Promise` discrimination…
## Purpose
`compose` (AND semantics) — combine multiple validators into a single
validator that passes only when every input validator passes. Failures
short-circuit on the first failing validator and the composed
validator returns that validator's `messageKey` so the user sees the
single most specific error rather than a fan-out of all violations.
The same module exports `runChain`, the chain-runner used by both
`compose` and the `or` composer in the sibling module. Centralising
the chain logic here keeps the AND/OR/SEQUENCE composers thin and
guarantees consistent sync-vs-async dispatch semantics across all
three.
## Features
- AND semantics: short-circuits on first failure.
- Sync if every input validator is sync; async if any input is async.
- Generic over the value type `T`.
- `runChain` exported as the shared engine for related composers.
## Usage: Basic Example
```ts
import { compose } from "@suaveplan/react-forms/validation/compose";
import {
minLength,
pattern,
} from "@suaveplan/react-forms/validation/validators";
const validateSlug = compose(
minLength(3),
pattern(/^[a-z0-9-]+$/)
);
validateSlug("abc"); // → { valid: true }
validateSlug("ab"); // → { valid: false, messageKey: "react_forms.validation.min_length" }
validateSlug("ABC"); // → { valid: false, messageKey: "react_forms.validation.pattern" }
```
## Usage: Advanced Example
```ts
import { compose } from "@suaveplan/react-forms/validation/compose";
import {
custom,
email,
required,
} from "@suaveplan/react-forms/validation/validators";
const validateEmail = compose(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey),
custom(
async (v: string) => Promise.resolve(!v.endsWith("@temp.com")),
"auth.email.disposable" as TranslationKey
)
);
await validateEmail("alice@temp.com", new AbortController().signal);
// → { valid: false, messageKey: "auth.email.disposable" }
```
## API Reference
- `compose<T>(...validators: Validator<T>[]): Validator<T>` — sync
overload.
- `compose<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
AsyncValidator<T>` — mixed overload returning an async validator.
- `runChain<T>(validators, value, signal, mode)` — internal chain-
runner exported for use by sibling composers (`or`, `sequence`).
- `mode: "and" | "or"` — combinator semantics.
## Implementation Notes
- The chain-runner uses an iterative `step()` function rather than a
reducer so the sync path stays sync — `Array.prototype.reduce`
cannot represent a partial early-return cleanly while preserving
sync-vs-async narrowing.
- The runner uses an injected `AbortSignal` (or a fresh one when none
is supplied) for every async validator invocation. This guarantees
every async validator sees a signal, so a validator that ignores the
argument on the assumption "the caller will handle abort" never
silently leaks a hanging promise.
- `runChain` is exported only as an internal helper for the sibling
composers — it is NOT re-exported from the package barrel because
consumers should always go through the named composer (`compose`,
`or`, `sequence`).

View File

@@ -0,0 +1,74 @@
# `or.ts`
## Purpose
OR-semantics composer for the validator suite. Returns a validator
that passes when any of the input validators passes. Successes short-
circuit on the first match (so cheap validators should come first); if
every validator fails the LAST failure is returned, which gives the
caller the most specific error message rather than the most generic.
## Features
- OR semantics: short-circuits on first success.
- Sync if every input is sync; async if any input is async.
- Generic over the value type `T`.
- Empty chain returns `{ valid: true }` (vacuous truth — OR-of-zero
is identity-true; treat as a no-op).
## Usage: Basic Example
```ts
import { or } from "@suaveplan/react-forms/validation/compose";
import {
email,
url,
} from "@suaveplan/react-forms/validation/validators";
const validateContact = or(email(), url({ protocols: ["https"] }));
validateContact("alice@example.com"); // → { valid: true }
validateContact("https://alice.example"); // → { valid: true }
validateContact("nope"); // → { valid: false, messageKey: "react_forms.validation.url" }
```
## Usage: Advanced Example
```ts
import { or } from "@suaveplan/react-forms/validation/compose";
import {
custom,
email,
} from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const looksLikeUsername = custom<string>(
(s) => /^[a-z0-9_-]{3,30}$/i.test(s),
"form.username.invalid" as TranslationKey
);
const emailOrUsername = or(email(), looksLikeUsername);
emailOrUsername("alice"); // → { valid: true }
emailOrUsername("alice@example.com"); // → { valid: true }
emailOrUsername("!!!"); // → { valid: false, messageKey: "form.username.invalid" }
```
## API Reference
- `or<T>(...validators: Validator<T>[]): Validator<T>` — sync
overload.
- `or<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
AsyncValidator<T>` — mixed overload returning an async validator.
## Implementation Notes
- The runner returns the LAST failure (rather than the first or all of
them) because the convention in industry-standard form libraries
(Yup, Formik) is "show the most specific error, which is usually
the most recently checked".
- Cheap validators come first — `or(email(), expensiveAsyncCheck())`
short-circuits on the cheap email match in the common case and only
invokes the async check when the cheap one rejects.
- The chain runner is shared with `compose` (AND); only the `mode`
argument differs.

View File

@@ -0,0 +1,85 @@
# `sequence.ts`
## Purpose
Short-circuit AND composer with explicit ordering semantics. Identical
to `compose` in result — every validator must pass for the composed
validator to pass, and the first failure short-circuits — but the
order in which validators are passed is deliberately meaningful: cheap
synchronous validators come first, expensive async validators come
last. Use `sequence` whenever the order matters; use `compose` when
order is incidental.
The split (rather than just an alias) exists because:
- Naming the intent at the call site (`sequence(required, email,
asyncCheck)`) communicates "run these in order" to the next
developer reading the code, where `compose` reads as "combine
these by AND".
- Future runtime hooks (e.g. tracing-per-step latency, ordered failure
metrics) will land on `sequence` because it carries an explicit
ordering contract; `compose` is order-agnostic so the same hook on
`compose` would be misleading.
## Features
- AND semantics with documented ordering contract.
- Sync if every input is sync; async if any input is async.
- Generic over the value type `T`.
- Empty chain returns `{ valid: true }`.
## Usage: Basic Example
```ts
import { sequence } from "@suaveplan/react-forms/validation/compose";
import {
email,
required,
} from "@suaveplan/react-forms/validation/validators";
// `required` runs first (cheap), `email` runs only when required passed.
const validateEmail = sequence(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey)
);
```
## Usage: Advanced Example
```ts
import { sequence } from "@suaveplan/react-forms/validation/compose";
import {
custom,
email,
required,
} from "@suaveplan/react-forms/validation/validators";
const validateEmail = sequence(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey),
custom(
async (v: string) => Promise.resolve(!v.endsWith("@temp.com")),
"auth.email.disposable" as TranslationKey
)
);
await validateEmail("alice@temp.com", new AbortController().signal);
// → { valid: false, messageKey: "auth.email.disposable" }
```
## API Reference
- `sequence<T>(...validators: Validator<T>[]): Validator<T>` — sync
overload.
- `sequence<T>(...validators: Array<Validator<T> | AsyncValidator<T>>):
AsyncValidator<T>` — mixed overload returning an async validator.
## Implementation Notes
- The implementation delegates to `runChain` from `compose.ts` with
`mode: "and"`. The split is API-level only — there is one
short-circuit AND engine.
- Putting cheap validators first matters because async validators
invariably hit the network. A `sequence` that re-orders them at
runtime would silently fire network I/O the caller did not intend;
the convention is "preserve insertion order, full stop".

View File

@@ -0,0 +1,88 @@
# `shared.ts`
## Purpose
Tiny internal helper module shared by every composer in
`validation/compose`. Centralises the `Promise` discrimination check
so a future change to "what counts as async" only happens in one place.
Keeping the helper here (rather than in `compose.ts`) lets `or.ts` and
`sequence.ts` import it without pulling in the AND-combinator code or
risking circular imports.
## Features
- `isAsyncResult(out)` — type-narrowing predicate that tells
TypeScript whether a `ValidatorResult | Promise<ValidatorResult>` is
the async branch.
- Cross-realm safe — uses `instanceof Promise` rather than duck-typing
on `.then` so monkey-patched `Promise.resolve` shims do not produce
false positives.
## Usage: Basic Example
```ts
import { isAsyncResult } from "./shared.js";
const out: ValidatorResult | Promise<ValidatorResult> = validator(value);
if (isAsyncResult(out)) {
out.then((res) => handle(res));
} else {
handle(out);
}
```
## Usage: Advanced Example
```ts
import { isAsyncResult } from "./shared.js";
function reduceChain<T>(
chain: ReadonlyArray<Validator<T> | AsyncValidator<T>>,
value: T,
signal: AbortSignal
): ValidatorResult | Promise<ValidatorResult> {
let acc: ValidatorResult | Promise<ValidatorResult> = { valid: true };
for (const v of chain) {
if (isAsyncResult(acc)) {
acc = acc.then((prev) =>
prev.valid ? v(value, signal) : prev
);
} else if (!acc.valid) {
return acc;
} else {
acc = v(value, signal);
}
}
return acc;
}
```
## API Reference
- `isAsyncResult(out: ValidatorResult | Promise<ValidatorResult>): out
is Promise<ValidatorResult>` — narrows the union when the value is a
`Promise`.
## Implementation Notes
- The `instanceof Promise` check is the only safe cross-realm
discriminator JavaScript gives us. Duck-typing on `.then` is unsafe
because plain objects with a `.then` member ("thenables") would also
match — that's how callers regularly leak coroutine-style state into
validators that the chain runner would mistake for async results.
- The helper is not part of the package's public surface; it is
internal-only and not re-exported from any barrel — the composers
(`compose`, `or`, `sequence`) are the only call sites and they all
go through `runChain` from `compose.ts`.
- The two-line predicate is intentionally tiny so it inlines into the
caller after the JIT warms up. There is no real-world performance
cost to wrapping every result through this guard, and the cost of
a wrong discrimination (an async result mistakenly handled as sync)
is a missed Promise resolution, which is far worse than the cost
of the function call.
- For micro-tasks where every cycle matters, callers writing tight
loops over many validators could short-circuit by checking
`isAsyncResult` once on the first result and assuming subsequent
results follow the same shape — but the chain runner deliberately
does NOT make this assumption because mixing sync + async in a
composition pipeline is the common case, not an edge case.

View File

@@ -0,0 +1,78 @@
# `bic.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string matches the
ISO 9362 Business Identifier Code (BIC, also known as SWIFT code)
format. The structure is fixed: 4-letter institution code +
2-letter ISO 3166-1 country code + 2-character location code +
optional 3-character branch code, totalling 8 or 11 alphanumeric
characters. There is no checksum.
The default failure message resolves the `TranslationKey`
`react_forms.validation.bic`.
## Features
- Whitespace-tolerant — `"DEUT DEFF"` and `"DEUTDEFF"` both pass.
- Case-insensitive — uppercased before matching.
- Length corridor 8/11 — values of any other length fail before regex
evaluation.
- Pure-JS regex — no runtime dependency.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { bic } from "@suaveplan/react-forms/validation/validators";
const validate = bic();
validate("DEUTDEFF"); // { valid: true }
validate("DEUTDEFF500"); // { valid: true } — branch-coded
validate("12345678"); // { valid: false } — first 4 chars must be letters
```
## Usage: Advanced Example
```ts
import { bic } from "@suaveplan/react-forms/validation/validators";
import { sequence } from "@suaveplan/react-forms/validation/compose";
import { required } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateBic = sequence(
required("payment.bic.required" as TranslationKey),
bic("payment.bic.invalid" as TranslationKey)
);
```
## API Reference
- `bic(messageKey?: TranslationKey): Validator` — factory.
## Implementation Notes
- The regex enforces the ISO 9362 partitioning explicitly so a
superficially-correct string like `"12345678"` fails (the institution
code MUST be alphabetic).
- The country-code segment accepts any 2-letter combination —
enforcing it against the ISO 3166-1 list would require shipping the
list (changes ~1×/year) inside the package; consumers needing strict
country validation should layer a separate check on the slice
`value.slice(4, 6)` against their preferred country registry.
- The location-code and branch-code segments accept alphanumerics per
the spec (`[A-Z0-9]`) — letters and digits are equally legal.
- Compared with the IBAN validator's mod-97 checksum, BIC has no
cryptographic-style integrity check; a malformed-but-syntax-correct
BIC will still pass and only fail at the receiving bank. Consumers
routing payments should layer a directory lookup on top of the BIC
validator for high-value transactions.
- Whitespace stripping is applied before length checking so a value
formatted with the spec-recommended 4/2/2/3 spacing (e.g.
`"DEUT DE FF 500"`) matches the same regex as the contiguous form.
- The validator is dependency-free: a flat regex plus length corridor
is the entire implementation, which keeps bundle size negligible
and makes the rule auditable from a single 30-line source file.

View File

@@ -0,0 +1,82 @@
# `credit-card.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string is a
syntactically valid credit-card number per the Luhn checksum algorithm
(ISO/IEC 7812-1). The validator strips whitespace and hyphens before
running Luhn so user-friendly formats like `4111-1111-1111-1111` and
`4111 1111 1111 1111` are accepted alongside the contiguous
`4111111111111111` form.
The default failure message resolves the `TranslationKey`
`react_forms.validation.credit_card`.
## Features
- ISO/IEC 7812-1 length corridor (12–19 digits) checked before the
checksum.
- Whitespace and hyphens stripped from the input.
- Pure-JS Luhn implementation — no runtime dependency, no PCI scope
expansion.
- The Luhn helper (`luhnValid(digits: string)`) is exported so callers
can use it standalone — e.g. for a related ID format that also uses
the algorithm (IMEI, ICCID, NPI for US healthcare).
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { creditCard } from "@suaveplan/react-forms/validation/validators";
const validate = creditCard();
validate("4111 1111 1111 1111"); // { valid: true } — Luhn-valid Visa test card
validate("4111-1111-1111-1112"); // { valid: false } — Luhn fails
```
## Usage: Advanced Example
```ts
import {
creditCard,
luhnValid,
} from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateCard = creditCard(
"checkout.card.invalid" as TranslationKey
);
// Reuse the helper for IMEI validation
const isValidImei = (imei: string) =>
/^\d{15}$/.test(imei) && luhnValid(imei);
```
## API Reference
- `creditCard(messageKey?: TranslationKey): Validator` — factory.
- Returns a validator that passes on Luhn-valid 12–19 digit strings,
fails otherwise.
- `luhnValid(digits: string): boolean` — pure Luhn helper. Accepts a
digits-only string and returns `true` when the checksum is satisfied.
Does NOT validate length or strip non-digits — call sites should
pre-process.
## Implementation Notes
- The Luhn implementation iterates from the rightmost digit, doubles
every second digit, and subtracts 9 when the doubled value exceeds
9. The character-code arithmetic (`charCode - 48`) avoids
`parseInt` allocation per digit and is roughly 4× faster on hot
paths.
- Brand-prefix rules (Visa starts with 4, MasterCard 51-55, etc.) are
intentionally NOT enforced because card networks add new brands
routinely and BIN ranges are owned by ISO/IEC 7812-1 issuers, not
the form library. Consumers wanting brand detection should layer a
separate library on top after Luhn passes.
- The validator does not log, store, or transmit the card number;
values stay on the caller's stack frame and are eligible for
garbage collection immediately after the validator returns.

View File

@@ -0,0 +1,92 @@
# `custom.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Adapter that wraps a caller-supplied boolean predicate into a
`Validator` (or `AsyncValidator`) returning the canonical
`{ valid, messageKey }` discriminated union. The caller writes a
plain predicate that returns `true` for "valid" and `false` for
"invalid"; `custom` translates that into the validator-suite return
shape so the predicate composes uniformly with every other validator
in the package.
The wrapper detects synchronous-vs-asynchronous at call time: if the
predicate returns a `Promise`, the wrapper returns a `Promise<ValidatorResult>`;
otherwise it returns the result synchronously. The TypeScript overloads
declare the two return shapes so callers get the right type back.
## Features
- Two overloads — pure-sync (`(value) => boolean`) → `Validator`, and
the union form (`(value) => boolean | Promise<boolean>`) →
`AsyncValidator`.
- Generic over the value type `T` so the predicate body has its
argument fully typed.
- `messageKey` is required and typed as `TranslationKey` — there is no
default key since custom predicates are opaque to the package.
## Usage: Basic Example
```ts
import { custom } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const isEven = custom<number>(
(n) => n % 2 === 0,
"form.must_be_even" as TranslationKey
);
isEven(4); // → { valid: true }
isEven(7); // → { valid: false, messageKey: "form.must_be_even" }
```
## Usage: Advanced Example
```ts
import { custom } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
// Async predicate against a shared cache
const cache = new Map<string, boolean>();
const inAllowlist = custom<string>(
async (handle) => {
const cached = cache.get(handle);
if (cached !== undefined) return cached;
const fresh = await fetch(`/api/allowlist/${handle}`).then((r) =>
r.ok
);
cache.set(handle, fresh);
return fresh;
},
"auth.handle.not_allowed" as TranslationKey
);
await inAllowlist("alice"); // → { valid: true }
```
## API Reference
- `custom<T>(predicate: (value: T) => boolean, messageKey:
TranslationKey): Validator<T>` — synchronous overload.
- `custom<T>(predicate: (value: T) => boolean | Promise<boolean>,
messageKey: TranslationKey): AsyncValidator<T>` — async overload.
- `type CustomPredicate<T> = (value: T) => boolean | Promise<boolean>`.
## Implementation Notes
- The function-body `if (out instanceof Promise)` discriminator is the
same shape every "sometimes-sync" wrapper in JS uses (see RxJS,
effect-ts). It avoids forcing every caller into `async`-await even
for cheap predicates.
- The `as Validator<T> | AsyncValidator<T>` cast in the implementation
body is a deliberate widening — the public overloads pin the
precise return type per call site, but the implementation must
satisfy both signatures simultaneously.
- `messageKey` has no default because the predicate's failure
semantics are opaque to the package — there is no useful generic key
the package could supply that the consumer's catalog would also
carry.

View File

@@ -0,0 +1,78 @@
# `email.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value is a
syntactically valid email address per the practical RFC 5322 subset
documented in the source. The pattern accepts the 99.9% of email
addresses real users actually type — local parts of unreserved
characters separated by dots, a domain of dot-separated alphanumeric
labels, and a TLD of at least two ASCII letters — while rejecting
exotic forms (quoted local parts, bracket-IP literals,
internationalised domain names in their punycoded form, comments) that
no real-world signup form should accept.
The default failure message resolves the `TranslationKey`
`react_forms.validation.email`.
## Features
- Length-bounded — rejects inputs above the 254-byte RFC-defined limit
before regex evaluation, both for correctness and for ReDoS
resistance.
- Local-part length capped at 64 chars per the RFC.
- Domain labels enforced to start and end with an alphanumeric and to
be ≤ 63 chars each.
- TLD enforced to ≥ 2 ASCII letters so common typos like `foo@bar.x`
fail.
- Pure factory — the returned validator carries no state.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { email } from "@suaveplan/react-forms/validation/validators";
const validate = email();
validate("alice@example.com"); // { valid: true }
validate("not-an-email"); // { valid: false, messageKey: "react_forms.validation.email" }
```
## Usage: Advanced Example
```ts
import { email } from "@suaveplan/react-forms/validation/validators";
import { sequence } from "@suaveplan/react-forms/validation/compose";
import { required } from "@suaveplan/react-forms/validation/validators";
const validateEmail = sequence(
required("auth.email.required" as TranslationKey),
email("auth.email.invalid" as TranslationKey)
);
validateEmail(""); // → { valid: false, messageKey: "auth.email.required" }
validateEmail("nope"); // → { valid: false, messageKey: "auth.email.invalid" }
validateEmail("alice@example.com"); // → { valid: true }
```
## API Reference
- `email(messageKey?: TranslationKey): Validator` — factory. Returns a
validator that passes on valid email syntax, fails otherwise.
## Implementation Notes
- The pattern is split into three string fragments (`EMAIL_LOCAL`,
`EMAIL_LABEL`, `EMAIL_DOMAIN`) and assembled with `new RegExp` so the
RFC-derived structure is auditable from the source diff.
- ReDoS exposure is bounded by the 254-byte length cap and the
non-backtracking structure of the alternation (no nested quantifiers
on overlapping character classes).
- Non-string inputs ALWAYS fail — pair with `required` if you need the
presence check to come first with its own message.
- Internationalised email addresses (IDN domains) must be punycoded
before validation. The factory does NOT perform automatic conversion
to keep the validator deterministic and dependency-free.

View File

@@ -0,0 +1,84 @@
# `iban.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string is a
syntactically valid International Bank Account Number per ISO 13616.
Implements the full mod-97 checksum: country code + check digits move
to the end, every letter expands to its two-digit numeric equivalent
(A=10..Z=35), and the resulting integer mod 97 must equal 1.
The default failure message resolves the `TranslationKey`
`react_forms.validation.iban`.
## Features
- Whitespace-tolerant — `"GB29 NWBK 6016 1331 9268 19"` and
`"GB29NWBK60161331926819"` both pass.
- Case-insensitive — uppercased before mod-97.
- Length corridor 15–34 (the per-country IBAN length range; we don't
enforce per-country length since the country registry changes too
often to bake in).
- Streaming mod-97 implementation that avoids BigInt allocation:
process 9 digits at a time so the running integer stays under
`Number.MAX_SAFE_INTEGER`.
- Helpers `expandLettersToDigits` and `mod97` exported so callers can
reuse the primitives in related ID validators (e.g. national
fiscal-ID checksums that share the mod-97 structure).
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { iban } from "@suaveplan/react-forms/validation/validators";
const validate = iban();
validate("GB29 NWBK 6016 1331 9268 19"); // { valid: true }
validate("GB29 NWBK 6016 1331 9268 18"); // { valid: false } — checksum fails
```
## Usage: Advanced Example
```ts
import {
iban,
mod97,
expandLettersToDigits,
} from "@suaveplan/react-forms/validation/validators";
const validateIban = iban("payment.iban.invalid" as TranslationKey);
// Reuse the helpers for related checksums
function isValidIsin(isin: string) {
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return false;
const digits = expandLettersToDigits(isin);
return mod97(digits) === 1;
}
```
## API Reference
- `iban(messageKey?: TranslationKey): Validator` — factory.
- `expandLettersToDigits(input: string): string` — replace each letter
with its two-digit numeric equivalent (A=10..Z=35) and pass digits
through unchanged.
- `mod97(digits: string): number` — streaming mod-97 over a digit
string.
## Implementation Notes
- The streaming mod-97 algorithm is the standard ISO/IEC 7064 trick
for modular reduction over arbitrary-length integers without BigInt.
Concatenating the running remainder with the next 9-digit chunk and
parsing the combined integer keeps the value under 2^53 at every
step.
- Per-country length tables (BBAN length per ISO 13616) are
intentionally omitted because the SWIFT registry adds and changes
countries multiple times per year. Callers requiring strict per-
country length checks can layer `pattern` on top.
- The validator does not log, store, or transmit the IBAN; values
stay on the caller's stack frame and are eligible for garbage
collection immediately after the validator returns.

View File

@@ -0,0 +1,70 @@
# `max-length.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value's length is
less than or equal to `n`. Operates on strings (UTF-16 code-unit
length, same metric as `String.prototype.length`) and arrays. Other
shapes — `null`, `undefined`, plain objects, numbers — pass because
length is meaningless for them; pair this validator with `required` to
enforce presence.
The default failure message resolves the `TranslationKey`
`react_forms.validation.max_length` and carries
`messageArgs: { max, actual }` so consumer catalogs can interpolate the
limits into a localised error string.
## Features
- Accepts `string` and `Array<unknown>`; passes silently for shapes
with no measurable length so `maxLength` is composable with
type-permissive Zod schemas.
- Throws `RangeError` from the factory when `n` is not a non-negative
integer — invalid configuration is loud, not silent.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { maxLength } from "@suaveplan/react-forms/validation/validators";
const validate = maxLength(10);
validate("short"); // { valid: true }
validate("this is too long"); // { valid: false, messageKey, messageArgs: { max: 10, actual: 16 } }
```
## Usage: Advanced Example
```ts
import { maxLength } from "@suaveplan/react-forms/validation/validators";
import { compose } from "@suaveplan/react-forms/validation/compose";
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validateBio = compose(
minLength(20, "bio.too_short" as TranslationKey),
maxLength(280, "bio.too_long" as TranslationKey)
);
```
## API Reference
- `maxLength(n: number, messageKey?: TranslationKey): Validator` —
factory.
- Throws `RangeError` if `n` is not a non-negative integer.
- Returns a validator that yields `{ valid: true }` on pass and
`{ valid: false, messageKey, messageArgs: { max, actual } }` on
failure.
## Implementation Notes
- The factory eagerly validates `n` so misconfiguration surfaces at
module evaluation rather than on first form interaction.
- Unsupported shapes pass instead of failing (the inverse of
`minLength`'s policy) because a max-length constraint on a value
without a length is semantically vacuous — `[1,2,3]` violates "max
length 2" but `42` does not.
- For grapheme-aware counting, callers compose
`custom((v) => Array.from(new Intl.Segmenter().segment(v)).length <= n)`.

View File

@@ -0,0 +1,70 @@
# `min-length.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value's length is
greater than or equal to `n`. Operates on strings (UTF-16 code-unit
length, same as `String.prototype.length`) and arrays. Other shapes —
`null`, `undefined`, plain objects, `Map`, `Set`, numbers — fail
because length has no meaning for them; pair this validator with
`required` when nullable inputs are legal.
The default failure message resolves the `TranslationKey`
`react_forms.validation.min_length` and carries
`messageArgs: { min, actual }` so consumer catalogs can interpolate the
limits into a localised error string.
## Features
- Accepts `string` and `Array<unknown>` inputs; rejects every other
shape with a deterministic failure that still carries the requested
`min` so the UI can render a consistent message.
- Throws `RangeError` from the factory when `n` is not a non-negative
integer — invalid configuration is loud, not silent.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validate = minLength(8);
validate("password"); // { valid: true }
validate("short"); // { valid: false, messageKey, messageArgs: { min: 8, actual: 5 } }
```
## Usage: Advanced Example
```ts
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validateTags = minLength(2, "tags.too_few" as TranslationKey);
validateTags(["one"]);
// → {
// valid: false,
// messageKey: "tags.too_few",
// messageArgs: { min: 2, actual: 1 },
// }
```
## API Reference
- `minLength(n: number, messageKey?: TranslationKey): Validator` —
factory.
- Throws `RangeError` if `n` is not a non-negative integer.
- Returns a validator that yields `{ valid: true }` on pass and
`{ valid: false, messageKey, messageArgs: { min, actual } }` on
failure.
## Implementation Notes
- The factory eagerly validates `n` so misconfiguration surfaces at
module evaluation rather than on first form interaction.
- `lengthOf` returns `undefined` for unsupported shapes, which the
caller maps to `actual: 0`. This preserves a stable failure shape
while signalling that the input had no measurable length.
- For grapheme-aware counting, callers compose
`custom((v) => Array.from(new Intl.Segmenter().segment(v)).length >= n)`.

View File

@@ -0,0 +1,94 @@
# `password-strength.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied password scores at
or above a configured threshold on the zxcvbn 0–4 scale, as computed
by `@zxcvbn-ts/core`. zxcvbn is the de-facto standard for password
strength estimation — it accounts for keyboard patterns, dictionary
matches, leet-speak substitutions, l33t-shifts, repeated characters,
and date-pattern guesses, producing a score that maps directly to
"how many guesses to crack offline".
The default failure message resolves the `TranslationKey`
`react_forms.validation.password_strength` and carries `messageArgs:
{ score, minScore }` so the consumer's catalog can render strength-
specific copy ("too short", "too common", "almost there", ...).
## Features
- Forwards to `@zxcvbn-ts/core`'s `zxcvbn` function — full strength
estimation with no extra dependencies.
- Configurable `minScore` (`0|1|2|3|4`); default `3` (safely
unguessable, the modern consumer-app baseline).
- Failure exposes both `score` and `minScore` so dashboard / UI copy
can show a strength bar without re-running the estimator.
- Optional `messageKey` typed as `TranslationKey`.
- Dictionary-free at the package level — consumer apps register
English/language wordlists at app boot via
`zxcvbnOptions.setOptions(...)`. This keeps the package lean and
avoids forcing every consumer to ship the same ~50 KB of wordlists.
## Usage: Basic Example
```ts
import { passwordStrength } from "@suaveplan/react-forms/validation/validators";
const validate = passwordStrength();
validate("password"); // { valid: false, score: 0, minScore: 3 }
validate("9!ApricotZebra-2024"); // { valid: true }
```
## Usage: Advanced Example
```ts
// In your app's bootstrap module — register dictionaries once.
import { zxcvbnOptions } from "@zxcvbn-ts/core";
import { dictionary, adjacencyGraphs } from "@zxcvbn-ts/language-en";
zxcvbnOptions.setOptions({
dictionary,
graphs: adjacencyGraphs,
translations: zxcvbnOptions.translations,
});
// In your form definition — use the validator with a strict threshold.
import { passwordStrength } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validate = passwordStrength({
minScore: 4,
messageKey: "auth.password.weak" as TranslationKey,
});
```
## API Reference
- `passwordStrength(opts?: PasswordStrengthOptions): Validator` —
factory.
- `opts.minScore?: 0|1|2|3|4` — minimum acceptable score. Default 3.
- `opts.messageKey?: TranslationKey` — message key on failure.
- `interface PasswordStrengthOptions { minScore?: PasswordStrengthScore;
messageKey?: TranslationKey }`.
- `type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4` — the zxcvbn score
scale. Re-exported so consumers building strength-bar UIs can pin
their own discriminated unions.
## Implementation Notes
- The validator never calls `zxcvbnOptions.setOptions` — that is the
consumer's responsibility at app bootstrap so dictionaries (which are
large) are registered once globally.
- Empty strings always fail with `score: 0` before invoking the
estimator. Non-string inputs do the same.
- `@zxcvbn-ts/core` is intentionally a `dependencies` rather than a
`peerDependencies` entry: forms cannot enforce the consumer registers
language packs, but the core estimator is required for the default
validator to work at all (it returns score 0 with no dictionary,
which still rejects most weak passwords).
- For asynchronous strength estimation (e.g. against a server-side
HIBP-style breach corpus), wrap `zxcvbnAsync` from `@zxcvbn-ts/core`
in a `custom` async validator.

View File

@@ -0,0 +1,71 @@
# `pattern.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string matches a
caller-supplied `RegExp`. The factory clones the regex without `g`/`y`
flags so `RegExp.prototype.test` cannot leak `lastIndex` state between
invocations — a footgun that breaks roughly half of all user-written
form validators when the same validator is called twice.
The default failure message resolves the `TranslationKey`
`react_forms.validation.pattern`.
## Features
- Strips `g` and `y` flags so `test()` is stateless across calls.
- Throws `TypeError` from the factory when the supplied argument is
not a `RegExp` — invalid configuration is loud, not silent.
- Optional `messageKey` typed as `TranslationKey`.
- Non-string inputs always fail; pair with `required` for presence.
## Usage: Basic Example
```ts
import { pattern } from "@suaveplan/react-forms/validation/validators";
const validateSlug = pattern(/^[a-z0-9-]+$/);
validateSlug("hello-world"); // { valid: true }
validateSlug("Hello World"); // { valid: false, messageKey: "react_forms.validation.pattern" }
```
## Usage: Advanced Example
```ts
import { pattern } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateZip = pattern(
/^\d{5}(?:-\d{4})?$/,
"address.zip.invalid" as TranslationKey
);
validateZip("90210"); // → { valid: true }
validateZip("90210-1234"); // → { valid: true }
validateZip("9021"); // → { valid: false, messageKey: "address.zip.invalid" }
```
## API Reference
- `pattern(re: RegExp, messageKey?: TranslationKey): Validator` —
factory.
- Throws `TypeError` if `re` is not a `RegExp`.
- Returns a validator that yields `{ valid: true }` on match,
`{ valid: false, messageKey }` otherwise.
## Implementation Notes
- The flag-stripping is mandatory; without it,
`pattern(/abc/g)("abc")` would return true on first call and false
on second because `lastIndex` advances through `abc` and never
resets.
- The factory clones the regex via `new RegExp(re.source, safeFlags)`
to insulate the validator from any subsequent mutation of the
original instance (callers occasionally hand a regex out of a shared
config object that other code later edits).
- For more complex constraints (cross-field references, async checks),
combine `pattern` with `compose` / `sequence` from
`@suaveplan/react-forms/validation/compose`.

View File

@@ -0,0 +1,76 @@
# `phone.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string parses as a
valid telephone number per Google's `libphonenumber-js`. Covers every
ITU-T E.164 country code and every per-country numbering plan the
upstream metadata corpus carries — the same corpus Android, the iOS
Phone app, and Google Voice rely on for validation.
The default failure message resolves the `TranslationKey`
`react_forms.validation.phone`.
## Features
- Two acceptance modes: international (E.164 with leading `+`) and
national (when a `region` is supplied).
- Region argument typed as ISO 3166-1 alpha-2 (`"GB"`, `"US"`,
`"FR"`, ...).
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { phone } from "@suaveplan/react-forms/validation/validators";
const validateGB = phone({ region: "GB" });
validateGB("020 7946 0958"); // { valid: true }
validateGB("not a phone"); // { valid: false }
const validateIntl = phone();
validateIntl("+44 20 7946 0958"); // { valid: true }
validateIntl("020 7946 0958"); // { valid: false } — needs leading +
```
## Usage: Advanced Example
```ts
import { phone } from "@suaveplan/react-forms/validation/validators";
import type { TranslationKey } from "@suaveplan/react-i18n";
const validateUsPhone = phone({
region: "US",
messageKey: "checkout.phone.invalid" as TranslationKey,
});
validateUsPhone("(415) 555-2671"); // → { valid: true }
validateUsPhone("nope"); // → { valid: false, messageKey: "checkout.phone.invalid" }
```
## API Reference
- `phone(opts?: PhoneValidatorOptions): Validator` — factory.
- `opts.region?: string` — ISO 3166-1 alpha-2 country code; treated
as the default country when the input is in national format.
- `opts.messageKey?: TranslationKey` — message key on failure.
- `interface PhoneValidatorOptions { region?: string; messageKey?:
TranslationKey }`.
## Implementation Notes
- The validator is intentionally permissive on output format —
libphonenumber-js accepts spaces, hyphens, dots, and parentheses as
separators. Callers wanting a strict format requirement should layer
`pattern` on top via `compose`.
- The `libphonenumber-js` package ships ~120 KB of metadata even after
tree-shaking. Consumers concerned about bundle size can swap to
`libphonenumber-js/min` (the lighter metadata bundle) at the
application level via Vite/Webpack alias — the validator's API
surface is unchanged.
- Empty strings always fail before the underlying parser is invoked.
This is a small optimisation but matters for forms that re-validate
on every keystroke.

View File

@@ -0,0 +1,88 @@
# `postcode.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied string matches the
configured country's postcode format. v1 ships curated regexes for the
five most common deployment countries — `GB`, `US`, `CA`, `DE`, `FR`.
Additional countries are added per consumer request: the alternative
(generating from a third-party data source) would introduce a hard
runtime dependency without meaningfully improving coverage for the core
five.
The default failure message resolves the `TranslationKey`
`react_forms.validation.postcode`.
## Features
- Per-country regex registry — explicit, auditable, no runtime data
source.
- Throws `RangeError` from the factory when `country` is not in the
supported set so misconfiguration surfaces at module evaluation
rather than at form-submit time.
- Trims whitespace before matching so `" SW1A 1AA "` validates the
same as `"SW1A 1AA"`.
- Exposes `SUPPORTED_POSTCODE_COUNTRIES` so consumer apps can render
a country-aware postcode field UI without re-keeping the registry
in sync.
- Optional `messageKey` typed as `TranslationKey`.
## Usage: Basic Example
```ts
import { postcode } from "@suaveplan/react-forms/validation/validators";
const validateGB = postcode({ country: "GB" });
validateGB("SW1A 1AA"); // { valid: true }
validateGB("12345"); // { valid: false }
const validateUS = postcode({ country: "US" });
validateUS("90210"); // { valid: true }
validateUS("90210-1234"); // { valid: true }
```
## Usage: Advanced Example
```ts
import {
postcode,
SUPPORTED_POSTCODE_COUNTRIES,
} from "@suaveplan/react-forms/validation/validators";
function makeValidator(country: string) {
if (!SUPPORTED_POSTCODE_COUNTRIES.includes(country)) {
return (() => ({ valid: true })) as Validator;
}
return postcode({
country,
messageKey: "address.postcode.invalid" as TranslationKey,
});
}
```
## API Reference
- `postcode(opts: PostcodeValidatorOptions): Validator` — factory.
- `opts.country: string` — ISO 3166-1 alpha-2 country code; case-
insensitive.
- `opts.messageKey?: TranslationKey` — message key on failure.
- Throws `RangeError` when the country is not in the supported set.
- `interface PostcodeValidatorOptions { country: string; messageKey?:
TranslationKey }`.
- `SUPPORTED_POSTCODE_COUNTRIES: readonly string[]` — the supported
country codes.
## Implementation Notes
- The factory uppercases the country code internally so callers can
pass either case.
- The five country regexes are commonly used in upstream libraries
(`postal-codes-js`, `i18n-postal-address`); we ship them inline so
the package stays leaf-clean and so the regexes are auditable from
the diff.
- For new country requests, file an OpenSpec change adding the regex +
unit tests against the country's official format reference (Royal
Mail PAF, USPS Pub 28, Canada Post Specs Manual, etc.).

View File

@@ -0,0 +1,81 @@
# `required.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous presence validator for form fields. Returns success when the
supplied value carries semantic content, failure otherwise. Unlike the
typical "is it truthy?" check, this validator treats whitespace-only
strings, empty arrays, and empty `Map` / `Set` instances as absent so a
field full of spaces or an empty tag input fails just as a blank field
would.
The default failure message resolves the `TranslationKey`
`react_forms.validation.required`. Callers may override the key per
field — a registration form might want
`auth.email.required` rather than the generic key — so the same
validator factory can render any message the consumer's catalog
declares.
## Features
- Presence semantics that catch real-world "blank" inputs: `null`,
`undefined`, `""`, `" "`, `[]`, empty `Map`, empty `Set`.
- Falsy primitives (`false`, `0`, `0n`) count as PRESENT so checkbox
and numeric-zero inputs are not silently rejected.
- Optional `messageKey` argument typed as `TranslationKey` so callers
cannot pass a raw literal that would slip past the translation
pipeline.
- Pure factory — the validator returned has no closure over time, RNG,
or network and is safe to call from any pipeline step.
## Usage: Basic Example
```ts
import { required } from "@suaveplan/react-forms/validation/validators";
const validateName = required();
validateName("Jane"); // { valid: true }
validateName(" "); // { valid: false, messageKey: "react_forms.validation.required" }
```
## Usage: Advanced Example
```ts
import { required } from "@suaveplan/react-forms/validation/validators";
import { sequence } from "@suaveplan/react-forms/validation/compose";
import { minLength } from "@suaveplan/react-forms/validation/validators";
const validateUsername = sequence(
required("auth.username.required"),
minLength(3, "auth.username.too_short")
);
validateUsername(""); // → { valid: false, messageKey: "auth.username.required" }
validateUsername("ab"); // → { valid: false, messageKey: "auth.username.too_short" }
validateUsername("alice"); // → { valid: true }
```
## API Reference
- `required(messageKey?: TranslationKey): Validator` — returns a
`Validator<unknown>` that returns `{ valid: true }` when the value is
present, `{ valid: false, messageKey }` otherwise. The default key is
`react_forms.validation.required`.
## Implementation Notes
- The presence check is intentionally narrower than `Boolean(value)` so
legitimate falsy primitives are not counted as missing. This matches
the behaviour every mature form library converges on (Yup, Zod,
react-hook-form's `validate.required`).
- Whitespace-only strings are stripped via `value.trim().length > 0`.
Locale-aware whitespace (e.g. CJK ideographic space U+3000) is
included via JavaScript's spec-compliant `String.prototype.trim`.
- `Array.isArray` is used in preference to `instanceof Array` to handle
cross-realm arrays, which appear when forms are rendered inside
iframes.
- `Map` and `Set` checks come AFTER the array check so `Array.isArray`
short-circuits on the common case.

View File

@@ -0,0 +1,75 @@
# `url.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Synchronous validator that passes when the supplied value parses
successfully via the WHATWG `URL` constructor and (optionally) matches
a configured protocol allowlist. Using the native parser is both more
correct than any hand-rolled regex (it handles IDN, percent-encoding,
authority components, port ranges, and edge-case input gracefully) and
ReDoS-safe by construction.
The default failure message resolves the `TranslationKey`
`react_forms.validation.url`.
## Features
- WHATWG `URL`-backed parsing — every spec edge case handled by the
runtime.
- Optional `protocols` allowlist (e.g. `["http", "https"]`) so http(s)
inputs can be required and `javascript:` URIs rejected.
- Optional `messageKey` typed as `TranslationKey`.
- Backwards-compatible single-argument form: `url("custom.key")` is
equivalent to `url({ messageKey: "custom.key" })` so the validator
composes uniformly with the other one-arg validators.
## Usage: Basic Example
```ts
import { url } from "@suaveplan/react-forms/validation/validators";
const validate = url();
validate("https://example.com"); // { valid: true }
validate("not a url"); // { valid: false, messageKey: "react_forms.validation.url" }
```
## Usage: Advanced Example
```ts
import { url } from "@suaveplan/react-forms/validation/validators";
const validateWebUrl = url({
protocols: ["http", "https"],
messageKey: "profile.website.invalid" as TranslationKey,
});
validateWebUrl("ftp://example.com"); // → { valid: false, messageKey: "profile.website.invalid" }
validateWebUrl("javascript:alert(1)"); // → { valid: false, messageKey: "profile.website.invalid" }
validateWebUrl("https://example.com/about"); // → { valid: true }
```
## API Reference
- `url(opts?: UrlValidatorOptions | TranslationKey): Validator` —
factory.
- `opts.protocols?: readonly string[]` — protocols (without the
trailing colon) accepted. When omitted, every protocol the runtime
accepts is allowed.
- `opts.messageKey?: TranslationKey` — message key on failure.
- `interface UrlValidatorOptions { protocols?: readonly string[];
messageKey?: TranslationKey }`.
## Implementation Notes
- The constructor's `try/catch` is the cheapest deterministic test for
validity. The catch suppresses the `TypeError` thrown by `URL` on
malformed input and converts it to a structured failure.
- Protocol matching is case-insensitive because `URL.protocol` is
always returned lowercase per the WHATWG URL spec.
- The string-shorthand overload exists so callers writing
`url("auth.url.invalid")` get the same surface as the other
validators (`required`, `email`, `pattern`, ...). The function body
unwraps the shorthand into the canonical options bag.

21
docs/ui/react/README.md Normal file
View File

@@ -0,0 +1,21 @@
# `react`
React hooks and components for audio, auth, and UI surfaces.
<!-- begin:auto-docs -->
## Packages in `react`
9 packages.
| Package | Description |
|---|---|
| [`@suaveplan/react-audio`](./react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
| [`@suaveplan/react-audio-fingerprint`](./react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
| [`@suaveplan/react-audio-player`](./react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
| [`@suaveplan/react-audio-stream`](./react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
| [`@suaveplan/react-auth`](./react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
| [`@suaveplan/react-auth-whitelabel`](./react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
| [`@suaveplan/react-control-surface`](./react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
| [`@suaveplan/react-midi`](./react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
| [`@suaveplan/react-ui`](./react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
<!-- end:auto-docs -->

1
docs/ui/react/_intro.md Normal file
View File

@@ -0,0 +1 @@
React hooks and components for audio, auth, and UI surfaces.

View File

@@ -0,0 +1,91 @@
# @suaveplan/react-audio-fingerprint - React 19 surface for client-side audio fingerprinting (Shazam-style constellation algorithm) and identity matching. Consumes FFT/spectrogram primitives from @suaveplan/audio-analysis-spectrum so the audio family has a single FFT implementation. Universal fingerprint + matcher cores; React hook + accessible component on top.
React 19 surface for client-side audio fingerprinting and identity matching.
The package implements the classic Shazam-style constellation algorithm
(2D-peak detection + pair hashing + time-offset histogram matching) on top of
the FFT/spectrogram primitives from
[`@suaveplan/audio-analysis-spectrum`](../../../media/audio/audio-analysis-spectrum)
— there is exactly **one** FFT implementation across the audio family.
<!-- begin:auto-docs -->
## Documentation tree
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
- [local-backend](./src/backends/local-backend/local-backend.md) — `LocalBackend` is the default implementation of the `FingerprintBackend` contract that hooks and components depend on.…
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
- [fingerprint-match-display](./src/components/fingerprint-match-display/fingerprint-match-display.md) — `<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match` as an accessible ranked list. Each row sho…
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
- [memory-corpus](./src/corpus/memory-corpus/memory-corpus.md) — `MemoryCorpus` is the default `Corpus` implementation: a `Map<string, Fingerprint>` wrapped with a `Promise`-returning…
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
- [error-codes](./src/error-codes/error-codes.md) — This module declares every error code that `@suaveplan/react-audio-fingerprint` can throw. Each code is registered thro…
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
- [audio-fingerprinter](./src/fingerprint/audio-fingerprinter/audio-fingerprinter.md) — `AudioFingerprinter` is the entry point that turns an `AudioDocument` into a deterministic constellation fingerprint. I…
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
- [constellation](./src/fingerprint/constellation/constellation.md) — The constellation module converts a list of spectrogram peaks into a stable, hashable fingerprint by pairing each ancho…
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
- [fingerprint-matcher](./src/fingerprint/fingerprint-matcher/fingerprint-matcher.md) — `FingerprintMatcher` scores a query fingerprint against a corpus of candidate fingerprints using the classic Shazam tim…
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
- [peak-detector](./src/fingerprint/peak-detector/peak-detector.md) — The peak detector consumes a spectrogram (a sequence of FFT magnitude frames produced by `@suaveplan/audio-analysis-spe…
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
- [use-audio-fingerprint](./src/hooks/use-audio-fingerprint/use-audio-fingerprint.md) — `useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a React-managed `AudioDocument` and returns t…
- [test-setup](./src/test-setup.md) — This file is loaded by `vitest.config.ts` via `setupFiles` so every test in the package runs against a consistent jest-…
<!-- end:auto-docs -->
## Status
bootstrap landing — initial slice focused on:
- Six error codes via `defineCodes("REACT_AUDIO_FINGERPRINT", …)`.
- `peak-detector`, `constellation`, `audio-fingerprinter`.
- `fingerprint-matcher` (time-offset histogramming).
- `memory-corpus` (`Map`-backed, async-shaped, subscribable).
- `local-backend` (default `FingerprintBackend` running in-process).
- `useAudioFingerprint` hook (driver with abort + progress).
- `<FingerprintMatchDisplay>` component + stories.
Deferred to follow-up: `IndexedDbCorpus`, `<AudioFingerprintUploader>`,
`<FingerprintVisualizer>`, `python-backend`, `__benchmarks__`, integration +
purity + property + bundle-size tests.
## Quick Start
```tsx
import { decode } from "@suaveplan/audio-engine-browser/decode";
import {
LocalBackend,
MemoryCorpus,
FingerprintMatchDisplay,
useAudioFingerprint,
} from "@suaveplan/react-audio-fingerprint";
function MatchSurface({ file }: { file: File }) {
const [doc, setDoc] = useState<AudioDocument | undefined>(undefined);
useEffect(() => {
decode(file).then(setDoc);
}, [file]);
const { state, fingerprint } = useAudioFingerprint(doc);
return state === "done"
? <FingerprintMatchDisplay results={[]} />
: <progress />;
}
```
## Algorithm
`AudioFingerprinter` composes three deterministic stages:
1. **Spectrogram** (delegated to `@suaveplan/audio-analysis-spectrum`).
2. **2D peak detection** (`peak-detector`) — local-max sweep above the
`noiseFloor`, capped at `peaksPerFrame` per frame.
3. **Constellation pair hashing** (`constellation`) — 32-bit packed
`binA[12] | binB[12] | dt[8]` over each anchor's `fanout` next peaks
inside a `targetZoneSec` window.
Matching is identical to the original Wang (2003) paper: build a 10 ms-wide
histogram of `(candidateOffset − queryOffset)` per shared hash, return the
heaviest bin's count as the score and its midpoint as the alignment offset.
## License
MIT — see `LICENSE` at the repo root.

View File

@@ -0,0 +1,66 @@
# `local-backend` — Default in-process `FingerprintBackend`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LocalBackend` is the default implementation of the `FingerprintBackend`
contract that hooks and components depend on. It composes `AudioFingerprinter`
and `FingerprintMatcher` so callers get a single facade with the same shape
the optional `PythonBackend` exposes from its gated subpath. The class owns
no state apart from its two collaborator instances.
## Features
- Single facade for `fingerprint(doc, opts?)` and `match(query, corpus)`.
- `AbortSignal` and progress passthrough to the underlying fingerprinter.
- Pure in-process: no I/O, no fetch, no server dependency.
- Matches the `FingerprintBackend` interface byte-for-byte so it can be
swapped at runtime via a hook prop.
- Constructor takes no arguments — defaults rule.
## Usage: Basic Example
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const fp = await backend.fingerprint(doc);
```
## Usage: Advanced Example
Inject the backend into the hook explicitly to keep the default exporting tree
shaken when a custom backend is in use:
```ts
import { LocalBackend } from "@suaveplan/react-audio-fingerprint/backends/local-backend";
const backend = new LocalBackend();
const controller = new AbortController();
const fp = await backend.fingerprint(doc, {
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
const results = await backend.match(fp, corpus);
```
## API Reference
- `class LocalBackend implements FingerprintBackend`.
- `fingerprint(doc, opts?): Promise<Fingerprint>`.
- `match(query, corpus): Promise<MatchResult[]>`.
## Implementation Notes
The class deliberately avoids exposing the underlying `AudioFingerprinter` and
`FingerprintMatcher` instances so consumers cannot mutate them after
construction — this preserves determinism and keeps the public surface stable
across versions. There is no caching layer here; consumers wishing to cache
fingerprint results between renders should do so at the hook layer, where the
relationship to the React commit phase is explicit. Errors from the
underlying components propagate unchanged; the backend deliberately does NOT
re-wrap them so the original `ReactAudioFingerprintError` code surfaces to the
caller.

View File

@@ -0,0 +1,67 @@
# `FingerprintMatchDisplay` — Ranked results list
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<FingerprintMatchDisplay>` renders the output of `FingerprintMatcher.match`
as an accessible ranked list. Each row shows the result identifier, a
confidence bar whose width is proportional to the top score, and the recovered
time offset in seconds. The component is intentionally lightweight — it has
no fetching logic, no internal state, and no animation — so consumers can
compose it inside any layout without conflicts.
## Features
- Empty-state message via `<p role="status">`.
- Confidence bar with proper `role="progressbar"` and `aria-valuemin/max/now`.
- Optional `onSelect` callback renders the row as a `<button>` for keyboard
activation.
- Optional `renderTitle` to override the default `id` label.
- Accessible `aria-label` on the wrapping list (`"Match results"` default).
- Zero side-effects; pure rendering function.
## Usage: Basic Example
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay results={ranked} />;
```
## Usage: Advanced Example
Map IDs to friendlier titles and listen for selection:
```tsx
import { FingerprintMatchDisplay } from "@suaveplan/react-audio-fingerprint/components/fingerprint-match-display";
<FingerprintMatchDisplay
results={ranked}
label="Top matches in your library"
onSelect={(r) => navigateToTrack(r.id)}
renderTitle={(r) => library.titles[r.id] ?? r.id}
/>;
```
## API Reference
- `FingerprintMatchDisplayProps = { results, label?, onSelect?, renderTitle? }`.
- `results: ReadonlyArray<MatchResult>`.
- `label?: string` (default `"Match results"`).
- `onSelect?: (result: MatchResult) => void`.
- `renderTitle?: (result: MatchResult) => string`.
## Implementation Notes
The component never mutates `props.results`. The confidence bar's width
percentage is computed against the top score (`results[0].score`) so the best
match always reads 100% and the rest scale proportionally — this provides an
intuitive visual ranking regardless of the absolute hash counts. When
`onSelect` is supplied each row renders as a `<button type="button">` so the
default keyboard behaviour (Enter / Space) just works; without `onSelect` the
rows are plain `<li>` to avoid spurious focus stops. The empty state uses
`role="status"` (rather than rendering nothing) so assistive tech announces
"no matches" rather than silently leaving the user in the dark.

View File

@@ -0,0 +1,72 @@
# `memory-corpus` — In-memory `Map`-backed fingerprint corpus
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryCorpus` is the default `Corpus` implementation: a `Map<string,
Fingerprint>` wrapped with a `Promise`-returning surface and a subscription
hook. It satisfies the matcher's `CorpusLike` contract and the React hook's
`subscribe` requirement, making it the simplest store you can plug into the
fingerprint pipeline. The asynchronous API matches the `IndexedDbCorpus`
follow-up so caller code does not need to branch on backing store.
## Features
- `add`, `remove`, `get`, `size`, `values`, `subscribe`.
- Sync work behind an async surface — no I/O cost in tests.
- Notifies subscribers on every successful mutation.
- Throws `REACT_AUDIO_FINGERPRINT.CORPUS_FAILED` on invalid IDs.
- `subscribe(listener)` returns an unsubscribe function (React-friendly).
- Iteration order matches insertion order.
## Usage: Basic Example
```ts
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
await corpus.add("song-1", fingerprint);
console.log(corpus.size()); // 1
```
## Usage: Advanced Example
Subscribe to mutations from a `useSyncExternalStore` consumer and clean up on
unmount:
```ts
import { useSyncExternalStore } from "react";
import { MemoryCorpus } from "@suaveplan/react-audio-fingerprint/corpus/memory-corpus";
const corpus = new MemoryCorpus();
function useCorpusSize(): number {
return useSyncExternalStore(
(cb) => corpus.subscribe(cb),
() => corpus.size(),
() => 0
);
}
```
## API Reference
- `class MemoryCorpus implements Corpus`.
- `add(id, fingerprint): Promise<void>`.
- `remove(id): Promise<void>`.
- `get(id): Promise<FingerprintCorpusEntry | undefined>`.
- `size(): number`.
- `values(): IterableIterator<FingerprintCorpusEntry>`.
- `subscribe(listener): () => void`.
## Implementation Notes
The listener set is a `Set` so subscribe/unsubscribe is O(1) and duplicate
registrations are de-duped. `remove` only notifies when the key actually
existed; this matches the React convention that no-op mutations should not
trigger a render. Empty-string IDs are rejected so consumers cannot create a
ghost entry that `get("")` would later return — the matcher relies on `id`
being a stable label. Iteration uses a generator so callers can `break` early
without paying for a full copy of the store.

View File

@@ -0,0 +1,89 @@
# `error-codes` — Stable error registry for `@suaveplan/react-audio-fingerprint`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares every error code that `@suaveplan/react-audio-fingerprint`
can throw. Each code is registered through `defineCodes` from
`@suaveplan/error`, producing namespaced strings (e.g.
`REACT_AUDIO_FINGERPRINT.DECODE_FAILED`) that downstream code, telemetry, and
i18n catalogues can branch on without depending on free-form messages. The
companion `ReactAudioFingerprintError` class extends `SuaveplanError`, carries
the code as a structural field, and round-trips through the error wire format
defined by the error package.
## Features
- Six explicit codes covering the full failure surface: decode, fingerprint,
match, corpus, abort, and option validation.
- Frozen metadata table keyed by code with `description` and `severity`.
- `ReactAudioFingerprintError` class with stable `category = "react-audio-fingerprint"`.
- Idempotent `registerReactAudioFingerprintErrorCodes()` hook for parity with
sibling packages.
- Strict TypeScript typing — codes are inferred from the literal tuple.
## Usage: Basic Example
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
} from "@suaveplan/react-audio-fingerprint/error-codes";
throw new ReactAudioFingerprintError(
REACT_AUDIO_FINGERPRINT_CODES.DECODE_FAILED,
"react-audio-fingerprint.decode.failed",
{ context: { mime: "audio/mp3" } }
);
```
## Usage: Advanced Example
Discriminate caught errors by code and surface a structured toast:
```ts
import {
REACT_AUDIO_FINGERPRINT_CODES,
ReactAudioFingerprintError,
REACT_AUDIO_FINGERPRINT_ERROR_METADATA,
} from "@suaveplan/react-audio-fingerprint/error-codes";
try {
await fingerprint(source);
} catch (e) {
if (e instanceof ReactAudioFingerprintError) {
const meta = REACT_AUDIO_FINGERPRINT_ERROR_METADATA[e.code];
if (e.code === REACT_AUDIO_FINGERPRINT_CODES.ABORTED) {
return; // user-driven cancel, no toast
}
toast.error(`${meta.severity}: ${meta.description}`);
}
throw e;
}
```
## API Reference
- `REACT_AUDIO_FINGERPRINT_CODES` — frozen object whose keys/values are the
six dotted code strings.
- `ReactAudioFingerprintErrorCode` — union of valid code strings.
- `REACT_AUDIO_FINGERPRINT_ERROR_METADATA` — frozen lookup keyed by code.
- `ReactAudioFingerprintError extends SuaveplanError` — `code`, `messageKey`,
optional `cause` / `context`, `category: "react-audio-fingerprint"`.
- `registerReactAudioFingerprintErrorCodes()` — no-op registration hook for
symmetry with packages that defer metadata registration to runtime.
## Implementation Notes
The metadata table is `Object.freeze`d so consumers cannot mutate it. Every
code in `REACT_AUDIO_FINGERPRINT_CODES` MUST have a metadata entry; the test
suite asserts the two sets line up via `Object.keys`. The class sets
`this.name` to include the severity for friendlier debugger output without
mutating the underlying code, leaving structural equality intact. Registration
runs at module load via the side-effectless hook so importing the barrel is
sufficient to make codes discoverable from the central registry. No biome
suppression comments are present; the file passes biome's strict ruleset
unaided.

View File

@@ -0,0 +1,73 @@
# `audio-fingerprinter` — Spectrogram → peaks → constellation orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudioFingerprinter` is the entry point that turns an `AudioDocument` into a
deterministic constellation fingerprint. It composes three pieces: the
spectrogram from `@suaveplan/audio-analysis-spectrum` (the ONLY FFT used by
this package), the local-peak detector defined alongside this file, and the
constellation pair-hasher. The class owns no DSP — every numerical step is
delegated, which keeps the fingerprint algorithm in one auditable file.
## Features
- Zod-validated options (`frameSize`, `hopSize`, `window`, `noiseFloor`,
`peaksPerFrame`, `neighbourhood`, `fanout`, `targetZoneSec`).
- Cooperative cancellation via `AbortSignal` checked at every pipeline stage.
- Monotonic progress callback (`{ fraction }`) reported at 0, 0.5, 0.8, 1.
- Wraps spectrogram failures in `REACT_AUDIO_FINGERPRINT.FINGERPRINT_FAILED`
while preserving the underlying `cause`.
- Stable output: identical input bytes produce a byte-equal fingerprint.
- Returns the sample rate, hop, and frame size alongside the fingerprint so
the matcher / visualizer can reconstruct frame ↔ time arithmetic.
## Usage: Basic Example
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const fp = await new AudioFingerprinter().fingerprint(doc);
console.log(fp.fingerprint.length, "pairs");
```
## Usage: Advanced Example
Pass an `AbortSignal` and a progress callback while running a non-default
window:
```ts
import { AudioFingerprinter } from "@suaveplan/react-audio-fingerprint/fingerprint/audio-fingerprinter";
const controller = new AbortController();
const fp = await new AudioFingerprinter().fingerprint(doc, {
window: "blackman",
frameSize: 2048,
hopSize: 512,
signal: controller.signal,
onProgress: ({ fraction }) => console.log(fraction),
});
```
## API Reference
- `class AudioFingerprinter`.
- `fingerprint(doc, opts?): Promise<FingerprintResult>`.
- `FingerprintResult = { fingerprint, sampleRate, hopSize, frameSize }`.
- `FingerprintOptionsSchema` — Zod schema; defaults `frameSize=4096`,
`hopSize=1024`, `window="hann"`, `targetZoneSec=5`.
- `signal?: AbortSignal`, `onProgress?: ({ fraction }) => void`.
## Implementation Notes
`targetZoneFrames` is derived from `targetZoneSec` using the document's actual
`sampleRate` and the configured `hopSize`. This keeps the target zone musically
consistent across rates (e.g. 16 kHz speech vs 48 kHz music). The class only
re-throws a `ReactAudioFingerprintError` when it can identify the original
failure as an abort; everything else becomes `FINGERPRINT_FAILED` with the
original error as `cause`. There is no caching — every call re-runs the
spectrogram. Callers wanting to fingerprint the same document twice should
memoise the result themselves.

View File

@@ -0,0 +1,69 @@
# `constellation` — Pair-hash construction over detected peaks
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The constellation module converts a list of spectrogram peaks into a stable,
hashable fingerprint by pairing each anchor peak with several subsequent peaks
inside a fixed target zone. Each pair `(binA, binB, Δframes)` is packed into a
32-bit integer; the resulting `(hash, timeOffsetSec)` records form the
fingerprint that the matcher consumes. This is the classic Shazam construction
described by Wang (2003) without any modifications.
## Features
- 32-bit packed hash with explicit `binA[12] | binB[12] | dt[8]` layout.
- `packHash` / `unpackHash` round-trip helpers for diagnostics and tests.
- Configurable `fanout`, `targetZoneFrames`, `minDeltaFrames` via Zod schema.
- Deterministic output sorted by anchor `timeOffsetSec`.
- Empty-input safe (no peaks → empty fingerprint, no throw).
- Validates `hopSamples` / `sampleRate` and rejects non-positive grids with
`ReactAudioFingerprintError(INVALID_OPTIONS)`.
## Usage: Basic Example
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 512, 48_000);
console.log(fp.length, fp[0]?.hash);
```
## Usage: Advanced Example
Tighten the target zone for short percussive clips:
```ts
import { buildConstellation } from "@suaveplan/react-audio-fingerprint/fingerprint/constellation";
const fp = buildConstellation(peaks, 256, 48_000, {
fanout: 8,
targetZoneFrames: 80,
minDeltaFrames: 2,
});
```
## API Reference
- `buildConstellation(peaks, hopSamples, sampleRate, opts?): Fingerprint`.
- `Fingerprint = ReadonlyArray<{ hash: number; timeOffsetSec: number }>`.
- `packHash(binA, binB, dt): number` — 32-bit pack.
- `unpackHash(h): { binA; binB; dt }` — inverse.
- `ConstellationOptionsSchema` — Zod schema with defaults `fanout=5`,
`targetZoneFrames=200`, `minDeltaFrames=1`.
## Implementation Notes
The Δt field is clamped to 8 bits, which limits the effective target zone to
255 hop-frames regardless of the schema value. At the default 512-sample hop
and 48 kHz sample rate this is ~2.72 seconds — well inside the classic
constellation target-zone width. Hashes are unsigned 32-bit integers; the
`>>> 0` at the end of `packHash` reinterprets the bitwise result, which is
otherwise a signed 32-bit integer in JavaScript's number type. Output sorting
makes equal-fingerprint comparison trivial (`JSON.stringify` round-trip is
enough for byte equality). Pair construction is O(`peaks × fanout`) because
each anchor stops emitting once the fanout cap is hit or the target zone is
exhausted.

View File

@@ -0,0 +1,67 @@
# `fingerprint-matcher` — Time-offset histogram match scoring
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FingerprintMatcher` scores a query fingerprint against a corpus of candidate
fingerprints using the classic Shazam time-offset histogramming technique. For
every hash that appears in both query and candidate, the matcher accumulates
`(candidateOffset − queryOffset)` into a 10 ms-wide histogram bucket; the
heaviest bucket count is the match score and its midpoint is the alignment
offset. Results sort by descending score, ready for ranked display.
## Features
- `match(query, corpus)` returns ranked `MatchResult[]`.
- `matchOne(query, candidate)` returns the best bin only — useful for unit
tests and direct pairwise comparison.
- Configurable `binWidthSec` (default 0.01 s).
- Hash-index pre-step keeps the cost at `O(|query| + |candidate|)` per pair.
- Wraps iteration-time corpus failures in
`REACT_AUDIO_FINGERPRINT.MATCH_FAILED`.
- Empty inputs return zero-scored results without throwing.
## Usage: Basic Example
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const ranked = new FingerprintMatcher().match(queryFp, corpus);
console.log(ranked[0]?.id, ranked[0]?.score);
```
## Usage: Advanced Example
Drive a tighter alignment window for short clips:
```ts
import { FingerprintMatcher } from "@suaveplan/react-audio-fingerprint/fingerprint/fingerprint-matcher";
const m = new FingerprintMatcher();
const one = m.matchOne(queryFp, candidateFp, { binWidthSec: 0.005 });
console.log(one.score, one.offsetSec, one.matchedHashCount);
```
## API Reference
- `class FingerprintMatcher`.
- `match(query, corpus, opts?): MatchResult[]`.
- `matchOne(query, candidate, opts?): MatchOneResult`.
- `MatchResult = { id, score, offsetSec, matchedHashCount }`.
- `CorpusLike = { values(): Iterable<{ id; fingerprint }> }`.
- `MatcherOptions = { binWidthSec?: number }` (default `0.01`).
## Implementation Notes
A candidate's hash table is built lazily on each call — caching is left to
callers because corpora are typically streamed. Hash indices use `Map<number,
number[]>` since duplicate hashes inside one fingerprint are common (the same
constellation pair can recur at different times). The histogram is also a
`Map<number, number>` because the bucket range is unbounded in principle.
Bucket midpoints (rather than left edges) keep the reported offset symmetric
around the true alignment. The matcher is pure — given the same inputs it
produces the same outputs — so callers can safely memoise results keyed by
`(queryFp, corpusVersion)`.

View File

@@ -0,0 +1,69 @@
# `peak-detector` — 2D spectrogram local-peak detection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The peak detector consumes a spectrogram (a sequence of FFT magnitude frames
produced by `@suaveplan/audio-analysis-spectrum`) and returns the time-frequency
coordinates of cells that dominate their local neighbourhood. The output drives
constellation hashing, which is the core of every Shazam-style fingerprint
algorithm. The detector is deterministic — identical input produces a
byte-equal peak list — and is the only place in the package where raw
spectrogram cells are inspected.
## Features
- Configurable square `neighbourhood` radius (default 3).
- `noiseFloor` threshold (linear magnitude, default 1e-3 ≈ −60 dBFS).
- `peaksPerFrame` cap (default 5) — bounds memory and downstream hash count.
- Deterministic ordering: ascending frame, then descending magnitude.
- Zod-validated options with a `ReactAudioFingerprintError(INVALID_OPTIONS)` on
invalid input.
## Usage: Basic Example
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram);
console.log(peaks.length, peaks[0]);
```
## Usage: Advanced Example
Tighten the neighbourhood and lower the floor to catch quiet harmonics in a
chirp:
```ts
import { detectPeaks } from "@suaveplan/react-audio-fingerprint/fingerprint/peak-detector";
const peaks = detectPeaks(spectrogram, {
neighbourhood: 2,
noiseFloor: 1e-5,
peaksPerFrame: 8,
});
```
## API Reference
- `detectPeaks(spectrogram, opts?): Peak[]` — runs the 2D local-max sweep.
- `Peak = { frame: number; bin: number; magnitude: number }`.
- `PeakDetectorOptionsSchema` — Zod schema; defaults shown above.
- `PeakDetectorOptionsInput` — pre-parse input shape (everything optional).
## Implementation Notes
The implementation uses a straightforward `O(F · B · N²)` sweep where `F` is
the frame count, `B` is the bin count, and `N` is the neighbourhood radius. The
constants in the spec (default `N=3`, `peaksPerFrame=5`, `bins≈1025` for a
2048-point FFT) keep the cost firmly in `Float32Array` index arithmetic — no
heap allocations inside the inner loop other than the per-frame `frameHits`
array, which is bounded by the bin count. Sorting by descending magnitude and
truncating to `peaksPerFrame` ensures the loudest local maxima win when more
candidates qualify than the per-frame cap allows; ties resolve to the lower
bin via the stable native sort. Empty spectrograms produce an empty array
without throwing; downstream constellation hashing handles that case by
emitting no pairs.

View File

@@ -0,0 +1,71 @@
# `useAudioFingerprint` — React hook for the fingerprint pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAudioFingerprint(source, options?)` drives a `FingerprintBackend` off a
React-managed `AudioDocument` and returns the live state, the resulting
fingerprint when done, a `0..1` progress fraction, and any error encountered.
The hook centralises the abort-on-unmount and progress wiring so component
authors do not have to repeat that work across uploaders, visualisers, and
match displays.
## Features
- `source` can be `undefined`/`null` to mean "do nothing" — useful while
waiting for an upload to decode.
- `state` cycles through `'idle'` → `'fingerprinting'` → `'done'` (or
`'error'`); a fresh source resets the cycle.
- Abort-on-unmount and abort-on-source-change via internal `AbortController`.
- Backend swap via `options.backend`; defaults to `LocalBackend`.
- Wraps non-`ReactAudioFingerprintError` rejections in `FINGERPRINT_FAILED`.
- Suppresses `ABORTED` errors — they settle back to `'idle'`.
## Usage: Basic Example
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function FingerprintPreview({ doc }: { doc: AudioDocument }) {
const { state, fingerprint, progress } = useAudioFingerprint(doc);
if (state === "fingerprinting") return <progress value={progress} />;
if (state === "done") return <span>{fingerprint?.length} pairs</span>;
return null;
}
```
## Usage: Advanced Example
Inject a custom backend (e.g. the optional Python backend) and surface errors:
```tsx
import { useAudioFingerprint } from "@suaveplan/react-audio-fingerprint/hooks/use-audio-fingerprint";
function Surface({ doc, backend }: { doc: AudioDocument; backend: FingerprintBackend }) {
const { state, error, fingerprint } = useAudioFingerprint(doc, { backend });
if (state === "error") return <Banner code={error?.code}>{error?.message}</Banner>;
if (state === "done") return <Result fp={fingerprint!} />;
return <Spinner />;
}
```
## API Reference
- `useAudioFingerprint(source, options?): UseAudioFingerprintResult`.
- `source: AudioDocument | undefined | null`.
- `options?: { backend?: FingerprintBackend }`.
- `UseAudioFingerprintResult = { state, fingerprint, progress, error }`.
- `state: 'idle' | 'fingerprinting' | 'done' | 'error'`.
## Implementation Notes
The hook uses `useMemo` to stabilise the default `LocalBackend` so changing
unrelated props does not retrigger the effect. The `alive` flag guarding all
post-await `setState` calls is the canonical React pattern for protecting
against the lifecycle race between an async fetch completing and a fast
unmount. Aborts settle to `'idle'` (not `'error'`) so user-driven cancels do
not produce a banner. Non-Suaveplan errors get wrapped so consumers can always
discriminate via `error.code` without an `instanceof` chain.

View File

@@ -0,0 +1,58 @@
# `test-setup` — Vitest global setup
## Purpose
This file is loaded by `vitest.config.ts` via `setupFiles` so every test in
the package runs against a consistent jest-dom matcher set and tears down its
React tree after each case. It is intentionally short — heavy work happens in
the runner-agnostic polyfills shipped by `@suaveplan/testing/polyfills`, which
this file complements with DOM-specific affordances.
## Features
- Imports `@testing-library/jest-dom/vitest` to register matchers such as
`.toBeInTheDocument()` and `.toHaveAccessibleName()`.
- Registers an `afterEach` that calls `cleanup()` from
`@testing-library/react`, unmounting trees between tests and avoiding
cross-test DOM pollution.
- No mocks, no spies, no global stubs — keeps the suite faithful to the
CLAUDE.md "no mocks" rule.
## Usage: Basic Example
`vitest.config.ts` references this file:
```ts
setupFiles: [
"@suaveplan/testing/polyfills",
"./src/test-setup.ts",
],
```
## Usage: Advanced Example
If a future component needs an additional matcher (for example to assert
custom accessibility properties), add the relevant import here so every test
in the package benefits without per-file boilerplate:
```ts
import "@testing-library/jest-dom/vitest";
import { afterEach } from "@suaveplan/testing/runner";
import { cleanup } from "@testing-library/react";
// (Add additional matchers or global afterEach hooks here.)
afterEach(() => cleanup());
```
## API Reference
- No exports; runs purely for side-effects at vitest setup time.
- Depends on `@suaveplan/testing/runner` for `afterEach` (rule 11 compliance).
## Implementation Notes
The setup file runs once per worker, so the `cleanup` afterEach registration
is shared across every test file in that worker. This is the canonical
pattern documented by Testing Library and matches every sibling Suaveplan
React package's setup. The package owns no provider configuration so there is
no provider-tree wrapper to install — tests render components directly via
`@testing-library/react`'s `render` / `renderHook`.

View File

@@ -0,0 +1,136 @@
# @suaveplan/react-audio-player - Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.
Track-style media-player surface for the Suaveplan audio family.
The package ships two kinds of exports:
1. **Karaoke** — a self-contained LRC parser, `useLyrics` hook, and
`<LyricsDisplay>` component implemented in this package.
2. **Re-export façade** — namespace and flat aliases for the player
UI that already lives in
[`@suaveplan/react-audio`](../react-audio/README.md):
`<AudioProvider>`, `<AudioPlayer>`, `<BasicPlayer>`,
`<PremiumPlayer>`, `<TransportControls>`, `<Mixer>`,
`<PluginChainView>`, `<PresetManager>`, plus hooks
(`useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`,
…).
Consumers can either import directly from `@suaveplan/react-audio`
(the original home of those components) or from
`@suaveplan/react-audio-player`, whichever reads better in their
codebase. The named entry points resolve to the same singletons via
ESM re-export — no duplication, no extra bundle weight.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
<!-- end:auto-docs -->
## Status
This package targets the *additive* scope of the
`enterprise-react-audio-player` change. The pre-existing player
components (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`,
visualisers, transport controls, mixer, preset manager) already live
in `@suaveplan/react-audio`; duplicating them here would violate the
"do not duplicate enterprise components" guidance. Instead this
package adds:
- The karaoke surface — LRC parser, `useLyrics`, `<LyricsDisplay>`.
- A namespaced re-export façade (`ReactAudio.*`) plus flat aliases
for the most common entry points.
- Stable `REACT_AUDIO_PLAYER.*` error codes.
### Deferred scope
- **Multi-peer playback sync** (`useMultiPeerSync`) is deferred until
`@suaveplan/react-audio-stream` exists . The spec
requirement is retained in the canonical capability spec with a
`> Deferred (multi-peer)` marker; once the dependency lands, the
hook adds in a follow-up change without breaking this package's
public surface.
- R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`) — the spec
requirement is retained; consumers needing these today can
compose any R3F surface as a sibling of `<PremiumPlayer>`. The
optional `./screensavers/*` subpaths will be filled by a focused
follow-up change.
The two hooks consumed by visualisers (`useAnalyser`) and queues
(`usePlaylist`) **are** shipped — they were added to
`@suaveplan/react-audio` alongside this package and are re-exported
here for ergonomic reach.
## Quick start — karaoke
```tsx
import {
AudioProvider,
AudioPlayer,
LyricsDisplay,
} from "@suaveplan/react-audio-player";
function App({ track, lrc }: { track: string; lrc: string }) {
return (
<AudioProvider options={{ /* … */ }}>
<AudioPlayer source={track} />
<KaraokeLine lrc={lrc} />
</AudioProvider>
);
}
import { useTransportTime } from "@suaveplan/react-audio";
function KaraokeLine({ lrc }: { lrc: string }) {
const seconds = useTransportTime();
return <LyricsDisplay lrc={lrc} currentSec={seconds} />;
}
```
## Quick start — standalone preview (no provider)
```tsx
import {
parseLrc,
LyricsDisplay,
} from "@suaveplan/react-audio-player";
import { useMemo, useState } from "react";
export function Preview({ lrc }: { lrc: string }) {
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
const [t, setT] = useState(0);
return (
<div>
<input
type="range"
min={0}
max={120}
step={0.1}
value={t}
onChange={(e) => setT(Number(e.currentTarget.value))}
aria-label="time"
/>
<LyricsDisplay lyrics={lyrics} currentSec={t} />
</div>
);
}
```
## Subpath exports
| Subpath | Surface |
| --- | --- |
| `.` | Root barrel — karaoke + flat aliases + `ReactAudio` namespace + error codes. |
| `./karaoke` | All karaoke exports. |
| `./karaoke/lrc-parser` | `parseLrc`, `activeLyricIndex`, types. |
| `./karaoke/use-lyrics` | `useLyrics` and types. |
| `./karaoke/lyrics-display` | `<LyricsDisplay>` and types. |
| `./error-codes` | `REACT_AUDIO_PLAYER.*` registry. |
See the per-feature `.md` files for full API references.

View File

@@ -0,0 +1,98 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/react-audio-player`.
Every error thrown by a public surface in this package carries a
code from `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n
resolution, and an optional `context` / `cause` chain. Codes are
namespaced under the `REACT_AUDIO_PLAYER.` prefix so the wire format
`{ code: "REACT_AUDIO_PLAYER.LRC_PARSE_FAILED" }` is greppable and
stable across versions.
The registry is the single source of truth for the package's error
contract — the test suite walks `REACT_AUDIO_PLAYER_ERROR_METADATA`
and confirms every code is reachable from a real call site, so a
code that gets added without a thrower (or a thrower whose code
gets removed) is a build break.
## Features
- Five public codes: `NO_PROVIDER`, `PLAYLIST_EMPTY`,
`PERSISTENCE_FAILED`, `LRC_PARSE_FAILED`, `INVALID_OPTIONS`.
- Frozen metadata map keyed by code; every entry has a `description`
and `severity` (`"error"` | `"warning"`).
- `ReactAudioPlayerError` class extends `SuaveplanError`; carries a
stable `category = "react-audio-player"` field.
- Idempotent `registerReactAudioPlayerErrorCodes()` for parity with
sibling audio packages.
## Usage: Basic
```ts
import {
REACT_AUDIO_PLAYER_CODES,
ReactAudioPlayerError,
} from "@suaveplan/react-audio-player/error-codes";
if (!provider) {
throw new ReactAudioPlayerError(
REACT_AUDIO_PLAYER_CODES.NO_PROVIDER,
"react-audio-player.no-provider"
);
}
```
## Usage: Advanced
```ts
import {
REACT_AUDIO_PLAYER_CODES,
REACT_AUDIO_PLAYER_ERROR_METADATA,
ReactAudioPlayerError,
} from "@suaveplan/react-audio-player/error-codes";
try {
parseLrc(input);
} catch (cause) {
const code = REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED;
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
throw new ReactAudioPlayerError(code, "lrc.parse.failed", {
cause,
context: { severity: meta.severity, bytes: input.length },
});
}
```
## API Reference
- `REACT_AUDIO_PLAYER_CODES` — `Readonly<Record<…, string>>` of the
five public codes (frozen).
- `ReactAudioPlayerErrorCode` — union of the code string-literals.
- `REACT_AUDIO_PLAYER_ERROR_METADATA` — frozen metadata map.
- `ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode>`.
- `registerReactAudioPlayerErrorCodes(): void` — idempotent no-op.
## Implementation Notes
`defineCodes` is the workspace's standard helper for producing
namespaced code maps; it freezes the result and asserts that every
key is a valid identifier at module load. Metadata is declared with
`Object.freeze` so consumers (and the test suite) cannot mutate it
at runtime.
`SuaveplanError` is the universal base error from
`@suaveplan/error`; it carries `code`, `messageKey`, `context`, and
`cause` plus a stable wire-format serialisation. The
`category = "react-audio-player"` literal is the discriminator used
by the cross-package error router when an error crosses a package
boundary.
`name` is set to `ReactAudioPlayerError(<severity>)` so stack-trace
output includes the severity at a glance. This is a string-only
augmentation; it does not change the prototype chain so
`instanceof ReactAudioPlayerError` still narrows correctly.

View File

@@ -0,0 +1,106 @@
# `karaoke/lrc-parser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseLrc(source, opts?)` parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
karaoke source into a timestamp-sorted array of `Lyric` entries
suitable for the package's `<LyricsDisplay>` and any consumer-built
karaoke surface. The parser is universal — it has no React, DOM, or
audio-context dependency — so it is safe to call in workers, on the
server during SSR, or inside other Suaveplan tools that need to
preview lyric timing.
The companion `activeLyricIndex(lyrics, currentSec)` performs a
binary search for the active line and is the bridge between the
transport's `seconds` and the `<LyricsDisplay>` highlight state.
Both functions are pure; the parser throws structured
`ReactAudioPlayerError` for the two invalid-input cases.
## Features
- Recognises `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, and
`[h:mm:ss(.xxx)]` timestamp forms.
- Expands multi-timestamp lines (`[00:10][00:20]hello`) by default;
togglable via `expandMultiTimestamp`.
- Drops the LRC metadata tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]`,
`[by:]`, `[offset:]`, `[re:]`, `[ve:]` — no extraction in v1.
- Skips empty lines, malformed lines, and lines with no leading
timestamp.
- Output is sorted ascending by `timestampSec` and frozen.
- Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
or input exceeding `maxLength` (default 1 MiB).
- `activeLyricIndex` is O(log n) and allocation-free.
## Usage: Basic
```ts
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
const lyrics = parseLrc(`
[ar:Test Artist]
[00:10.50]First line
[00:25.00]Second line
`);
// → [{ timestampSec: 10.5, text: "First line" },
// { timestampSec: 25.0, text: "Second line" }]
```
## Usage: Advanced
```tsx
import {
parseLrc,
activeLyricIndex,
} from "@suaveplan/react-audio-player/karaoke/lrc-parser";
import { useTransportTime } from "@suaveplan/react-audio";
import { useMemo } from "react";
function LyricLine({ source }: { source: string }) {
const lyrics = useMemo(
() => parseLrc(source, { expandMultiTimestamp: false }),
[source]
);
const seconds = useTransportTime();
const idx = activeLyricIndex(lyrics, seconds);
return (
<span aria-live="polite">
{idx >= 0 ? lyrics[idx].text : "…"}
</span>
);
}
```
## API Reference
- `parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]`
- `activeLyricIndex(lyrics: readonly Lyric[], currentSec: number): number`
- `Lyric` — `{ timestampSec: number; text: string }`.
- `ParseLrcOptions` — `maxLength?: number` (default 1 MiB),
`expandMultiTimestamp?: boolean` (default `true`).
## Implementation Notes
The timestamp regex `/\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g`
captures optional hours, minutes, seconds, and an optional
fractional component. The fractional value is padded right with
zeros to three digits then divided by 1000, so `.5` parses to 0.500
seconds, `.50` to 0.500, and `.500` to 0.500 — all equivalent. Only `.` is accepted as the
fractional separator; the `:` form would conflict with the
`[h:mm:ss]` extended timestamp grammar.
Multi-timestamp expansion walks the regex's global state, but only
honours timestamps that form a contiguous prefix of the line. A
bracketed sequence later in the line is treated as text — many
karaoke services emit `[00:10]hello [00:11]world` to mean two
separate lyric lines and this parser handles both layouts
correctly.
`activeLyricIndex` uses the upper-bound binary search idiom: pick
the greatest index whose timestamp is ≤ current. When `current` is
between two entries, the earlier entry wins; when `current`
precedes the first entry, `-1` is returned (so the consumer can
render an empty active line until the first cue).

View File

@@ -0,0 +1,109 @@
# `karaoke/lyrics-display`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LyricsDisplay>` is the karaoke surface that renders a stack of
lyric lines with the active line highlighted. It is the visible
end-product of the `parseLrc` → `useLyrics` pipeline and the
component the player variants compose when their `lrc` prop is
populated.
The component is clock-agnostic by design: the caller passes
`currentSec` (a plain number) and the component renders the
highlighted line. The typical wiring is `useTransportTime()` from
`@suaveplan/react-audio` when the display sits inside an
`<AudioProvider>`, but standalone preview and test surfaces can
hand in any number — Playwright spec runs use a deterministic
clock; documentation pages drive a `<range>` input. Keeping the time
source explicit removes a hidden provider dependency.
## Features
- Renders one `<span>` per lyric line with `data-active="true|false"`
for CSS-driven styling; consumers needing structural control can
pass `renderLine` for full custom rendering.
- Active line carries `aria-live="polite"` by default so screen
readers announce lyric changes; togglable via `announceLyrics`.
- Wrapping `<div>` exposes `role="region"` + `aria-label="Lyrics"`.
- Re-binds the active index every 100 ms (configurable) regardless
of the consumer's render cadence.
- Both time-source modes share the same internal render path; the
outer dispatch is a one-time component-type pick.
## Usage: Basic
```tsx
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
import { useTransportTime } from "@suaveplan/react-audio";
function Player({ lrcSource }: { lrcSource: string }) {
const seconds = useTransportTime();
return <LyricsDisplay lrc={lrcSource} currentSec={seconds} />;
}
```
## Usage: Advanced
```tsx
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
import { useMemo } from "react";
function StyledLyrics({ lrc, time }: { lrc: string; time: number }) {
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
return (
<LyricsDisplay
lyrics={lyrics}
currentSec={time}
intervalMs={50}
announceLyrics={false}
renderLine={({ lyric, isActive }) => (
<p
style={{
opacity: isActive ? 1 : 0.4,
fontWeight: isActive ? 700 : 400,
}}
>
{lyric.text || <span aria-hidden="true">·</span>}
</p>
)}
/>
);
}
```
## API Reference
- `<LyricsDisplay>` accepts `LyricsDisplayProps`:
- `lyrics?: readonly Lyric[]` — preferred over `lrc`.
- `lrc?: string` — parsed once per identity change.
- `currentSec: number` — required.
- `intervalMs?: number` (default 100).
- `announceLyrics?: boolean` (default `true`).
- `className?: string`.
- `renderLine?: ({ lyric, isActive, index }) => ReactNode`.
## Implementation Notes
The component is a single-branch render — no dual-component
dispatch — because `currentSec` is always a plain prop. This keeps
the Rules of Hooks trivially satisfied (one render path, one hook
sequence) and removes a hidden coupling to the surrounding
`<AudioProvider>`. Consumers that want the transport-driven path
call `useTransportTime()` themselves and pass the result down.
The `data-active="true|false"` attribute is the recommended CSS
selector hook. Toggling `aria-live` only on the active line ensures
exactly one element narrates per change cycle; without that targeted
gating, every line's render would re-announce on every re-mount,
which is acoustically unusable.
When `renderLine` is supplied, the component delegates to it
verbatim and skips the default `<span>` + `data-active` path. The
delegated node is wrapped in a keyed `<Fragment>` so consumers can
return any element they like — `<li>`, `<p>`, `<div>` — without
fighting an enclosing wrapper.

View File

@@ -0,0 +1,121 @@
# `karaoke/use-lyrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the
active-line state, samples it at a fixed interval (100 ms by
default), and returns `{ lyrics, activeIndex, activeLyric }`. The
consumer hands in either pre-parsed `Lyric[]` or raw LRC source,
plus the current playback time in seconds. The hook deliberately
does not call `useTransportTime` itself so it can be driven from any
clock — `<LyricsDisplay>` wires the transport in for you, but a
preview panel running under a deterministic test clock can call this
hook directly.
The 100 ms re-bind ceiling matches the spec requirement that the
active lyric advance within 100 ms of the transport. Sampling at
that cadence (rather than per-frame) saves a substantial amount of
React commit work on songs with hundreds of lyric lines.
## Features
- Pure: no audio context, no global side-effect.
- Pre-parsed `lyrics` or raw `lrc` source — both accepted; if both
provided, `lyrics` wins (no double work).
- Configurable `intervalMs` (default 100). The hook re-evaluates
the active line on each tick and only commits when the index
actually changes — silent renders cost nothing.
- Returns `activeLyric: undefined` before the first cue or when
`lyrics` is empty.
## Usage: Basic
```tsx
import { useLyrics } from "@suaveplan/react-audio-player/karaoke/use-lyrics";
import { useTransportTime } from "@suaveplan/react-audio";
function Lyrics({ lrc }: { lrc: string }) {
const seconds = useTransportTime();
const { activeLyric } = useLyrics({ lrc, currentSec: seconds });
return <span aria-live="polite">{activeLyric?.text ?? "…"}</span>;
}
```
## Usage: Advanced
```tsx
import {
parseLrc,
useLyrics,
} from "@suaveplan/react-audio-player/karaoke";
import { useMemo, useState } from "react";
function StandaloneLyricPreview({ lrc }: { lrc: string }) {
// Parse once at the call site rather than on every mount.
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
// Deterministic clock — no <AudioProvider> required.
const [tick, setTick] = useState(0);
const { activeIndex, activeLyric, lyrics: ls } = useLyrics({
lyrics,
currentSec: tick * 0.5,
intervalMs: 50,
});
return (
<div>
<button type="button" onClick={() => setTick((t) => t + 1)}>
+0.5 s
</button>
<ol>
{ls.map((l, i) => (
<li
key={`${l.timestampSec}-${i}`}
data-active={i === activeIndex}
>
{l.text}
</li>
))}
</ol>
<p>Active: {activeLyric?.text ?? "—"}</p>
</div>
);
}
```
## API Reference
- `useLyrics(opts: UseLyricsOptions): UseLyricsResult`
- `UseLyricsOptions`:
- `lyrics?: readonly Lyric[]` — preferred.
- `lrc?: string` — parsed once per identity change.
- `intervalMs?: number` (default 100).
- `currentSec: number` — required.
- `UseLyricsResult`:
- `lyrics: readonly Lyric[]`,
- `activeIndex: number` (`-1` for "before first cue / empty"),
- `activeLyric: Lyric | undefined`.
## Implementation Notes
The hook owns one piece of React state — the active index — and one
`setInterval`. Both `lyrics` and `currentSec` are mirrored through
refs so the interval callback always sees the latest values without
re-binding the timer on every render. The interval only fires
`setActiveIndex` when the recomputed index actually differs, which
is the standard React micro-optimisation for high-frequency external
clocks.
`activeLyricIndex` (from the sibling `lrc-parser` module) is a
binary search; combined with the 100 ms sampling cadence, the hook's
steady-state work per frame is one comparison plus a "same index?"
branch. Long-form lyrics (10k+ lines) are not measurably more
expensive than short ones.
The hook lazily initialises its `activeIndex` state from
`activeLyricIndex(lyrics, currentSec)` so the first render — before
the effect mounts — already reflects the correct active line. This
matters for SSR-rendered previews and snapshot tests that read the
hook's output without ever ticking the interval.

View File

@@ -0,0 +1,132 @@
# @suaveplan/react-audio-stream - Stream-tier React surface for the Suaveplan audio family — session-core foundation (chunk A). Provides StreamSession (FSM lifecycle), InterruptionClassifier, StreamSourcePlugin, error codes, and OTel instruments. Transports, hooks, components, and transport-share land in chunks B/C/D.
React 19 surface for live audio streaming sessions: a Provider, six hooks, and four
accessible components. Chunk C of the four-chunk `enterprise-react-audio-stream` split.
> Chunks A (session-core) and B (transports) deliver the headless engine. Chunk D
> delivers `useTransportShare` and its banner. This chunk delivers the React surface
> consumers actually touch.
<!-- begin:auto-docs -->
## Documentation tree
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
- [shared-transport-indicator](./src/components/shared-transport-indicator/shared-transport-indicator.md) — `SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'re…
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
- [stream-mixer](./src/components/stream-mixer/stream-mixer.md) — `<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a mute toggle, a solo toggle, and a…
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
- [stream-permissions-banner](./src/components/stream-permissions-banner/stream-permissions-banner.md) — `<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It renders a `role="status"` ARIA-l…
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
- [stream-recorder](./src/components/stream-recorder/stream-recorder.md) — `<StreamRecorder>` is a record/stop control with a live duration display. It delegates recording lifecycle to `useStrea…
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
- [stream-room](./src/components/stream-room/stream-room.md) — `<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup of its own — instead it expose…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module is the canonical source of truth for every failure mode published by `@suaveplan/react-audio-str…
- [internal](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
- [use-stream-context](./src/hooks/internal/use-stream-context.md) — Internal helper consumed by every public chunk-C hook. It reads the `StreamReactContext`, returns the non-null `{ sessi…
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
- [use-stream-levels](./src/hooks/use-stream-levels/use-stream-levels.md) — `useStreamLevels` projects realtime peak/RMS samples out of the session. Called without an argument it returns the loca…
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
- [use-stream-mixer](./src/hooks/use-stream-mixer/use-stream-mixer.md) — `useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer FX slot count — and implements D…
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
- [use-stream-peers](./src/hooks/use-stream-peers/use-stream-peers.md) — `useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each peer entry carries the partici…
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
- [use-stream-permissions](./src/hooks/use-stream-permissions/use-stream-permissions.md) — `useStreamPermissions` exposes the microphone permission state with the typed `MediaPermissionState` enum (`"granted" |…
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
- [use-stream-recording](./src/hooks/use-stream-recording/use-stream-recording.md) — `useStreamRecording` exposes the start/stop controls for capturing a session as a single audio file. The hook delegates…
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
- [use-stream-room](./src/hooks/use-stream-room/use-stream-room.md) — `useStreamRoom` projects the current room scope out of the session: the room id (or `null` when the session is not conn…
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
- [use-stream-session](./src/hooks/use-stream-session/use-stream-session.md) — `useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live `StreamSession` instance, its cur…
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
- [use-transport-share](./src/hooks/use-transport-share/use-transport-share.md) — `useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator…
- [stream-context](./src/provider/stream-context.md) — `StreamReactContext` is the single React context that publishes the live `StreamSession` to every chunk-C hook and comp…
- [stream-provider](./src/provider/stream-provider.md) — `<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for a tree. It calls the caller-su…
- [session](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
- [interruption-classifier](./src/session/interruption-classifier/interruption-classifier.md) — `InterruptionClassifier` maps a structural disconnect cause to exactly one of `network | media-permission | signalling…
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
- [session-fsm](./src/session/session-fsm/session-fsm.md) — The `session-fsm` module declares the canonical seven-state finite state machine that drives every `StreamSession`. Bui…
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
- [stream-session](./src/session/stream-session/stream-session.md) — `StreamSession` is the public face of the stream-tier session core. It wraps the FSM, the transport adapter, the interr…
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
- [stream-source-plugin](./src/session/stream-source-plugin/stream-source-plugin.md) — `StreamSourcePlugin` is the bridge that lets a remote peer's `MediaStream` participate in the audio engine's plugin-cha…
- [reconnect](./src/session/reconnect.md) — The reconnect module owns the auto-reconnect lifecycle that `StreamSession` runs after a disconnect interruption. It wr…
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
- [telemetry](./src/telemetry/telemetry.md) — The telemetry module owns every OpenTelemetry instrument published by `@suaveplan/react-audio-stream`. Concentrating th…
- [control-channel](./src/transport-share/control-channel.md) — The control-channel module is the wire-protocol heart of transport sharing. It defines the envelope shapes that flow be…
- [share-state](./src/transport-share/share-state.md) — The share-state module owns the role-machine that decides whether a peer is in `'local'`, `'driving'`, or `'receiving'`…
- [transports](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
- [trystero](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
- [trystero-transport](./src/transports/trystero/trystero-transport.md) — `TrysteroTransport` is the serverless `StreamTransportAdapter` powered by `@suaveplan/network-discovery-trystero`. It p…
- [webrtc](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
- [webrtc-transport](./src/transports/webrtc/webrtc-transport.md) — `WebRtcTransport` is the low-latency default `StreamTransportAdapter` for `@suaveplan/react-audio-stream`. It wires a c…
- [websocket](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
- [websocket-transport](./src/transports/websocket/websocket-transport.md) — `WebSocketTransport` is the fallback `StreamTransportAdapter` consumers choose when a corporate or carrier NAT blocks W…
- [transport](./src/transports/transport.md) — The `transport.types.ts` module declares the structural contract that `StreamSession` (authored in chunk A of the `@sua…
- [test-setup](./src/test-setup.md) — `test-setup.ts` is the per-worker bootstrap file referenced by `vitest.config.ts`'s `setupFiles`. It registers `@testin…
- [test-utils](./src/test-utils.md) — `test-utils.tsx` provides the in-memory `FakeSession` and the `withProvider` wrapper used by every chunk-C hook + compo…
<!-- end:auto-docs -->
## Provider
- `<StreamProvider sessionFactory={…}>` — StrictMode-safe singleton; injects a
`StreamSession` lazily; fans seven session event channels into a single render-bump
counter; disconnects on unmount.
- `StreamReactContext` — exposed for advanced consumers that need raw context access.
- Type-only mirrors (`SessionState`, `Peer`, `PeerMixerState`, `MediaPermissionState`,
`LevelSample`, `RecordingResult`, `StreamSessionFactory`, `StreamSessionLike`,
`SessionEventMap`, `ReactAudioStreamErrorLike`) for downstream typing.
## Hooks
- `useStreamSession()` — session, FSM state, and `connect` / `join` / `leave` /
`disconnect`.
- `useStreamRoom()` — `roomId`, `isJoined`, `joinedAt`, `participants`.
- `useStreamPeers()` — live `readonly Peer[]`.
- `useStreamMixer()` — per-peer `setVolume` / `setMute` / `setSolo` with DAW
solo semantics (when any peer is soloed, every non-soloed peer's track-volume → 0).
Every mutation routes through the session's `setTrackVolume` / `setTrackMute` /
`setTrackSolo`.
- `useStreamLevels(peerId?)` — `{ peak, rms }` for the local mic or named peer.
- `useStreamRecording()` — `start(peerIds?)` and `stop()` returning a
`RecordingResult` blob.
- `useStreamPermissions()` — typed `MediaPermissionState` with `request()` callback.
## Components
- `<StreamRoom>` — headless render-prop wrapper exposing the room snapshot plus join /
leave callbacks.
- `<StreamMixer>` — per-peer mixer strip with range slider, `aria-pressed`
mute/solo toggles, FX-slot read-out; supports `renderStrip` render-prop.
- `<StreamRecorder>` — record/stop toggle with `mm:ss` duration display and ARIA-live
read-out.
- `<StreamPermissionsBanner>` — `role="status"` `aria-live="polite"` banner; renders
nothing when permission is granted.
## Quick start
```tsx
import { StreamProvider, useStreamSession } from "@suaveplan/react-audio-stream";
function Room() {
const { state, connect, join } = useStreamSession();
if (state === "idle") return <button onClick={() => connect("lobby").then(join)}>Join</button>;
return <p>Joined!</p>;
}
export function App() {
return (
<StreamProvider sessionFactory={() => createMyStreamSession()}>
<Room />
</StreamProvider>
);
}
```
## Constraints (from the canonical spec)
- Zero browser audio APIs: no `AudioContext` / `AudioNode` / `AudioWorklet` imports.
- DAW solo semantics live in the mixer hook, not in each component.
- Every hook throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.

View File

@@ -0,0 +1,67 @@
# SharedTransportIndicator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SharedTransportIndicator` renders the spec-mandated "Shared transport active" badge whenever the local peer is in `'receiving'` mode. The component is intentionally presentational — it consumes a `ShareState` snapshot from the caller (typically `useTransportShare().state`) and renders nothing when no share is active.
Decoupling the visual from the hook lets consumers compose the indicator anywhere in their layout: in a status bar, inside a mixer panel, as part of a notifications drawer, or even pinned next to a specific peer's avatar. Because the component reads its data from props, it is also straightforward to test without wiring up a session or a coordinator.
## Features
- Renders only when the local share role matches the configured `visibleRoles` (default `['receiving']`).
- Carries `aria-live="polite"` and `role="status"` so screen readers announce share-state transitions politely.
- `data-share-role` and `data-driver-id` attributes for selector-based styling.
- Optional `label` override (string or any `ReactNode`).
- Optional `className` for layout integration.
- Spec-aligned default label: `"Shared transport active"`.
## Usage: Basic
```tsx
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
import { SharedTransportIndicator } from "@suaveplan/react-audio-stream/components/shared-transport-indicator";
function StatusBar(props: HostProps) {
const share = useTransportShare(props);
return <SharedTransportIndicator state={share.state} />;
}
```
## Usage: Advanced
```tsx
function MixerHeader(props: HostProps) {
const share = useTransportShare(props);
return (
<header className="mixer-header">
<SharedTransportIndicator
state={share.state}
className="mixer-header__share-badge"
visibleRoles={["driving", "receiving"]}
label={
share.state.role === "driving"
? `Driving ${share.state.receivers.length} peer(s)`
: `Receiving from ${share.state.driverId}`
}
/>
</header>
);
}
```
## API Reference
- `SharedTransportIndicator(props): ReactNode`
- `SharedTransportIndicatorProps`
- `state: ShareState` — required.
- `className?: string` — optional root class.
- `label?: ReactNode` — optional label override.
- `visibleRoles?: ReadonlyArray<ShareRole>` — defaults to `['receiving']`.
## Implementation Notes
The component does not memoise its rendered output — it is a thin function-of-props with no internal state, so React's default reconciliation already short-circuits when the parent passes referentially-stable props. The `data-driver-id` attribute defaults to `""` when the driver identity is unknown so consumers can rely on the attribute always being present. The default label is exposed as a private constant rather than a JSX literal so the component remains testable with a single equality check, and the `visibleRoles` default is a frozen `as const` tuple to avoid accidental mutation by consumers.

View File

@@ -0,0 +1,63 @@
# stream-mixer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<StreamMixer>` renders one strip per remote peer, each with a volume range slider, a
mute toggle, a solo toggle, and a read-only FX-slot-count indicator. The component is
the visible counterpart to `useStreamMixer()` — both surfaces are required by
Requirement 6 ("Per-peer stream mixer") and the component's solo button proves the
DAW-semantics test case from the spec.
Markup is intentionally minimal and unstyled — every interactive control carries an
`aria-label` derived from the peer id so screen readers announce the right peer, and
the toggles use `aria-pressed` for accessibility. Consumers who want richer chrome pass
a `renderStrip` render-prop and own the layout entirely.
## Features
- Default markup with `range` slider, `aria-pressed` mute/solo toggles, FX-slot count.
- `renderStrip` render-prop for custom layouts (e.g. shadcn cards, drag handles).
- All mutations route through `useStreamMixer()` — DAW solo semantics inherited
unchanged.
- Group `role="group"` with consumer-supplied `aria-label` for screen readers.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
## Usage: Basic
```tsx
<StreamMixer label="Live room mix" />
```
## Usage: Advanced
```tsx
<StreamMixer
label="Studio mix"
renderStrip={(peer, { setVolume, setMute, setSolo }) => (
<PeerStrip
peer={peer}
onVolume={setVolume}
onMute={() => setMute(!peer.muted)}
onSolo={() => setSolo(!peer.soloed)}
/>
)}
/>
```
## API Reference
- `StreamMixerProps` — `{ label?: string; renderStrip?: (peer, controls) => ReactNode }`
- `StreamMixer(props): ReactNode`
## Implementation Notes
The `aria-pressed` attribute is set on both the mute and solo buttons because they are
two-state toggles — this matches WAI-ARIA's authoring practice for toggle buttons and
allows assistive tech to announce the current state without parsing the button label.
The range slider's `value` is bound to the live `peer.volume` so it stays in sync with
ducking caused by other peers' solo state (per Requirement 6: when any peer is soloed,
non-soloed peers' track volumes go to 0, and the slider reflects that).

View File

@@ -0,0 +1,62 @@
# stream-permissions-banner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<StreamPermissionsBanner>` is the visible counterpart to `useStreamPermissions()`. It
renders a `role="status"` ARIA-live region with copy reflecting the current microphone
permission state and an explicit request button when the state is actionable
(`"prompt"` or `"unknown"`). When the user has already granted access, the banner
renders nothing so the room UI is unobstructed.
The banner is intentionally opinionated about accessibility — `aria-live="polite"`
ensures screen readers announce state transitions without interrupting other speech —
but stays unopinionated about styling. The default copy is overridable on a
per-state basis via the `messages` prop.
## Features
- ARIA-live `role="status"` with `aria-live="polite"`.
- Per-state copy with sensible defaults (`granted` / `denied` / `prompt` / `unknown`).
- Request button rendered only when the state is `"prompt"` or `"unknown"`.
- `onResolved` callback invoked with the next state after `request()` settles.
- Renders `null` when permission is already granted.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
## Usage: Basic
```tsx
<StreamPermissionsBanner />
```
## Usage: Advanced
```tsx
<StreamPermissionsBanner
requestLabel="Allow microphone"
messages={{
denied: "Microphone blocked. Open site settings to allow access.",
prompt: "We use your mic to stream live audio to other participants.",
}}
onResolved={(state) => {
if (state === "granted") void session.connect("lobby").then(() => session.join());
}}
/>
```
## API Reference
- `StreamPermissionsBannerProps` — `{ messages?, requestLabel?, onResolved? }`
- `StreamPermissionsBanner(props): ReactNode`
## Implementation Notes
The `data-state` attribute on the root element mirrors the `MediaPermissionState`
string so consumers can target it via CSS attribute selectors (e.g.
`[data-state="denied"] { color: red; }`) without re-deriving the state in JS. The
request button renders only on `"prompt"` and `"unknown"` because `"denied"` cannot be
escalated back to `"prompt"` through `getUserMedia` alone — the user must change
browser-level settings, which the copy explicitly tells them.

View File

@@ -0,0 +1,60 @@
# stream-recorder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<StreamRecorder>` is a record/stop control with a live duration display. It delegates
recording lifecycle to `useStreamRecording()` and adds two pieces of presentation logic:
an interval-driven duration counter that ticks at 4 Hz while recording, and an
ARIA-live duration read-out so screen-reader users hear updates. When recording stops,
the component invokes the optional `onComplete` callback with the full `RecordingResult`
(blob + timing metadata) so consumers can upload, download, or transcribe the capture.
The component supports the optional `peerIds` filter — passing it forwards the list to
`start()` so only the named peers (plus the local mic) are mixed into the resulting
blob. This makes the same component usable for "record the whole room" and
"record only the guest" flows without branching the consumer's code.
## Features
- Toggle button with `aria-pressed` for the current state.
- ARIA-live duration string formatted as `mm:ss`.
- 4 Hz interval, cleaned up on unmount and on `isRecording=false`.
- `onComplete` callback receives the full `RecordingResult` after `stop()` resolves.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
## Usage: Basic
```tsx
<StreamRecorder onComplete={(r) => download(r.blob)} />
```
## Usage: Advanced
```tsx
const guests = useStreamPeers().filter((p) => p.id !== "host").map((p) => p.id);
<StreamRecorder
peerIds={guests}
onComplete={async ({ blob, durationMs }) => {
await fetch("/api/interviews", { method: "POST", body: blob });
analytics.track("interview_recorded", { durationMs });
}}
/>;
```
## API Reference
- `StreamRecorderProps` — `{ onComplete?, peerIds?, label? }`
- `StreamRecorder(props): ReactNode`
## Implementation Notes
The duration counter uses `Date.now()` rather than accumulating `setInterval` ticks
because intervals drift; sampling wall-clock time keeps the display in sync with the
underlying recording timeline. On stop the component snaps the displayed duration to
the precise `durationMs` returned by the session, which is the authoritative value
(the session's offline render runs at a fixed sample rate). The interval is cleared in
the effect cleanup, so re-mount under StrictMode does not leak a timer.

View File

@@ -0,0 +1,72 @@
# stream-room
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<StreamRoom>` is the headless layout primitive for the room scope. It renders no markup
of its own — instead it exposes the live room snapshot (`roomId`, `isJoined`,
`participants`, `state`) and the `join` / `leave` callbacks via a render-prop. This
keeps presentation decisions (layout, theming, copy, animations) entirely in the
consumer's hands while the audio-stream package owns the data flow.
The render-prop pattern was chosen over compound components because the room scope is
trivially small (six fields, two callbacks) and a single function child is the smallest
possible surface that does not lock consumers into a particular DOM structure.
## Features
- Headless: no DOM, no classes, no inline styles.
- Render-prop returns the room snapshot and lifecycle callbacks.
- Re-renders on every relevant session event via the underlying hooks.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when rendered outside a `<StreamProvider>`.
## Usage: Basic
```tsx
<StreamRoom>
{({ roomId, isJoined, join, leave }) =>
isJoined ? (
<button onClick={leave}>Leave {roomId}</button>
) : (
<button onClick={join}>Join room</button>
)
}
</StreamRoom>
```
## Usage: Advanced
```tsx
<StreamRoom>
{({ roomId, participants, state, join, leave }) => (
<section aria-busy={state === "joining" || state === "leaving"}>
<h2>{roomId ?? "no room"}</h2>
<ul>
{participants.map((p) => (
<li key={p.id}>{p.displayName}</li>
))}
</ul>
<div role="group">
<button disabled={state !== "connected"} onClick={join}>Join</button>
<button disabled={state !== "joined"} onClick={leave}>Leave</button>
</div>
</section>
)}
</StreamRoom>
```
## API Reference
- `StreamRoomRenderProps` — `{ roomId, isJoined, participants, join, leave, state }`
- `StreamRoomProps` — `{ children: (rp: StreamRoomRenderProps) => ReactNode }`
- `StreamRoom(props): ReactNode`
## Implementation Notes
`state` is forwarded from `useStreamSession()` so consumers can disable buttons during
`"joining"` / `"leaving"` without re-deriving the FSM state themselves. The component
delegates to two narrow hooks rather than reading the context directly, which keeps the
render-prop forwarding cost at exactly two memoised hook calls per render.

View File

@@ -0,0 +1,104 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module is the canonical source of truth for every
failure mode published by `@suaveplan/react-audio-stream`. It declares
the seven dotted codes under the `REACT_AUDIO_STREAM.` namespace via
`defineCodes`, the frozen metadata table, and the `ReactAudioStreamError`
class — every error thrown by the package extends this class and carries
one of these codes. Consumers discriminate failures with `instanceof
ReactAudioStreamError` or by matching on the stable `error.code` string
without needing to know which sub-feature originated the throw.
The codes line up one-to-one with the Requirement 9 surface of the
parent OpenSpec change so the human-authored spec can be cross-checked
against runtime reality: a test asserts every entry in
`REACT_AUDIO_STREAM_ERROR_METADATA` is reachable from at least one
production call site.
## Features
- Seven stable, lexically sorted codes: `NO_PROVIDER`, `INVALID_STATE`,
`SIGNALLING_FAILED`, `PEER_NOT_FOUND`, `MEDIA_PERMISSION_DENIED`,
`RECONNECT_EXHAUSTED`, `INVALID_OPTIONS`.
- `ReactAudioStreamError extends SuaveplanError` — inherits structured
context, cause-chain support, and serialization to the shared wire
format.
- Idempotent `registerReactAudioStreamErrorCodes()` registration hook
for symmetry with sibling Suaveplan packages.
- `Object.freeze`-d metadata table — mutation attempts throw in strict
mode and a test guards the invariant.
## Usage: Basic
```ts
import {
REACT_AUDIO_STREAM_CODES,
ReactAudioStreamError,
} from "@suaveplan/react-audio-stream/error-codes";
throw new ReactAudioStreamError(
REACT_AUDIO_STREAM_CODES.NO_PROVIDER,
"react-audio-stream.no-provider",
);
```
## Usage: Advanced
```ts
import {
REACT_AUDIO_STREAM_CODES,
REACT_AUDIO_STREAM_ERROR_METADATA,
ReactAudioStreamError,
} from "@suaveplan/react-audio-stream/error-codes";
try {
await session.join();
} catch (err) {
if (err instanceof ReactAudioStreamError) {
const meta = REACT_AUDIO_STREAM_ERROR_METADATA[err.code];
logger.warn({
code: err.code,
severity: meta.severity,
description: meta.description,
context: err.context,
}, "stream session failure");
}
throw err;
}
```
## API Reference
- `REACT_AUDIO_STREAM_CODES` — frozen object literal keyed by short
code, value is the dotted string (`"REACT_AUDIO_STREAM.NO_PROVIDER"`).
- `ReactAudioStreamErrorCode` — derived union of every value in the
code registry.
- `ReactAudioStreamErrorMetadata` — `{ description, severity }` pair
per code.
- `REACT_AUDIO_STREAM_ERROR_METADATA` — `Record<ReactAudioStreamErrorCode,
ReactAudioStreamErrorMetadata>`, `Object.freeze`-d at module load.
- `ReactAudioStreamError` — `SuaveplanError<ReactAudioStreamErrorCode>`
subclass; `category` is the literal `"react-audio-stream"`.
- `registerReactAudioStreamErrorCodes(): void` — idempotent no-op
retained for parity with sibling registries.
## Implementation Notes
`defineCodes` literally typesetting the runtime values means the
`ReactAudioStreamErrorCode` union is derived without a second
hand-maintained declaration. Adding a code requires only an entry in
both `REACT_AUDIO_STREAM_CODES` and `REACT_AUDIO_STREAM_ERROR_METADATA`
— the metadata test fails otherwise. Severity is currently `error` on
every entry; the field exists so future warning-class diagnostics
(deprecated transport probes, lossy fallbacks) slot in without a
schema change. The class extends the foundation `SuaveplanError`
directly — the parent OpenSpec change references an `AudioError` from
`@suaveplan/audio-core` but the chunk-A dep allowlist excludes that
package, so we follow the same pattern as sibling `@suaveplan/react-audio`
which also extends the foundation base.

View File

@@ -0,0 +1,59 @@
# use-stream-context (internal)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Internal helper consumed by every public chunk-C hook. It reads the
`StreamReactContext`, returns the non-null `{ session, version }` container, and throws a
structured `REACT_AUDIO_STREAM.NO_PROVIDER`-coded error when the hook ran outside any
`<StreamProvider>`. Centralising the null check in one place guarantees every hook in
the package surfaces the same code string and the same exact message wording, which
keeps consumer-facing error handling consistent.
The function is intentionally not exported from the package root — public hooks
re-export their own narrow surface area and consumers never see this helper directly.
## Features
- Single source of truth for the `NO_PROVIDER` error.
- Attaches a stable `code` property to the thrown `Error` so tests can switch on the
code without parsing the message.
- Returns the typed `StreamContextValue` (no `| null`) so downstream hooks need no
defensive guards.
## Usage: Basic
```ts
import { useStreamContextOrThrow } from "../internal/use-stream-context.js";
export function useStreamSession() {
const { session } = useStreamContextOrThrow();
return session;
}
```
## Usage: Advanced
```ts
try {
useStreamContextOrThrow();
} catch (e) {
if ((e as { code?: string }).code === "REACT_AUDIO_STREAM.NO_PROVIDER") {
// Render a fallback UI explaining the missing provider.
}
}
```
## API Reference
- `useStreamContextOrThrow(): StreamContextValue` — throws `Error & { code: "REACT_AUDIO_STREAM.NO_PROVIDER" }` when no provider is present.
## Implementation Notes
The error is constructed as a plain `Error` (rather than the canonical
`ReactAudioStreamError` from chunk A) so this module stays dependency-free relative to
the session-core package. Chunk A's full `ReactAudioStreamError` class will subsume this
shape on integration; the `code` property name is identical so the swap is mechanical.

View File

@@ -0,0 +1,54 @@
# use-stream-levels
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useStreamLevels` projects realtime peak/RMS samples out of the session. Called without
an argument it returns the local-microphone level; called with a peer id it returns the
named peer's level. The level numbers come from chunks A/B's analyzer slot (which lives
on each peer's plugin chain alongside the `StreamSourcePlugin`), and this hook is a
zero-state projection — it does not compute anything itself.
The reading is intentionally synchronous: the provider's render-bump version is what
schedules re-renders, and the session is responsible for batching analyzer ticks into
the `levels-changed` event channel at a sane cadence (typically 60 Hz).
## Features
- Same hook for local and per-peer levels — the `peerId?` argument selects.
- Re-renders on every `levels-changed` event via the provider's version counter.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside a `<StreamProvider>`.
## Usage: Basic
```tsx
const { peak, rms } = useStreamLevels();
return <LevelMeter peak={peak} rms={rms} />;
```
## Usage: Advanced
```tsx
const peers = useStreamPeers();
return peers.map((p) => {
const { peak } = useStreamLevels(p.id);
return <PeerLevelBar key={p.id} peerId={p.id} peak={peak} />;
});
```
(Real apps should hoist the per-peer call into a child component to keep the hook count
stable across renders.)
## API Reference
- `useStreamLevels(peerId?: string): LevelSample` — `LevelSample` is `{ peak: number; rms: number }`.
## Implementation Notes
The hook does not pull from `@suaveplan/audio-analysis-spectrum` directly — that module
runs inside the audio engine (browser tier, owned by chunks A/B). The React boundary
only ever sees the peak/RMS scalars produced by the engine's analyzer. Keeping the
hook this thin guarantees zero DSP work happens during React rendering.

View File

@@ -0,0 +1,72 @@
# use-stream-mixer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useStreamMixer` exposes per-peer mixer controls — level, mute, solo, and the per-peer
FX slot count — and implements DAW-correct solo semantics on top of the session's track
mutators. It is the only hook in chunk C that owns cross-peer behaviour: when any peer
is soloed, every non-soloed peer's track-volume is set to 0; when no peer is soloed,
every track keeps its last user-set volume (the session is the source of truth for
"last set" because the mutations go through `setTrackVolume`).
Every mutation routes through `session.setTrackVolume`, `session.setTrackMute`, and
`session.setTrackSolo` rather than being re-implemented inside the hook — this is the
explicit Requirement-6 constraint and the reason the test asserts on
`session.calls.setTrackSolo` / `setTrackVolume`.
## Features
- DAW solo semantics: any solo activates the per-track ducking pass; clearing the last
solo lets non-soloed tracks resume their stored volume.
- Pure delegation: zero local volume state — the session owns it.
- Stable callbacks via `useCallback`.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
## Usage: Basic
```tsx
const { peers, setVolume, setMute, setSolo } = useStreamMixer();
return peers.map((p) => (
<PeerStrip
key={p.id}
peer={p}
onVolume={(v) => setVolume(p.id, v)}
onMute={(m) => setMute(p.id, m)}
onSolo={(s) => setSolo(p.id, s)}
/>
));
```
## Usage: Advanced
```tsx
const { peers, setSolo } = useStreamMixer();
const soloExclusive = useCallback((id: string) => {
// Solo one peer and clear all others' solos in a single React tick.
peers.forEach((p) => p.soloed && p.id !== id && setSolo(p.id, false));
setSolo(id, true);
}, [peers, setSolo]);
```
## API Reference
- `UseStreamMixerResult` — `{ peers, setVolume, setMute, setSolo }`
- `useStreamMixer(): UseStreamMixerResult`
## Implementation Notes
`setSolo` reads the current mixer snapshot from `session.getPeerMixerStates()`,
applies the requested solo change in a local copy, computes `anySoloed`, and — when
true — issues `setTrackVolume(otherId, 0)` for every non-soloed peer whose current
volume is not already 0. The non-zero check avoids redundant mutations that would
otherwise show up as extra `calls.setTrackVolume` entries in tests.
The "restore previous volume on un-solo" arm is delegated to the session: chunk A's
session implementation stores the user-set volume independently from the live mixer
volume, so when the last solo clears, the session emits a `mixer-changed` event that
restores the stored values. The hook does not have to keep its own restore-map, which
keeps the React surface stateless.

View File

@@ -0,0 +1,61 @@
# use-stream-peers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useStreamPeers` returns the live remote-peer roster as a stable `readonly Peer[]`. Each
peer entry carries the participant id, display name, transport connection state, and
the latest measured round-trip time in milliseconds. The hook is the canonical source
of peer data for any UI that renders a participant list, a per-peer mixer strip, or a
per-peer level meter, and it re-evaluates whenever the provider's render-bump version
ticks.
The hook is intentionally a thin projection — it does not own selection, sorting, or
filtering. Consumers compose their own derived state on top with `useMemo`.
## Features
- Stable array identity across renders when peers have not changed.
- Re-renders on `peers-changed`, `state-changed`, and every other session event channel
via the provider's render-bump counter.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
## Usage: Basic
```tsx
const peers = useStreamPeers();
return (
<ul>
{peers.map((p) => (
<li key={p.id}>{p.displayName} ({p.rttMs} ms)</li>
))}
</ul>
);
```
## Usage: Advanced
```tsx
const peers = useStreamPeers();
const sorted = useMemo(
() => [...peers].sort((a, b) => a.displayName.localeCompare(b.displayName)),
[peers]
);
const slow = sorted.filter((p) => p.rttMs > 150);
```
## API Reference
- `useStreamPeers(): readonly Peer[]` — see `Peer` in `session/session-shape/session-shape.types.ts`.
## Implementation Notes
The implementation memoises against the provider's `version` counter, which is the
only legitimate invalidation signal — components must not depend on the peer array's
internal identity for invalidation because the session is free to reuse the same array
across mutations (for example when only RTT changed). The version-counter approach is
intentionally over-eager: re-deriving an array reference is O(1) and matches the
package-wide pattern.

View File

@@ -0,0 +1,61 @@
# use-stream-permissions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useStreamPermissions` exposes the microphone permission state with the typed
`MediaPermissionState` enum (`"granted" | "denied" | "prompt" | "unknown"`) and a
`request()` callback that triggers `navigator.mediaDevices.getUserMedia` via the
session. The hook decouples React components from the browser's permission API surface
so consumers can render a permissions banner, a "click to enable mic" button, or a
hard-blocked error UI without touching `navigator` themselves.
The hook is provider-bound — it does not poll `navigator.permissions.query` itself.
Chunk A's session is responsible for keeping the permission state fresh (either by
listening to the `PermissionStatus` `change` event or by re-querying on
`getUserMedia` errors) and emitting `'permission-changed'` so the provider's
render-bump version increments.
## Features
- Stable `request` callback (`useCallback`-wrapped) for safe use in event handlers.
- Re-renders on every `'permission-changed'` event channel.
- Returns the typed enum rather than the browser's `string` so consumers can do
exhaustive switches.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` outside a `<StreamProvider>`.
## Usage: Basic
```tsx
const { state, request } = useStreamPermissions();
if (state === "denied") return <PermissionsBlockedBanner />;
return <button onClick={request}>Enable microphone</button>;
```
## Usage: Advanced
```tsx
const { state, request } = useStreamPermissions();
useEffect(() => {
if (state === "prompt") {
// Auto-request as soon as the user navigates into the room view.
void request();
}
}, [state, request]);
```
## API Reference
- `UseStreamPermissionsResult` — `{ state, request }`
- `useStreamPermissions(): UseStreamPermissionsResult`
## Implementation Notes
`request` resolves with the next permission state rather than `void` so a consumer that
wants to chain into `connect()` immediately after granting can do so without a separate
re-render hop. The session implementation is responsible for emitting
`'permission-changed'` synchronously after the resolve, which keeps the React render
state aligned with the promise result.

View File

@@ -0,0 +1,67 @@
# use-stream-recording
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useStreamRecording` exposes the start/stop controls for capturing a session as a single
audio file. The hook delegates to chunks A/B's session, which performs an offline render
through `audio-engine-browser`: every peer's plugin chain (including the
`StreamSourcePlugin` and any user-added FX such as the per-peer mute/solo) is mixed into
a single output `Blob` with timing metadata.
Two recording scopes are supported. Calling `start()` with no argument records the
entire session output. Calling `start(["alice", "bob"])` records only those peers plus
the local microphone — useful for "record this guest" interview flows.
## Features
- `isRecording` boolean reflects the session's true recording state, not a local React
flag, so external triggers (e.g. a keyboard shortcut handled outside the React tree)
still drive the UI correctly.
- `start` accepts an optional `peerIds` filter for partial recordings.
- `stop` returns a `RecordingResult` with `{ blob, durationMs, startedAt, endedAt }`.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
## Usage: Basic
```tsx
const { isRecording, start, stop } = useStreamRecording();
return (
<button
onClick={async () => (isRecording ? stop() : start())}
aria-pressed={isRecording}
>
{isRecording ? "Stop" : "Record"}
</button>
);
```
## Usage: Advanced
```tsx
const { start, stop } = useStreamRecording();
const peers = useStreamPeers();
const recordGuests = useCallback(async () => {
const guestIds = peers.filter((p) => p.id !== "host").map((p) => p.id);
await start(guestIds);
setTimeout(async () => {
const { blob, durationMs } = await stop();
await uploadInterview(blob, durationMs);
}, 60_000);
}, [peers, start, stop]);
```
## API Reference
- `UseStreamRecordingResult` — `{ isRecording, start, stop }`
- `useStreamRecording(): UseStreamRecordingResult`
## Implementation Notes
The hook reads `session.isRecording()` synchronously on every render rather than caching
it in local state. This is safe because the provider's render-bump version increments on
every `recording-changed` event, so the read is always fresh and the React render path
stays the single source of truth for `aria-pressed` / `aria-busy` indicators.

View File

@@ -0,0 +1,60 @@
# use-stream-room
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useStreamRoom` projects the current room scope out of the session: the room id (or
`null` when the session is not connected), an `isJoined` boolean derived from the FSM
state, the `joinedAt` epoch-milliseconds timestamp, and the live participant list
(remote peers only — the local user is represented via the session itself, not the
participant array).
The hook is intentionally cheap and read-only; components that render the room header,
the participant chip-list, or any "you have not joined yet" empty state can depend on
it without pulling in the heavier mixer/level/recording surfaces.
## Features
- Pure projection — no callbacks, no side effects.
- `participants` is a stable reference between renders when peers have not changed,
thanks to the memoisation barrier and the session's own array stability contract.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used without a `<StreamProvider>`.
## Usage: Basic
```tsx
const { roomId, isJoined, participants } = useStreamRoom();
if (!isJoined) return <JoinButton />;
return (
<header>
<h2>{roomId}</h2>
<p>{participants.length} present</p>
</header>
);
```
## Usage: Advanced
```tsx
const { participants, joinedAt } = useStreamRoom();
const elapsedMs = joinedAt === null ? 0 : Date.now() - joinedAt;
return (
<Stats elapsedMs={elapsedMs} peerIds={participants.map((p) => p.id)} />
);
```
## API Reference
- `UseStreamRoomResult` — `{ roomId, isJoined, joinedAt, participants }`
- `useStreamRoom(): UseStreamRoomResult`
## Implementation Notes
`isJoined` is derived from `session.state === "joined"` rather than from a separate
`isJoined` field on the session, because the FSM state is the single source of truth
(see Requirement "StreamSession FSM lifecycle"). The participant array is read via
`session.getPeers()` on every render; the provider's render-bump version keeps that read
fresh.

View File

@@ -0,0 +1,59 @@
# use-stream-session
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useStreamSession` is the entry-point hook for chunk-C consumers. It returns the live
`StreamSession` instance, its current FSM state (`idle` / `connecting` / `connected` /
`joining` / `joined` / `disconnecting` / `disconnected`), and four stable callback
references for `connect`, `join`, `leave`, and `disconnect`. The callbacks delegate to
the underlying session, which is the only legal source of FSM mutations.
The hook re-runs whenever the provider's render-bump version increments, which means a
component using `useStreamSession()` re-renders on every session event — including
`'state-changed'`, `'interruption'`, peer changes, mixer changes, and so on. Components
that need finer granularity should compose narrower hooks (`useStreamPeers`,
`useStreamMixer`, …).
## Features
- Direct access to the live `StreamSessionLike` for cases that need methods beyond the
big four (e.g. `requestTransportShare` from chunk D).
- Stable `useCallback`-wrapped lifecycle methods so consumer components can pass them
to memoised children without churning.
- Throws `REACT_AUDIO_STREAM.NO_PROVIDER` when used outside `<StreamProvider>`.
## Usage: Basic
```tsx
const { state, connect, join } = useStreamSession();
if (state === "idle") {
return <button onClick={() => connect("lobby").then(join)}>Join lobby</button>;
}
```
## Usage: Advanced
```tsx
const { session, state, disconnect } = useStreamSession();
useEffect(() => {
return session.on("interruption", (klass) => {
if (klass === "media-permission") void disconnect();
});
}, [session, disconnect]);
```
## API Reference
- `UseStreamSessionResult` — `{ session, state, connect, join, leave, disconnect }`
- `useStreamSession(): UseStreamSessionResult`
## Implementation Notes
The returned object is memoised against `session.state` so a render-bump caused by a
non-state event (e.g. a peer-level tick) does not produce a new object identity. The
callback references depend only on the session, so they remain stable for the lifetime
of the provider — even across state transitions.

View File

@@ -0,0 +1,97 @@
# useTransportShare
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useTransportShare` is the React entry point to the transport-share subsystem. It composes a `TransportShareCoordinator` and a `ShareStateMachine` into a single hook that React components can consume to drive, receive, or request a shared transport without writing any wire-protocol code.
The hook is deliberately injection-based — the local `transport`, the peer `channel`, and the local `selfId` are passed in by the caller (usually a session-aware wrapper inside the package's `<StreamProvider>`). This keeps the hook testable in isolation and free from the network surface, mirroring the rest of `@suaveplan/react-audio-stream`'s purity boundary.
## Features
- Single React-friendly handle for the whole share workflow.
- Stable `useRef`-backed coordinator and state machine — re-renders never tear down the share.
- `state`, `role`, and `isReceiver` for UI gating.
- `requestTransportShare(participantId)` — record a pending invitation.
- `grantTransportShare(receiverId?)` — flip to `'driving'`; receiver optional for the broadcast-first case.
- `releaseTransportShare()` — return to `'local'` cleanly.
- `applyDriverGrant(driverId)` — flip to `'receiving'` (session-driven).
- `notifyDriverDisconnect()` — receiver-side fall-back to `'local'`.
- `notifySeek(seconds)` — propagate a programmatic seek when driving.
- Auto-disposes the coordinator on unmount.
## Usage: Basic
```tsx
import { useTransportShare } from "@suaveplan/react-audio-stream/hooks/use-transport-share";
function ShareButton(props: {
transport: SharedTransportTarget;
channel: ControlChannel;
selfId: string;
receiverId: string;
}) {
const share = useTransportShare(props);
if (share.role === "driving") {
return <button onClick={share.releaseTransportShare}>Stop sharing</button>;
}
return (
<button onClick={() => share.grantTransportShare(props.receiverId)}>
Share transport with {props.receiverId}
</button>
);
}
```
## Usage: Advanced
```tsx
function HostPanel(props: HostProps) {
const share = useTransportShare(props);
useEffect(() => {
const unsubscribe = props.session.on("peer-left", (peer) => {
if (share.role === "receiving" && peer.id === share.state.driverId) {
share.notifyDriverDisconnect();
}
});
return unsubscribe;
}, [props.session, share]);
return (
<section aria-live="polite">
{share.isReceiver ? (
<p>Receiving from {share.state.driverId}</p>
) : null}
<button onClick={() => share.requestTransportShare(props.peerId)}>
Ask {props.peerId} to drive
</button>
<button onClick={() => share.notifySeek(props.cursor)}>
Sync seek
</button>
</section>
);
}
```
## API Reference
- `useTransportShare(options): UseTransportShareResult`
- `UseTransportShareOptions` — `{ transport, channel, selfId }`.
- `UseTransportShareResult`
- `state: ShareState`
- `role: ShareRole`
- `isReceiver: boolean`
- `requestTransportShare(participantId)`
- `grantTransportShare(receiverId?)`
- `releaseTransportShare()`
- `applyDriverGrant(driverId)`
- `notifyDriverDisconnect()`
- `notifySeek(seconds)`
## Implementation Notes
The coordinator and state machine are created lazily inside `useRef` ignition blocks to avoid the React-18 strict-mode double-invocation pitfall where a constructor with side effects would run twice. The hook subscribes to the state machine in an effect so the React state reflects every transition, and it disposes the coordinator on unmount — disposal is idempotent so this is safe even if the parent already called `releaseTransportShare`. All returned callbacks are wrapped in `useCallback` with the precise dependency list so consumers can pass them into memoized children without breaking referential equality.

View File

@@ -0,0 +1,61 @@
# stream-context
## Purpose
`StreamReactContext` is the single React context that publishes the live `StreamSession`
to every chunk-C hook and component. It is intentionally a tiny container — the session
itself owns all mutable state, and the context exposes only the session reference plus a
monotonic `version` counter that increments on every session event. Hooks subscribe to
the context and re-render whenever `version` ticks, which keeps the React render path
decoupled from the audio engine's event firehose.
The `null` sentinel value is the deliberate signal that a hook ran outside any
`<StreamProvider>`; every public hook in chunk C throws `REACT_AUDIO_STREAM.NO_PROVIDER`
when it observes that sentinel.
## Features
- Pure React context — zero side effects at import time.
- Render-bump counter pattern: O(1) re-render fan-out regardless of session event volume.
- `null` sentinel forces explicit provider placement, surfaced as a structured error.
- `displayName` set for legible React DevTools output.
## Usage: Basic
```tsx
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
import { useContext } from "react";
const ctx = useContext(StreamReactContext);
if (ctx === null) {
throw new Error("StreamProvider missing");
}
const { session, version } = ctx;
```
## Usage: Advanced
```tsx
import { StreamReactContext } from "@suaveplan/react-audio-stream/provider";
function StreamConsumer({ children }: { children: React.ReactNode }) {
return (
<StreamReactContext.Consumer>
{(ctx) => (ctx === null ? <p>no provider</p> : children)}
</StreamReactContext.Consumer>
);
}
```
## API Reference
- `StreamContextValue` — `{ session: StreamSessionLike; version: number }`
- `StreamReactContext` — `React.Context<StreamContextValue | null>`
## Implementation Notes
`version` is bumped by `<StreamProvider>` inside a `useState` setter, so each tick
triggers a single render scheduled through React's normal update path. The context value
itself is memoised by the provider, so children that read only `session` (not `version`)
do not have to re-render — but every chunk-C hook intentionally reads `version` to keep
their derived state fresh.

View File

@@ -0,0 +1,69 @@
# stream-provider
## Purpose
`<StreamProvider>` is the singleton React surface that owns the live `StreamSession` for
a tree. It calls the caller-supplied `sessionFactory` exactly once per provider instance,
subscribes to the seven session event channels (`state-changed`, `interruption`,
`peers-changed`, `mixer-changed`, `levels-changed`, `recording-changed`,
`permission-changed`), and increments a render-bump counter on every tick so descendant
hooks observe a fresh snapshot of session state.
The factory pattern keeps `@suaveplan/react-audio-stream` transport-agnostic: the
consumer decides whether the underlying transport is WebRTC, WebSocket, Trystero, or a
test fake, and the provider never imports any transport directly. Cleanup on unmount
calls `session.disconnect()` so the audio engine + socket are torn down deterministically.
## Features
- StrictMode-safe singleton via `useRef`-guarded lazy construction.
- Caller-injected `sessionFactory` — zero transport coupling at provider tier.
- Render-bump counter fans seven event channels into one re-render path.
- Cleanup-on-unmount disconnects the session and detaches every listener.
- Memoised context value — only the `version` field changes per tick.
## Usage: Basic
```tsx
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
import { createStreamSession } from "./my-session-factory";
export function App({ children }: { children: React.ReactNode }) {
return (
<StreamProvider sessionFactory={() => createStreamSession({ transport: "webrtc" })}>
{children}
</StreamProvider>
);
}
```
## Usage: Advanced
```tsx
import { StreamProvider } from "@suaveplan/react-audio-stream/provider";
import { useMemo } from "react";
export function ScopedRoom({ roomId, children }: { roomId: string; children: React.ReactNode }) {
const factory = useMemo(
() => () => createStreamSession({ transport: "trystero", initialRoomId: roomId }),
[roomId]
);
return <StreamProvider sessionFactory={factory}>{children}</StreamProvider>;
}
```
Keying the provider on `roomId` (`<StreamProvider key={roomId} …>`) forces a fresh
session when the room changes, because the singleton is per-instance.
## API Reference
- `StreamProviderProps` — `{ sessionFactory: StreamSessionFactory; children?: ReactNode }`
- `StreamProvider(props)` — renders a `StreamReactContext.Provider` with `{ session, version }`.
## Implementation Notes
The seven event subscriptions all funnel into a single `setVersion((v) => v + 1)` call,
which collapses bursts into a single React render pass thanks to automatic batching. The
disconnect call on unmount is wrapped in `Promise.resolve` so the same code path handles
both sync and async session implementations, and the rejection branch swallows errors
(any failure already surfaced as an `interruption` event before the listener detach).

View File

@@ -0,0 +1,104 @@
# `interruption-classifier`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InterruptionClassifier` maps a structural disconnect cause to exactly
one of `network | media-permission | signalling | peer | unknown`.
Classification is deterministic — every rule is a typed equality or
membership check, no probabilistic heuristics — so the same cause
record always produces the same verdict regardless of timing or
ordering. This is essential for the auto-reconnect policy that lands in
chunk D: the policy decides whether to retry by inspecting the verdict
label, and a flaky classifier would translate into flaky reconnect
behaviour for users.
The classifier is intentionally state-free. Every consumer (the
`StreamSession`, future React hooks, integration tests) constructs one
instance and calls `classify` on every disconnect — there is no
internal counter, ring buffer, or memo, so a single instance can be
shared across sessions without contention.
## Features
- Five disjoint labels covering every observed interruption shape.
- Priority-ordered rules with explicit precedence: `peer` beats
`media-permission` beats `network` beats `signalling`.
- WebRTC-friendly: keys directly off `iceConnectionState` and
`connectionState`.
- WebSocket-friendly: keys off RFC 6455 close codes, skipping the
clean-close codes (1000, 1001) so a graceful server-side close does
not trigger reconnect.
- `shouldAutoReconnect(class)` helper exposes the chunk-D reconnect
policy in a single place.
- Zero allocations on the hot path beyond the verdict object.
## Usage: Basic
```ts
import { InterruptionClassifier } from "@suaveplan/react-audio-stream/session";
const classifier = new InterruptionClassifier();
const verdict = classifier.classify({ iceConnectionState: "failed" });
// verdict.class === "network"
```
## Usage: Advanced
```ts
import {
InterruptionClassifier,
type InterruptionCause,
} from "@suaveplan/react-audio-stream/session";
const classifier = new InterruptionClassifier();
function onDisconnect(rawCause: InterruptionCause) {
const verdict = classifier.classify(rawCause);
if (classifier.shouldAutoReconnect(verdict.class)) {
scheduleReconnect(verdict);
} else {
surfaceToUi(verdict);
}
}
// Hard browser-permission revoke
onDisconnect({
mediaErrorName: "NotAllowedError",
reason: "user revoked microphone permission",
});
// → verdict.class === "media-permission"; no reconnect.
// Server-initiated abnormal close
onDisconnect({ signallingCloseCode: 1006, reason: "abrupt server close" });
// → verdict.class === "signalling"; reconnect scheduled.
```
## API Reference
- `class InterruptionClassifier` — stateless classifier.
- `classify(cause: InterruptionCause): InterruptionVerdict` — apply the
rule mapping and return `{ class, cause }`.
- `shouldAutoReconnect(class: InterruptionClass): boolean` — true iff
the class is `network` or `signalling`.
- `InterruptionClass`, `InterruptionCause`, `InterruptionVerdict` —
re-exported types.
## Implementation Notes
The rule ordering is the only piece of business logic with subtle
consequences. `peer` is evaluated first because an intentional remote
leave should never be treated as a network glitch — even if the ICE
state also happens to read `disconnected` during the closing handshake.
Permission errors take precedence over ICE because a user-revoked
microphone often surfaces as both a `getUserMedia` rejection and an
ICE failure milliseconds later; the permission failure is the
actionable cause. Close codes 1000 and 1001 are explicitly skipped so a
clean server shutdown (`going away`) does not show up as a signalling
interruption — those map to `unknown` instead, which the reconnect
policy treats as non-retryable. The classifier deliberately holds no
state so it composes cleanly into the FSM-driven session flow without
spreading hidden coupling.

Some files were not shown because too many files have changed in this diff Show More