attaform 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +21 -11
  2. package/bin/attaform.mjs +78 -0
  3. package/dist/abstract.cjs +47 -0
  4. package/dist/abstract.cjs.map +1 -0
  5. package/dist/abstract.d.cts +42 -0
  6. package/dist/abstract.d.mts +42 -0
  7. package/dist/abstract.d.ts +42 -0
  8. package/dist/abstract.mjs +4 -0
  9. package/dist/abstract.mjs.map +1 -0
  10. package/dist/chunks/devtools.cjs +2 -2
  11. package/dist/chunks/devtools.cjs.map +1 -1
  12. package/dist/chunks/devtools.mjs +1 -1
  13. package/dist/chunks/fingerprint2.cjs +1 -1
  14. package/dist/chunks/fingerprint2.mjs +1 -1
  15. package/dist/esbuild.cjs +2 -2
  16. package/dist/esbuild.cjs.map +1 -1
  17. package/dist/esbuild.d.cts +3 -3
  18. package/dist/esbuild.d.mts +3 -3
  19. package/dist/esbuild.d.ts +3 -3
  20. package/dist/esbuild.mjs +2 -2
  21. package/dist/esbuild.mjs.map +1 -1
  22. package/dist/index.cjs +41 -56
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +194 -282
  25. package/dist/index.d.mts +194 -282
  26. package/dist/index.d.ts +194 -282
  27. package/dist/index.mjs +4 -25
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/nuxt.cjs +3 -1
  30. package/dist/nuxt.cjs.map +1 -1
  31. package/dist/nuxt.d.cts +17 -4
  32. package/dist/nuxt.d.mts +17 -4
  33. package/dist/nuxt.d.ts +17 -4
  34. package/dist/nuxt.mjs +4 -2
  35. package/dist/nuxt.mjs.map +1 -1
  36. package/dist/rollup.cjs +2 -2
  37. package/dist/rollup.cjs.map +1 -1
  38. package/dist/rollup.d.cts +3 -3
  39. package/dist/rollup.d.mts +3 -3
  40. package/dist/rollup.d.ts +3 -3
  41. package/dist/rollup.mjs +2 -2
  42. package/dist/rollup.mjs.map +1 -1
  43. package/dist/rspack.cjs +1 -1
  44. package/dist/rspack.d.cts +1 -1
  45. package/dist/rspack.d.mts +1 -1
  46. package/dist/rspack.d.ts +1 -1
  47. package/dist/rspack.mjs +1 -1
  48. package/dist/runtime/plugins/attaform.cjs +4 -5
  49. package/dist/runtime/plugins/attaform.cjs.map +1 -1
  50. package/dist/runtime/plugins/attaform.mjs +1 -2
  51. package/dist/runtime/plugins/attaform.mjs.map +1 -1
  52. package/dist/shared/{attaform.BRzKBj2g.cjs → attaform.8tFZu1Ub.cjs} +2 -2
  53. package/dist/shared/{attaform.BRzKBj2g.cjs.map → attaform.8tFZu1Ub.cjs.map} +1 -1
  54. package/dist/shared/{attaform.k9V0py0s.mjs → attaform.BERt5YMU.mjs} +2 -2
  55. package/dist/shared/{attaform.k9V0py0s.mjs.map → attaform.BERt5YMU.mjs.map} +1 -1
  56. package/dist/shared/{attaform.CN4ZEGo6.mjs → attaform.BceJrhQ7.mjs} +5 -5
  57. package/dist/shared/{attaform.CN4ZEGo6.mjs.map → attaform.BceJrhQ7.mjs.map} +1 -1
  58. package/dist/shared/{attaform.i_LA3X9o.d.cts → attaform.C3WnCDXz.d.cts} +81 -24
  59. package/dist/shared/{attaform.i_LA3X9o.d.ts → attaform.C3WnCDXz.d.mts} +81 -24
  60. package/dist/shared/{attaform.i_LA3X9o.d.mts → attaform.C3WnCDXz.d.ts} +81 -24
  61. package/dist/shared/{attaform.DuzQYscR.d.ts → attaform.C9fPTxjB.d.cts} +5 -5
  62. package/dist/shared/{attaform.DuzQYscR.d.cts → attaform.C9fPTxjB.d.mts} +5 -5
  63. package/dist/shared/{attaform.DuzQYscR.d.mts → attaform.C9fPTxjB.d.ts} +5 -5
  64. package/dist/shared/{attaform.B3cgZE3c.d.ts → attaform.CC_y1_Ej.d.ts} +33 -33
  65. package/dist/shared/{attaform.DS_zBcdr.d.ts → attaform.CEXUlUkc.d.ts} +1588 -1190
  66. package/dist/shared/{attaform.Xe8dATED.cjs → attaform.C__F01CE.cjs} +15 -15
  67. package/dist/shared/{attaform.Xe8dATED.cjs.map → attaform.C__F01CE.cjs.map} +1 -1
  68. package/dist/shared/{attaform.3heDHayA.cjs → attaform.Co5LNRnv.cjs} +186 -129
  69. package/dist/shared/attaform.Co5LNRnv.cjs.map +1 -0
  70. package/dist/shared/{attaform.BJt4ZCkM.d.mts → attaform.CpXHK60W.d.cts} +33 -33
  71. package/dist/shared/{attaform.DmKLa4tL.d.cts → attaform.CvVYOvZF.d.mts} +33 -33
  72. package/dist/shared/{attaform.DK3xUBkj.mjs → attaform.CyQxTOdX.mjs} +162 -79
  73. package/dist/shared/attaform.CyQxTOdX.mjs.map +1 -0
  74. package/dist/shared/{attaform.B5GFS3Su.d.cts → attaform.D99pd7JG.d.cts} +1588 -1190
  75. package/dist/shared/{attaform.CwXXbZIQ.mjs → attaform.DDAmSFnl.mjs} +4 -4
  76. package/dist/shared/{attaform.CwXXbZIQ.mjs.map → attaform.DDAmSFnl.mjs.map} +1 -1
  77. package/dist/shared/{attaform.BkUzunyB.cjs → attaform.DQT7_-FO.cjs} +371 -287
  78. package/dist/shared/attaform.DQT7_-FO.cjs.map +1 -0
  79. package/dist/shared/{attaform.CqwcTExO.mjs → attaform.Da8EdI2t.mjs} +182 -129
  80. package/dist/shared/attaform.Da8EdI2t.mjs.map +1 -0
  81. package/dist/shared/{attaform.sks1MFVO.cjs → attaform.DhVgG2jh.cjs} +16 -16
  82. package/dist/shared/{attaform.sks1MFVO.cjs.map → attaform.DhVgG2jh.cjs.map} +1 -1
  83. package/dist/shared/{attaform.sHkHv_98.mjs → attaform.DhnPb6nK.mjs} +6 -3
  84. package/dist/shared/attaform.DhnPb6nK.mjs.map +1 -0
  85. package/dist/shared/attaform.DkD0DR7R.mjs +42 -0
  86. package/dist/shared/attaform.DkD0DR7R.mjs.map +1 -0
  87. package/dist/shared/{attaform.DMNnfQq_.mjs → attaform.DlSu08F5.mjs} +4 -4
  88. package/dist/shared/{attaform.DMNnfQq_.mjs.map → attaform.DlSu08F5.mjs.map} +1 -1
  89. package/dist/shared/{attaform.LEWUFqUw.cjs → attaform.DoQm_Vkd.cjs} +7 -3
  90. package/dist/shared/attaform.DoQm_Vkd.cjs.map +1 -0
  91. package/dist/shared/{attaform.B5YcWrKi.cjs → attaform.Drsi43GG.cjs} +19 -19
  92. package/dist/shared/{attaform.B5YcWrKi.cjs.map → attaform.Drsi43GG.cjs.map} +1 -1
  93. package/dist/shared/attaform.K6Thc6By.cjs +46 -0
  94. package/dist/shared/attaform.K6Thc6By.cjs.map +1 -0
  95. package/dist/shared/{attaform.Vp2-CWZB.mjs → attaform.KvlEedlF.mjs} +3 -3
  96. package/dist/shared/{attaform.Vp2-CWZB.mjs.map → attaform.KvlEedlF.mjs.map} +1 -1
  97. package/dist/shared/{attaform.BRGIpZo4.cjs → attaform.RaTzp1WF.cjs} +3 -3
  98. package/dist/shared/attaform.RaTzp1WF.cjs.map +1 -0
  99. package/dist/shared/{attaform.8vmdOURQ.d.mts → attaform.kBkYlKP_.d.mts} +1588 -1190
  100. package/dist/shared/{attaform.C3Doa9Pt.mjs → attaform.v0fQtWyA.mjs} +3 -3
  101. package/dist/shared/attaform.v0fQtWyA.mjs.map +1 -0
  102. package/dist/shared/{attaform.ZXZ6dH72.cjs → attaform.yUrz7C2z.cjs} +3 -3
  103. package/dist/shared/{attaform.ZXZ6dH72.cjs.map → attaform.yUrz7C2z.cjs.map} +1 -1
  104. package/dist/transforms.cjs +1 -1
  105. package/dist/transforms.mjs +1 -1
  106. package/dist/vite.cjs +21 -3
  107. package/dist/vite.cjs.map +1 -1
  108. package/dist/vite.d.cts +70 -17
  109. package/dist/vite.d.mts +70 -17
  110. package/dist/vite.d.ts +70 -17
  111. package/dist/vite.mjs +20 -4
  112. package/dist/vite.mjs.map +1 -1
  113. package/dist/webpack.cjs +1 -1
  114. package/dist/webpack.d.cts +1 -1
  115. package/dist/webpack.d.mts +1 -1
  116. package/dist/webpack.d.ts +1 -1
  117. package/dist/webpack.mjs +1 -1
  118. package/dist/zod-v3.cjs +41 -10
  119. package/dist/zod-v3.cjs.map +1 -1
  120. package/dist/zod-v3.d.cts +5 -5
  121. package/dist/zod-v3.d.mts +5 -5
  122. package/dist/zod-v3.d.ts +5 -5
  123. package/dist/zod-v3.mjs +4 -2
  124. package/dist/zod-v3.mjs.map +1 -1
  125. package/dist/zod-v4.cjs +41 -10
  126. package/dist/zod-v4.cjs.map +1 -1
  127. package/dist/zod-v4.d.cts +7 -7
  128. package/dist/zod-v4.d.mts +7 -7
  129. package/dist/zod-v4.d.ts +7 -7
  130. package/dist/zod-v4.mjs +4 -2
  131. package/dist/zod-v4.mjs.map +1 -1
  132. package/dist/zod.cjs +44 -49
  133. package/dist/zod.cjs.map +1 -1
  134. package/dist/zod.d.cts +6 -248
  135. package/dist/zod.d.mts +6 -248
  136. package/dist/zod.d.ts +6 -248
  137. package/dist/zod.mjs +4 -42
  138. package/dist/zod.mjs.map +1 -1
  139. package/package.json +12 -2
  140. package/skills/attaform/SKILL.md +128 -0
  141. package/skills/attaform/references/custom-components.md +72 -0
  142. package/skills/attaform/references/errors.md +62 -0
  143. package/skills/attaform/references/ssr.md +26 -0
  144. package/skills/attaform/references/validation.md +40 -0
  145. package/skills/attaform/references/wizards.md +104 -0
  146. package/dist/shared/attaform.3heDHayA.cjs.map +0 -1
  147. package/dist/shared/attaform.BRGIpZo4.cjs.map +0 -1
  148. package/dist/shared/attaform.BkUzunyB.cjs.map +0 -1
  149. package/dist/shared/attaform.C3Doa9Pt.mjs.map +0 -1
  150. package/dist/shared/attaform.CO0e7YVY.d.cts +0 -94
  151. package/dist/shared/attaform.CO0e7YVY.d.mts +0 -94
  152. package/dist/shared/attaform.CO0e7YVY.d.ts +0 -94
  153. package/dist/shared/attaform.CqwcTExO.mjs.map +0 -1
  154. package/dist/shared/attaform.DK3xUBkj.mjs.map +0 -1
  155. package/dist/shared/attaform.LEWUFqUw.cjs.map +0 -1
  156. package/dist/shared/attaform.OLtKd9gM.mjs +0 -35
  157. package/dist/shared/attaform.OLtKd9gM.mjs.map +0 -1
  158. package/dist/shared/attaform.sHkHv_98.mjs.map +0 -1
  159. package/dist/shared/attaform.xBDo0Ko0.cjs +0 -39
  160. package/dist/shared/attaform.xBDo0Ko0.cjs.map +0 -1
  161. package/dist/shared/{attaform.DdjDqTah.d.mts → attaform.DRUT703x.d.cts} +14 -14
  162. package/dist/shared/{attaform.DdjDqTah.d.ts → attaform.DRUT703x.d.mts} +14 -14
  163. package/dist/shared/{attaform.DdjDqTah.d.cts → attaform.DRUT703x.d.ts} +14 -14
@@ -1,931 +1,556 @@
1
- import { F as FormKey, b as UseFormReturnType, G as GenericForm, W as PathKey, m as DisplayCtx, d as GetDisplayState, n as DisplayMachine, V as Path, aa as SlimPrimitiveKind, C as CoercionEntry, g as CoercionRegistry, ag as ValidationError, a as AbstractSchema, ak as WriteMeta, j as DeepPartial, al as WriteShape, at as TransformAbortHolder, ae as ValidateOn, A as AttaformDefaults, c as RegisterValue } from './attaform.i_LA3X9o.cjs';
2
- import { Ref, ComputedRef, App, InjectionKey } from 'vue';
1
+ import { G as GenericForm, F as FormKey, a as UseFormReturnType, a4 as RegisterValue, T as PathKey, k as DisplayCtx, w as GetDisplayState, l as DisplayMachine, S as Path, a9 as SlimPrimitiveKind, C as CoercionEntry, e as CoercionRegistry, af as ValidationError, A as AbstractSchema, aj as WriteMeta, h as DeepPartial, ak as WriteShape, as as TransformAbortHolder, ad as ValidateOn, d as AttaformDefaults, at as RegisterModelDynamicCustomDirective } from './attaform.C3WnCDXz.cjs';
2
+ import { Ref, ComputedRef, App, InjectionKey, Plugin } from 'vue';
3
3
 
4
4
  /**
5
- * Public types for `useWizard` the multistep-form orchestrator.
6
- *
7
- * The wizard is built around an ordered list of step slots. Each slot
8
- * resolves to a participating form: an existing `useForm` reference, a
9
- * bare string key (desugared to a noop form so affordance steps
10
- * participate uniformly), an eagerly-evaluated function slot for
11
- * runtime branching, or a `lazy()`-wrapped function slot that caches
12
- * its resolution and re-fires only on its own tracked deps.
13
- *
14
- * The wizard surface is loosely keyed (`Record<FormKey, …>`).
15
- * Cross-component flows threaded through `injectWizard` lose lexical
16
- * key knowledge anyway, so the public read surface is a string-keyed
17
- * record. Typed per-form access flows back through the original form
18
- * refs and through `wizard.handleSubmit`'s `ctx.get(formRef)` accessor.
19
- */
20
-
21
- /**
22
- * Minimum structural shape the wizard requires from a participating
23
- * form. Constraining to the full `UseFormReturnType` would force
24
- * contravariant unification of the storage / read shapes across all
25
- * steps; the wizard does not care about those — it routes by `key` at
26
- * runtime and exposes the original form objects untouched.
5
+ * Options accepted by `injectForm` when passing an object instead of
6
+ * a bare key string. `__ssrAccessed: true` is set by the Phase 3
7
+ * `attaform-vite` transform on descendant calls whose template reads
8
+ * the injected form's reactive state it tells the runtime to
9
+ * enqueue the form for SSR prefetch and register the descendant's
10
+ * `onServerPrefetch` hook. Consumers may set it manually as the
11
+ * escape hatch when the transform isn't installed or doesn't see
12
+ * the reference.
27
13
  */
