diff --git a/docs/form-validation-framework.md b/docs/form-validation-framework.md
new file mode 100644
index 0000000..bf7ef63
--- /dev/null
+++ b/docs/form-validation-framework.md
@@ -0,0 +1,111 @@
+# Shared form validation framework
+
+Closes #183.
+
+Before this, each dashboard form (asset registration, compliance/whitelist
+actions, minting) rolled its own validation and its own error markup —
+one global banner in some forms, an ad-hoc field message in others,
+different icons, different wording for "this field is required." The
+shared framework in `src/features/forms/validation/` gives every form the
+same building blocks so validation logic and error display look and behave
+the same way everywhere.
+
+It does **not** replace the existing per-domain validators
+(`assetCreationRequest.ts`, `whitelist.ts`, `mintRequest.ts`) — those still
+own the business rules for their domain (ticker format, duplicate checks,
+compliance guards, etc). This framework standardizes how *any* form —
+including those — tracks and displays field errors.
+
+## What's in the module
+
+| File | Purpose |
+| --- | --- |
+| `types.ts` | `FieldErrors`, `ValidationRule` — the shared shapes. |
+| `rules.ts` | Composable rule builders (`required`, `minLength`, `maxLength`, `pattern`, `numberInRange`, `notIn`, `oneOf`) plus `validateField` / `validateForm` runners. |
+| `useFormErrors.ts` | React hook that holds a form's `FieldErrors` state and exposes `setFieldError`, `clearFieldError`, `clearAll`, `errorFor`, `hasErrors`. |
+| `FormFieldError.tsx` | Renders a single field's error message under its input (icon + red text, `role="alert"`). Renders nothing when there's no message. |
+| `FormError.tsx` | Renders a form-level banner for errors that aren't tied to one field (e.g. a submit-time or async check failure). |
+
+Everything is re-exported from `src/features/forms/validation/index.ts`.
+
+## Using it in a new form
+
+```tsx
+import { useFormErrors, FormFieldError, FormError, validateForm, required, minLength, pattern } from '@/features/forms/validation';
+
+type Field = 'name' | 'ticker';
+
+function MyForm() {
+ const [name, setName] = useState('');
+ const [ticker, setTicker] = useState('');
+ const [formError, setFormError] = useState('');
+ const fieldErrors = useFormErrors();
+
+ const handleSubmit = () => {
+ const { errors, isValid } = validateForm(
+ { name, ticker },
+ {
+ name: [required('Name is required.'), minLength(3, 'Name must be at least 3 characters.')],
+ ticker: [required('Ticker is required.'), pattern(/^[A-Z0-9]{2,10}$/, 'Ticker must be 2-10 letters/numbers.')],
+ },
+ );
+
+ fieldErrors.setErrors(errors);
+ if (!isValid) return;
+
+ // ...submit
+ };
+
+ return (
+
+ );
+}
+```
+
+## Adopting it in an existing form with its own validator
+
+If a form already has a domain validator that returns one error code at a
+time (like `validateAssetCreationRequest`), map each error code to the field
+it belongs to and route it through `fieldErrors.setFieldError`, falling back
+to the `FormError` banner for errors that aren't about a single field (e.g.
+"fill in every field"). See `AssetCreationWizard.tsx` for a worked example
+of this pattern (`ERROR_FIELD` map + `applyValidationError`).
+
+## Where it's used today
+
+- **`AssetCreationWizard`** (asset registration) — per-field errors for
+ asset name, ticker, amount, and jurisdiction; a `FormError` banner for
+ non-field-specific failures (e.g. missing fields).
+- **`WhitelistManager`** (compliance/admin) — per-field error under the
+ address input, replacing the previous single ad-hoc error paragraph.
+- **`MintWorkflow`** (minting) — its submit-time and async compliance-check
+ errors now render through the shared `FormError` banner instead of a
+ one-off `
`, so the visual style matches every other form.
+
+## Accessibility
+
+- `FormFieldError` and `FormError` both use `role="alert"` so assistive
+ technology announces new errors as they appear.
+- Pass an `id` to `FormFieldError` and wire it to the input's
+ `aria-describedby` (see the examples in `AssetCreationWizard.tsx` and
+ `WhitelistManager.tsx`) so screen readers announce the specific error
+ when the field is focused, not just when it first appears.
+
+## Testing
+
+`rules.test.ts` and `useFormErrors.test.ts` cover the pure logic and the
+hook in isolation (no DOM needed for the rules; `@testing-library/react`'s
+`renderHook` for the hook). Component-level tests for each form continue to
+assert on the rendered error text/role as before — adopting the shared
+framework didn't change any form's public behavior or copy, only how the
+error state and markup are produced internally.
diff --git a/src/features/asset-creation/components/AssetCreationWizard.tsx b/src/features/asset-creation/components/AssetCreationWizard.tsx
index 3a9f427..ad61480 100644
--- a/src/features/asset-creation/components/AssetCreationWizard.tsx
+++ b/src/features/asset-creation/components/AssetCreationWizard.tsx
@@ -4,10 +4,28 @@ import {
ASSET_CREATION_ERROR_MESSAGES,
SUPPORTED_JURISDICTIONS,
ASSET_CLASS_OPTIONS,
+ type AssetCreationErrorCode,
} from '@/lib/assetCreationRequest';
+import { useFormErrors, FormFieldError, FormError } from '@/features/forms/validation';
import type { IssuanceRequest } from '@/fixtures/issuer';
type WizardStep = 'form' | 'review' | 'success';
+type AssetCreationField = 'assetName' | 'ticker' | 'amount' | 'jurisdiction';
+
+/**
+ * `validateAssetCreationRequest` reports one error code at a time. Map each
+ * code to the field it belongs to so it renders under the right input via
+ * the shared `FormFieldError`; codes that aren't about a single field (e.g.
+ * several fields left blank) fall back to the form-level `FormError` banner.
+ */
+const ERROR_FIELD: Partial> = {
+ ASSET_NAME_TOO_SHORT: 'assetName',
+ INVALID_TICKER: 'ticker',
+ DUPLICATE_TICKER: 'ticker',
+ NON_POSITIVE_AMOUNT: 'amount',
+ AMOUNT_TOO_LARGE: 'amount',
+ UNSUPPORTED_JURISDICTION: 'jurisdiction',
+};
export interface AssetCreationWizardProps {
/** Tickers already registered, for duplicate-ticker validation. */
@@ -48,18 +66,34 @@ export default function AssetCreationWizard({
const [amount, setAmount] = useState('');
const [jurisdiction, setJurisdiction] = useState(SUPPORTED_JURISDICTIONS[0]);
const [assetClass, setAssetClass] = useState(ASSET_CLASS_OPTIONS[0]);
- const [error, setError] = useState('');
+ const [formError, setFormError] = useState('');
const [lastCreated, setLastCreated] = useState(null);
+ const fieldErrors = useFormErrors();
+
+ /** Routes a validation error code to the right field or the form banner. */
+ const applyValidationError = (code: AssetCreationErrorCode) => {
+ const message = ASSET_CREATION_ERROR_MESSAGES[code];
+ const field = ERROR_FIELD[code];
+ if (field) {
+ fieldErrors.clearAll();
+ fieldErrors.setFieldError(field, message);
+ setFormError('');
+ } else {
+ fieldErrors.clearAll();
+ setFormError(message);
+ }
+ };
const handleReview = () => {
- setError('');
+ setFormError('');
+ fieldErrors.clearAll();
const validation = validateAssetCreationRequest(
{ assetName, ticker, amount, jurisdiction, assetClass },
{ existingTickers },
);
if (!validation.valid) {
- setError(ASSET_CREATION_ERROR_MESSAGES[validation.error!]);
+ applyValidationError(validation.error!);
return;
}
@@ -75,7 +109,7 @@ export default function AssetCreationWizard({
// Re-validate on confirm: existingTickers may have changed while the
// user sat on the review screen (e.g. another request was created).
if (!validation.valid || validation.parsedAmount === undefined) {
- setError(ASSET_CREATION_ERROR_MESSAGES[validation.error!]);
+ applyValidationError(validation.error!);
setStep('form');
return;
}
@@ -102,7 +136,8 @@ export default function AssetCreationWizard({
setAmount('');
setJurisdiction(SUPPORTED_JURISDICTIONS[0]);
setAssetClass(ASSET_CLASS_OPTIONS[0]);
- setError('');
+ setFormError('');
+ fieldErrors.clearAll();
setLastCreated(null);
setStep('form');
};
@@ -168,11 +203,7 @@ export default function AssetCreationWizard({
- {error && (
-
- {error}
-
- )}
+
Submitting sends this asset for compliance review. This is a protocol-level
@@ -207,11 +238,7 @@ export default function AssetCreationWizard({
approval — this does not mint supply directly.