@cosmicdrift/kumiko-headless 0.1.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.
@@ -0,0 +1,333 @@
1
+ import type { FieldIssue } from "../dispatcher";
2
+ import { createStore } from "../store";
3
+ import type {
4
+ FieldConditions,
5
+ FieldConditionValue,
6
+ FieldState,
7
+ FormController,
8
+ FormControllerOptions,
9
+ FormSnapshot,
10
+ FormValues,
11
+ SubmitResult,
12
+ } from "./types";
13
+ import { groupIssuesByPath, zodErrorToFieldIssues } from "./zod-bridge";
14
+
15
+ // Resolve one condition-value (boolean | predicate) against current values + ctx.
16
+ // `undefined` means "condition not declared"; the caller substitutes the
17
+ // field-default (visible:true, readonly:false, required:false).
18
+ function evalCondition<TValues extends FormValues, TCtx>(
19
+ condition: FieldConditionValue<TValues, TCtx> | undefined,
20
+ fallback: boolean,
21
+ values: TValues,
22
+ ctx: TCtx,
23
+ ): boolean {
24
+ if (condition === undefined) return fallback;
25
+ if (typeof condition === "boolean") return condition;
26
+ return condition(values, ctx);
27
+ }
28
+
29
+ function computeFieldStates<TValues extends FormValues, TCtx>(
30
+ rules: Readonly<Record<string, FieldConditions<TValues, TCtx>>> | undefined,
31
+ values: TValues,
32
+ ctx: TCtx,
33
+ ): Record<string, FieldState> {
34
+ if (!rules) return {};
35
+ const out: Record<string, FieldState> = {};
36
+ for (const [fieldKey, rule] of Object.entries(rules)) {
37
+ out[fieldKey] = {
38
+ visible: evalCondition(rule.visible, true, values, ctx),
39
+ readonly: evalCondition(rule.readonly, false, values, ctx),
40
+ required: evalCondition(rule.required, false, values, ctx),
41
+ };
42
+ }
43
+ return out;
44
+ }
45
+
46
+ // Reference equality only. For primitive-valued fields this is exact; for
47
+ // object/array fields it treats "same reference" as "unchanged" — callers
48
+ // who mutate in place instead of swapping references (which is bad form
49
+ // anyway, but common in ad-hoc tests) would miss a change. Kumiko's
50
+ // form-controller contract is that mutators build new references.
51
+ // Object.is is chosen over === so NaN !== NaN doesn't mark a field dirty
52
+ // after you re-type the same number back in.
53
+ function valuesDiff<TValues extends FormValues>(
54
+ current: TValues,
55
+ initial: TValues,
56
+ ): Partial<TValues> {
57
+ const out: Partial<TValues> = {};
58
+ // Iterate over BOTH sides — a field that existed on initial but was
59
+ // deleted from current (via `setValues({ foo: undefined })`) still
60
+ // counts as a change.
61
+ // FormValues<T> erlaubt kein keyof-T-Iteration (T ist generic). Cast
62
+ // zu Record<string, unknown> für dynamic-key-Inspection.
63
+ const cur = current as Record<string, unknown>; // @cast-boundary form-values
64
+ const ini = initial as Record<string, unknown>; // @cast-boundary form-values
65
+ const o = out as Record<string, unknown>; // @cast-boundary form-values
66
+ const keys = new Set<string>([...Object.keys(cur), ...Object.keys(ini)]);
67
+ for (const key of keys) {
68
+ const a = cur[key];
69
+ const b = ini[key];
70
+ if (!Object.is(a, b)) {
71
+ o[key] = a;
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Shallow-freeze so accidental mutations on the snapshot (e.g. a test
78
+ // writing `snapshot.values.title = "x"`) throw in strict mode instead of
79
+ // silently diverging from the controller's internal state. Deep-freeze
80
+ // would be safer but expensive — keeping it shallow matches React's own
81
+ // immutability expectations and leaves room for sub-controllers to manage
82
+ // their own sub-trees without double-frozen parents blocking them.
83
+ function freezeSnapshot<TValues extends FormValues>(
84
+ snapshot: FormSnapshot<TValues>,
85
+ ): FormSnapshot<TValues> {
86
+ return Object.freeze(snapshot);
87
+ }
88
+
89
+ export function createFormController<TValues extends FormValues, TCtx = unknown>(
90
+ options: FormControllerOptions<TValues, TCtx>,
91
+ ): FormController<TValues> {
92
+ // Shallow copy so mutating the input after creation doesn't bleed into
93
+ // the controller's internal state. `initial` stays fixed across the
94
+ // controller's lifetime unless rebase() replaces it.
95
+ let values: TValues = { ...options.initial };
96
+ let initial: TValues = { ...options.initial };
97
+ let errors: Readonly<Record<string, readonly FieldIssue[]>> = Object.freeze({});
98
+ // ctx is cast-held as TCtx; `undefined` is valid when callers don't
99
+ // declare conditional predicates that depend on it.
100
+ let ctx: TCtx = (options.ctx as TCtx) ?? (undefined as TCtx);
101
+
102
+ // Snapshot lives in a Store — same reference across getSnapshot() calls
103
+ // until a mutator invalidates it via setState. React's useSyncExternalStore
104
+ // compares snapshot identity to decide on a re-render; the Store holds the
105
+ // identity stable until invalidate() swaps it for a fresh build.
106
+ const snapshotStore = createStore<FormSnapshot<TValues>>(buildSnapshot());
107
+
108
+ // In-flight submit tracker. When a submit() is pending, a second call
109
+ // awaits the same promise instead of firing a parallel write — avoids
110
+ // double-submission on double-click and keeps the rebase semantics
111
+ // coherent (rebasing twice in quick succession would mis-align the
112
+ // baseline with what the server actually saw).
113
+ let submitInFlight: Promise<unknown> | null = null;
114
+
115
+ function buildSnapshot(): FormSnapshot<TValues> {
116
+ const changes = valuesDiff(values, initial);
117
+ const isDirty = Object.keys(changes).length > 0;
118
+ const fields = Object.freeze(computeFieldStates(options.fields, values, ctx));
119
+ return freezeSnapshot({
120
+ values,
121
+ initial,
122
+ changes,
123
+ isDirty,
124
+ isUnchanged: !isDirty,
125
+ errors,
126
+ fields,
127
+ });
128
+ }
129
+
130
+ function invalidate() {
131
+ snapshotStore.setState(buildSnapshot());
132
+ }
133
+
134
+ // Local shared implementations so submit() can call validate/rebase
135
+ // without the this-in-object-literal dance. Both also expose themselves
136
+ // as methods on the returned controller.
137
+ function runValidate(): boolean {
138
+ if (!options.schema) {
139
+ if (Object.keys(errors).length > 0) {
140
+ errors = Object.freeze({});
141
+ invalidate();
142
+ }
143
+ return true;
144
+ }
145
+ const fieldStates = computeFieldStates(options.fields, values, ctx);
146
+ const parsed = options.schema.safeParse(values);
147
+ if (parsed.success) {
148
+ if (Object.keys(errors).length > 0) {
149
+ errors = Object.freeze({});
150
+ invalidate();
151
+ }
152
+ return true;
153
+ }
154
+ const hiddenFields = new Set<string>();
155
+ for (const [fieldKey, state] of Object.entries(fieldStates)) {
156
+ if (!state.visible) hiddenFields.add(fieldKey);
157
+ }
158
+ const allIssues = zodErrorToFieldIssues(parsed.error);
159
+ const relevantIssues = allIssues.filter((issue) => {
160
+ const rootField = issue.path.split(".")[0] ?? "";
161
+ return !hiddenFields.has(rootField);
162
+ });
163
+ if (relevantIssues.length === 0) {
164
+ if (Object.keys(errors).length > 0) {
165
+ errors = Object.freeze({});
166
+ invalidate();
167
+ }
168
+ return true;
169
+ }
170
+ errors = Object.freeze(groupIssuesByPath(relevantIssues));
171
+ invalidate();
172
+ return false;
173
+ }
174
+
175
+ function runRebase(): void {
176
+ initial = { ...values };
177
+ if (Object.keys(errors).length > 0) errors = Object.freeze({});
178
+ invalidate();
179
+ }
180
+
181
+ // Stale-submit-safe rebase. Difference to runRebase: the baseline is
182
+ // taken from the values that were actually SENT to the server, not
183
+ // from `values` at the moment the server replied. If the user typed
184
+ // into a field while the submit was in flight, those edits stay as
185
+ // dirty changes after the call — they never made it to the server, so
186
+ // pretending they did (via `initial = { ...values }`) would mask unsaved
187
+ // input. The race shows up as "user edits a field during a 2s save, sees
188
+ // it as saved, closes the tab — the edit is lost". This path keeps
189
+ // `values` untouched; only `initial` shifts to the submitted snapshot.
190
+ function runRebaseToSnapshot(snapped: TValues): void {
191
+ initial = { ...snapped };
192
+ if (Object.keys(errors).length > 0) errors = Object.freeze({});
193
+ invalidate();
194
+ }
195
+
196
+ return {
197
+ getSnapshot: snapshotStore.getSnapshot,
198
+ subscribe: snapshotStore.subscribe,
199
+ setField(key, value) {
200
+ // Skip work when the new value is identical to the current one —
201
+ // avoids a notify + re-render for "setField with same value" which
202
+ // happens a lot in controlled inputs on every keystroke of an
203
+ // untouched field.
204
+ if (Object.is(values[key], value)) return;
205
+ values = { ...values, [key]: value };
206
+ invalidate();
207
+ },
208
+ setValues(partial) {
209
+ // Detect any effective change before rebuilding — setValues with a
210
+ // partial that matches current values shouldn't fire listeners.
211
+ let changed = false;
212
+ const v = values as Record<string, unknown>; // @cast-boundary form-values
213
+ const p = partial as Record<string, unknown>; // @cast-boundary form-values
214
+ for (const k of Object.keys(p)) {
215
+ if (!Object.is(v[k], p[k])) {
216
+ changed = true;
217
+ break;
218
+ }
219
+ }
220
+ if (!changed) return;
221
+ values = { ...values, ...partial };
222
+ invalidate();
223
+ },
224
+ clearErrors(path) {
225
+ if (path === undefined) {
226
+ if (Object.keys(errors).length === 0) return;
227
+ errors = Object.freeze({});
228
+ } else {
229
+ if (!(path in errors)) return;
230
+ const next: Record<string, readonly FieldIssue[]> = { ...errors };
231
+ delete next[path];
232
+ errors = Object.freeze(next);
233
+ }
234
+ invalidate();
235
+ },
236
+ setErrors(nextErrors) {
237
+ errors = Object.freeze({ ...nextErrors });
238
+ invalidate();
239
+ },
240
+ validate: runValidate,
241
+ reset() {
242
+ // Cheap no-op when already at baseline and no errors to clear.
243
+ const alreadyClean = !snapshotStore.getSnapshot().isDirty && Object.keys(errors).length === 0;
244
+ if (alreadyClean) return;
245
+ values = { ...initial };
246
+ errors = Object.freeze({});
247
+ invalidate();
248
+ },
249
+ rebase: runRebase,
250
+ setCtx(nextCtx) {
251
+ // Cast back to TCtx: setCtx's public signature takes unknown so
252
+ // callers don't need to plumb generics. If conditions depend on ctx
253
+ // and callers pass the wrong shape, the predicate throws at
254
+ // evaluation time — which is the same fail mode as any other
255
+ // callback contract violation.
256
+ ctx = nextCtx as TCtx;
257
+ invalidate();
258
+ },
259
+ async submit<TData = unknown>(): Promise<SubmitResult<TData>> {
260
+ const submitCfg = options.submit;
261
+ if (!submitCfg) {
262
+ throw new Error(
263
+ "createFormController: submit() called without a `submit` config. Configure `{ dispatcher, type }` on the controller or drive dispatching manually.",
264
+ );
265
+ }
266
+
267
+ // Concurrent-submit guard: a double-click (two invocations before
268
+ // the first network call returned) would otherwise fire two writes
269
+ // AND rebase twice — compounding with the stale-submit race below.
270
+ // Serialize: subsequent calls await the in-flight promise. Same
271
+ // pattern the server-side event-dispatcher uses (passInFlight).
272
+ if (submitInFlight) return submitInFlight as Promise<SubmitResult<TData>>;
273
+
274
+ if (!runValidate()) {
275
+ return { validationBlocked: true, isSuccess: false };
276
+ }
277
+
278
+ const payloadMode = submitCfg.payloadMode ?? "values";
279
+ // Capture the whole snapshot AT submit-time. The user may keep
280
+ // typing while the network call is in flight; on success we rebase
281
+ // ONLY to the values that were actually sent, leaving any
282
+ // subsequent edits as a fresh dirty delta. Without this, an edit
283
+ // during the await would be swallowed into the new baseline and
284
+ // the user would see it as "saved" despite the server never seeing
285
+ // it. Same snapshot is fed to buildPayload — a custom transformer
286
+ // (nested-write case) sees exactly what submit() sees.
287
+ const submittedSnapshot = snapshotStore.getSnapshot();
288
+ const submittedValues = submittedSnapshot.values;
289
+
290
+ // buildPayload wins over payloadMode when both are set.
291
+ let payload: unknown;
292
+ if (submitCfg.buildPayload) {
293
+ payload = submitCfg.buildPayload(submittedSnapshot);
294
+ } else if (payloadMode === "changes") {
295
+ if (submittedSnapshot.isUnchanged) {
296
+ return {
297
+ validationBlocked: false,
298
+ isSuccess: true,
299
+ data: submittedValues as unknown as TData,
300
+ };
301
+ }
302
+ payload = submittedSnapshot.changes;
303
+ } else {
304
+ payload = submittedValues;
305
+ }
306
+
307
+ const runWrite = async (): Promise<SubmitResult<TData>> => {
308
+ const result = await submitCfg.dispatcher.write<TData>(submitCfg.type, payload);
309
+
310
+ if (result.isSuccess) {
311
+ // Rebase to the SNAPSHOT, not to the current values — see
312
+ // runRebaseToSnapshot comment for why.
313
+ runRebaseToSnapshot(submittedValues);
314
+ return { validationBlocked: false, isSuccess: true, data: result.data };
315
+ }
316
+
317
+ const serverFields = result.error.details?.fields;
318
+ if (serverFields && serverFields.length > 0) {
319
+ errors = Object.freeze(groupIssuesByPath(serverFields));
320
+ invalidate();
321
+ }
322
+ return { validationBlocked: false, isSuccess: false, error: result.error };
323
+ };
324
+
325
+ submitInFlight = runWrite();
326
+ try {
327
+ return await (submitInFlight as Promise<SubmitResult<TData>>);
328
+ } finally {
329
+ submitInFlight = null;
330
+ }
331
+ },
332
+ };
333
+ }
@@ -0,0 +1,15 @@
1
+ export { createFormController } from "./form-controller";
2
+ export type {
3
+ FieldConditionPredicate,
4
+ FieldConditions,
5
+ FieldConditionValue,
6
+ FieldState,
7
+ FormController,
8
+ FormControllerOptions,
9
+ FormSnapshot,
10
+ FormValues,
11
+ SubmitConfig,
12
+ SubmitPayloadMode,
13
+ SubmitResult,
14
+ } from "./types";
15
+ export { groupIssuesByPath, zodErrorToFieldIssues } from "./zod-bridge";
@@ -0,0 +1,264 @@
1
+ import type { ZodType } from "zod";
2
+ import type { Dispatcher, FieldIssue, WriteResult } from "../dispatcher";
3
+
4
+ // Form-Controller contract.
5
+ //
6
+ // One controller per edit/create screen. Wraps a mutable "values" record
7
+ // plus derived state (changes vs initial, per-field errors) and exposes a
8
+ // subscribe API shaped for React's `useSyncExternalStore`. The framework's
9
+ // renderer-react wraps this in a thin `useForm` hook; the mobile renderer
10
+ // will do the same with the same controller.
11
+ //
12
+ // Why not just Zustand / a state library?
13
+ // Form state has a small, well-defined shape — values, initial, changes,
14
+ // errors, per-field meta. Building it on raw subscribe/emit keeps the
15
+ // runtime deps at zero (this package imports nothing but types from
16
+ // @cosmicdrift/kumiko-framework and zod), and leaves renderer-specific decisions
17
+ // (batching, suspense) to the host framework.
18
+ //
19
+ // Snapshot identity: `getSnapshot()` returns the SAME reference until a
20
+ // mutator (setField / setValues / reset / ...) runs. React compares by
21
+ // identity when deciding whether to re-render — recomputing the snapshot
22
+ // on every call would re-render on every tick. Mutators rebuild the
23
+ // snapshot once, then notify listeners.
24
+
25
+ export type FormValues = Record<string, unknown>;
26
+
27
+ // Per-field conditional rules (Kumiko's "visible/readonly/required as
28
+ // functions" decision, 2026-03-30). A rule is either a static boolean or a
29
+ // predicate `(values, ctx) => boolean`. The controller evaluates them on
30
+ // every snapshot rebuild and exposes the resolved booleans as
31
+ // FieldState — renderers read `snapshot.fields[key].visible` and never
32
+ // re-evaluate the predicate themselves.
33
+ //
34
+ // Why here and not on the framework's FieldDefinition: conditions are
35
+ // UI-layer concerns (visibility depends on form state, not entity state)
36
+ // and shouldn't couple the server-side schema. The App wires them up per
37
+ // screen via the screen-def / form-controller options. The framework's
38
+ // own `field.required` is mirrored here when present, so feature authors
39
+ // can still declare "always required" once at the entity level.
40
+ export type FieldConditionPredicate<TValues extends FormValues, TCtx> = (
41
+ values: TValues,
42
+ ctx: TCtx,
43
+ ) => boolean;
44
+
45
+ export type FieldConditionValue<TValues extends FormValues, TCtx> =
46
+ | boolean
47
+ | FieldConditionPredicate<TValues, TCtx>;
48
+
49
+ export type FieldConditions<TValues extends FormValues, TCtx = unknown> = {
50
+ readonly visible?: FieldConditionValue<TValues, TCtx>;
51
+ readonly readonly?: FieldConditionValue<TValues, TCtx>;
52
+ readonly required?: FieldConditionValue<TValues, TCtx>;
53
+ };
54
+
55
+ // Resolved per-field state, keyed by field name, surfaced on the snapshot.
56
+ // Defaults (when no condition is declared): visible=true, readonly=false,
57
+ // required=false — renderer treats a field with no rules as a normal
58
+ // always-shown-always-editable field.
59
+ export type FieldState = {
60
+ readonly visible: boolean;
61
+ readonly readonly: boolean;
62
+ readonly required: boolean;
63
+ };
64
+
65
+ // Immutable view handed to renderers. Every mutator call produces a fresh
66
+ // snapshot; previous snapshot references stay valid for `useSyncExternalStore`'s
67
+ // identity compare.
68
+ export type FormSnapshot<TValues extends FormValues> = {
69
+ // Current values — the user's in-progress edit.
70
+ readonly values: TValues;
71
+ // Pristine values at mount / last `reset()`. Needed for:
72
+ // - diffing `changes` (see below)
73
+ // - the "discard changes?" prompt when the user navigates away dirty
74
+ // - re-hydrating the form after an optimistic-update rollback
75
+ readonly initial: TValues;
76
+ // Changes-only: keys whose current value differs from initial, with the
77
+ // NEW value. The server uses this directly as the `payload` of an
78
+ // entity-level update command (Kumiko writes carry changes, not full
79
+ // objects) — see `docs/plans/architecture/event-sourcing-pivot.md`.
80
+ readonly changes: Partial<TValues>;
81
+ // True iff `changes` is non-empty. Convenience for the submit-button's
82
+ // disabled state and the unsaved-changes guard.
83
+ readonly isDirty: boolean;
84
+ // True iff no field was touched, kept separate from `isDirty` so UI code
85
+ // reads the right one without negation (= `!isDirty`). Form consumers
86
+ // will reach for both — they're not symmetric in intent (one is "can I
87
+ // submit?" and the other is "can I safely close?").
88
+ readonly isUnchanged: boolean;
89
+ // Per-field errors keyed by dotted path (`title`, `address.city`,
90
+ // `tasks.2.title`). Empty when the form is valid, populated after
91
+ // `validate()` or a failed submit. The dotted convention matches the
92
+ // server's ValidationError.fields[].path — a failed dispatcher call
93
+ // pushes its field issues here without any translation.
94
+ readonly errors: Readonly<Record<string, readonly FieldIssue[]>>;
95
+ // Resolved per-field state from FieldConditions. Renderers read this to
96
+ // decide whether to show the field, whether the input is disabled, and
97
+ // whether to show the required-marker. Fields without declared
98
+ // conditions default to `{ visible: true, readonly: false, required: false }`.
99
+ readonly fields: Readonly<Record<string, FieldState>>;
100
+ };
101
+
102
+ export type FormController<TValues extends FormValues> = {
103
+ // --- Subscribe surface (for useSyncExternalStore) ---
104
+
105
+ // Returns the current snapshot. SAME reference across calls until a
106
+ // mutator runs — callers rely on identity compare.
107
+ getSnapshot(): FormSnapshot<TValues>;
108
+
109
+ // Registers a listener that fires whenever the snapshot changes. Returns
110
+ // an unsubscribe function. Matches the `subscribe(listener) => unsubscribe`
111
+ // shape required by `useSyncExternalStore`.
112
+ subscribe(listener: () => void): () => void;
113
+
114
+ // --- Value mutators ---
115
+
116
+ // Sets one field. For scalar fields, `value` is the new value; for
117
+ // nested paths (lines/sub-forms) you'd use a sub-controller (Block 2b.4)
118
+ // instead of poking this with a dotted path.
119
+ setField<K extends keyof TValues>(key: K, value: TValues[K]): void;
120
+
121
+ // Bulk-update multiple fields at once. One snapshot rebuild + one
122
+ // notify, so a 5-field form-reset from a remote fetch doesn't cascade 5
123
+ // re-renders.
124
+ setValues(partial: Partial<TValues>): void;
125
+
126
+ // Clears field-level errors, keyed by dotted path. Used when the user
127
+ // starts typing again in a field that had a server-side failure —
128
+ // the UX hint should go away immediately, before the next validate()
129
+ // run. Pass no arguments to clear ALL errors.
130
+ clearErrors(path?: string): void;
131
+
132
+ // Replaces all errors (overwrite, not merge). Used by submit() to
133
+ // surface server-side validation failures and by external callers
134
+ // that want to project custom error state (e.g. a parent controller
135
+ // pushing down cross-field errors onto its children).
136
+ setErrors(errors: Readonly<Record<string, readonly FieldIssue[]>>): void;
137
+
138
+ // --- Validation ---
139
+
140
+ // Runs the controller's zod schema (if configured) against current
141
+ // values. Populates errors and returns true iff valid. Noop-returns
142
+ // `true` when no schema was wired — a controller without a schema
143
+ // relies entirely on server-side validation via the submit() path.
144
+ validate(): boolean;
145
+
146
+ // Reverts values to `initial`, clears errors. Doesn't fire a new
147
+ // "initial" baseline — to adopt the current values as the new baseline
148
+ // (e.g. after a successful submit), use `rebase()`.
149
+ reset(): void;
150
+
151
+ // Promotes the current values to the new `initial`, clears errors.
152
+ // After a successful submit the user should no longer see "unsaved
153
+ // changes" — rebase() is what the submit path calls internally to
154
+ // achieve that.
155
+ rebase(): void;
156
+
157
+ // Swap the external context threaded into field-condition predicates.
158
+ // Rare — most conditionals depend on values, not ctx — but needed when
159
+ // e.g. the user switches tenant mid-form and visibility rules key off
160
+ // tenant-scoped config.
161
+ setCtx(ctx: unknown): void;
162
+
163
+ // --- Submit ---
164
+ //
165
+ // Runs validate() first; if it fails, returns `{ validationBlocked: true,
166
+ // isSuccess: false }` WITHOUT a network call (the caller knows it's a
167
+ // user-level failure — show the errors, let them retry).
168
+ //
169
+ // If validation passes, dispatches to the configured submit.type with
170
+ // values or changes per submit.payloadMode. On server-side
171
+ // ValidationError, the error's field issues are pushed onto the form
172
+ // via setErrors so the UI reacts identically to local and remote
173
+ // validation failures. On success, rebase() — the form becomes "clean"
174
+ // and the data returned by the handler flows back via the result.
175
+ //
176
+ // Throws (not returns a failure) if `submit` config wasn't provided —
177
+ // a controller without submit-wiring has no destination, and guessing
178
+ // one would hide an integration bug.
179
+ submit<TData = unknown>(): Promise<SubmitResult<TData>>;
180
+ };
181
+
182
+ export type FormControllerOptions<TValues extends FormValues, TCtx = unknown> = {
183
+ readonly initial: TValues;
184
+ // Optional zod schema used by `validate()`. When present, validate()
185
+ // runs schema.safeParse(values) and populates the snapshot's errors
186
+ // map. When absent, validate() is a no-op that returns true — the
187
+ // controller defers entirely to server-side validation on submit.
188
+ //
189
+ // Typed as `ZodType` (not `ZodType<TValues>`) because a feature's
190
+ // input schema often narrows a subset of the form's surface (e.g.
191
+ // "changes-only" update-schemas) and re-exporting that precise type
192
+ // all the way through would chain generics across every layer. The
193
+ // runtime contract is that schema accepts the form's `values` shape.
194
+ readonly schema?: ZodType;
195
+ // Per-field conditional rules. Keyed by field name; unlisted fields
196
+ // get the default {visible:true, readonly:false, required:false}.
197
+ // See FieldConditions for the predicate signature.
198
+ readonly fields?: Readonly<Record<string, FieldConditions<TValues, TCtx>>>;
199
+ // External context passed to every field-condition predicate. Host app
200
+ // picks the shape — typically `{ user, tenant, config, featureToggles }`.
201
+ // Captured once; a ctx change without a corresponding setField/validate
202
+ // won't re-evaluate predicates. Use setCtx() to trigger re-evaluation
203
+ // when the ctx itself changes (rare in practice — most conditionals
204
+ // depend on values, not ctx).
205
+ readonly ctx?: TCtx;
206
+ // Optional submit wiring. When configured, `controller.submit()` runs
207
+ // local validate() then dispatches to the configured type with the
208
+ // selected payload, maps server-side field errors back onto the form,
209
+ // and — on success — rebases so "unsaved changes" goes quiet.
210
+ //
211
+ // Omitted when the caller drives dispatching manually (custom-screen
212
+ // cases, or when a multi-step wizard needs finer control). In that
213
+ // case submit() throws rather than guessing a destination.
214
+ readonly submit?: SubmitConfig<TValues>;
215
+ };
216
+
217
+ // How the form's payload is derived when submit() runs. Kumiko's write
218
+ // convention says commands carry CHANGES (delta since initial), not full
219
+ // objects — but that assumes an update flow. For creates, `changes ===
220
+ // values` in practice because initial is empty.
221
+ //
222
+ // - "values" — send the full current `values` object. Right for create
223
+ // handlers whose schema expects a full entity payload.
224
+ // - "changes" — send only the `changes` delta. Right for update
225
+ // handlers; noop when the form is un-dirty (submit
226
+ // short-circuits into a no-network success).
227
+ //
228
+ // Default is "values" — the common M1 case is a create-screen wiring
229
+ // straight through. Update-screens opt in with "changes".
230
+ export type SubmitPayloadMode = "values" | "changes";
231
+
232
+ export type SubmitConfig<TValues extends FormValues = FormValues> = {
233
+ readonly dispatcher: Dispatcher;
234
+ // Qualified write-handler name (e.g. "orders:write:order:create").
235
+ readonly type: string;
236
+ readonly payloadMode?: SubmitPayloadMode;
237
+ // Optional payload transformer — overrides payloadMode. Used for
238
+ // nested-writes: the submit path calls buildPayload(snapshot) once at
239
+ // submit-time and sends the result. The snapshot is the one captured
240
+ // before the network call, so in-flight edits during the await don't
241
+ // leak into the payload.
242
+ //
243
+ // Typical shape for a parent form with hasMany child controllers:
244
+ //
245
+ // buildPayload: (snap) => ({
246
+ // ...snap.values,
247
+ // lines: lineControllers.map(c => c.getSnapshot().values),
248
+ // })
249
+ //
250
+ // When both buildPayload and payloadMode are set, buildPayload wins.
251
+ // That's intentional: a caller choosing to write a transformer is
252
+ // making an explicit statement about payload shape.
253
+ readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
254
+ };
255
+
256
+ // What submit() returns. Mirrors WriteResult so a failed submit can
257
+ // carry the structured DispatcherError unchanged — callers log or toast
258
+ // based on error.code without the form-controller guessing UX intent.
259
+ // `validationBlocked: true` signals a LOCAL (pre-dispatch) validate()
260
+ // failure — no network call happened; caller doesn't need to retry the
261
+ // network, the user needs to fix fields.
262
+ export type SubmitResult<TData = unknown> =
263
+ | ({ readonly validationBlocked: false } & WriteResult<TData>)
264
+ | { readonly validationBlocked: true; readonly isSuccess: false };