28
- type AnyForm = {
29
- readonly key: FormKey;
14
+ type InjectFormInput = {
15
+ readonly key?: FormKey;
16
+ /**
17
+ * Set by the Vite transform when this `injectForm` call site sits in
18
+ * a component whose template / script reads the form's reactive
19
+ * state. On the server, this enqueues the form for SSR prefetch and
20
+ * wires `onServerPrefetch` so the descendant awaits the activation
21
+ * promise before its render emits HTML.
22
+ *
23
+ * @internal Transform-emitted. Manual use is the documented escape
24
+ * hatch when the transform can't reach the reference (dynamic
25
+ * property access, untransformed bundlers).
26
+ */
27
+ readonly __ssrAccessed?: boolean;
30
28
  };
31
29
  /**
32
- * Per-form summary surface what `wizard.statuses[key]` exposes (and
33
- * what `defaultStatuses` seeds). Distinct from `form.meta`: `FormStatus`
34
- * is the cross-step rollup optimized for template ergonomics
35
- * (`{{ wizard.statuses.cargo.valid }}`), while `form.meta` carries the
36
- * full per-form lifecycle surface.
30
+ * Access an existing form from a descendant component without passing
31
+ * it through props. Counterpart to `useForm` `useForm` creates and
32
+ * provides; `injectForm` looks up via Vue's inject mechanism.
37
33
  *
38
- * Field semantics:
39
- * - `valid` — `form.meta.valid`. `false` while errors exist or while
40
- * the first-validation-done gate has not flipped.
41
- * - `dirty` — `form.meta.dirty`. `true` once any value differs from
42
- * the original defaults.
43
- * - `submitted` — `form.meta.submitted`. `true` once a `handleSubmit`
44
- * callback has resolved without throwing. A failed submit
45
- * (validation or callback rejection) leaves this `false`;
46
- * `submissionAttempts > 0` is the "user has tried" signal.
47
- * - `errorCount` — `form.meta.errorCount`. Count of active validation
48
- * errors (zero when valid).
34
+ * Three ways to call it:
49
35
  *
50
- * Noop forms generated for string slots surface as default-valid
51
- * (`{ valid: true, dirty: false, submitted: false, errorCount: 0 }`).
52
- */
53
- type FormStatus = {
54
- readonly valid: boolean;
55
- readonly dirty: boolean;
56
- readonly submitted: boolean;
57
- readonly errorCount: number;
58
- };
59
- /**
60
- * Flat error shape returned per form by `wizard.allErrors[key]`. Each
61
- * entry carries the formKey + path tuple so consumers can route to the
62
- * offending field from a wizard-wide error summary.
63
- */
64
- type WizardAggregateError = {
65
- readonly formKey: FormKey;
66
- readonly path: ReadonlyArray<string | number>;
67
- readonly message: string;
68
- readonly code?: string;
69
- };
70
- /**
71
- * Mirror of `form.values`' call-or-read pattern, one level deep.
72
- * Drillable as `wizard.statuses.cargo.valid` (readable), as
73
- * `wizard.statuses('cargo')` (callable single-key), or as
74
- * `wizard.statuses()` (callable no-arg returns the whole record).
75
- */
76
- type WizardStatusesProxy<S extends Record<string, FormStatus>> = ((key?: keyof S) => FormStatus | S) & Readonly<S>;
77
- /**
78
- * One compiled position in the wizard's flow. The wizard surface
79
- * exposes an ordered array of these as `wizard.steps`, plus a
80
- * `wizard.forms` record keyed by `step.key` for direct lookup.
36
+ * ```ts
37
+ * // Reach the nearest ancestor's anonymous useForm() call.
38
+ * const form = injectForm<SignupShape>()
81
39
  *
82
- * String slots in the source `steps` array desugar to noop forms
83
- * before compilation, so every compiled step carries a `form`
84
- * regardless of source kind.
85
- */
86
- type CompiledStep = {
87
- readonly key: FormKey;
88
- readonly form: AnyForm;
89
- };
90
- /**
91
- * Shape of a participating form as seen from inside a function slot's
92
- * `ctx.forms[key]` lookup. Adds `values` to the structural `AnyForm`
93
- * minimum so routing decisions can read live form state.
40
+ * // Reach a specific form by its key works from anywhere in the app.
41
+ * const cart = injectForm<CartShape>('cart')
94
42
  *
95
- * Values are typed loose because the wizard does not generically thread
96
- * each step's schema through `ctx.forms`. For typed access inside slot
97
- * bodies, close over the original form ref instead of routing through
98
- * `ctx.forms`.
43
+ * // Options form. The Vite transform emits this with `__ssrAccessed: true`
44
+ * // when the descendant's template / script reads the form's reactive
45
+ * // state, so the descendant participates in SSR prefetch coordination.
46
+ * const cart = injectForm<CartShape>({ key: 'cart', __ssrAccessed: true })
47
+ * ```
48
+ *
49
+ * Resolution rules (no-key form):
50
+ * - Closest ambient ancestor wins.
51
+ * - Only anonymous `useForm()` (no `key`) fills the ambient slot;
52
+ * keyed forms are reachable only via `injectForm(key)`.
53
+ * - Inherits the resolved ancestor's `formInstanceId`.
54
+ *
55
+ * Resolution rules (keyed form): registry lookup by string key,
56
+ * independent of component-tree position.
57
+ *
58
+ * Returns `null` when no matching form exists (no ambient ancestor, or
59
+ * the named key isn't registered yet). A dev-mode warning points at the
60
+ * call site, lists the registered keys, and flags the mount-timing case
61
+ * (a form created by a child or sibling isn't registered until its own
62
+ * setup runs). Always narrow before using:
63
+ *
64
+ * ```ts
65
+ * const form = injectForm<Shape>('signup')
66
+ * if (!form) return
67
+ * form.register('email')
68
+ * ```
69
+ *
70
+ * Pass the `Form` generic explicitly — Vue's provide/inject erases
71
+ * generics, so the library can't recover the shape automatically.
72
+ *
73
+ * The form is kept alive for this component's lifetime; once every
74
+ * consumer unmounts, the form is cleaned up automatically.
99
75
  */
100
- type WizardCtxForm = AnyForm & {
101
- readonly values: Readonly<Record<string, unknown>>;
102
- };
76
+ declare function injectForm<Form extends GenericForm, GetValueFormType extends GenericForm = Form>(input?: FormKey | InjectFormInput): UseFormReturnType<Form, GetValueFormType> | null;
77
+
103
78
  /**
104
- * Context object passed to function slots in the `steps` array. The
105
- * `forms` record exposes the wizard's statically-known forms (every
106
- * top-level `AnyForm` slot plus every noop form synthesized for a
107
- * top-level string slot). `currentKey` mirrors the live wizard step.
79
+ * Re-bind a parent's `v-register` onto an inner native element. Use
80
+ * inside a component that wraps a single form field whose root is
81
+ * NOT the input itself (e.g. a labelled-row that renders `<label>`
82
+ * around the input).
108
83
  *
109
- * Function slots re-evaluate reactively when the values they read
110
- * mutate (typically `ctx.forms.<key>.values.<path>`). The `forms`
111
- * accumulator itself is stable across re-evaluations so the slot's
112
- * lookup identity stays referentially equal. Effectful slot bodies
113
- * should be avoided; routing decisions live here.
84
+ * ```vue
85
+ * <!-- Parent -->
86
+ * <MyInput v-register="form.register('email')" />
87
+ *
88
+ * <!-- MyInput.vue -->
89
+ * <script setup lang="ts">
90
+ * import { useRegister } from 'attaform'
91
+ * const rv = useRegister()
92
+ * // rv.path / rv.segments / rv.formKey / rv.formInstanceId / rv.innerRef
93
+ * // are all reachable directly — no `.value` unwrap.
94
+ * </script>
95
+ *
96
+ * <template>
97
+ * <label class="field">
98
+ * <span>Email</span>
99
+ * <input v-register="rv" />
100
+ * </label>
101
+ * </template>
102
+ * ```
103
+ *
104
+ * Returns a hybrid Proxy: it answers `__v_isRef` / `.value` like a
105
+ * Vue `Ref<RegisterValue | undefined>` (so templates auto-unwrap
106
+ * correctly and `v-register="rv"` feeds the underlying RV to the
107
+ * directive — preserving the directive's path-migration diff across
108
+ * renders), AND every other property read pierces to the captured
109
+ * RV's field (so `rv.path` works directly in script setup). Reads
110
+ * inside reactive scopes (`computed` / `watchEffect`) track the
111
+ * underlying `shallowRef`, so `rv.path` re-runs when the parent
112
+ * rebinds to a different path.
113
+ *
114
+ * Unbound state: when the parent didn't pass `v-register`, every
115
+ * piercing read returns `undefined` at runtime, and the return type
116
+ * surfaces this honestly as `UseRegisterReturn<V> | undefined`.
117
+ * Consumers defend with optional chaining (`rv?.formKey`,
118
+ * `rv?.segments`); the directive accepts `undefined` peacefully (its
119
+ * binding value type is already `RegisterValue<V> | undefined`), so
120
+ * `v-register="rv"` works whether or not a parent has bound. The
121
+ * composable's `onMounted` warn fires once per instance to surface
122
+ * the misuse case at runtime.
123
+ *
124
+ * Diagnostic: in dev mode, a single `console.warn` fires per instance
125
+ * at `onMounted` if the captured value is still `undefined` — by then
126
+ * the parent has had its full mount lifecycle to bind, so a missing
127
+ * binding is conclusive misuse. The warn does NOT fire on every read
128
+ * of the proxy, and is intentionally silent under SSR
129
+ * (`renderToString` skips `onMounted`); the CSR hydration pass
130
+ * surfaces the same diagnostic without double-counting through Nuxt's
131
+ * `dev:ssr-logs` channel.
132
+ *
133
+ * When the wrapper's root IS the input itself, Vue's attribute
134
+ * fallthrough handles the binding and `useRegister` is unnecessary.
135
+ * For wrappers that bind multiple fields (compound forms), use
136
+ * `injectForm<Form>(key?)` and call `ctx.register(...)` directly.
114
137
  */
115
- type WizardCtx = {
116
- readonly forms: Readonly<Record<FormKey, WizardCtxForm>>;
117
- readonly currentKey: FormKey | undefined;
118
- };
138
+
119
139
  /**
120
- * Internal phantom brand for `LazyMarker`. The runtime brand symbol
121
- * lives in `core/wizard-lazy.ts`; this declaration keeps the marker
122
- * type unforgeable without circular module imports.
140
+ * Return type of `useRegister()`. Hybrid of `RegisterValue<V>` (so
141
+ * `rv.path` / `rv.segments` / `rv.formKey` etc. work directly in
142
+ * script setup) and `Ref<RegisterValue<V> | undefined>` (so Vue's
143
+ * template auto-unwrap surfaces the underlying RV to `v-register`
144
+ * and the directive's path-migration diff sees the real RV across
145
+ * renders).
146
+ *
147
+ * The two surfaces don't clash at the type level: `RegisterValue`
148
+ * doesn't carry a `value` field, and `Ref<T>`'s `value: T` becomes
149
+ * the hybrid's only `.value`. Older code that read `rv.value?.path`
150
+ * keeps working; new code can write `rv.path` directly.
123
151
  */
124
- declare const _lazyBrand: unique symbol;
152
+ type UseRegisterReturn<V = unknown> = RegisterValue<V> & Ref<RegisterValue<V> | undefined>;
153
+ declare function useRegister<V = unknown>(): UseRegisterReturn<V> | undefined;
154
+
125
155
  /**
126
- * Brand-typed marker returned by `lazy((ctx) => …)`. Wrapping a
127
- * function slot in `lazy()` gives that slot its own memoization cache:
128
- * the resolver fires once on the first compile pass, and the result
129
- * stays cached until one of the resolver's own tracked reactive reads
130
- * changes (or `wizard.reset()` invalidates the cache). Heavy or
131
- * one-shot lookups (network-backed factories, expensive derivations)
132
- * do not re-fire because an unrelated slot's deps changed.
156
+ * Per-form display engine: owns the clock and the timers that the pure
157
+ * `getDisplayState` reducer policy needs, so the reducer itself stays a
158
+ * deterministic `(prev, ctx) => next` function.
133
159
  *
134
- * Construct via the `lazy()` helper exported from the same entry as
135
- * `useWizard`. The marker is opaque at the type level; consumers do
136
- * not assemble it directly.
160
+ * It keeps a `Map` of the machines that are still *active* (a spinner is
161
+ * showing, a verdict is being held, or a `reviewAt` deadline is pending),
162
+ * a single reactive `tick` ref, and a single `setTimeout` aimed at the
163
+ * nearest `reviewAt` across every active field. When a field-state
164
+ * computed reads `resolve`, it subscribes to `tick`; when the timer
165
+ * fires, `tick` bumps and every dependent computed re-runs the reducer,
166
+ * so a field whose deadline elapsed transitions (verdict → spinner, or
167
+ * spinner → settled verdict) without any per-field watcher.
168
+ *
169
+ * Eviction: a machine is dropped once it reaches a terminal *idle* state
170
+ * with no pending review. Error / success / pending machines are retained
171
+ * so the reducer can hold the prior verdict under the show-delay window of
172
+ * the *next* validation streak (no success → idle → success flicker). The
173
+ * retained set is bounded by the rendered non-idle fields; none of them
174
+ * arm a timer, so retention costs memory, never CPU.
175
+ *
176
+ * Untrusted reducer: `getDisplayState` is consumer-overridable, so the
177
+ * engine treats the returned `reviewAt` as untrusted. A non-finite deadline
178
+ * (NaN / ±Infinity from a custom predicate's bad arithmetic) is ignored
179
+ * rather than handed to `setTimeout`, where it coerces to 0 and spins; an
180
+ * over-large finite deadline is clamped below the 32-bit `setTimeout`
181
+ * overflow; and the timer refuses to re-arm for the exact deadline it just
182
+ * fired, so a predicate re-emitting a fixed or past `reviewAt` can't drive an
183
+ * infinite fire loop. None of these arise from the library default, which
184
+ * always advances its deadline or drops it.
185
+ *
186
+ * Background tabs: `setTimeout` is throttled to >= 1s while a tab is hidden,
187
+ * so a min-visible hold can overshoot. A `visibilitychange` listener bumps
188
+ * the clock on return to the foreground, so any overdue deadline resolves at
189
+ * once instead of lingering.
190
+ *
191
+ * SSR: no clock, no timers, no listener. With `now` frozen and nothing
192
+ * validating at render, the reducer returns the plain verdict (never
193
+ * pending) and the engine stores nothing, so the server HTML and the
194
+ * client's first render agree — no hydration mismatch on the display
195
+ * projection.
137
196
  */
138
- type LazyMarker<Ctx = WizardCtx> = {
139
- readonly [_lazyBrand]: true;
140
- readonly resolve: (ctx: Ctx) => AnyForm | string | null | undefined;
197
+ type DisplayEngine = {
198
+ /**
199
+ * Resolve a path's next `DisplayMachine`. Subscribes the calling
200
+ * computed to the engine clock, threads the path's previous machine
201
+ * through `reducer`, persists or evicts the result, and re-arms the
202
+ * single timer to the nearest deadline.
203
+ */
204
+ resolve(key: PathKey, ctx: DisplayCtx, reducer: GetDisplayState): DisplayMachine;
205
+ /** Drop every retained machine and cancel the timer (used by `reset()`). */
206
+ clear(): void;
207
+ /** Tear down for good: `clear()` plus detaching the visibility listener. */
208
+ dispose(): void;
209
+ /** Introspection for tests: count of retained machines. */
210
+ size(): number;
211
+ /** Introspection for tests: whether a path currently has a retained machine. */
212
+ has(key: PathKey): boolean;
213
+ /** Introspection for tests: whether a deadline timer is currently armed. */
214
+ hasTimer(): boolean;
141
215
  };
216
+
142
217
  /**
143
- * One position in the source `useWizard({ steps })` array. Each slot
144
- * resolves to a compiled `{ key, form }` step, or drops out:
218
+ * Per-path field status. Replaced wholesale (not mutated in place) on
219
+ * every change. Five semantic groups:
145
220
  *
146
- * - `AnyForm` — a form declared via `useForm`. Surfaced as-is.
147
- * - `string` bare key. The wizard generates a noop form
148
- * under the hood so the external surface stays
149
- * uniform across affordance positions (intro,
150
- * terms, congratulations, review surfaces).
151
- * - `null` / `undefined` a literal absence, dropped from the compiled
152
- * list. Lets a conditional step read inline as
153
- * `cond ? form : null` without pre-filtering the
154
- * array.
155
- * - function — eager slot, re-evaluates reactively. Returns
156
- * one of the above, or `null` / `undefined` to
157
- * drop the slot from the compiled list.
158
- * - `LazyMarker` memoized function slot (see `lazy`).
159
- */
160
- type StepSlot<Ctx = WizardCtx> = AnyForm | string | null | undefined | ((ctx: Ctx) => AnyForm | string | null | undefined) | LazyMarker<Ctx>;
161
- /**
162
- * Shape returned by the `restore` callback. Carries the active step's
163
- * key; intentionally open-ended (object form) so future additions land
164
- * without a callback-signature break.
221
+ * - `connected` is a DOM element registered for this path?
222
+ * - `focused` / `blurred` DOM-state flags. `null` while no element
223
+ * is connected (no DOM means the concepts don't apply); plain
224
+ * booleans once connected, with the invariant `blurred === !focused`
225
+ * enforced by `markFocused`.
226
+ * - `touched` — focus/blur history, not DOM state. Always a plain
227
+ * boolean: `false` at registration, sticky `true` after first blur,
228
+ * cleared only by `form.reset()` / `form.resetField(path)`. Persists
229
+ * across disconnects so v-if'd-away fields don't lose their touched
230
+ * state on rehide (wizard "show review of touched fields" patterns
231
+ * rely on this).
232
+ * - `interacted` value-mutation history, not DOM state. Plain
233
+ * boolean: `false` at registration, sticky `true` once the user
234
+ * issues a value edit through the directive's input listeners
235
+ * (never on hydration, default seeding, or programmatic setValue);
236
+ * cleared with `touched` by `form.reset()` / `form.resetField(path)`.
237
+ * - `blurredAfterInteraction` the first blur that follows a value
238
+ * edit (the field has been edited and then left). Plain boolean,
239
+ * sticky `true`. A tab-through blur with no prior edit does NOT set
240
+ * it (`interacted` is still false at that blur). Composes
241
+ * `interacted` with the departure; drives the default display gate.
165
242
  */
166
- type WizardRestoreState = {
167
- readonly step?: FormKey;
243
+ type FieldRecord = {
244
+ readonly path: Path;
245
+ readonly updatedAt: string | null;
246
+ readonly connected: boolean;
247
+ readonly focused: boolean | null;
248
+ readonly blurred: boolean | null;
249
+ readonly touched: boolean;
250
+ readonly interacted: boolean;
251
+ readonly blurredAfterInteraction: boolean;
252
+ };
253
+ /** Per-path DOM element tracking. Client-only. */
254
+ type ElementRecord = {
255
+ /**
256
+ * Original Path captured at first registration. Stored alongside the
257
+ * elements Set so the DOM-order sort cache can recover the structured
258
+ * Path without round-tripping through `JSON.parse(pathKey)`.
259
+ */
260
+ readonly path: Path;
261
+ readonly elements: Set<HTMLElement>;
168
262
  };
169
263
  /**
170
- * `restore` callback signature. Invoked at construction and watched
171
- * reactively via `watchEffect` so external state changes (browser
172
- * back/forward, cross-tab events, route changes) re-apply through the
173
- * wizard. Returning `undefined` falls through to the first step.
174
- */
175
- type WizardRestoreFn = () => WizardRestoreState | undefined;
176
- /**
177
- * `persist` callback signature. Invoked whenever `wizard.currentStep`
178
- * changes; the wizard diffs against the last persisted value to break
179
- * the restore-persist loop, so the callback only fires when the active
180
- * step actually moves.
264
+ * Per-path record stored in `originals`. Pairing `segments` with the tracked
265
+ * value means `dirty` and `resetField`'s container loop don't have to
266
+ * `JSON.parse(pathKey)` on every iteration the canonical Path is already
267
+ * sitting next to the value it belongs to. PathKey still keys the Map (the
268
+ * stable string is the only collision-free identifier), but downstream
269
+ * iteration reads `segments` directly.
181
270
  */
182
- type WizardPersistFn = (state: WizardRestoreState) => void;
271
+ type OriginalsRecord = {
272
+ readonly segments: Path;
273
+ readonly value: unknown;
274
+ };
275
+
183
276
  /**
184
- * Submit context passed to the `onSubmit` callback registered via
185
- * `wizard.handleSubmit(onSubmit, onError?)`. `handleSubmit` always
186
- * processes the whole step list, so `values` carries every form's
187
- * parsed output regardless of which step fired the submit.
277
+ * Schema-driven coercion of user-typed DOM values at the v-register
278
+ * directive layer. When the slim schema declares a numeric or
279
+ * boolean type at a path, the directive coerces incoming string
280
+ * values (`'25'` `25`, `'true'` `true`) before the slim-primitive
281
+ * gate sees the write — making the schema authoritative for storage
282
+ * shape and freeing consumers from sprinkling `.number` modifiers
283
+ * across templates.
188
284
  *
189
- * - `values` namespaced aggregate keyed by form key, mirroring
190
- * `wizard.allValues`. Reflects parsed output for every
191
- * form whose validation has settled; noops contribute an
192
- * empty record.
193
- * - `get(form)` typed accessor that reads the parsed output for a
194
- * specific form ref. Works across cross-component graphs
195
- * because the form ref carries its schema info.
196
- * - `currentKey` key of the step that fired this submission.
197
- * - `isFinal` — `true` when `currentKey` is the last position in
198
- * `wizard.steps`. Positional only: it reports where the
199
- * submit fired, never what got validated. A user who steps
200
- * back, edits, and submits from the middle sees
201
- * `isFinal === false`, yet the whole list is still
202
- * processed and `done` still latches on success.
285
+ * Coercion is consumer-extensible: a `CoercionRegistry` is just an
286
+ * `Array<CoercionEntry>` keyed at config time by `(input, output)`
287
+ * `SlimPrimitiveKind` literals. The library ships
288
+ * `defaultCoercionRules` (string→number, string→boolean) and
289
+ * `defineCoercion` for type-narrowed authoring; consumers spread the
290
+ * defaults to extend or supply their own array to replace.
291
+ *
292
+ * Coercion applies ONLY to user-typed DOM values flowing through
293
+ * the directive's assigner. Programmatic writes (`form.setValue`,
294
+ * `setValueWithInternalPath`) bypass coercion they're authoritative
295
+ * writes whose strict typing is on the caller. This mirrors the
296
+ * `transforms` pipeline's user-input-only contract.
203
297
  */
204
- type WizardSubmitContext = {
205
- readonly values: Readonly<Record<FormKey, unknown>>;
206
- readonly get: <F extends AnyForm>(form: F) => F extends {
207
- readonly values: infer V;
208
- } ? V : unknown;
209
- readonly currentKey: FormKey;
210
- readonly isFinal: boolean;
211
- };
298
+
212
299
  /**
213
- * `onSubmit` callback registered via `wizard.handleSubmit`. Sync or
214
- * async; the returned promise gates `wizard.submitting`.
300
+ * Type-narrowing helper for authoring entries. At runtime it's
301
+ * identity; at compile time it preserves the `input` / `output`
302
+ * literal types so `transform`'s parameter is narrowed to the
303
+ * runtime type instead of widening to `SlimRuntimeOf<SlimPrimitiveKind>`.
304
+ *
305
+ * Without this helper, authoring `{ input: 'string', output:
306
+ * 'number', transform: (s) => ... }` against the broader
307
+ * `CoercionEntry` widens `s` to `string | number | boolean | ...`,
308
+ * forcing a cast in every transform body. `defineCoercion` is the
309
+ * opaque-free idiom.
215
310
  */
216
- type WizardOnSubmit = (ctx: WizardSubmitContext) => void | Promise<void>;
311
+ declare function defineCoercion<I extends SlimPrimitiveKind, O extends SlimPrimitiveKind>(entry: CoercionEntry<I, O>): CoercionEntry<I, O>;
217
312
  /**
218
- * Optional `onError` callback registered via `wizard.handleSubmit`.
219
- * Receives the aggregate error list spanning EVERY step (handleSubmit
220
- * validates the whole wizard), so a failed submit surfaces every form's
221
- * errors at once, not just the active step's. Entries originate from
222
- * per-form validation, activation failures (`atta:activation-failed`),
223
- * and a submit callback that left errors on a processed step (the
224
- * `setErrors(...); return` server-rejection path). Sync or async; the
225
- * returned promise gates `wizard.submitting`.
313
+ * Internal index built from a `CoercionRegistry` at config-resolve
314
+ * time. Keyed by `${input}->${output}` for O(1) per-keystroke
315
+ * dispatch. The authoring shape (array, ergonomic, type-narrowing-
316
+ * friendly) and the dispatch shape (Map, fast) decouple cleanly.
226
317
  */
227
- type WizardOnError = (errors: readonly WizardAggregateError[]) => void | Promise<void>;
318
+ type CoercionIndex = ReadonlyMap<`${SlimPrimitiveKind}->${SlimPrimitiveKind}`, CoercionEntry>;
228
319
  /**
229
- * Options for `useWizard({ steps, })`. `steps` is the only required
230
- * field; the rest are optional and default sensibly for the common
231
- * URL-synchronized wizard case.
320
+ * The library's built-in registry. Two cells: string→number and
321
+ * string→boolean. Re-exported so consumers can spread it when
322
+ * supplying a custom registry that extends defaults.
232
323
  */
233
- type WizardOptions = {
324
+ declare const defaultCoercionRules: CoercionRegistry;
325
+
326
+ type FormStore<F extends GenericForm, G extends GenericForm = F> = {
327
+ readonly formKey: FormKey;
328
+ readonly form: Ref<F>;
329
+ readonly fields: Map<PathKey, FieldRecord>;
330
+ readonly elements: Map<PathKey, ElementRecord>;
234
331
  /**
235
- * Ordered list of slots that compile into the wizard's positional
236
- * step list. See `StepSlot` for the per-slot shape contract.
332
+ * Schema-driven errors. Written ONLY by the schema validation pipeline:
333
+ * `scheduleFieldValidation`, `handleSubmit`, the construction-time seed,
334
+ * history restore, and hydration. Cleared by `reset` / `resetField` and by
335
+ * a successful submit. `setErrors` / `clearErrors` do NOT touch this Map.
237
336
  */
238
- readonly steps: ReadonlyArray<StepSlot>;
337
+ readonly schemaErrors: Map<PathKey, ValidationError[]>;
239
338
  /**
240
- * Identifier used to register the wizard handle in the per-app
241
- * registry. Descendant components call `injectWizard(key)` to reach
242
- * the same wizard without prop-threading. Anonymous wizards (option
243
- * omitted) get a synthetic `__atta:anon-wizard:<id>` key resolved
244
- * via `useId()` so SSR-rendered and client-hydrated trees agree on
245
- * the same registry entry; the synthetic key is opaque and
246
- * descendants reach an anonymous wizard via ambient `injectWizard()`
247
- * rather than by key.
248
- *
249
- * Duplicate-key registration is first-wins-silently (dev-warn on the
250
- * second registration) to mirror `useForm`'s shared-key behavior.
251
- * The dev-warn fires only for explicit keys — two anonymous wizards
252
- * are guaranteed distinct synthetic keys, so the warning never
253
- * misfires on independent anonymous wizards on the same page.
339
+ * User-injected errors. Written ONLY by the `setErrors` / `clearErrors`
340
+ * API surface (and history / hydration replay). Survives schema revalidation and
341
+ * successful submits the consumer owns its lifetime explicitly.
254
342
  */
255
- readonly key?: string;
343
+ readonly userErrors: Map<PathKey, ValidationError[]>;
256
344
  /**
257
- * Seed status payload used while a form is pre-resolved (async
258
- * `defaultValues` in flight, or wizard-deferred non-current).
259
- * Mirrors `defaultValues`' trichotomy: plain object, sync factory,
260
- * or async factory.
345
+ * Reactively-derived "No value supplied" errors. Pure function of
346
+ * `(blankPaths, schema.isRequiredAtPath)` no writers, no clears.
347
+ * Membership tracks `blankPaths` automatically: typing a value into
348
+ * a blank required numeric field removes the path from `blankPaths`
349
+ * and the derived error vanishes; clearing the numeric input re-adds
350
+ * the path and the error reappears. The `errors` proxy and
351
+ * `getErrorsForPath` merge this map in alongside `schemaErrors` and
352
+ * `userErrors`, so consumers see the "this required field is empty"
353
+ * error the moment it's true — no `validate()` / `handleSubmit`
354
+ * call required. Honors the founding principle that
355
+ * `errors = f(schema, state)`.
261
356
  *
262
- * Status resolution priority per form:
263
- * 1. `store.defaultsResolved === true` derive from `form.meta`
264
- * 2. else noop form built-in always-valid status
265
- * 3. else seed value for this key frozen seed
266
- * 4. else pending sentinel
357
+ * Most entries flow through this map for `number` / `bigint` leaves
358
+ * (where the side-channel is needed to distinguish "user typed 0"
359
+ * from "user supplied nothing"). String / boolean leaves only land
360
+ * here when the consumer explicitly opted in via the `unset`
361
+ * sentinel see `docs/validation/blank.md`.
362
+ */
363
+ readonly derivedBlankErrors: ComputedRef<ReadonlyMap<PathKey, ValidationError[]>>;
364
+ readonly originals: Map<PathKey, OriginalsRecord>;
365
+ /**
366
+ * Reactive set of paths whose displayed state should be EMPTY even
367
+ * though storage holds a real, schema-conformant value (the slim
368
+ * default). It exists exclusively to record **storage / display
369
+ * divergence** — the case where the runtime can't tell "user typed
370
+ * 0" from "user supplied nothing" by looking at storage alone.
267
371
  *
268
- * Unknown keys in the seed object dev-warn so a stale resume payload
269
- * surfaces at construction.
372
+ * The mechanism shines for `number` / `bigint`: storage holds the
373
+ * slim default (`0` / `0n`) but the DOM input shows `''`, so the
374
+ * directive's input listener marks the path here on clear. Strings
375
+ * and booleans don't need it — `''` storage equals `''` display,
376
+ * `false` storage equals unchecked display — so they're never
377
+ * auto-marked. Consumers can still mark any primitive leaf
378
+ * explicitly via the `unset` sentinel (`defaultValues: { x: unset }`,
379
+ * `setValue('x', unset)`, `reset({ x: unset })`); the mark is then
380
+ * a documented signal of consumer intent rather than runtime
381
+ * inference.
382
+ *
383
+ * Reads (`displayValue` computed, `fields.<path>.blank`,
384
+ * `derivedBlankErrors` computed) track via Vue 3.5's reactive Set
385
+ * handlers. Writes happen inside `setValueAtPath` (gate-hook
386
+ * bookkeeping: `blank: true` meta adds the path; any other write
387
+ * removes it) and `reset`.
388
+ *
389
+ * Storage NEVER reflects this set — calculations and reads against
390
+ * `form.value` see the slim default. The set is purely a UI/intent
391
+ * channel that `derivedBlankErrors` consults to surface
392
+ * "No value supplied" errors for required schemas.
393
+ *
394
+ * See `docs/validation/blank.md` for the conceptual model.
270
395
  */
271
- readonly defaultStatuses?: Record<string, FormStatus> | (() => Record<string, FormStatus>) | (() => Promise<Record<string, FormStatus>>);
396
+ readonly blankPaths: Set<PathKey>;
272
397
  /**
273
- * Optional progress override. When omitted, the wizard exposes
274
- * `progress` as `valid_step_count / count` (normalised to `[0, 1]`).
275
- * When provided, the returned number is used as-is the consumer is
276
- * responsible for any normalisation.
398
+ * Snapshot of `blankPaths` captured at construction (and
399
+ * re-captured on `reset(args)`). Used by dirty calculation: a path
400
+ * whose membership differs from the snapshot is dirty even if
401
+ * storage matches the original. Eagerly populated to avoid a "dirty
402
+ * on first read" race after construction.
403
+ */
404
+ readonly originalBlankPaths: Set<PathKey>;
405
+ readonly schema: AbstractSchema<F, G>;
406
+ /**
407
+ * Server-side flag, plumbed in from `registry.ssr`. The
408
+ * `register()`-returned `markConnectedOptimistically()` reads this
409
+ * before flipping `connected: true`; on the client it's a no-op so
410
+ * the eventual directive lifecycle remains the source of truth.
411
+ */
412
+ readonly ssr: boolean;
413
+ /**
414
+ * Resolved `getDisplayState` predicate driving `field.displayState`,
415
+ * the `show*` booleans, and their `form.meta` rollups. Resolved once
416
+ * at construction via `resolveGetDisplayState(options.getDisplayState)`;
417
+ * `undefined` config falls through to `defaultDisplayState`. The
418
+ * field-state computeds read the resolved function directly on every
419
+ * read.
420
+ */
421
+ readonly getDisplayState: GetDisplayState;
422
+ /**
423
+ * Per-form display engine: owns the clock and the single timer the timed
424
+ * `getDisplayState` reducer policy needs, keeping the reducer itself a
425
+ * pure `(prev, ctx) => next` function. The field-state computeds route
426
+ * every `displayState` read through `displayEngine.resolve(...)`, which
427
+ * threads the path's previous machine, persists or evicts the result, and
428
+ * re-arms the nearest-deadline timer. Constructed once at form
429
+ * construction; torn down via `registerCleanup` on store eviction.
430
+ */
431
+ readonly displayEngine: DisplayEngine;
432
+ readonly submitting: Ref<boolean>;
433
+ readonly activeSubmissions: Ref<number>;
434
+ readonly submissionAttempts: Ref<number>;
435
+ /**
436
+ * `true` once a `handleSubmit` callback resolved without throwing.
437
+ * Independent of `submissionAttempts` — a failed submit increments
438
+ * attempts but leaves `submitted` at `false`. Cleared by `reset()`
439
+ * alongside the rest of the submission surface.
440
+ */
441
+ readonly submitted: Ref<boolean>;
442
+ readonly submitError: Ref<Error | null>;
443
+ readonly departAttempts: Ref<number>;
444
+ /**
445
+ * `true` while a function-form `defaultValues` factory is in flight.
446
+ * Stays `false` for plain-value `defaultValues`. Shared across every
447
+ * `useForm({ key })` call that resolves to this store — the second
448
+ * caller sees the first caller's hydration state.
449
+ */
450
+ readonly hydrating: Ref<boolean>;
451
+ /**
452
+ * Error from the most recent function-form `defaultValues` factory.
453
+ * Normalized to a `ValidationError` (code `atta:hydration-failed`) so the
454
+ * shape matches `form.errors` / `form.meta.errors` entries. `null` when
455
+ * no factory has fired or the last one succeeded.
456
+ */
457
+ readonly hydrateError: Ref<ValidationError | null>;
458
+ /**
459
+ * The function-form `defaultValues` factory, captured at the first
460
+ * `useForm({ key })` call that wired this store. `undefined` for
461
+ * plain-value forms. Read by `form.rehydrate()`.
462
+ */
463
+ readonly defaultValuesFactory: Ref<(() => unknown | Promise<unknown>) | undefined>;
464
+ /**
465
+ * `true` when this store carries an SSR prefetch queue (server path
466
+ * where `state.activate()` must enqueue intent before deciding
467
+ * whether to fire). The flag lets `buildFormApi` skip the lazy
468
+ * activation gate for forms with no factory AND no SSR prefetch —
469
+ * the common client-side case where `gated()` is otherwise pure
470
+ * reactive overhead on every public method call.
471
+ */
472
+ readonly hasSsrPrefetch: boolean;
473
+ /**
474
+ * `true` once the form's effective defaults have been applied —
475
+ * sync `defaultValues` at construction, or async factory whose
476
+ * settle completed. Stays `false` for dormant lazy forms until they
477
+ * activate. Read by `useWizard` to decide whether seed status or
478
+ * live meta should surface.
479
+ */
480
+ readonly defaultsResolved: Ref<boolean>;
481
+ /**
482
+ * `true` once the captured async factory has been kicked off (set
483
+ * synchronously by `activate()`, before the factory itself resolves).
484
+ * Distinct from `defaultsResolved`, which only flips after the factory
485
+ * settles. The pair lets the API surface tell "we've started" apart
486
+ * from "we're done."
487
+ */
488
+ readonly activated: Ref<boolean>;
489
+ /**
490
+ * In-flight activation promise. Concurrent callers (cross-component
491
+ * SSR consumers, recursive factory reads, parallel `activate()`
492
+ * calls) receive the same promise, ensuring the factory runs once
493
+ * even under contention.
494
+ */
495
+ readonly activationPromise: Ref<Promise<void> | undefined>;
496
+ /**
497
+ * Idempotent activation entrypoint. Fires the captured function-form
498
+ * `defaultValues` factory on first call and stores the in-flight
499
+ * promise. Subsequent calls return the same promise until the factory
500
+ * settles; thereafter calls return `Promise.resolve()`. Plain-value
501
+ * forms (no factory captured) always return a resolved promise. The
502
+ * public API surface routes all reactive interactions (getters and
503
+ * methods, except `key`) through this entrypoint so the form
504
+ * activates on first use.
505
+ */
506
+ activate(): Promise<void>;
507
+ /**
508
+ * Re-fire the captured function-form `defaultValues` factory. Throws
509
+ * synchronously when no factory was captured (plain-value form).
510
+ * Resolves after `hydrating` flips back to `false`; consumers can
511
+ * `await form.rehydrate()` to gate UI on the fresh load.
277
512
  *
278
- * The override is invoked inside a Vue `computed` so it must be
279
- * synchronous and may only read reactive sources.
513
+ * Does NOT touch dirty / touched / submit state chain
514
+ * `form.reset()` if you want a clean baseline.
280
515
  */
281
- readonly progress?: (steps: ReadonlyArray<CompiledStep>) => number;
516
+ rehydrate(): Promise<void>;
282
517
  /**
283
- * When `wizard.handleSubmit` finds errors, automatically focus the
284
- * first failing form: jump to its step and invoke its
285
- * `applyInvalidSubmitPolicy()` (focus / scroll per the form's own
286
- * `onInvalidSubmit` configuration). Default `true`; pass `false` to
287
- * keep the active step where the user left it and handle navigation
288
- * manually in the `onError` callback.
518
+ * Incremented by every `reset()` call. The submit wrapper captures
519
+ * this at entry and skips writing `submitError` from a catch that
520
+ * fires *after* a reset otherwise a reset-during-submit would
521
+ * visibly clear `submitError` and then have it reappear when the
522
+ * in-flight promise rejects.
289
523
  */
290
- readonly focusFirstError?: boolean;
524
+ readonly submissionGeneration: Ref<number>;
291
525
  /**
292
- * Source of truth for the active step. Invoked at construction and
293
- * re-evaluated reactively via `watchEffect`. Default callback reads
294
- * `?step=<key>` from the URL via `wizard-history.ts`; pass `false`
295
- * to disable URL sync, or provide a custom callback for non-router
296
- * persistence (localStorage, broadcast channel, etc.).
526
+ * Counts in-flight validation calls across every `validate()` ref and
527
+ * every `validateAsync(...)` / `handleSubmit` pre-check. `validating`
528
+ * on the public API mirrors `activeValidations.value > 0`. Tracked
529
+ * separately from submissions because a validate-while-submitting
530
+ * (e.g. a debounced field check overlapping a submit) needs to show
531
+ * the union of both surfaces.
297
532
  */
298
- readonly restore?: WizardRestoreFn | false;
533
+ readonly activeValidations: Ref<number>;
299
534
  /**
300
- * Destination for the active step. Invoked whenever `currentStep`
301
- * changes, with a diff check to break the restore-persist loop.
302
- * Default callback writes `?step=<key>` via `wizard-history.ts`;
303
- * pass `false` to disable persistence, or provide a custom callback
304
- * to scope the param name or write to alternate storage.
305
- */
306
- readonly persist?: WizardPersistFn | false;
307
- };
308
- /**
309
- * True when a single slot is guaranteed to contribute exactly one
310
- * compiled step: a bare form or affordance string that is neither
311
- * nullish nor a (maybe-absent) function / `lazy()` slot. One of these
312
- * anywhere in the tuple proves the compiled list is non-empty.
313
- */
314
- type IsGuaranteedStep<T> = [null] extends [T] ? false : [undefined] extends [T] ? false : T extends LazyMarker | ((...args: unknown[]) => unknown) ? false : T extends string ? true : T extends {
315
- readonly key: string;
316
- } ? true : false;
317
- /**
318
- * Predicate: is the steps tuple statically guaranteed to compile to a
319
- * non-empty list? True when at least one element is a guaranteed step
320
- * (see `IsGuaranteedStep`). Function / `lazy()` slots may resolve to
321
- * nothing, and `null` / `undefined` (a literal absence or a
322
- * `cond ? form : null` union) is an explicit drop, so none of those
323
- * prove non-emptiness; a tuple of only maybe-absent slots stays
324
- * honestly `| undefined`.
325
- *
326
- * Used to narrow `currentStep` / `activeForm` to their non-`undefined`
327
- * shapes in the common-case wizard, while keeping the honest union
328
- * everywhere a runtime drop is reachable.
329
- */
330
- type StaticallyNonEmpty<S> = S extends readonly [infer First, ...infer Rest] ? IsGuaranteedStep<First> extends true ? true : StaticallyNonEmpty<Rest> : false;
331
- /** Active step's key, narrowed to `string` when `S` is statically safe. */
332
- type CurrentStepOf<S> = StaticallyNonEmpty<S> extends true ? FormKey : FormKey | undefined;
333
- /**
334
- * Active step's form handle, schema-erased to `UseFormReturnType<GenericForm>`
335
- * and narrowed to non-`undefined` when `S` is statically safe. The runtime
336
- * value is a live facade over the active step, so reads (`.values`, `.meta`,
337
- * `.history`) and `.handleSubmit` always target the current step. Values are
338
- * loose here because the active step's schema is not statically known; reach
339
- * for the original form ref or `ctx.get(ref)` when you need typed per-step
340
- * values.
341
- */
342
- type ActiveFormOf<S> = StaticallyNonEmpty<S> extends true ? UseFormReturnType<GenericForm> : UseFormReturnType<GenericForm> | undefined;
343
- /**
344
- * Per-slot contribution to {@link FormsRecordOf}. Strips a `null` /
345
- * `undefined` (a literal absence or a `cond ? form : null` union) off the
346
- * slot first, so a conditionally-present form still maps to its key and
347
- * stays concretely typed on `wizard.forms`; a purely nullish slot
348
- * contributes nothing.
349
- */
350
- type SlotRecord<First, F = Exclude<First, null | undefined>> = [F] extends [never] ? unknown : F extends string ? {
351
- readonly [P in F]: AnyForm;
352
- } : F extends {
353
- readonly key: infer K extends string;
354
- } ? {
355
- readonly [P in K]: F;
356
- } : unknown;
357
- /**
358
- * Recursive tuple walk that builds the static portion of
359
- * `wizard.forms`. Each step slot contributes to the record:
360
- *
361
- * - **String slot** (`'review'`): the literal becomes the record key
362
- * and the value is `AnyForm` (the noop form synthesized for the
363
- * affordance position is opaque at the type level).
364
- * - **Form slot** (a `useForm` reference with a literal `key` field):
365
- * the form's own `key` becomes the record key, and the value is
366
- * the concrete form handle type — so drilling
367
- * `wizard.forms.shipping.values.address` carries the schema-derived
368
- * field types through. A form kept behind a `cond ? form : null`
369
- * conditional still maps to its key.
370
- * - **`null` / `undefined` slot**: contributes nothing.
371
- * - **Function / `lazy()` slot**: contributes nothing to the static
372
- * map. Runtime-resolved forms are still reachable via the
373
- * catch-all index signature on `WizardForms` (typed as `AnyForm`).
374
- *
375
- * Recursion is bounded by the tuple length; real-world wizards land
376
- * well below the TS instantiation budget.
377
- */
378
- type FormsRecordOf<S> = S extends readonly [
379
- infer First,
380
- ...infer Rest extends ReadonlyArray<StepSlot>
381
- ] ? SlotRecord<First> & FormsRecordOf<Rest> : unknown;
382
- /**
383
- * `wizard.forms` typed view. Combines the static per-step type map
384
- * with a catch-all `Record<FormKey, AnyForm>` fallback so:
385
- *
386
- * - Statically known slot keys → concrete form type via `FormsRecordOf`
387
- * - Any other string key → `AnyForm` via the index signature
388
- *
389
- * The intersection collapses to the concrete form for statically
390
- * known keys (because the concrete form type extends `AnyForm`) and
391
- * to `AnyForm` for unknown keys.
392
- */
393
- type WizardForms<S> = FormsRecordOf<S> & Readonly<Record<FormKey, AnyForm>>;
394
- /**
395
- * Return shape of `useWizard({ steps, … })`. Every reactive read is a
396
- * plain getter (no `.value`) — `wizard.currentStep`, `wizard.progress`,
397
- * `wizard.allValues` track inside `computed` / template effects
398
- * directly.
399
- *
400
- * Parameterized by the steps tuple `S` so active-position fields
401
- * (`currentStep`, `activeForm`) narrow to non-undefined for the common
402
- * case (all positional Form / string slots) and stay as honest unions
403
- * when a function or `lazy()` slot can drop the compiled position at
404
- * runtime. The `const` type parameter on `useWizard` preserves literal
405
- * tuple types without consumer-side `as const`, so the narrowing
406
- * happens automatically from the call site.
407
- *
408
- * - `currentStep` — key of the active step. Narrows to `string` when
409
- * the steps tuple is statically guaranteed to
410
- * compile to a non-empty list (all positional
411
- * Form / string slots, no function or `lazy()`
412
- * slots). Otherwise reads as `string | undefined`
413
- * so the degenerate case (empty list at runtime)
414
- * surfaces honestly.
415
- * - `activeForm` — a LIVE view of the active step's form. Operating
416
- * through it always targets the current step, so a
417
- * handler captured once at setup
418
- * (`wizard.activeForm.handleSubmit(() =>
419
- * wizard.next())`) retargets as the wizard advances.
420
- * No longer `===` the raw per-step handle; reach for
421
- * `wizard.forms[key]` when you need raw identity.
422
- * Same `undefined` narrowing as `currentStep`. Noop
423
- * forms cover string slots in the normal path.
424
- * - `activeIndex` — 0-based position of the active step.
425
- * - `isFinalStep` — `true` when `currentStep === steps[count - 1].key`.
426
- * - `steps` — ordered list of compiled `{ key, form }` slots.
427
- * - `forms` — record indexable by step key; the value is the
428
- * full form handle resolved for that slot.
429
- * - `count` — `steps.length`.
430
- * - `statuses` — callable readonly proxy over the per-key
431
- * `FormStatus` record. Noop-form keys always read
432
- * as default-valid.
433
- * - `allValues` — namespaced aggregate of each form's values, keyed
434
- * by step key.
435
- * - `allErrors` — namespaced aggregate of each form's validation
436
- * errors, keyed by step key. Noop forms map to an
437
- * empty list.
438
- * - `progress` — normalised step-validity ratio (or the consumer's
439
- * `progress` override). Forward-looking: noops count
440
- * as always-valid.
441
- * - `canAdvance` — `true` when `activeIndex < count - 1`. Pure
442
- * positional check; navigation never gates on
443
- * validity.
444
- * - `canGoBack` — `true` when `activeIndex > 0`.
445
- * - `complete` — `isFinalStep && every step's form is valid`.
446
- * Forward-looking; reactive to current form
447
- * validity. Gates "Finish button enable" style UI.
448
- * - `done` — monotonic latch: flips `true` the first time a
449
- * `handleSubmit` resolves without throwing AND leaves
450
- * no errors set on any step, and stays `true` through
451
- * subsequent edits or invalidations. A callback that
452
- * calls `setErrors` and returns (the documented
453
- * server-rejection path) is a failed submit, so it
454
- * does not flip `done`. Only `reset()` flips it back.
455
- * Gates "show success card" style UI that should
456
- * reflect submission history rather than current
457
- * validity.
458
- * - `submitting` — `true` while a `wizard.handleSubmit` call is in
459
- * flight. Global re-entrance guard: every
460
- * navigation method also refuses while this is on.
461
- * - `submissionAttempts` — count of `wizard.handleSubmit` invocations
462
- * (success or failure). Always bumps, including on
463
- * noop-form steps.
464
- * - `submitError` — the error THROWN by the most recent
465
- * `wizard.handleSubmit` callback (or its `onError`),
466
- * coerced to a real `Error`. Mirrors
467
- * `form.meta.submitError`: this is the unexpected-throw
468
- * channel, so an expected rejection handled via
469
- * `setErrors` (no throw) surfaces through the error
470
- * surface and `onError` instead, leaving this `null`.
471
- * Cleared at submit entry and by `reset()`, parked here
472
- * rather than re-thrown, so the handler resolves and
473
- * never manufactures a `window` unhandledrejection.
474
- * `null` on success.
475
- * - `visited` — append-only breadcrumb of navigated step keys.
476
- * `back()` does not pop; the trail is the audit
477
- * log, not the back-stack.
478
- * - `next/back/goTo` — pure navigation. Refuses while `submitting`.
479
- * - `tryNext()` — validate the active step, then advance iff it
480
- * passed; invalid input keeps the pin put under the
481
- * form's standard error reveal (first error focused,
482
- * display state advanced). The inline-bindable
483
- * shorthand for `activeForm.handleSubmit(() =>
484
- * next())`. Resolves to whether the pin moved. No-ops
485
- * to `false` on a degenerate or final-step wizard
486
- * (finish via `handleSubmit`).
487
- * - `handleSubmit(onSubmit, onError?)` — always validates the entire
488
- * step list, from any step, and never advances the
489
- * pin: on success it latches `done`; on any error it
490
- * focuses the first failing step and fires `onError`
491
- * with errors spanning every step. To gate advancing
492
- * a step on its own validity, compose with the active
493
- * form's submit: `activeForm.handleSubmit(() =>
494
- * wizard.next())`. Returns an event handler suitable
495
- * for `<form @submit>` or imperative use.
496
- * - `reset()` — zeros wizard lifecycle (`submissionAttempts`,
497
- * `visited`), resets every form, returns
498
- * `currentStep` to `steps[0].key`, and invokes
499
- * `persist` with the cleared state.
500
- */
501
- type UseWizardReturnType<S extends ReadonlyArray<StepSlot> = ReadonlyArray<StepSlot>> = {
502
- readonly key: string;
503
- readonly currentStep: CurrentStepOf<S>;
504
- readonly activeForm: ActiveFormOf<S>;
505
- readonly activeIndex: number;
506
- readonly isFinalStep: boolean;
507
- readonly steps: ReadonlyArray<CompiledStep>;
508
- readonly forms: WizardForms<S>;
509
- readonly count: number;
510
- readonly statuses: WizardStatusesProxy<Record<string, FormStatus>>;
511
- readonly allValues: Readonly<Record<FormKey, unknown>>;
512
- readonly allErrors: Readonly<Record<FormKey, readonly WizardAggregateError[]>>;
513
- readonly progress: number;
514
- readonly canAdvance: boolean;
515
- readonly canGoBack: boolean;
516
- readonly complete: boolean;
517
- readonly done: boolean;
518
- readonly submitting: boolean;
519
- readonly submissionAttempts: number;
520
- readonly submitError: Error | null;
521
- readonly visited: readonly FormKey[];
522
- readonly next: () => Promise<void>;
523
- readonly back: () => void;
524
- readonly goTo: (key: string) => void;
525
- readonly tryNext: () => Promise<boolean>;
526
- readonly handleSubmit: (onSubmit: WizardOnSubmit, onError?: WizardOnError) => (event?: Event) => Promise<void>;
527
- readonly reset: () => void;
528
- };
529
-
530
- /**
531
- * Per-form display engine: owns the clock and the timers that the pure
532
- * `getDisplayState` reducer policy needs, so the reducer itself stays a
533
- * deterministic `(prev, ctx) => next` function.
534
- *
535
- * It keeps a `Map` of the machines that are still *active* (a spinner is
536
- * showing, a verdict is being held, or a `reviewAt` deadline is pending),
537
- * a single reactive `tick` ref, and a single `setTimeout` aimed at the
538
- * nearest `reviewAt` across every active field. When a field-state
539
- * computed reads `resolve`, it subscribes to `tick`; when the timer
540
- * fires, `tick` bumps and every dependent computed re-runs the reducer,
541
- * so a field whose deadline elapsed transitions (verdict → spinner, or
542
- * spinner → settled verdict) without any per-field watcher.
543
- *
544
- * Eviction: a machine is dropped once it reaches a terminal *idle* state
545
- * with no pending review. Error / success / pending machines are retained
546
- * so the reducer can hold the prior verdict under the show-delay window of
547
- * the *next* validation streak (no success → idle → success flicker). The
548
- * retained set is bounded by the rendered non-idle fields; none of them
549
- * arm a timer, so retention costs memory, never CPU.
550
- *
551
- * Untrusted reducer: `getDisplayState` is consumer-overridable, so the
552
- * engine treats the returned `reviewAt` as untrusted. A non-finite deadline
553
- * (NaN / ±Infinity from a custom predicate's bad arithmetic) is ignored
554
- * rather than handed to `setTimeout`, where it coerces to 0 and spins; an
555
- * over-large finite deadline is clamped below the 32-bit `setTimeout`
556
- * overflow; and the timer refuses to re-arm for the exact deadline it just
557
- * fired, so a predicate re-emitting a fixed or past `reviewAt` can't drive an
558
- * infinite fire loop. None of these arise from the library default, which
559
- * always advances its deadline or drops it.
560
- *
561
- * Background tabs: `setTimeout` is throttled to >= 1s while a tab is hidden,
562
- * so a min-visible hold can overshoot. A `visibilitychange` listener bumps
563
- * the clock on return to the foreground, so any overdue deadline resolves at
564
- * once instead of lingering.
565
- *
566
- * SSR: no clock, no timers, no listener. With `now` frozen and nothing
567
- * validating at render, the reducer returns the plain verdict (never
568
- * pending) and the engine stores nothing, so the server HTML and the
569
- * client's first render agree — no hydration mismatch on the display
570
- * projection.
571
- */
572
- type DisplayEngine = {
573
- /**
574
- * Resolve a path's next `DisplayMachine`. Subscribes the calling
575
- * computed to the engine clock, threads the path's previous machine
576
- * through `reducer`, persists or evicts the result, and re-arms the
577
- * single timer to the nearest deadline.
578
- */
579
- resolve(key: PathKey, ctx: DisplayCtx, reducer: GetDisplayState): DisplayMachine;
580
- /** Drop every retained machine and cancel the timer (used by `reset()`). */
581
- clear(): void;
582
- /** Tear down for good: `clear()` plus detaching the visibility listener. */
583
- dispose(): void;
584
- /** Introspection for tests: count of retained machines. */
585
- size(): number;
586
- /** Introspection for tests: whether a path currently has a retained machine. */
587
- has(key: PathKey): boolean;
588
- /** Introspection for tests: whether a deadline timer is currently armed. */
589
- hasTimer(): boolean;
590
- };
591
-
592
- /**
593
- * Per-path field status. Replaced wholesale (not mutated in place) on
594
- * every change. Five semantic groups:
595
- *
596
- * - `connected` — is a DOM element registered for this path?
597
- * - `focused` / `blurred` — DOM-state flags. `null` while no element
598
- * is connected (no DOM means the concepts don't apply); plain
599
- * booleans once connected, with the invariant `blurred === !focused`
600
- * enforced by `markFocused`.
601
- * - `touched` — focus/blur history, not DOM state. Always a plain
602
- * boolean: `false` at registration, sticky `true` after first blur,
603
- * cleared only by `form.reset()` / `form.resetField(path)`. Persists
604
- * across disconnects so v-if'd-away fields don't lose their touched
605
- * state on rehide (wizard "show review of touched fields" patterns
606
- * rely on this).
607
- * - `interacted` — value-mutation history, not DOM state. Plain
608
- * boolean: `false` at registration, sticky `true` once the user
609
- * issues a value edit through the directive's input listeners
610
- * (never on hydration, default seeding, or programmatic setValue);
611
- * cleared with `touched` by `form.reset()` / `form.resetField(path)`.
612
- * - `blurredAfterInteraction` — the first blur that follows a value
613
- * edit (the field has been edited and then left). Plain boolean,
614
- * sticky `true`. A tab-through blur with no prior edit does NOT set
615
- * it (`interacted` is still false at that blur). Composes
616
- * `interacted` with the departure; drives the default display gate.
617
- */
618
- type FieldRecord = {
619
- readonly path: Path;
620
- readonly updatedAt: string | null;
621
- readonly connected: boolean;
622
- readonly focused: boolean | null;
623
- readonly blurred: boolean | null;
624
- readonly touched: boolean;
625
- readonly interacted: boolean;
626
- readonly blurredAfterInteraction: boolean;
627
- };
628
- /** Per-path DOM element tracking. Client-only. */
629
- type ElementRecord = {
630
- /**
631
- * Original Path captured at first registration. Stored alongside the
632
- * elements Set so the DOM-order sort cache can recover the structured
633
- * Path without round-tripping through `JSON.parse(pathKey)`.
634
- */
635
- readonly path: Path;
636
- readonly elements: Set<HTMLElement>;
637
- };
638
- /**
639
- * Per-path record stored in `originals`. Pairing `segments` with the tracked
640
- * value means `dirty` and `resetField`'s container loop don't have to
641
- * `JSON.parse(pathKey)` on every iteration — the canonical Path is already
642
- * sitting next to the value it belongs to. PathKey still keys the Map (the
643
- * stable string is the only collision-free identifier), but downstream
644
- * iteration reads `segments` directly.
645
- */
646
- type OriginalsRecord = {
647
- readonly segments: Path;
648
- readonly value: unknown;
649
- };
650
-
651
- /**
652
- * Schema-driven coercion of user-typed DOM values at the v-register
653
- * directive layer. When the slim schema declares a numeric or
654
- * boolean type at a path, the directive coerces incoming string
655
- * values (`'25'` → `25`, `'true'` → `true`) before the slim-primitive
656
- * gate sees the write — making the schema authoritative for storage
657
- * shape and freeing consumers from sprinkling `.number` modifiers
658
- * across templates.
659
- *
660
- * Coercion is consumer-extensible: a `CoercionRegistry` is just an
661
- * `Array<CoercionEntry>` keyed at config time by `(input, output)`
662
- * `SlimPrimitiveKind` literals. The library ships
663
- * `defaultCoercionRules` (string→number, string→boolean) and
664
- * `defineCoercion` for type-narrowed authoring; consumers spread the
665
- * defaults to extend or supply their own array to replace.
666
- *
667
- * Coercion applies ONLY to user-typed DOM values flowing through
668
- * the directive's assigner. Programmatic writes (`form.setValue`,
669
- * `setValueWithInternalPath`) bypass coercion — they're authoritative
670
- * writes whose strict typing is on the caller. This mirrors the
671
- * `transforms` pipeline's user-input-only contract.
672
- */
673
-
674
- /**
675
- * Type-narrowing helper for authoring entries. At runtime it's
676
- * identity; at compile time it preserves the `input` / `output`
677
- * literal types so `transform`'s parameter is narrowed to the
678
- * runtime type instead of widening to `SlimRuntimeOf<SlimPrimitiveKind>`.
679
- *
680
- * Without this helper, authoring `{ input: 'string', output:
681
- * 'number', transform: (s) => ... }` against the broader
682
- * `CoercionEntry` widens `s` to `string | number | boolean | ...`,
683
- * forcing a cast in every transform body. `defineCoercion` is the
684
- * opaque-free idiom.
685
- */
686
- declare function defineCoercion<I extends SlimPrimitiveKind, O extends SlimPrimitiveKind>(entry: CoercionEntry<I, O>): CoercionEntry<I, O>;
687
- /**
688
- * Internal index built from a `CoercionRegistry` at config-resolve
689
- * time. Keyed by `${input}->${output}` for O(1) per-keystroke
690
- * dispatch. The authoring shape (array, ergonomic, type-narrowing-
691
- * friendly) and the dispatch shape (Map, fast) decouple cleanly.
692
- */
693
- type CoercionIndex = ReadonlyMap<`${SlimPrimitiveKind}->${SlimPrimitiveKind}`, CoercionEntry>;
694
- /**
695
- * The library's built-in registry. Two cells: string→number and
696
- * string→boolean. Re-exported so consumers can spread it when
697
- * supplying a custom registry that extends defaults.
698
- */
699
- declare const defaultCoercionRules: CoercionRegistry;
700
-
701
- type FormStore<F extends GenericForm, G extends GenericForm = F> = {
702
- readonly formKey: FormKey;
703
- readonly form: Ref<F>;
704
- readonly fields: Map<PathKey, FieldRecord>;
705
- readonly elements: Map<PathKey, ElementRecord>;
706
- /**
707
- * Schema-driven errors. Written ONLY by the schema validation pipeline:
708
- * `scheduleFieldValidation`, `handleSubmit`, the construction-time seed,
709
- * history restore, and hydration. Cleared by `reset` / `resetField` and by
710
- * a successful submit. `setErrors` / `clearErrors` do NOT touch this Map.
711
- */
712
- readonly schemaErrors: Map<PathKey, ValidationError[]>;
713
- /**
714
- * User-injected errors. Written ONLY by the `setErrors` / `clearErrors`
715
- * API surface (and history / hydration replay). Survives schema revalidation and
716
- * successful submits — the consumer owns its lifetime explicitly.
717
- */
718
- readonly userErrors: Map<PathKey, ValidationError[]>;
719
- /**
720
- * Reactively-derived "No value supplied" errors. Pure function of
721
- * `(blankPaths, schema.isRequiredAtPath)` — no writers, no clears.
722
- * Membership tracks `blankPaths` automatically: typing a value into
723
- * a blank required numeric field removes the path from `blankPaths`
724
- * and the derived error vanishes; clearing the numeric input re-adds
725
- * the path and the error reappears. The `errors` proxy and
726
- * `getErrorsForPath` merge this map in alongside `schemaErrors` and
727
- * `userErrors`, so consumers see the "this required field is empty"
728
- * error the moment it's true — no `validate()` / `handleSubmit`
729
- * call required. Honors the founding principle that
730
- * `errors = f(schema, state)`.
731
- *
732
- * Most entries flow through this map for `number` / `bigint` leaves
733
- * (where the side-channel is needed to distinguish "user typed 0"
734
- * from "user supplied nothing"). String / boolean leaves only land
735
- * here when the consumer explicitly opted in via the `unset`
736
- * sentinel — see `docs/validation/blank.md`.
737
- */
738
- readonly derivedBlankErrors: ComputedRef<ReadonlyMap<PathKey, ValidationError[]>>;
739
- readonly originals: Map<PathKey, OriginalsRecord>;
740
- /**
741
- * Reactive set of paths whose displayed state should be EMPTY even
742
- * though storage holds a real, schema-conformant value (the slim
743
- * default). It exists exclusively to record **storage / display
744
- * divergence** — the case where the runtime can't tell "user typed
745
- * 0" from "user supplied nothing" by looking at storage alone.
746
- *
747
- * The mechanism shines for `number` / `bigint`: storage holds the
748
- * slim default (`0` / `0n`) but the DOM input shows `''`, so the
749
- * directive's input listener marks the path here on clear. Strings
750
- * and booleans don't need it — `''` storage equals `''` display,
751
- * `false` storage equals unchecked display — so they're never
752
- * auto-marked. Consumers can still mark any primitive leaf
753
- * explicitly via the `unset` sentinel (`defaultValues: { x: unset }`,
754
- * `setValue('x', unset)`, `reset({ x: unset })`); the mark is then
755
- * a documented signal of consumer intent rather than runtime
756
- * inference.
757
- *
758
- * Reads (`displayValue` computed, `fields.<path>.blank`,
759
- * `derivedBlankErrors` computed) track via Vue 3.5's reactive Set
760
- * handlers. Writes happen inside `setValueAtPath` (gate-hook
761
- * bookkeeping: `blank: true` meta adds the path; any other write
762
- * removes it) and `reset`.
763
- *
764
- * Storage NEVER reflects this set — calculations and reads against
765
- * `form.value` see the slim default. The set is purely a UI/intent
766
- * channel that `derivedBlankErrors` consults to surface
767
- * "No value supplied" errors for required schemas.
768
- *
769
- * See `docs/validation/blank.md` for the conceptual model.
770
- */
771
- readonly blankPaths: Set<PathKey>;
772
- /**
773
- * Snapshot of `blankPaths` captured at construction (and
774
- * re-captured on `reset(args)`). Used by dirty calculation: a path
775
- * whose membership differs from the snapshot is dirty even if
776
- * storage matches the original. Eagerly populated to avoid a "dirty
777
- * on first read" race after construction.
778
- */
779
- readonly originalBlankPaths: Set<PathKey>;
780
- readonly schema: AbstractSchema<F, G>;
781
- /**
782
- * Server-side flag, plumbed in from `registry.ssr`. The
783
- * `register()`-returned `markConnectedOptimistically()` reads this
784
- * before flipping `connected: true`; on the client it's a no-op so
785
- * the eventual directive lifecycle remains the source of truth.
786
- */
787
- readonly ssr: boolean;
788
- /**
789
- * Resolved `getDisplayState` predicate driving `field.displayState`,
790
- * the `show*` booleans, and their `form.meta` rollups. Resolved once
791
- * at construction via `resolveGetDisplayState(options.getDisplayState)`;
792
- * `undefined` config falls through to `defaultDisplayState`. The
793
- * field-state computeds read the resolved function directly on every
794
- * read.
795
- */
796
- readonly getDisplayState: GetDisplayState;
797
- /**
798
- * Per-form display engine: owns the clock and the single timer the timed
799
- * `getDisplayState` reducer policy needs, keeping the reducer itself a
800
- * pure `(prev, ctx) => next` function. The field-state computeds route
801
- * every `displayState` read through `displayEngine.resolve(...)`, which
802
- * threads the path's previous machine, persists or evicts the result, and
803
- * re-arms the nearest-deadline timer. Constructed once at form
804
- * construction; torn down via `registerCleanup` on store eviction.
805
- */
806
- readonly displayEngine: DisplayEngine;
807
- readonly submitting: Ref<boolean>;
808
- readonly activeSubmissions: Ref<number>;
809
- readonly submissionAttempts: Ref<number>;
810
- /**
811
- * `true` once a `handleSubmit` callback resolved without throwing.
812
- * Independent of `submissionAttempts` — a failed submit increments
813
- * attempts but leaves `submitted` at `false`. Cleared by `reset()`
814
- * alongside the rest of the submission surface.
815
- */
816
- readonly submitted: Ref<boolean>;
817
- readonly submitError: Ref<Error | null>;
818
- readonly departAttempts: Ref<number>;
819
- /**
820
- * `true` while a function-form `defaultValues` factory is in flight.
821
- * Stays `false` for plain-value `defaultValues`. Shared across every
822
- * `useForm({ key })` call that resolves to this store — the second
823
- * caller sees the first caller's hydration state.
824
- */
825
- readonly hydrating: Ref<boolean>;
826
- /**
827
- * Error from the most recent function-form `defaultValues` factory.
828
- * Normalized to a `ValidationError` (code `atta:hydration-failed`) so the
829
- * shape matches `form.errors` / `form.meta.errors` entries. `null` when
830
- * no factory has fired or the last one succeeded.
831
- */
832
- readonly hydrateError: Ref<ValidationError | null>;
833
- /**
834
- * The function-form `defaultValues` factory, captured at the first
835
- * `useForm({ key })` call that wired this store. `undefined` for
836
- * plain-value forms. Read by `form.rehydrate()`.
837
- */
838
- readonly defaultValuesFactory: Ref<(() => unknown | Promise<unknown>) | undefined>;
839
- /**
840
- * `true` when this store carries an SSR prefetch queue (server path
841
- * where `state.activate()` must enqueue intent before deciding
842
- * whether to fire). The flag lets `buildFormApi` skip the lazy
843
- * activation gate for forms with no factory AND no SSR prefetch —
844
- * the common client-side case where `gated()` is otherwise pure
845
- * reactive overhead on every public method call.
846
- */
847
- readonly hasSsrPrefetch: boolean;
848
- /**
849
- * `true` once the form's effective defaults have been applied —
850
- * sync `defaultValues` at construction, or async factory whose
851
- * settle completed. Stays `false` for dormant lazy forms until they
852
- * activate. Read by `useWizard` to decide whether seed status or
853
- * live meta should surface.
854
- */
855
- readonly defaultsResolved: Ref<boolean>;
856
- /**
857
- * `true` once the captured async factory has been kicked off (set
858
- * synchronously by `activate()`, before the factory itself resolves).
859
- * Distinct from `defaultsResolved`, which only flips after the factory
860
- * settles. The pair lets the API surface tell "we've started" apart
861
- * from "we're done."
862
- */
863
- readonly activated: Ref<boolean>;
864
- /**
865
- * In-flight activation promise. Concurrent callers (cross-component
866
- * SSR consumers, recursive factory reads, parallel `activate()`
867
- * calls) receive the same promise, ensuring the factory runs once
868
- * even under contention.
869
- */
870
- readonly activationPromise: Ref<Promise<void> | undefined>;
871
- /**
872
- * Idempotent activation entrypoint. Fires the captured function-form
873
- * `defaultValues` factory on first call and stores the in-flight
874
- * promise. Subsequent calls return the same promise until the factory
875
- * settles; thereafter calls return `Promise.resolve()`. Plain-value
876
- * forms (no factory captured) always return a resolved promise. The
877
- * public API surface routes all reactive interactions (getters and
878
- * methods, except `key`) through this entrypoint so the form
879
- * activates on first use.
880
- */
881
- activate(): Promise<void>;
882
- /**
883
- * Re-fire the captured function-form `defaultValues` factory. Throws
884
- * synchronously when no factory was captured (plain-value form).
885
- * Resolves after `hydrating` flips back to `false`; consumers can
886
- * `await form.rehydrate()` to gate UI on the fresh load.
887
- *
888
- * Does NOT touch dirty / touched / submit state — chain
889
- * `form.reset()` if you want a clean baseline.
890
- */
891
- rehydrate(): Promise<void>;
892
- /**
893
- * Incremented by every `reset()` call. The submit wrapper captures
894
- * this at entry and skips writing `submitError` from a catch that
895
- * fires *after* a reset — otherwise a reset-during-submit would
896
- * visibly clear `submitError` and then have it reappear when the
897
- * in-flight promise rejects.
898
- */
899
- readonly submissionGeneration: Ref<number>;
900
- /**
901
- * Counts in-flight validation calls across every `validate()` ref and
902
- * every `validateAsync(...)` / `handleSubmit` pre-check. `validating`
903
- * on the public API mirrors `activeValidations.value > 0`. Tracked
904
- * separately from submissions because a validate-while-submitting
905
- * (e.g. a debounced field check overlapping a submit) needs to show
906
- * the union of both surfaces.
907
- */
908
- readonly activeValidations: Ref<number>;
909
- /**
910
- * `true` once the form has completed at least one validation pass
911
- * — flips when `activeValidations` returns to 0 from any positive
912
- * value. Until that happens, `meta.valid` and `field.valid` report
913
- * `false` even when `schemaErrors.size === 0`, because the absence
914
- * of errors at frame 1 is just "we haven't checked yet," not "we
915
- * checked and it's clean."
916
- *
917
- * This closes the brief flash window for schemas where the slim
918
- * default-derivation parse strips refinements (`.refine`,
919
- * `.superRefine`, async validators): the slim parse passes, no
920
- * construction-time errors land, and the queued microtask hasn't
921
- * run yet — so without the gate, frame 1 paints the form as
922
- * "valid" before the real verdict arrives a tick later.
923
- *
924
- * Initialized to `!strict`: non-strict consumers opt out of the
925
- * validation pipeline by design, so locking them on
926
- * `firstValidationDone === false` would defeat the opt-out.
927
- * Reset is left untouched — the post-reset validation flips it
928
- * back true on completion, same as the construction-time path.
535
+ * `true` once the form has completed at least one validation pass
536
+ * flips when `activeValidations` returns to 0 from any positive
537
+ * value. Until that happens, `meta.valid` and `field.valid` report
538
+ * `false` even when `schemaErrors.size === 0`, because the absence
539
+ * of errors at frame 1 is just "we haven't checked yet," not "we
540
+ * checked and it's clean."
541
+ *
542
+ * This closes the brief flash window for schemas where the slim
543
+ * default-derivation parse strips refinements (`.refine`,
544
+ * `.superRefine`, async validators): the slim parse passes, no
545
+ * construction-time errors land, and the queued microtask hasn't
546
+ * run yet so without the gate, frame 1 paints the form as
547
+ * "valid" before the real verdict arrives a tick later.
548
+ *
549
+ * Initialized to `!strict`: non-strict consumers opt out of the
550
+ * validation pipeline by design, so locking them on
551
+ * `firstValidationDone === false` would defeat the opt-out.
552
+ * Reset is left untouched — the post-reset validation flips it
553
+ * back true on completion, same as the construction-time path.
929
554
  */
930
555
  readonly firstValidationDone: Ref<boolean>;
931
556
  /**
@@ -1214,132 +839,658 @@ type FormStore<F extends GenericForm, G extends GenericForm = F> = {
1214
839
  * `transformError`, register `holder` for later abort. Returns the run
1215
840
  * token. See `InternalRegisterValue.beginTransform`.
1216
841
  */
1217
- beginTransform(key: PathKey, holder: TransformAbortHolder): number;
1218
- /** `true` while `token` is the live async-transform run at `key`. */
1219
- isCurrentTransform(key: PathKey, token: number): boolean;
842
+ beginTransform(key: PathKey, holder: TransformAbortHolder): number;
843
+ /** `true` while `token` is the live async-transform run at `key`. */
844
+ isCurrentTransform(key: PathKey, token: number): boolean;
845
+ /**
846
+ * Close the run `token` at `key`: release the counters (no-op if the
847
+ * run was already released by a supersede / cancel) and flush settled
848
+ * `settleTransforms` waiters.
849
+ */
850
+ endTransform(key: PathKey, token: number): void;
851
+ /** Record a per-field normalization failure at `key` (`field.transformError`). */
852
+ setTransformError(key: PathKey, err: Error): void;
853
+ /**
854
+ * Abort + release every in-flight async-transform run (all paths) and
855
+ * clear `transformErrors`. Mirrors `cancelFieldValidation`; called by
856
+ * `reset()` and store teardown.
857
+ */
858
+ cancelTransforms(): void;
859
+ /**
860
+ * Path-scoped counterpart to `cancelTransforms`: abort + release only
861
+ * the runs at-or-under `prefix`, clearing their `transformError`.
862
+ * Called by `resetField`.
863
+ */
864
+ cancelTransformsUnder(prefix: Path): void;
865
+ /**
866
+ * Resolve once async transforms are quiescent — globally (`path`
867
+ * omitted) or at-or-under `path`. Resolve-never-reject. See
868
+ * `UseFormReturnType.settleTransforms`.
869
+ */
870
+ settleTransforms(path?: string | Path): Promise<void>;
871
+ /**
872
+ * Kick off (or schedule) a field-level validation run for `path`. Pass
873
+ * `path = []` to cover the whole form; `applySchemaErrorsForSubtree`
874
+ * then wipes every `schemaErrors` entry and replaces them with the
875
+ * adapter's full async response. Used by persistence's post-hydration
876
+ * revalidation and by the construction-time async-refine seed.
877
+ *
878
+ * `immediate: true` skips the debounce window — the runtime kicks off
879
+ * the adapter call on the next microtask. Internal callsites use this
880
+ * for one-shot triggers; the per-keystroke writers pass `false` to
881
+ * coalesce rapid mutations under the configured debounceMs.
882
+ *
883
+ * `override` carries per-`useForm()`-instance values: when provided,
884
+ * the scheduler honors `override.mode` instead of the store's
885
+ * captured `validateOn`, and `override.debounceMs` instead of the
886
+ * store's captured `debounceMs`. Used so sibling instances sharing a
887
+ * FormStore can each validate on their own cadence.
888
+ */
889
+ scheduleFieldValidation(path: Path, immediate: boolean, override?: {
890
+ readonly mode?: ValidateOn;
891
+ readonly debounceMs?: number;
892
+ }): void;
893
+ /**
894
+ * Subscribe to every `applyFormReplacement`. Fires synchronously
895
+ * after `form.value` has been swapped to `next` and all field /
896
+ * originals bookkeeping has run. Used by undo/redo to hook the single
897
+ * mutation funnel. The optional `meta` carries the originating call
898
+ * site's intent; subscribers that don't care about meta can ignore the
899
+ * parameter. Returns an unsubscribe function.
900
+ */
901
+ onFormChange(listener: (next: F, meta?: WriteMeta) => void): () => void;
902
+ /**
903
+ * Subscribe to successful submissions. Fires after the consumer's
904
+ * `onSubmit` callback has resolved — not on validation failure,
905
+ * not on callback throw. The DevTools panel rides this to surface a
906
+ * submit event. Returns an unsubscribe function.
907
+ */
908
+ onSubmitSuccess(listener: () => void): () => void;
909
+ /**
910
+ * Subscribe to `reset()` calls. Fires AFTER reset has replaced
911
+ * the form and cleared errors + lifecycle, so listeners see the
912
+ * fresh post-reset state. Used by the history module to drop the
913
+ * undo/redo stack on reset. Returns an unsubscribe function.
914
+ */
915
+ onReset(listener: () => void): () => void;
916
+ /**
917
+ * Internal: notify submit-success subscribers. Called by
918
+ * `handleSubmit` in `process-form.ts` once the user callback has
919
+ * resolved. Consumers shouldn't call this directly.
920
+ */
921
+ emitSubmitSuccess(): void;
922
+ /**
923
+ * Register a teardown function whose lifetime is bound to the
924
+ * FormStore itself (not a consumer's Vue effect scope). Called by
925
+ * `dispose()` when the last consumer unmounts. Used by persistence /
926
+ * history wiring so their subscribers aren't detached prematurely
927
+ * when only the first consumer unmounts but others remain.
928
+ */
929
+ registerCleanup(fn: () => void): void;
930
+ /**
931
+ * Register an async drain function. Called by the registry before
932
+ * `dispose()` so async background work — chiefly the persistence
933
+ * layer's debounced storage writes — has a chance to settle without
934
+ * losing the last keystroke. Each registered function is awaited in
935
+ * parallel; failures are swallowed to keep eviction reliable.
936
+ */
937
+ registerDrain(fn: () => Promise<void>): void;
938
+ /**
939
+ * Drain async work registered via `registerDrain`. Resolves once
940
+ * every registered drain has settled (in parallel). Safe to call
941
+ * repeatedly — registered drains decide their own idempotency.
942
+ */
943
+ awaitPendingWrites(): Promise<void>;
944
+ /**
945
+ * Cache for per-state modules (history, persistence) that must
946
+ * outlive any single consumer. Subsequent `useForm` / `injectForm`
947
+ * calls for the same key read from this map so the public API shape
948
+ * is identical regardless of mount order. Keyed by a string identifier
949
+ * owned by the caller (e.g. `'history'`).
950
+ */
951
+ readonly modules: Map<string, unknown>;
1220
952
  /**
1221
- * Close the run `token` at `key`: release the counters (no-op if the
1222
- * run was already released by a supersede / cancel) and flush settled
1223
- * `settleTransforms` waiters.
953
+ * Resolved schema-coercion index the merged config from
954
+ * `createAttaform({ defaults: { coerce } })` `useForm({ coerce })`,
955
+ * keyed by `${input}->${output}` for O(1) per-keystroke dispatch.
956
+ * Empty Map when coercion is disabled. Read at `register()` time
957
+ * by `buildCoerceFn` to bake the per-path coerce closure on
958
+ * `RegisterValue.coerce`.
1224
959
  */
1225
- endTransform(key: PathKey, token: number): void;
1226
- /** Record a per-field normalization failure at `key` (`field.transformError`). */
1227
- setTransformError(key: PathKey, err: Error): void;
960
+ readonly coerceIndex: CoercionIndex;
1228
961
  /**
1229
- * Abort + release every in-flight async-transform run (all paths) and
1230
- * clear `transformErrors`. Mirrors `cancelFieldValidation`; called by
1231
- * `reset()` and store teardown.
962
+ * Tear down non-reactive resources owned by this FormStore. Invoked
963
+ * by the registry when the last consumer unmounts. Cancels pending
964
+ * field-validation timers, drops every subscriber, and fires each
965
+ * cleanup hook registered via `registerCleanup`.
1232
966
  */
1233
- cancelTransforms(): void;
967
+ dispose(): void;
968
+ };
969
+
970
+ /**
971
+ * Public types for `useWizard` — the multistep-form orchestrator.
972
+ *
973
+ * The wizard is built around an ordered list of step slots. Each slot
974
+ * resolves to a participating form: an existing `useForm` reference, a
975
+ * bare string key (desugared to a noop form so affordance steps
976
+ * participate uniformly), an eagerly-evaluated function slot for
977
+ * runtime branching, or a `lazy()`-wrapped function slot that caches
978
+ * its resolution and re-fires only on its own tracked deps.
979
+ *
980
+ * The wizard surface is loosely keyed (`Record<FormKey, …>`).
981
+ * Cross-component flows threaded through `injectWizard` lose lexical
982
+ * key knowledge anyway, so the public read surface is a string-keyed
983
+ * record. Typed per-form access flows back through the original form
984
+ * refs and through `wizard.handleSubmit`'s `ctx.get(formRef)` accessor.
985
+ */
986
+
987
+ /**
988
+ * Minimum structural shape the wizard requires from a participating
989
+ * form. Constraining to the full `UseFormReturnType` would force
990
+ * contravariant unification of the storage / read shapes across all
991
+ * steps; the wizard does not care about those — it routes by `key` at
992
+ * runtime and exposes the original form objects untouched.
993
+ */
994
+ type AnyForm = {
995
+ readonly key: FormKey;
996
+ };
997
+ /**
998
+ * Per-form summary surface — what `wizard.statuses[key]` exposes (and
999
+ * what `defaultStatuses` seeds). Distinct from `form.meta`: `FormStatus`
1000
+ * is the cross-step rollup optimized for template ergonomics
1001
+ * (`{{ wizard.statuses.cargo.valid }}`), while `form.meta` carries the
1002
+ * full per-form lifecycle surface.
1003
+ *
1004
+ * Field semantics:
1005
+ * - `valid` — `form.meta.valid`. `false` while errors exist or while
1006
+ * the first-validation-done gate has not flipped.
1007
+ * - `dirty` — `form.meta.dirty`. `true` once any value differs from
1008
+ * the original defaults.
1009
+ * - `submitted` — `form.meta.submitted`. `true` once a `handleSubmit`
1010
+ * callback has resolved without throwing. A failed submit
1011
+ * (validation or callback rejection) leaves this `false`;
1012
+ * `submissionAttempts > 0` is the "user has tried" signal.
1013
+ * - `errorCount` — `form.meta.errorCount`. Count of active validation
1014
+ * errors (zero when valid).
1015
+ *
1016
+ * Noop forms generated for string slots surface as default-valid
1017
+ * (`{ valid: true, dirty: false, submitted: false, errorCount: 0 }`).
1018
+ */
1019
+ type FormStatus = {
1020
+ readonly valid: boolean;
1021
+ readonly dirty: boolean;
1022
+ readonly submitted: boolean;
1023
+ readonly errorCount: number;
1024
+ };
1025
+ /**
1026
+ * Flat error shape returned per form by `wizard.allErrors[key]`. Each
1027
+ * entry carries the formKey + path tuple so consumers can route to the
1028
+ * offending field from a wizard-wide error summary.
1029
+ */
1030
+ type WizardAggregateError = {
1031
+ readonly formKey: FormKey;
1032
+ readonly path: ReadonlyArray<string | number>;
1033
+ readonly message: string;
1034
+ readonly code?: string;
1035
+ };
1036
+ /**
1037
+ * Mirror of `form.values`' call-or-read pattern, one level deep.
1038
+ * Drillable as `wizard.statuses.cargo.valid` (readable), as
1039
+ * `wizard.statuses('cargo')` (callable single-key), or as
1040
+ * `wizard.statuses()` (callable no-arg returns the whole record).
1041
+ */
1042
+ type WizardStatusesProxy<S extends Record<string, FormStatus>> = ((key?: keyof S) => FormStatus | S) & Readonly<S>;
1043
+ /**
1044
+ * One compiled position in the wizard's flow. The wizard surface
1045
+ * exposes an ordered array of these as `wizard.steps`, plus a
1046
+ * `wizard.forms` record keyed by `step.key` for direct lookup.
1047
+ *
1048
+ * String slots in the source `steps` array desugar to noop forms
1049
+ * before compilation, so every compiled step carries a `form`
1050
+ * regardless of source kind.
1051
+ */
1052
+ type CompiledStep = {
1053
+ readonly key: FormKey;
1054
+ readonly form: AnyForm;
1055
+ };
1056
+ /**
1057
+ * Shape of a participating form as seen from inside a function slot's
1058
+ * `ctx.forms[key]` lookup. Adds `values` to the structural `AnyForm`
1059
+ * minimum so routing decisions can read live form state.
1060
+ *
1061
+ * Values are typed loose because the wizard does not generically thread
1062
+ * each step's schema through `ctx.forms`. For typed access inside slot
1063
+ * bodies, close over the original form ref instead of routing through
1064
+ * `ctx.forms`.
1065
+ */
1066
+ type WizardCtxForm = AnyForm & {
1067
+ readonly values: Readonly<Record<string, unknown>>;
1068
+ };
1069
+ /**
1070
+ * Context object passed to function slots in the `steps` array. The
1071
+ * `forms` record exposes the wizard's statically-known forms (every
1072
+ * top-level `AnyForm` slot plus every noop form synthesized for a
1073
+ * top-level string slot). `currentKey` mirrors the live wizard step.
1074
+ *
1075
+ * Function slots re-evaluate reactively when the values they read
1076
+ * mutate (typically `ctx.forms.<key>.values.<path>`). The `forms`
1077
+ * accumulator itself is stable across re-evaluations so the slot's
1078
+ * lookup identity stays referentially equal. Effectful slot bodies
1079
+ * should be avoided; routing decisions live here.
1080
+ */
1081
+ type WizardCtx = {
1082
+ readonly forms: Readonly<Record<FormKey, WizardCtxForm>>;
1083
+ readonly currentKey: FormKey | undefined;
1084
+ };
1085
+ /**
1086
+ * Internal phantom brand for `LazyMarker`. The runtime brand symbol
1087
+ * lives in `core/wizard-lazy.ts`; this declaration keeps the marker
1088
+ * type unforgeable without circular module imports.
1089
+ */
1090
+ declare const _lazyBrand: unique symbol;
1091
+ /**
1092
+ * Brand-typed marker returned by `lazy((ctx) => …)`. Wrapping a
1093
+ * function slot in `lazy()` gives that slot its own memoization cache:
1094
+ * the resolver fires once on the first compile pass, and the result
1095
+ * stays cached until one of the resolver's own tracked reactive reads
1096
+ * changes (or `wizard.reset()` invalidates the cache). Heavy or
1097
+ * one-shot lookups (network-backed factories, expensive derivations)
1098
+ * do not re-fire because an unrelated slot's deps changed.
1099
+ *
1100
+ * Construct via the `lazy()` helper exported from the same entry as
1101
+ * `useWizard`. The marker is opaque at the type level; consumers do
1102
+ * not assemble it directly.
1103
+ */
1104
+ type LazyMarker<Ctx = WizardCtx> = {
1105
+ readonly [_lazyBrand]: true;
1106
+ readonly resolve: (ctx: Ctx) => AnyForm | string | null | undefined;
1107
+ };
1108
+ /**
1109
+ * One position in the source `useWizard({ steps })` array. Each slot
1110
+ * resolves to a compiled `{ key, form }` step, or drops out:
1111
+ *
1112
+ * - `AnyForm` — a form declared via `useForm`. Surfaced as-is.
1113
+ * - `string` — bare key. The wizard generates a noop form
1114
+ * under the hood so the external surface stays
1115
+ * uniform across affordance positions (intro,
1116
+ * terms, congratulations, review surfaces).
1117
+ * - `null` / `undefined` — a literal absence, dropped from the compiled
1118
+ * list. Lets a conditional step read inline as
1119
+ * `cond ? form : null` without pre-filtering the
1120
+ * array.
1121
+ * - function — eager slot, re-evaluates reactively. Returns
1122
+ * one of the above, or `null` / `undefined` to
1123
+ * drop the slot from the compiled list.
1124
+ * - `LazyMarker` — memoized function slot (see `lazy`).
1125
+ */
1126
+ type StepSlot<Ctx = WizardCtx> = AnyForm | string | null | undefined | ((ctx: Ctx) => AnyForm | string | null | undefined) | LazyMarker<Ctx>;
1127
+ /**
1128
+ * Shape returned by the `restore` callback. Carries the active step's
1129
+ * key; intentionally open-ended (object form) so future additions land
1130
+ * without a callback-signature break.
1131
+ */
1132
+ type WizardRestoreState = {
1133
+ readonly step?: FormKey;
1134
+ };
1135
+ /**
1136
+ * `restore` callback signature. Invoked at construction and watched
1137
+ * reactively via `watchEffect` so external state changes (browser
1138
+ * back/forward, cross-tab events, route changes) re-apply through the
1139
+ * wizard. Returning `undefined` falls through to the first step.
1140
+ */
1141
+ type WizardRestoreFn = () => WizardRestoreState | undefined;
1142
+ /**
1143
+ * `persist` callback signature. Invoked whenever `wizard.currentStep`
1144
+ * changes; the wizard diffs against the last persisted value to break
1145
+ * the restore-persist loop, so the callback only fires when the active
1146
+ * step actually moves.
1147
+ */
1148
+ type WizardPersistFn = (state: WizardRestoreState) => void;
1149
+ /**
1150
+ * Submit context passed to the `onSubmit` callback registered via
1151
+ * `wizard.handleSubmit(onSubmit, onError?)`. `handleSubmit` always
1152
+ * processes the whole step list, so `values` carries every form's
1153
+ * parsed output regardless of which step fired the submit.
1154
+ *
1155
+ * - `values` — namespaced aggregate keyed by form key, mirroring
1156
+ * `wizard.allValues`. Reflects parsed output for every
1157
+ * form whose validation has settled; noops contribute an
1158
+ * empty record.
1159
+ * - `get(form)` — typed accessor that reads the parsed output for a
1160
+ * specific form ref. Works across cross-component graphs
1161
+ * because the form ref carries its schema info.
1162
+ * - `currentKey` — key of the step that fired this submission.
1163
+ * - `isFinal` — `true` when `currentKey` is the last position in
1164
+ * `wizard.steps`. Positional only: it reports where the
1165
+ * submit fired, never what got validated. A user who steps
1166
+ * back, edits, and submits from the middle sees
1167
+ * `isFinal === false`, yet the whole list is still
1168
+ * processed and `done` still latches on success.
1169
+ */
1170
+ type WizardSubmitContext = {
1171
+ readonly values: Readonly<Record<FormKey, unknown>>;
1172
+ readonly get: <F extends AnyForm>(form: F) => F extends {
1173
+ readonly values: infer V;
1174
+ } ? V : unknown;
1175
+ readonly currentKey: FormKey;
1176
+ readonly isFinal: boolean;
1177
+ };
1178
+ /**
1179
+ * `onSubmit` callback registered via `wizard.handleSubmit`. Sync or
1180
+ * async; the returned promise gates `wizard.submitting`.
1181
+ */
1182
+ type WizardOnSubmit = (ctx: WizardSubmitContext) => void | Promise<void>;
1183
+ /**
1184
+ * Optional `onError` callback registered via `wizard.handleSubmit`.
1185
+ * Receives the aggregate error list spanning EVERY step (handleSubmit
1186
+ * validates the whole wizard), so a failed submit surfaces every form's
1187
+ * errors at once, not just the active step's. Entries originate from
1188
+ * per-form validation, activation failures (`atta:activation-failed`),
1189
+ * and a submit callback that left errors on a processed step (the
1190
+ * `setErrors(...); return` server-rejection path). Sync or async; the
1191
+ * returned promise gates `wizard.submitting`.
1192
+ */
1193
+ type WizardOnError = (errors: readonly WizardAggregateError[]) => void | Promise<void>;
1194
+ /**
1195
+ * Options for `useWizard({ steps, … })`. `steps` is the only required
1196
+ * field; the rest are optional and default sensibly for the common
1197
+ * URL-synchronized wizard case.
1198
+ */
1199
+ type WizardOptions = {
1234
1200
  /**
1235
- * Path-scoped counterpart to `cancelTransforms`: abort + release only
1236
- * the runs at-or-under `prefix`, clearing their `transformError`.
1237
- * Called by `resetField`.
1201
+ * Ordered list of slots that compile into the wizard's positional
1202
+ * step list. See `StepSlot` for the per-slot shape contract.
1238
1203
  */
1239
- cancelTransformsUnder(prefix: Path): void;
1204
+ readonly steps: ReadonlyArray<StepSlot>;
1240
1205
  /**
1241
- * Resolve once async transforms are quiescent globally (`path`
1242
- * omitted) or at-or-under `path`. Resolve-never-reject. See
1243
- * `UseFormReturnType.settleTransforms`.
1206
+ * Identifier used to register the wizard handle in the per-app
1207
+ * registry. Descendant components call `injectWizard(key)` to reach
1208
+ * the same wizard without prop-threading. Anonymous wizards (option
1209
+ * omitted) get a synthetic `__atta:anon-wizard:<id>` key resolved
1210
+ * via `useId()` so SSR-rendered and client-hydrated trees agree on
1211
+ * the same registry entry; the synthetic key is opaque and
1212
+ * descendants reach an anonymous wizard via ambient `injectWizard()`
1213
+ * rather than by key.
1214
+ *
1215
+ * Duplicate-key registration is first-wins-silently (dev-warn on the
1216
+ * second registration) to mirror `useForm`'s shared-key behavior.
1217
+ * The dev-warn fires only for explicit keys — two anonymous wizards
1218
+ * are guaranteed distinct synthetic keys, so the warning never
1219
+ * misfires on independent anonymous wizards on the same page.
1244
1220
  */
1245
- settleTransforms(path?: string | Path): Promise<void>;
1221
+ readonly key?: string;
1246
1222
  /**
1247
- * Kick off (or schedule) a field-level validation run for `path`. Pass
1248
- * `path = []` to cover the whole form; `applySchemaErrorsForSubtree`
1249
- * then wipes every `schemaErrors` entry and replaces them with the
1250
- * adapter's full async response. Used by persistence's post-hydration
1251
- * revalidation and by the construction-time async-refine seed.
1223
+ * Seed status payload used while a form is pre-resolved (async
1224
+ * `defaultValues` in flight, or wizard-deferred non-current).
1225
+ * Mirrors `defaultValues`' trichotomy: plain object, sync factory,
1226
+ * or async factory.
1252
1227
  *
1253
- * `immediate: true` skips the debounce window — the runtime kicks off
1254
- * the adapter call on the next microtask. Internal callsites use this
1255
- * for one-shot triggers; the per-keystroke writers pass `false` to
1256
- * coalesce rapid mutations under the configured debounceMs.
1228
+ * Status resolution priority per form:
1229
+ * 1. `store.defaultsResolved === true` derive from `form.meta`
1230
+ * 2. else noop form built-in always-valid status
1231
+ * 3. else seed value for this key → frozen seed
1232
+ * 4. else → pending sentinel
1257
1233
  *
1258
- * `override` carries per-`useForm()`-instance values: when provided,
1259
- * the scheduler honors `override.mode` instead of the store's
1260
- * captured `validateOn`, and `override.debounceMs` instead of the
1261
- * store's captured `debounceMs`. Used so sibling instances sharing a
1262
- * FormStore can each validate on their own cadence.
1263
- */
1264
- scheduleFieldValidation(path: Path, immediate: boolean, override?: {
1265
- readonly mode?: ValidateOn;
1266
- readonly debounceMs?: number;
1267
- }): void;
1268
- /**
1269
- * Subscribe to every `applyFormReplacement`. Fires synchronously
1270
- * after `form.value` has been swapped to `next` and all field /
1271
- * originals bookkeeping has run. Used by undo/redo to hook the single
1272
- * mutation funnel. The optional `meta` carries the originating call
1273
- * site's intent; subscribers that don't care about meta can ignore the
1274
- * parameter. Returns an unsubscribe function.
1275
- */
1276
- onFormChange(listener: (next: F, meta?: WriteMeta) => void): () => void;
1277
- /**
1278
- * Subscribe to successful submissions. Fires after the consumer's
1279
- * `onSubmit` callback has resolved — not on validation failure,
1280
- * not on callback throw. The DevTools panel rides this to surface a
1281
- * submit event. Returns an unsubscribe function.
1282
- */
1283
- onSubmitSuccess(listener: () => void): () => void;
1284
- /**
1285
- * Subscribe to `reset()` calls. Fires AFTER reset has replaced
1286
- * the form and cleared errors + lifecycle, so listeners see the
1287
- * fresh post-reset state. Used by the history module to drop the
1288
- * undo/redo stack on reset. Returns an unsubscribe function.
1289
- */
1290
- onReset(listener: () => void): () => void;
1291
- /**
1292
- * Internal: notify submit-success subscribers. Called by
1293
- * `handleSubmit` in `process-form.ts` once the user callback has
1294
- * resolved. Consumers shouldn't call this directly.
1295
- */
1296
- emitSubmitSuccess(): void;
1297
- /**
1298
- * Register a teardown function whose lifetime is bound to the
1299
- * FormStore itself (not a consumer's Vue effect scope). Called by
1300
- * `dispose()` when the last consumer unmounts. Used by persistence /
1301
- * history wiring so their subscribers aren't detached prematurely
1302
- * when only the first consumer unmounts but others remain.
1303
- */
1304
- registerCleanup(fn: () => void): void;
1305
- /**
1306
- * Register an async drain function. Called by the registry before
1307
- * `dispose()` so async background work — chiefly the persistence
1308
- * layer's debounced storage writes — has a chance to settle without
1309
- * losing the last keystroke. Each registered function is awaited in
1310
- * parallel; failures are swallowed to keep eviction reliable.
1234
+ * Unknown keys in the seed object dev-warn so a stale resume payload
1235
+ * surfaces at construction.
1311
1236
  */
1312
- registerDrain(fn: () => Promise<void>): void;
1237
+ readonly defaultStatuses?: Record<string, FormStatus> | (() => Record<string, FormStatus>) | (() => Promise<Record<string, FormStatus>>);
1313
1238
  /**
1314
- * Drain async work registered via `registerDrain`. Resolves once
1315
- * every registered drain has settled (in parallel). Safe to call
1316
- * repeatedly registered drains decide their own idempotency.
1239
+ * Optional progress override. When omitted, the wizard exposes
1240
+ * `progress` as `valid_step_count / count` (normalised to `[0, 1]`).
1241
+ * When provided, the returned number is used as-is — the consumer is
1242
+ * responsible for any normalisation.
1243
+ *
1244
+ * The override is invoked inside a Vue `computed` so it must be
1245
+ * synchronous and may only read reactive sources.
1317
1246
  */
1318
- awaitPendingWrites(): Promise<void>;
1247
+ readonly progress?: (steps: ReadonlyArray<CompiledStep>) => number;
1319
1248
  /**
1320
- * Cache for per-state modules (history, persistence) that must
1321
- * outlive any single consumer. Subsequent `useForm` / `injectForm`
1322
- * calls for the same key read from this map so the public API shape
1323
- * is identical regardless of mount order. Keyed by a string identifier
1324
- * owned by the caller (e.g. `'history'`).
1249
+ * When `wizard.handleSubmit` finds errors, automatically focus the
1250
+ * first failing form: jump to its step and invoke its
1251
+ * `applyInvalidSubmitPolicy()` (focus / scroll per the form's own
1252
+ * `onInvalidSubmit` configuration). Default `true`; pass `false` to
1253
+ * keep the active step where the user left it and handle navigation
1254
+ * manually in the `onError` callback.
1325
1255
  */
1326
- readonly modules: Map<string, unknown>;
1256
+ readonly focusFirstError?: boolean;
1327
1257
  /**
1328
- * Resolved schema-coercion index the merged config from
1329
- * `createAttaform({ defaults: { coerce } })` `useForm({ coerce })`,
1330
- * keyed by `${input}->${output}` for O(1) per-keystroke dispatch.
1331
- * Empty Map when coercion is disabled. Read at `register()` time
1332
- * by `buildCoerceFn` to bake the per-path coerce closure on
1333
- * `RegisterValue.coerce`.
1258
+ * Source of truth for the active step. Invoked at construction and
1259
+ * re-evaluated reactively via `watchEffect`. Default callback reads
1260
+ * `?step=<key>` from the URL via `wizard-history.ts`; pass `false`
1261
+ * to disable URL sync, or provide a custom callback for non-router
1262
+ * persistence (localStorage, broadcast channel, etc.).
1334
1263
  */
1335
- readonly coerceIndex: CoercionIndex;
1264
+ readonly restore?: WizardRestoreFn | false;
1336
1265
  /**
1337
- * Tear down non-reactive resources owned by this FormStore. Invoked
1338
- * by the registry when the last consumer unmounts. Cancels pending
1339
- * field-validation timers, drops every subscriber, and fires each
1340
- * cleanup hook registered via `registerCleanup`.
1266
+ * Destination for the active step. Invoked whenever `currentStep`
1267
+ * changes, with a diff check to break the restore-persist loop.
1268
+ * Default callback writes `?step=<key>` via `wizard-history.ts`;
1269
+ * pass `false` to disable persistence, or provide a custom callback
1270
+ * to scope the param name or write to alternate storage.
1341
1271
  */
1342
- dispose(): void;
1272
+ readonly persist?: WizardPersistFn | false;
1273
+ };
1274
+ /**
1275
+ * True when a single slot is guaranteed to contribute exactly one
1276
+ * compiled step: a bare form or affordance string that is neither
1277
+ * nullish nor a (maybe-absent) function / `lazy()` slot. One of these
1278
+ * anywhere in the tuple proves the compiled list is non-empty.
1279
+ */
1280
+ type IsGuaranteedStep<T> = [null] extends [T] ? false : [undefined] extends [T] ? false : T extends LazyMarker | ((...args: unknown[]) => unknown) ? false : T extends string ? true : T extends {
1281
+ readonly key: string;
1282
+ } ? true : false;
1283
+ /**
1284
+ * Predicate: is the steps tuple statically guaranteed to compile to a
1285
+ * non-empty list? True when at least one element is a guaranteed step
1286
+ * (see `IsGuaranteedStep`). Function / `lazy()` slots may resolve to
1287
+ * nothing, and `null` / `undefined` (a literal absence or a
1288
+ * `cond ? form : null` union) is an explicit drop, so none of those
1289
+ * prove non-emptiness; a tuple of only maybe-absent slots stays
1290
+ * honestly `| undefined`.
1291
+ *
1292
+ * Used to narrow `currentStep` / `activeForm` to their non-`undefined`
1293
+ * shapes in the common-case wizard, while keeping the honest union
1294
+ * everywhere a runtime drop is reachable.
1295
+ */
1296
+ type StaticallyNonEmpty<S> = S extends readonly [infer First, ...infer Rest] ? IsGuaranteedStep<First> extends true ? true : StaticallyNonEmpty<Rest> : false;
1297
+ /** Active step's key, narrowed to `string` when `S` is statically safe. */
1298
+ type CurrentStepOf<S> = StaticallyNonEmpty<S> extends true ? FormKey : FormKey | undefined;
1299
+ /**
1300
+ * Active step's form handle, schema-erased to `UseFormReturnType<GenericForm>`
1301
+ * and narrowed to non-`undefined` when `S` is statically safe. The runtime
1302
+ * value is a live facade over the active step, so reads (`.values`, `.meta`,
1303
+ * `.history`) and `.handleSubmit` always target the current step. Values are
1304
+ * loose here because the active step's schema is not statically known; reach
1305
+ * for the original form ref or `ctx.get(ref)` when you need typed per-step
1306
+ * values.
1307
+ */
1308
+ type ActiveFormOf<S> = StaticallyNonEmpty<S> extends true ? UseFormReturnType<GenericForm> : UseFormReturnType<GenericForm> | undefined;
1309
+ /**
1310
+ * Per-slot contribution to {@link FormsRecordOf}. Strips a `null` /
1311
+ * `undefined` (a literal absence or a `cond ? form : null` union) off the
1312
+ * slot first, so a conditionally-present form still maps to its key and
1313
+ * stays concretely typed on `wizard.forms`; a purely nullish slot
1314
+ * contributes nothing.
1315
+ */
1316
+ type SlotRecord<First, F = Exclude<First, null | undefined>> = [F] extends [never] ? unknown : F extends string ? {
1317
+ readonly [P in F]: AnyForm;
1318
+ } : F extends {
1319
+ readonly key: infer K extends string;
1320
+ } ? {
1321
+ readonly [P in K]: F;
1322
+ } : unknown;
1323
+ /**
1324
+ * Recursive tuple walk that builds the static portion of
1325
+ * `wizard.forms`. Each step slot contributes to the record:
1326
+ *
1327
+ * - **String slot** (`'review'`): the literal becomes the record key
1328
+ * and the value is `AnyForm` (the noop form synthesized for the
1329
+ * affordance position is opaque at the type level).
1330
+ * - **Form slot** (a `useForm` reference with a literal `key` field):
1331
+ * the form's own `key` becomes the record key, and the value is
1332
+ * the concrete form handle type — so drilling
1333
+ * `wizard.forms.shipping.values.address` carries the schema-derived
1334
+ * field types through. A form kept behind a `cond ? form : null`
1335
+ * conditional still maps to its key.
1336
+ * - **`null` / `undefined` slot**: contributes nothing.
1337
+ * - **Function / `lazy()` slot**: contributes nothing to the static
1338
+ * map. Runtime-resolved forms are still reachable via the
1339
+ * catch-all index signature on `WizardForms` (typed as `AnyForm`).
1340
+ *
1341
+ * Recursion is bounded by the tuple length; real-world wizards land
1342
+ * well below the TS instantiation budget.
1343
+ */
1344
+ type FormsRecordOf<S> = S extends readonly [
1345
+ infer First,
1346
+ ...infer Rest extends ReadonlyArray<StepSlot>
1347
+ ] ? SlotRecord<First> & FormsRecordOf<Rest> : unknown;
1348
+ /**
1349
+ * `wizard.forms` typed view. Combines the static per-step type map
1350
+ * with a catch-all `Record<FormKey, AnyForm>` fallback so:
1351
+ *
1352
+ * - Statically known slot keys → concrete form type via `FormsRecordOf`
1353
+ * - Any other string key → `AnyForm` via the index signature
1354
+ *
1355
+ * The intersection collapses to the concrete form for statically
1356
+ * known keys (because the concrete form type extends `AnyForm`) and
1357
+ * to `AnyForm` for unknown keys.
1358
+ */
1359
+ type WizardForms<S> = FormsRecordOf<S> & Readonly<Record<FormKey, AnyForm>>;
1360
+ /**
1361
+ * Return shape of `useWizard({ steps, … })`. Every reactive read is a
1362
+ * plain getter (no `.value`) — `wizard.currentStep`, `wizard.progress`,
1363
+ * `wizard.allValues` track inside `computed` / template effects
1364
+ * directly.
1365
+ *
1366
+ * Parameterized by the steps tuple `S` so active-position fields
1367
+ * (`currentStep`, `activeForm`) narrow to non-undefined for the common
1368
+ * case (all positional Form / string slots) and stay as honest unions
1369
+ * when a function or `lazy()` slot can drop the compiled position at
1370
+ * runtime. The `const` type parameter on `useWizard` preserves literal
1371
+ * tuple types without consumer-side `as const`, so the narrowing
1372
+ * happens automatically from the call site.
1373
+ *
1374
+ * - `currentStep` — key of the active step. Narrows to `string` when
1375
+ * the steps tuple is statically guaranteed to
1376
+ * compile to a non-empty list (all positional
1377
+ * Form / string slots, no function or `lazy()`
1378
+ * slots). Otherwise reads as `string | undefined`
1379
+ * so the degenerate case (empty list at runtime)
1380
+ * surfaces honestly.
1381
+ * - `activeForm` — a LIVE view of the active step's form. Operating
1382
+ * through it always targets the current step, so a
1383
+ * handler captured once at setup
1384
+ * (`wizard.activeForm.handleSubmit(() =>
1385
+ * wizard.next())`) retargets as the wizard advances.
1386
+ * No longer `===` the raw per-step handle; reach for
1387
+ * `wizard.forms[key]` when you need raw identity.
1388
+ * Same `undefined` narrowing as `currentStep`. Noop
1389
+ * forms cover string slots in the normal path.
1390
+ * - `activeIndex` — 0-based position of the active step.
1391
+ * - `isFinalStep` — `true` when `currentStep === steps[count - 1].key`.
1392
+ * - `steps` — ordered list of compiled `{ key, form }` slots.
1393
+ * - `forms` — record indexable by step key; the value is the
1394
+ * full form handle resolved for that slot.
1395
+ * - `count` — `steps.length`.
1396
+ * - `statuses` — callable readonly proxy over the per-key
1397
+ * `FormStatus` record. Noop-form keys always read
1398
+ * as default-valid.
1399
+ * - `allValues` — namespaced aggregate of each form's values, keyed
1400
+ * by step key.
1401
+ * - `allErrors` — namespaced aggregate of each form's validation
1402
+ * errors, keyed by step key. Noop forms map to an
1403
+ * empty list.
1404
+ * - `progress` — normalised step-validity ratio (or the consumer's
1405
+ * `progress` override). Forward-looking: noops count
1406
+ * as always-valid.
1407
+ * - `canAdvance` — `true` when `activeIndex < count - 1`. Pure
1408
+ * positional check; navigation never gates on
1409
+ * validity.
1410
+ * - `canGoBack` — `true` when `activeIndex > 0`.
1411
+ * - `complete` — `isFinalStep && every step's form is valid`.
1412
+ * Forward-looking; reactive to current form
1413
+ * validity. Gates "Finish button enable" style UI.
1414
+ * - `done` — monotonic latch: flips `true` the first time a
1415
+ * `handleSubmit` resolves without throwing AND leaves
1416
+ * no errors set on any step, and stays `true` through
1417
+ * subsequent edits or invalidations. A callback that
1418
+ * calls `setErrors` and returns (the documented
1419
+ * server-rejection path) is a failed submit, so it
1420
+ * does not flip `done`. Only `reset()` flips it back.
1421
+ * Gates "show success card" style UI that should
1422
+ * reflect submission history rather than current
1423
+ * validity.
1424
+ * - `submitting` — `true` while a `wizard.handleSubmit` call is in
1425
+ * flight. Global re-entrance guard: every
1426
+ * navigation method also refuses while this is on.
1427
+ * - `submissionAttempts` — count of `wizard.handleSubmit` invocations
1428
+ * (success or failure). Always bumps, including on
1429
+ * noop-form steps.
1430
+ * - `submitError` — the error THROWN by the most recent
1431
+ * `wizard.handleSubmit` callback (or its `onError`),
1432
+ * coerced to a real `Error`. Mirrors
1433
+ * `form.meta.submitError`: this is the unexpected-throw
1434
+ * channel, so an expected rejection handled via
1435
+ * `setErrors` (no throw) surfaces through the error
1436
+ * surface and `onError` instead, leaving this `null`.
1437
+ * Cleared at submit entry and by `reset()`, parked here
1438
+ * rather than re-thrown, so the handler resolves and
1439
+ * never manufactures a `window` unhandledrejection.
1440
+ * `null` on success.
1441
+ * - `visited` — append-only breadcrumb of navigated step keys.
1442
+ * `back()` does not pop; the trail is the audit
1443
+ * log, not the back-stack.
1444
+ * - `next/back/goTo` — pure navigation. Refuses while `submitting`.
1445
+ * - `tryNext()` — validate the active step, then advance iff it
1446
+ * passed; invalid input keeps the pin put under the
1447
+ * form's standard error reveal (first error focused,
1448
+ * display state advanced). The inline-bindable
1449
+ * shorthand for `activeForm.handleSubmit(() =>
1450
+ * next())`. Resolves to whether the pin moved. No-ops
1451
+ * to `false` on a degenerate or final-step wizard
1452
+ * (finish via `handleSubmit`).
1453
+ * - `handleSubmit(onSubmit, onError?)` — always validates the entire
1454
+ * step list, from any step, and never advances the
1455
+ * pin: on success it latches `done`; on any error it
1456
+ * focuses the first failing step and fires `onError`
1457
+ * with errors spanning every step. To gate advancing
1458
+ * a step on its own validity, compose with the active
1459
+ * form's submit: `activeForm.handleSubmit(() =>
1460
+ * wizard.next())`. Returns an event handler suitable
1461
+ * for `<form @submit>` or imperative use.
1462
+ * - `reset()` — zeros wizard lifecycle (`submissionAttempts`,
1463
+ * `visited`), resets every form, returns
1464
+ * `currentStep` to `steps[0].key`, and invokes
1465
+ * `persist` with the cleared state.
1466
+ */
1467
+ type UseWizardReturnType<S extends ReadonlyArray<StepSlot> = ReadonlyArray<StepSlot>> = {
1468
+ readonly key: string;
1469
+ readonly currentStep: CurrentStepOf<S>;
1470
+ readonly activeForm: ActiveFormOf<S>;
1471
+ readonly activeIndex: number;
1472
+ readonly isFinalStep: boolean;
1473
+ readonly steps: ReadonlyArray<CompiledStep>;
1474
+ readonly forms: WizardForms<S>;
1475
+ readonly count: number;
1476
+ readonly statuses: WizardStatusesProxy<Record<string, FormStatus>>;
1477
+ readonly allValues: Readonly<Record<FormKey, unknown>>;
1478
+ readonly allErrors: Readonly<Record<FormKey, readonly WizardAggregateError[]>>;
1479
+ readonly progress: number;
1480
+ readonly canAdvance: boolean;
1481
+ readonly canGoBack: boolean;
1482
+ readonly complete: boolean;
1483
+ readonly done: boolean;
1484
+ readonly submitting: boolean;
1485
+ readonly submissionAttempts: number;
1486
+ readonly submitError: Error | null;
1487
+ readonly visited: readonly FormKey[];
1488
+ readonly next: () => Promise<void>;
1489
+ readonly back: () => void;
1490
+ readonly goTo: (key: string) => void;
1491
+ readonly tryNext: () => Promise<boolean>;
1492
+ readonly handleSubmit: (onSubmit: WizardOnSubmit, onError?: WizardOnError) => (event?: Event) => Promise<void>;
1493
+ readonly reset: () => void;
1343
1494
  };
1344
1495
 
1345
1496
  /**
@@ -1484,218 +1635,67 @@ type AttaformRegistry = {
1484
1635
  */
1485
1636
  readonly shouldPrefetch: (key: FormKey) => boolean;
1486
1637
  /**
1487
- * Wait for all pending persistence writes across every live form
1488
- * to settle. Useful for SSR shutdown and integration tests that
1489
- * need a deterministic teardown.
1490
- * @internal
1491
- */
1492
- readonly shutdown: () => Promise<void>;
1493
- };
1494
- /**
1495
- * The Vue `InjectionKey` under which the registry is provided on the
1496
- * app. Most consumers never need this — `useForm` and
1497
- * `injectForm` resolve the registry automatically.
1498
- */
1499
- declare const kAttaformRegistry: InjectionKey<AttaformRegistry>;
1500
- declare module 'vue' {
1501
- interface App {
1502
- /** @internal */
1503
- _attaform?: AttaformRegistry;
1504
- }
1505
- }
1506
- /** Options for `createRegistry`. */
1507
- type CreateRegistryOptions = SSRDetectOptions & {
1508
- /**
1509
- * App-level defaults applied to every `useForm` call. Per-form
1510
- * options always win. Omitted is equivalent to `{}`.
1511
- */
1512
- defaults?: AttaformDefaults;
1513
- };
1514
- /**
1515
- * Create a fresh `AttaformRegistry`. `createAttaform()` calls
1516
- * this internally — most consumers never need to call it directly.
1517
- * Use it when building a custom plugin that doesn't want the
1518
- * `createAttaform` plugin's auto-install behaviour (e.g. test
1519
- * harnesses, embedded apps).
1520
- */
1521
- declare function createRegistry(options?: CreateRegistryOptions): AttaformRegistry;
1522
- /**
1523
- * Look up the current app's registry from inside a component's
1524
- * `setup()` (or any synchronous code on the setup call stack).
1525
- *
1526
- * Most consumers don't need this — `useForm` and `injectForm`
1527
- * call it on your behalf. Reach for it directly when building
1528
- * custom integrations that need the raw registry.
1529
- *
1530
- * Throws:
1531
- * - `OutsideSetupError` when called outside a Vue setup context
1532
- * (e.g. from an event handler or async callback). Move the call
1533
- * into setup, or trigger it from a child component.
1534
- * - `RegistryNotInstalledError` when called inside setup but the
1535
- * plugin wasn't installed. Add
1536
- * `app.use(createAttaform())` to your app entry.
1537
- */
1538
- declare function useRegistry(): AttaformRegistry;
1539
- /**
1540
- * Look up a Vue app's registry by `App` reference. Used by
1541
- * SSR helpers (`renderAttaformState`, `hydrateAttaformState`) that
1542
- * run outside a component setup context.
1543
- *
1544
- * Throws `RegistryNotInstalledError` when the app hasn't been wired
1545
- * with `createAttaform()`.
1546
- */
1547
- declare function getRegistryFromApp(app: App): AttaformRegistry;
1548
-
1549
- /**
1550
- * Options accepted by `injectForm` when passing an object instead of
1551
- * a bare key string. `__ssrAccessed: true` is set by the Phase 3
1552
- * `attaform-vite` transform on descendant calls whose template reads
1553
- * the injected form's reactive state — it tells the runtime to
1554
- * enqueue the form for SSR prefetch and register the descendant's
1555
- * `onServerPrefetch` hook. Consumers may set it manually as the
1556
- * escape hatch when the transform isn't installed or doesn't see
1557
- * the reference.
1558
- */
1559
- type InjectFormInput = {
1560
- readonly key?: FormKey;
1561
- /**
1562
- * Set by the Vite transform when this `injectForm` call site sits in
1563
- * a component whose template / script reads the form's reactive
1564
- * state. On the server, this enqueues the form for SSR prefetch and
1565
- * wires `onServerPrefetch` so the descendant awaits the activation
1566
- * promise before its render emits HTML.
1567
- *
1568
- * @internal Transform-emitted. Manual use is the documented escape
1569
- * hatch when the transform can't reach the reference (dynamic
1570
- * property access, untransformed bundlers).
1571
- */
1572
- readonly __ssrAccessed?: boolean;
1573
- };
1574
- /**
1575
- * Access an existing form from a descendant component without passing
1576
- * it through props. Counterpart to `useForm` — `useForm` creates and
1577
- * provides; `injectForm` looks up via Vue's inject mechanism.
1578
- *
1579
- * Three ways to call it:
1580
- *
1581
- * ```ts
1582
- * // Reach the nearest ancestor's anonymous useForm() call.
1583
- * const form = injectForm<SignupShape>()
1584
- *
1585
- * // Reach a specific form by its key — works from anywhere in the app.
1586
- * const cart = injectForm<CartShape>('cart')
1587
- *
1588
- * // Options form. The Vite transform emits this with `__ssrAccessed: true`
1589
- * // when the descendant's template / script reads the form's reactive
1590
- * // state, so the descendant participates in SSR prefetch coordination.
1591
- * const cart = injectForm<CartShape>({ key: 'cart', __ssrAccessed: true })
1592
- * ```
1593
- *
1594
- * Resolution rules (no-key form):
1595
- * - Closest ambient ancestor wins.
1596
- * - Only anonymous `useForm()` (no `key`) fills the ambient slot;
1597
- * keyed forms are reachable only via `injectForm(key)`.
1598
- * - Inherits the resolved ancestor's `formInstanceId`.
1599
- *
1600
- * Resolution rules (keyed form): registry lookup by string key,
1601
- * independent of component-tree position.
1602
- *
1603
- * Returns `null` when no matching form exists (no ambient ancestor, or
1604
- * the named key isn't registered yet). A dev-mode warning points at the
1605
- * call site, lists the registered keys, and flags the mount-timing case
1606
- * (a form created by a child or sibling isn't registered until its own
1607
- * setup runs). Always narrow before using:
1608
- *
1609
- * ```ts
1610
- * const form = injectForm<Shape>('signup')
1611
- * if (!form) return
1612
- * form.register('email')
1613
- * ```
1614
- *
1615
- * Pass the `Form` generic explicitly — Vue's provide/inject erases
1616
- * generics, so the library can't recover the shape automatically.
1617
- *
1618
- * The form is kept alive for this component's lifetime; once every
1619
- * consumer unmounts, the form is cleaned up automatically.
1638
+ * Wait for all pending persistence writes across every live form
1639
+ * to settle. Useful for SSR shutdown and integration tests that
1640
+ * need a deterministic teardown.
1641
+ * @internal
1642
+ */
1643
+ readonly shutdown: () => Promise<void>;
1644
+ };
1645
+ /**
1646
+ * The Vue `InjectionKey` under which the registry is provided on the
1647
+ * app. Most consumers never need this — `useForm` and
1648
+ * `injectForm` resolve the registry automatically.
1620
1649
  */
1621
- declare function injectForm<Form extends GenericForm, GetValueFormType extends GenericForm = Form>(input?: FormKey | InjectFormInput): UseFormReturnType<Form, GetValueFormType> | null;
1622
-
1650
+ declare const kAttaformRegistry: InjectionKey<AttaformRegistry>;
1651
+ declare module 'vue' {
1652
+ interface App {
1653
+ /** @internal */
1654
+ _attaform?: AttaformRegistry;
1655
+ }
1656
+ }
1657
+ /** Options for `createRegistry`. */
1658
+ type CreateRegistryOptions = SSRDetectOptions & {
1659
+ /**
1660
+ * App-level defaults applied to every `useForm` call. Per-form
1661
+ * options always win. Omitted is equivalent to `{}`.
1662
+ */
1663
+ defaults?: AttaformDefaults;
1664
+ };
1623
1665
  /**
1624
- * Re-bind a parent's `v-register` onto an inner native element. Use
1625
- * inside a component that wraps a single form field whose root is
1626
- * NOT the input itself (e.g. a labelled-row that renders `<label>`
1627
- * around the input).
1628
- *
1629
- * ```vue
1630
- * <!-- Parent -->
1631
- * <MyInput v-register="form.register('email')" />
1632
- *
1633
- * <!-- MyInput.vue -->
1634
- * <script setup lang="ts">
1635
- * import { useRegister } from 'attaform'
1636
- * const rv = useRegister()
1637
- * // rv.path / rv.segments / rv.formKey / rv.formInstanceId / rv.innerRef
1638
- * // are all reachable directly — no `.value` unwrap.
1639
- * </script>
1640
- *
1641
- * <template>
1642
- * <label class="field">
1643
- * <span>Email</span>
1644
- * <input v-register="rv" />
1645
- * </label>
1646
- * </template>
1647
- * ```
1648
- *
1649
- * Returns a hybrid Proxy: it answers `__v_isRef` / `.value` like a
1650
- * Vue `Ref<RegisterValue | undefined>` (so templates auto-unwrap
1651
- * correctly and `v-register="rv"` feeds the underlying RV to the
1652
- * directive — preserving the directive's path-migration diff across
1653
- * renders), AND every other property read pierces to the captured
1654
- * RV's field (so `rv.path` works directly in script setup). Reads
1655
- * inside reactive scopes (`computed` / `watchEffect`) track the
1656
- * underlying `shallowRef`, so `rv.path` re-runs when the parent
1657
- * rebinds to a different path.
1658
- *
1659
- * Unbound state: when the parent didn't pass `v-register`, every
1660
- * piercing read returns `undefined` at runtime, and the return type
1661
- * surfaces this honestly as `UseRegisterReturn<V> | undefined`.
1662
- * Consumers defend with optional chaining (`rv?.formKey`,
1663
- * `rv?.segments`); the directive accepts `undefined` peacefully (its
1664
- * binding value type is already `RegisterValue<V> | undefined`), so
1665
- * `v-register="rv"` works whether or not a parent has bound. The
1666
- * composable's `onMounted` warn fires once per instance to surface
1667
- * the misuse case at runtime.
1666
+ * Create a fresh `AttaformRegistry`. `createAttaform()` calls
1667
+ * this internally most consumers never need to call it directly.
1668
+ * Use it when building a custom plugin that doesn't want the
1669
+ * `createAttaform` plugin's auto-install behaviour (e.g. test
1670
+ * harnesses, embedded apps).
1671
+ */
1672
+ declare function createRegistry(options?: CreateRegistryOptions): AttaformRegistry;
1673
+ /**
1674
+ * Look up the current app's registry from inside a component's
1675
+ * `setup()` (or any synchronous code on the setup call stack).
1668
1676
  *
1669
- * Diagnostic: in dev mode, a single `console.warn` fires per instance
1670
- * at `onMounted` if the captured value is still `undefined` by then
1671
- * the parent has had its full mount lifecycle to bind, so a missing
1672
- * binding is conclusive misuse. The warn does NOT fire on every read
1673
- * of the proxy, and is intentionally silent under SSR
1674
- * (`renderToString` skips `onMounted`); the CSR hydration pass
1675
- * surfaces the same diagnostic without double-counting through Nuxt's
1676
- * `dev:ssr-logs` channel.
1677
+ * Most consumers don't need this `useForm` and `injectForm`
1678
+ * call it on your behalf. Reach for it directly when building
1679
+ * custom integrations that need the raw registry.
1677
1680
  *
1678
- * When the wrapper's root IS the input itself, Vue's attribute
1679
- * fallthrough handles the binding and `useRegister` is unnecessary.
1680
- * For wrappers that bind multiple fields (compound forms), use
1681
- * `injectForm<Form>(key?)` and call `ctx.register(...)` directly.
1681
+ * Throws:
1682
+ * - `OutsideSetupError` when called outside a Vue setup context
1683
+ * (e.g. from an event handler or async callback). Move the call
1684
+ * into setup, or trigger it from a child component.
1685
+ * - `RegistryNotInstalledError` when called inside setup but the
1686
+ * plugin wasn't installed. Add
1687
+ * `app.use(createAttaform())` to your app entry.
1682
1688
  */
1683
-
1689
+ declare function useRegistry(): AttaformRegistry;
1684
1690
  /**
1685
- * Return type of `useRegister()`. Hybrid of `RegisterValue<V>` (so
1686
- * `rv.path` / `rv.segments` / `rv.formKey` etc. work directly in
1687
- * script setup) and `Ref<RegisterValue<V> | undefined>` (so Vue's
1688
- * template auto-unwrap surfaces the underlying RV to `v-register`
1689
- * and the directive's path-migration diff sees the real RV across
1690
- * renders).
1691
+ * Look up a Vue app's registry by `App` reference. Used by
1692
+ * SSR helpers (`renderAttaformState`, `hydrateAttaformState`) that
1693
+ * run outside a component setup context.
1691
1694
  *
1692
- * The two surfaces don't clash at the type level: `RegisterValue`
1693
- * doesn't carry a `value` field, and `Ref<T>`'s `value: T` becomes
1694
- * the hybrid's only `.value`. Older code that read `rv.value?.path`
1695
- * keeps working; new code can write `rv.path` directly.
1695
+ * Throws `RegistryNotInstalledError` when the app hasn't been wired
1696
+ * with `createAttaform()`.
1696
1697
  */
1697
- type UseRegisterReturn<V = unknown> = RegisterValue<V> & Ref<RegisterValue<V> | undefined>;
1698
- declare function useRegister<V = unknown>(): UseRegisterReturn<V> | undefined;
1698
+ declare function getRegistryFromApp(app: App): AttaformRegistry;
1699
1699
 
1700
1700
  /**
1701
1701
  * Multistep-form orchestrator built around an ordered list of step slots.
@@ -1880,8 +1880,406 @@ declare const AttaformErrorCode: {
1880
1880
  * Override it per error by passing your own `code` (`api:…`, `auth:…`).
1881
1881
  */
1882
1882
  readonly UserError: "atta:user-error";
1883
+ /**
1884
+ * Stamped on a `ValidationError` synthesized from a throw or rejection
1885
+ * out of a `handleSubmit` `onSubmit` callback. The raw error also lands
1886
+ * on `form.meta.submitError` (a raw-`Error` inspection channel); this
1887
+ * code marks the copy piped into the user-error layer so the failure
1888
+ * also surfaces on `form.errors` / `meta.ownErrors` / `firstOwnError`
1889
+ * where the UI already reads. A throw carrying its own `path` / `code`
1890
+ * keeps them; a bare `Error` or a non-Error throw defaults here at the
1891
+ * form-level path `[]`.
1892
+ */
1893
+ readonly SubmitError: "atta:submit-error";
1883
1894
  };
1884
1895
  type AttaformErrorCode = (typeof AttaformErrorCode)[keyof typeof AttaformErrorCode];
1885
1896
 
1886
- export { AttaformErrorCode as c, createRegistry as p, defaultCoercionRules as q, defineCoercion as r, getRegistryFromApp as s, injectForm as t, injectWizard as u, kAttaformRegistry as v, lazy as w, useRegister as x, useRegistry as y, useWizard as z };
1887
- export type { AttaformRegistry as A, CompiledStep as C, FormStatus as F, InjectWizardInput as I, LazyMarker as L, SSRDetectOptions as S, UseRegisterReturn as U, WizardAggregateError as W, SerializedFormData as a, AnyForm as b, StepSlot as d, UseWizardReturnType as e, WizardCtx as f, WizardCtxForm as g, WizardOnError as h, WizardOnSubmit as i, WizardOptions as j, WizardPersistFn as k, WizardRestoreFn as l, WizardRestoreState as m, WizardStatusesProxy as n, WizardSubmitContext as o };
1897
+ /**
1898
+ * Options for `createAttaform()`.
1899
+ */
1900
+ type AttaformPluginOptions = SSRDetectOptions & {
1901
+ /**
1902
+ * Whether to install the Vue DevTools integration. Default `true`.
1903
+ * The integration is dev-only: the `import('./devtools')` sits behind
1904
+ * the `__DEV__` flag, so a consumer's production build folds it out
1905
+ * entirely (no devtools chunk shipped, no fetch attempted). In
1906
+ * development the DevTools peer is loaded lazily and a missing peer
1907
+ * fails silently. Pass `false` to skip the integration even in dev.
1908
+ */
1909
+ devtools?: boolean;
1910
+ /**
1911
+ * App-level defaults applied to every `useForm` call in this app.
1912
+ * Per-form options always win. See `AttaformDefaults` for
1913
+ * the supported option set and the merge rules.
1914
+ *
1915
+ * ```ts
1916
+ * app.use(
1917
+ * createAttaform({
1918
+ * defaults: { debounceMs: 100 },
1919
+ * })
1920
+ * )
1921
+ * ```
1922
+ */
1923
+ defaults?: AttaformDefaults;
1924
+ };
1925
+ /**
1926
+ * Create the Vue plugin that installs the form library on a Vue
1927
+ * application. Required only when you want app-wide options
1928
+ * (`defaults`, `devtools: false`, `ssr: true`) — for the default
1929
+ * setup, `useForm` / `injectForm` / `useRegister` lazy-install the
1930
+ * registry on first use.
1931
+ *
1932
+ * ```ts
1933
+ * import { createApp } from 'vue'
1934
+ * import { createAttaform } from 'attaform'
1935
+ *
1936
+ * createApp(App)
1937
+ * .use(createAttaform({ defaults: { debounceMs: 100 } }))
1938
+ * .mount('#app')
1939
+ * ```
1940
+ *
1941
+ * Under SSR with bare Vue 3, install explicitly with `{ ssr: true }`
1942
+ * from your server entry — the SSR serialization helpers
1943
+ * (`renderAttaformState` / `hydrateAttaformState`) require an
1944
+ * already-attached registry and don't trigger lazy install. Under
1945
+ * Nuxt, install via `attaform/nuxt` instead — the Nuxt module wires
1946
+ * both server and client automatically.
1947
+ *
1948
+ * Installing more than once on the same app is a no-op (the second
1949
+ * call logs a dev-mode warning).
1950
+ */
1951
+ declare function createAttaform(options?: AttaformPluginOptions): Plugin;
1952
+
1953
+ /**
1954
+ * Serialised snapshot of every form in a Vue app, produced by
1955
+ * `renderAttaformState` and consumed by `hydrateAttaformState`.
1956
+ *
1957
+ * JSON-safe — pass to `JSON.stringify`, `devalue`, or any other
1958
+ * serialiser before embedding in your SSR payload.
1959
+ */
1960
+ type SerializedAttaformState = {
1961
+ /** Tuples of `[formKey, snapshot]` for every form in the app. */
1962
+ readonly forms: ReadonlyArray<readonly [FormKey, SerializedFormData]>;
1963
+ };
1964
+ /**
1965
+ * Snapshot every form on a Vue app for SSR. Call from your server
1966
+ * entry after rendering the app:
1967
+ *
1968
+ * ```ts
1969
+ * import { renderToString } from '@vue/server-renderer'
1970
+ * import { renderAttaformState, escapeForInlineScript } from 'attaform'
1971
+ *
1972
+ * const html = await renderToString(app)
1973
+ * const state = renderAttaformState(app)
1974
+ * const payload = escapeForInlineScript(JSON.stringify(state))
1975
+ *
1976
+ * return `
1977
+ * ${html}
1978
+ * <script>window.__ATTAFORM_STATE__ = ${payload}</script>
1979
+ * `
1980
+ * ```
1981
+ *
1982
+ * Pair with `hydrateAttaformState` on the client to restore the
1983
+ * forms in their server-rendered state. Nuxt users don't need this —
1984
+ * `attaform/nuxt` wires SSR automatically.
1985
+ */
1986
+ declare function renderAttaformState(app: App): SerializedAttaformState;
1987
+ /**
1988
+ * Restore forms from a server-rendered snapshot on the client. Call
1989
+ * from your client entry before mounting:
1990
+ *
1991
+ * ```ts
1992
+ * import { createApp } from 'vue'
1993
+ * import { createAttaform, hydrateAttaformState } from 'attaform'
1994
+ *
1995
+ * const app = createApp(App).use(createAttaform())
1996
+ * hydrateAttaformState(app, window.__ATTAFORM_STATE__)
1997
+ * app.mount('#app')
1998
+ * ```
1999
+ *
2000
+ * The next `useForm({ key })` call for each serialised form picks up
2001
+ * the snapshot transparently — no further action is required.
2002
+ */
2003
+ declare function hydrateAttaformState(app: App, payload: SerializedAttaformState): void;
2004
+
2005
+ /**
2006
+ * Escape a JSON string so it's safe to embed inside an inline
2007
+ * `<script>` tag during SSR. Plain `JSON.stringify` is not safe — a
2008
+ * form value containing the literal substring `</script>` would
2009
+ * break out of the script tag.
2010
+ *
2011
+ * ```ts
2012
+ * const payload = escapeForInlineScript(JSON.stringify(renderAttaformState(app)))
2013
+ * // `<script>window.__ATTAFORM_STATE__ = ${payload}</script>` is safe.
2014
+ * ```
2015
+ *
2016
+ * Output remains valid JSON — `JSON.parse` round-trips back to the
2017
+ * original value on the client.
2018
+ */
2019
+ declare function escapeForInlineScript(json: string): string;
2020
+
2021
+ /**
2022
+ * Symbol slot used by custom directive integrations to install an
2023
+ * assigner on the bound element. Read by the v-register directive
2024
+ * when a DOM event fires:
2025
+ *
2026
+ * ```ts
2027
+ * import { assignKey } from 'attaform'
2028
+ * el[assignKey] = (value) => myCustomWriter(value)
2029
+ * ```
2030
+ *
2031
+ * Most consumers never need this — the built-in directives wire
2032
+ * default assigners for text inputs, checkboxes, radios, and selects.
2033
+ */
2034
+ declare const assignKey: unique symbol;
2035
+
2036
+ /**
2037
+ * The `v-register` directive. Bind a form field to a native input,
2038
+ * select, textarea, checkbox, or radio:
2039
+ *
2040
+ * ```vue
2041
+ * <input v-register="form.register('email')" />
2042
+ * <select v-register="form.register('country')">
2043
+ * <option value="us">US</option>
2044
+ * <option value="uk">UK</option>
2045
+ * </select>
2046
+ * ```
2047
+ *
2048
+ * The directive picks the right binding strategy automatically based
2049
+ * on the element's `tagName` and `type`. Registered globally by
2050
+ * `createAttaform()`. Most consumers never import it directly, but
2051
+ * it's exposed for advanced integrations that wire directives
2052
+ * manually.
2053
+ */
2054
+ declare const vRegister: RegisterModelDynamicCustomDirective;
2055
+
2056
+ /**
2057
+ * Type guard for a `RegisterValue`. Returns `true` when `val` looks
2058
+ * like the object returned from `form.register(path)`.
2059
+ *
2060
+ * ```ts
2061
+ * if (isRegisterValue(slotValue)) {
2062
+ * // slotValue.innerRef is now a Ref<unknown>
2063
+ * }
2064
+ * ```
2065
+ *
2066
+ * Useful when building wrapper components that accept either a
2067
+ * `RegisterValue` or a plain ref via the same prop.
2068
+ */
2069
+ declare function isRegisterValue<Value = unknown>(val: unknown): val is RegisterValue<Value>;
2070
+
2071
+ /**
2072
+ * Shared building blocks for Attaform's two devtools surfaces — the Vue
2073
+ * DevTools (Chrome-extension) inspector wired up in `./devtools.ts`, and
2074
+ * the Nuxt DevTools (overlay) panel wired up via `../../nuxt.ts` +
2075
+ * `../pages/_attaform_devtools.vue`.
2076
+ *
2077
+ * Houses the window-bridge contract both surfaces consume so a new
2078
+ * bridge field lands in one file. Both surfaces render RAW form values
2079
+ * by design — DevTools is a dev-only surface, and redaction across every
2080
+ * place a value surfaces is impractical security theater rather than a
2081
+ * real safeguard.
2082
+ */
2083
+
2084
+ /**
2085
+ * Property key on `window` that the Nuxt-side dev plugin attaches the
2086
+ * bridge object to. The iframe-mounted overlay panel reads
2087
+ * `window.parent[DEVTOOLS_WINDOW_KEY]` to reach the host app's registry.
2088
+ *
2089
+ * Underscored + namespaced to make accidental collision with consumer
2090
+ * globals vanishingly unlikely. Stable across versions — bumping it
2091
+ * would silently disconnect older library builds from newer overlay
2092
+ * panels in the same browser tab during a library upgrade.
2093
+ */
2094
+ declare const DEVTOOLS_WINDOW_KEY = "__attaform_devtools__";
2095
+ /**
2096
+ * Shape of the object the host plugin attaches to `window` in dev mode.
2097
+ * The iframe overlay panel reads this to discover the live registry and
2098
+ * render its forms.
2099
+ *
2100
+ * Single-registry assumption: the latest `createAttaform()` install
2101
+ * wins. Multi-app pages (rare; typically only seen in micro-frontend
2102
+ * setups) will only see one app's forms in the panel. Documented but
2103
+ * not actively supported — the alternative (a Set of registries with
2104
+ * union-rendering) is a future call if a real consumer hits it.
2105
+ */
2106
+ interface AttaformDevtoolsBridge {
2107
+ registry: AttaformRegistry;
2108
+ /**
2109
+ * The library version, surfaced in the panel's footer for support /
2110
+ * bug-report context. Read from `package.json` at host-plugin init.
2111
+ */
2112
+ version: string;
2113
+ }
2114
+ declare global {
2115
+ interface Window {
2116
+ [DEVTOOLS_WINDOW_KEY]?: AttaformDevtoolsBridge;
2117
+ }
2118
+ }
2119
+
2120
+ /**
2121
+ * Typed error classes thrown by the form library. Each one signals a
2122
+ * distinct misuse so calling code can branch on `instanceof` instead
2123
+ * of pattern-matching error messages.
2124
+ *
2125
+ * Every class extends `AttaformError`, so consumers can write a single
2126
+ * polymorphic catch (`catch (e) { if (e instanceof AttaformError) ... }`)
2127
+ * instead of OR-chaining checks for each subclass. `AttaformError` itself
2128
+ * extends the standard `Error`, so existing `instanceof Error` usage
2129
+ * keeps working.
2130
+ */
2131
+
2132
+ /**
2133
+ * Base for every error class thrown by `attaform`. Sets
2134
+ * `this.name` from the constructor's `new.target.name`, so subclasses
2135
+ * don't have to redeclare their own name override.
2136
+ */
2137
+ declare class AttaformError extends Error {
2138
+ constructor(message: string, options?: ErrorOptions);
2139
+ }
2140
+ /**
2141
+ * Thrown when a path string is malformed — typically a dotted path
2142
+ * with empty segments (e.g. `'a..b'`, leading or trailing dots).
2143
+ * Use array form (`['a', 'b']`) for keys that contain literal dots.
2144
+ */
2145
+ declare class InvalidPathError extends AttaformError {
2146
+ }
2147
+ /**
2148
+ * Thrown when `useForm` receives an invalid configuration — most often
2149
+ * a schema passed directly as the first argument, or no argument at
2150
+ * all. The configuration is an options bag; the schema is one of
2151
+ * several fields, even when it's the only one in use.
2152
+ *
2153
+ * ```ts
2154
+ * // ✗ Crashes deep inside the validator with an opaque message:
2155
+ * const form = useForm(z.object({ ... }))
2156
+ * // ✗ Same:
2157
+ * const form = useForm()
2158
+ * // ✓ Pass the schema as the `schema` field:
2159
+ * const form = useForm({ schema: z.object({ ... }) })
2160
+ * ```
2161
+ *
2162
+ * The same shape applies to every entry point: `attaform/zod`,
2163
+ * `attaform/zod-v3`, `attaform/zod-v4`, and the schema-agnostic
2164
+ * `attaform` root.
2165
+ */
2166
+ declare class InvalidUseFormConfigError extends AttaformError {
2167
+ constructor();
2168
+ }
2169
+ /**
2170
+ * Thrown when a `handleSubmit`-supplied `onError` callback itself
2171
+ * throws or rejects. Wraps the inner failure so both the original
2172
+ * cause (via `error.cause`) and the propagation site are visible.
2173
+ */
2174
+ declare class SubmitErrorHandlerError extends AttaformError {
2175
+ }
2176
+ /**
2177
+ * Thrown when an `attaform` API needs the registry attached to a Vue
2178
+ * app but it isn't there yet. Component-level entry points (`useForm`,
2179
+ * `injectForm`, `useRegister`) lazy-install the registry on first use,
2180
+ * so this error is mostly reached by SSR helpers — `renderAttaformState`
2181
+ * and `hydrateAttaformState` — which run outside a setup context and
2182
+ * have no current instance to install against.
2183
+ *
2184
+ * Fix: add `app.use(createAttaform())` (or `app.use(createAttaform({
2185
+ * ssr: true }))` on the server) to your SSR entry, before
2186
+ * `renderToString` / hydration. Under Nuxt, `attaform/nuxt` already
2187
+ * does this; the error usually points at a non-Nuxt SSR setup that
2188
+ * hasn't installed explicitly.
2189
+ */
2190
+ declare class RegistryNotInstalledError extends AttaformError {
2191
+ constructor();
2192
+ }
2193
+ /**
2194
+ * Thrown when `useForm` / `injectForm` is called outside of a
2195
+ * Vue `setup()` function — typically from an event handler, watcher,
2196
+ * or async callback that runs after mount.
2197
+ *
2198
+ * Fix: move the call into `setup()`, or trigger it from a child
2199
+ * component whose `setup()` runs the composable.
2200
+ */
2201
+ declare class OutsideSetupError extends AttaformError {
2202
+ constructor();
2203
+ }
2204
+ /**
2205
+ * Thrown when a `useForm({ key })` call uses a key starting with
2206
+ * `__atta:`. That prefix is reserved for keys the library generates
2207
+ * internally (e.g. for anonymous `useForm()` calls without an
2208
+ * explicit key). Pick a different prefix for your form.
2209
+ */
2210
+ declare class ReservedFormKeyError extends AttaformError {
2211
+ constructor(key: string);
2212
+ }
2213
+
2214
+ /**
2215
+ * Anti-flash timing for the library-default display reducer.
2216
+ *
2217
+ * - `showDelay` — how long in-flight work (a validation run or an async
2218
+ * register transform) may run before its spinner is allowed to show. Work
2219
+ * that settles inside this window never reveals `'pending'` at all, so a
2220
+ * fast (often synchronous) check does not flash a spinner on every keystroke.
2221
+ * - `minVisible` — once shown, the minimum time the spinner stays up. Work
2222
+ * that lands just past `showDelay` is held here so the spinner does not
2223
+ * itself flash on and immediately off.
2224
+ *
2225
+ * Both are milliseconds.
2226
+ */
2227
+ type DisplayTimings = {
2228
+ readonly showDelay: number;
2229
+ readonly minVisible: number;
2230
+ };
2231
+ /**
2232
+ * Library-default anti-flash timings. `showDelay: 120` cleanly swallows
2233
+ * synchronous, microtask-resolved, and tiny-async work (validation runs and
2234
+ * async register transforms alike) so none of them flash a spinner;
2235
+ * `minVisible: 120` keeps a shown spinner snappy once one does land.
2236
+ * Retune via {@link makeDefaultDisplayState} without touching the engine.
2237
+ */
2238
+ declare const DEFAULT_TIMINGS: DisplayTimings;
2239
+ /**
2240
+ * Build a default `getDisplayState` reducer with custom anti-flash timing.
2241
+ * Power users who want a tighter or looser spinner than {@link DEFAULT_TIMINGS}
2242
+ * pass their own `{ showDelay, minVisible }`:
2243
+ *
2244
+ * ```ts
2245
+ * import { makeDefaultDisplayState } from 'attaform'
2246
+ *
2247
+ * useForm({
2248
+ * schema,
2249
+ * getDisplayState: makeDefaultDisplayState({ showDelay: 50, minVisible: 200 }),
2250
+ * })
2251
+ * ```
2252
+ *
2253
+ * The returned reducer is pure: the engine injects `now` and threads the
2254
+ * previous machine, so the same `(prev, ctx)` always yields the same next
2255
+ * machine. It shapes only the display projection — `errors`, `valid`,
2256
+ * `validating`, and the underlying validation run exactly as before.
2257
+ */
2258
+ declare function makeDefaultDisplayState({ showDelay, minVisible, }: DisplayTimings): GetDisplayState;
2259
+ /**
2260
+ * Library-default `getDisplayState` reducer. Resolves every path's
2261
+ * `field.displayState` — and thus `field.show*` and the `form.meta`
2262
+ * rollups — whenever the consumer has not configured an override at the
2263
+ * per-form or plugin level. Built from {@link DEFAULT_TIMINGS}; publicly
2264
+ * re-exported so an override can compose with it (a layered reducer that
2265
+ * special-cases a subtree but otherwise defers picks up future
2266
+ * refinements for free):
2267
+ *
2268
+ * ```ts
2269
+ * import { defaultDisplayState } from 'attaform'
2270
+ *
2271
+ * useForm({
2272
+ * schema,
2273
+ * getDisplayState: (prev, ctx) => {
2274
+ * const next = defaultDisplayState(prev, ctx)
2275
+ * return next.display === 'success' && ctx.field.path[0] === 'username'
2276
+ * ? { display: 'idle' }
2277
+ * : next
2278
+ * },
2279
+ * })
2280
+ * ```
2281
+ */
2282
+ declare const defaultDisplayState: GetDisplayState;
2283
+
2284
+ export { useWizard as $, createRegistry as B, DEFAULT_TIMINGS as D, defaultCoercionRules as E, defaultDisplayState as G, defineCoercion as H, escapeForInlineScript as J, getRegistryFromApp as K, hydrateAttaformState as M, injectForm as N, OutsideSetupError as O, injectWizard as P, isRegisterValue as Q, RegistryNotInstalledError as R, kAttaformRegistry as T, lazy as V, makeDefaultDisplayState as X, renderAttaformState as Y, useRegister as Z, useRegistry as _, vRegister as a0, AttaformError as c, AttaformErrorCode as d, DEVTOOLS_WINDOW_KEY as f, InvalidPathError as h, InvalidUseFormConfigError as i, ReservedFormKeyError as j, SubmitErrorHandlerError as m, assignKey as y, createAttaform as z };
2285
+ export type { AttaformRegistry as A, CompiledStep as C, FormStatus as F, InjectWizardInput as I, LazyMarker as L, SerializedAttaformState as S, UseRegisterReturn as U, WizardAggregateError as W, AnyForm as a, AttaformDevtoolsBridge as b, AttaformPluginOptions as e, DisplayTimings as g, SerializedFormData as k, StepSlot as l, UseWizardReturnType as n, WizardCtx as o, WizardCtxForm as p, WizardOnError as q, WizardOnSubmit as r, WizardOptions as s, WizardPersistFn as t, WizardRestoreFn as u, WizardRestoreState as v, WizardStatusesProxy as w, WizardSubmitContext as x };