/** Control types supported by v1 (spec 01 §2). */ export type FieldType = 'text' | 'textarea' | 'number' | 'currency' | 'email' | 'password' | 'dropdown' | 'multiselect' | 'checkbox' | 'radio' | 'toggle' | 'date' | 'file' | 'label' | 'section' | 'repeater' | 'tags' | 'rating' | 'range' | 'reference'; export type FieldWidth = '1/4' | '1/2' | '3/4' | 'full'; /** `date` field selection mode (spec 01 §2). */ export type DateMode = 'single' | 'range' | 'multiple'; export interface FieldOption { key: string; label: string; /** Muted helper line: under the label on a radio card (variant: 'card'), * and in a dropdown (``) both under the label in each list * option and after the label on the closed trigger while selected. */ description?: string; /** radio (variant: 'card' only): icon name from the curated set (see icons.ts). */ icon?: string; /** radio (variant: 'card' only): this option's own share of the row (same * fractions as `FieldDescriptor.width`), instead of the default * auto-wrapping ~200px card. Unset options keep the default. */ width?: FieldWidth; } /** Ordering/equality ops available to the `compare` validator. */ export type CompareOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; /** * Declarative validator (spec 01 §4). `custom` resolves `fn` from the * registry; `compare` checks this value against a sibling field's value * (same form — or same row inside a repeater) and skips while the sibling * is empty or unknown. */ export interface ValidatorSpec { type: 'required' | 'minLength' | 'maxLength' | 'min' | 'max' | 'pattern' | 'email' | 'compare' | 'custom'; value?: unknown; fn?: string; op?: CompareOp; field?: string; message?: string; } /** One item of `fields[]` (spec 01 §1). */ export interface FieldDescriptor { order: number; type: FieldType; key: string; label?: string; placeholder?: string; /** * Leading icon inside the control (text/email/password/currency/textarea) * or before the value (dropdown) — icon name from the curated set (see * icons.ts). Unrecognized names render no icon (no layout shift). */ icon?: string; help?: string; autocomplete?: string; width?: FieldWidth; value?: unknown; disabled?: boolean; readonly?: boolean; required?: boolean; options?: FieldOption[]; /** Registry name resolving this field's options from current form data. */ options_from?: string; /** * Same-form field keys this field's options depend on. When one changes * (user edit), the options refetch and this field's value clears — * cascading through chained dependents (province -> district -> …). */ options_deps?: string[]; /** * text/textarea/… max character length; **tags**: max characters per tag * (default 32) — enforced per chip, not on the array as a whole. */ max_length?: number; min_length?: number; min?: number | string; /** number/currency/date upper bound; **rating**: star count (1–10, default 5); **range**: default 100. */ max?: number | string; /** * date: selection mode. 'single' (default) -> value is one ISO string; * 'range' -> value is `[start, end]` (both ISO, inclusive); 'multiple' -> * value is an ISO string[] in pick order. */ date_mode?: DateMode; /** * date: DISPLAY format of the typed/rendered text (flatpickr tokens, e.g. * 'd/m/Y', 'j M Y'). The VALUE stays ISO yyyy-mm-dd (or yyyy-mm-ddThh:mm * with `enable_time`) regardless. */ date_format?: string; /** * date: adds a time-of-day picker under the calendar. The VALUE switches * from an ISO day ('2026-04-13') to an ISO local datetime with no seconds * or offset ('2026-04-13T14:30'), for every `date_mode` (a range's two * ends each carry their own time). `min` / `max` stay calendar days — * they bound which DAY is pickable, not the time within it. */ enable_time?: boolean; /** date (`enable_time` only): 24-hour clock instead of AM/PM. Default false. */ time_24hr?: boolean; /** * date: days that cannot be picked — each entry an ISO date ('2026-04-13') * or an inclusive ISO interval ('2026-04-13/2026-04-15'). Enforced by the * engine too, so a hand-crafted submission cannot bypass the calendar. */ disabled_dates?: string[]; /** date: weekdays that cannot be picked, 0 = Sunday … 6 = Saturday. */ disabled_weekdays?: number[]; step?: number; /** number: reject fractions (validated on client and server). */ integer?: boolean; rows?: number; accept?: string; multiple?: boolean; max_size?: number; /** * Registry uploader name (spec 01 §13). With it, picking a file uploads * immediately and the VALUE becomes a serializable UploadedFile ref * (arrays when `multiple`) — without it, capture-only File handles (v1). */ uploader?: string; /** * reference: name of the record lookup in `registry.references` (spec 01 §2.1). The value * is the picked record's id; the lookup searches records and resolves an id to its label. */ reference?: string; text?: string; variant?: string; /** * Feature flag gating this field (spec 01 §12): shown only while the flag * is on ('name') or off ('!name'). Flag-off = as if the field were never in * the schema — not rendered, not validated, absent from the payload. */ flag?: string; fields?: FieldDescriptor[]; min_rows?: number; max_rows?: number; add_label?: string; /** tags: cap on the number of chips. Unset = unlimited. */ max_items?: number; /** * tags: false restricts chips to the suggestion list (matched * case-insensitively) — typed text that isn't a suggestion cannot become a * chip. Default true. The component enforces it against whatever options * are resolved (static or `options_from`); bank20baht-validator re-checks * only static `options` — it cannot resolve `options_from` and trusts the * client there, as it does for dropdown/multiselect. */ allow_new?: boolean; validators?: ValidatorSpec[]; formatter?: string; } /** * A named, client-side-only projection of a form: which fields to show, in * what order (e.g. per user role). Display sugar only — the submit payload is * always the full form and the server always validates the complete schema. */ export interface FormViewSpec { name: string; /** field keys included, in display order; keys absent from fields are ignored */ fields: string[]; } /** A Form definition — consumed standalone by (spec 00 §4.1). */ export interface FormDefinition { form_id: string; form_name: string; fields: FieldDescriptor[]; /** Optional named display subsets; select one via . */ views?: FormViewSpec[]; /** Client scripts (spec 01 §14) — handlers referenced by name, resolved from `registry.scripts`. */ scripts?: ScriptSpec[]; } /** When a client script runs: form load, one field's change, or submit/validate. */ export type ScriptEvent = 'load' | 'change' | 'submit'; /** * One item of `scripts[]`. `field` is required for `change` (the field key the * hook listens to) and ignored otherwise. `handler` names a function in * `registry.scripts` — functions never travel in JSON. */ export interface ScriptSpec { id?: string; on: ScriptEvent; field?: string; handler: string; } export type ScriptMessageType = 'info' | 'warning' | 'error'; export interface ScriptMessage { message: string; type: ScriptMessageType; } /** What a script handler receives as its second argument. */ export interface ScriptEventDetail { on: ScriptEvent; form_name: string; /** `change` only: the field that changed. */ key?: string; /** `change` only. */ oldValue?: unknown; newValue?: unknown; /** True while the form is loading (initial `change` pass + `load`), like ServiceNow's isLoading. */ isLoading: boolean; /** Snapshot of the form's flat data when the script was invoked. */ data: Record; } /** * The `g` object handed to every script — the ServiceNow `g_form` equivalent, * scoped to one form. State setters are OVERRIDES layered on top of the * workflow's rule-derived states (script wins) until `resetState(key)`. */ export interface ScriptContext { readonly formName: string; getValue(key: string): unknown; getData(): Record; getField(key: string): FieldDescriptor | undefined; /** Write a value — runs that field's `change` scripts (cascade, depth-guarded). */ setValue(key: string, value: unknown): void; /** Reset a field to its descriptor default. */ clearValue(key: string): void; setVisible(key: string, visible: boolean): void; setReadOnly(key: string, readonly: boolean): void; setRequired(key: string, required: boolean): void; setDisabled(key: string, disabled: boolean): void; /** Drop every script override on `key` (or on all fields when omitted). */ resetState(key?: string): void; /** Inline message under a field's control (one per field; replaces the previous). */ showFieldMsg(key: string, message: string, type?: ScriptMessageType): void; hideFieldMsg(key: string): void; /** Form-level messages; `error` ones become `{ key: '$' }` validation errors on submit. */ addErrorMessage(message: string): void; addInfoMessage(message: string): void; clearMessages(): void; } /** * A client script. `submit` handlers may return `false` to block the * submission; other return values are ignored. Handlers are synchronous. */ export type ScriptHandler = (g: ScriptContext, event: ScriptEventDetail) => void | boolean; export type ApprovalDecision = 'approved' | 'rejected'; /** Comment box policy on an approval step. */ export type ApprovalCommentMode = 'hidden' | 'optional' | 'requireOnReject' | 'required'; export interface ApprovalConfig { /** formNames whose data the summary shows; default = every earlier visible form. */ summary_forms?: string[]; approve_label?: string; reject_label?: string; comment?: ApprovalCommentMode; /** Reject behavior: end the workflow (default) or send the user back to a form. */ on_reject?: { action: 'end'; } | { action: 'goto'; form: string; }; } /** A form inside a workflow: adds its position in the linear order. */ export interface WorkflowForm extends FormDefinition { order: number; /** * 'approval' renders a decision step (summary + Approve/Reject) instead of * fields; its `decision`/`comment` values live in data like any field. * Default 'form'. */ kind?: 'form' | 'approval'; approval?: ApprovalConfig; /** Name of one of this form's `views` to render for this step (display only). */ view?: string; /** Feature flag gating this whole step (spec 01 §12). */ flag?: string; } export type ConditionOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn' | 'contains' | 'notContains' | 'startsWith' | 'endsWith' | 'isEmpty' | 'isNotEmpty'; /** Leaf: compare `form_name.fieldKey` against a constant or another field. */ export interface FieldCondition { field: string; op: ConditionOp; value?: unknown; value_field?: string; } export type Condition = FieldCondition | { and: Condition[]; } | { or: Condition[]; } | { not: Condition; }; export type ValueExpr = { ref: string; } | { const: unknown; } | { op: 'add' | 'sub' | 'mul' | 'div' | 'concat' | 'coalesce'; args: ValueExpr[]; } | { if: [Condition, ValueExpr, ValueExpr]; }; export interface FieldTarget { form: string; field: string; } export interface FormTarget { form: string; } export type FieldActionType = 'show' | 'hide' | 'enable' | 'disable' | 'readonly' | 'required' | 'optional'; export type Action = { action: FieldActionType; target: FieldTarget; } | { action: 'setValue'; target: FieldTarget; value: ValueExpr; lock?: boolean; } | { action: 'showForm' | 'hideForm' | 'skipForm'; target: FormTarget; }; export interface Rule { id?: string; priority?: number; when?: Condition; then?: Action[]; else?: Action[]; /** Feature flag gating this rule (spec 01 §12). */ flag?: string; } /** Flag values — typically fetched from one static JSON file (spec 01 §12). */ export type FeatureFlags = Record; /** * A conditional edge between steps (spec 03 §6). With `transitions` present * the wizard routes as a graph: from a form, the first edge whose `when` * matches wins; `to: 'end'` submits. Forms with no outgoing edges (and * non-matching edge sets) fall back to the next visible form in `order`, so * linear authoring keeps working and branches are added only where needed. */ export interface Transition { from: string; to: string; when?: Condition; flag?: string; } /** A Workflow definition — consumed by (spec 00 §4.2). */ export interface WorkflowDefinition { workflow_id: string; forms: WorkflowForm[]; rules: Rule[]; /** Optional graph routing (spec 03 §6); absent = linear by `order`. */ transitions?: Transition[]; } /** One record a reference lookup returns (spec 01 §2.1). */ export interface ReferenceOption { /** Record id — what the field's value holds. */ key: string; label: string; /** Secondary text shown right-aligned (e.g. the record's department or table). */ sub?: string; } /** * A record lookup for `reference` fields, registered as `registry.references[name]`. * `search` gets the typed term and the form's current flat data (for dependent lookups); * `resolve` turns a stored id back into its label — used to display an existing value, and by * `verifyReferences` in bank20baht-validator to reject ids that do not exist. Without * `resolve`, the raw id is shown and the server cannot verify it. */ export interface ReferenceLookup { search: (term: string, data: Record) => ReferenceOption[] | Promise; resolve?: (id: string) => ReferenceOption | null | Promise; } /** Serializable result of a registry uploader (spec 01 §13). */ export interface UploadedFile { name: string; size: number; type?: string; url: string; } export interface Registry { formatters?: Record unknown>; /** Return null when valid, or an error message string. */ validators?: Record string | null>; /** Option fetchers for `options_from` — receive the form's current flat data. */ options?: Record) => FieldOption[] | Promise>; /** File uploaders for `uploader` — take the picked File, return its ref. */ uploaders?: Record Promise>; /** Client scripts for `scripts[].handler` (spec 01 §14). */ scripts?: Record; /** Record lookups for `reference` fields, by `field.reference` name. */ references?: Record; } export interface DerivedFieldState { visible: boolean; enabled: boolean; readonly: boolean; required: boolean; /** True while a setValue rule with lock (default) is active on this field. */ computed: boolean; } export type NestedData = Record>; export interface Derived { /** Nested {form_name: {key: value}} — possibly mutated by setValue/clears. */ data: NestedData; /** Keyed "formName.key". */ fields: Record; /** skipForm/hideForm result per form_name. */ forms: Record; /** * 'schema' = authoring defect (unknown rule target) — belongs at store * time, not on a submission; 'data' = depends on the submitted values * (non-convergence). Absent kind = 'schema' (pre-0.0.12 engines). */ errors: { ref: string; message: string; kind?: 'schema' | 'data'; }[]; } export interface ValidationError { key: string; message: string; }