@usefillo/core 0.9.0 → 0.11.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.
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ interface JumpRule {
20
20
  /** Target page id, or "end" to finish the form. */
21
21
  to: string | "end";
22
22
  }
23
- type FieldKind = "short_text" | "long_text" | "email" | "url" | "phone" | "number" | "select" | "multi_select" | "dropdown" | "checkbox" | "rating" | "linear_scale" | "ranking" | "matrix" | "signature" | "date" | "file_upload" | "hidden" | "custom";
23
+ type FieldKind = "short_text" | "long_text" | "email" | "url" | "phone" | "number" | "select" | "multi_select" | "dropdown" | "checkbox" | "rating" | "linear_scale" | "ranking" | "matrix" | "signature" | "date" | "file_upload" | "hidden" | "calculated" | "custom";
24
24
  type ContentKind = "heading" | "paragraph" | "divider";
25
25
  type BlockKind = FieldKind | ContentKind;
26
26
  interface SelectOption {
@@ -61,6 +61,14 @@ interface NumberField extends BaseField {
61
61
  kind: "number";
62
62
  min?: number;
63
63
  max?: number;
64
+ /** DISPLAY-ONLY rounding/padding (0–6). Deliberate divergence from
65
+ * calculated.decimals, which rounds the STORED value — see decision 1. */
66
+ decimals?: number;
67
+ prefix?: string;
68
+ suffix?: string;
69
+ /** Locale-aware thousand separators in the SDK input. Display-only;
70
+ * never reaches the wire, the server, or exports. */
71
+ notation?: "grouped";
64
72
  }
65
73
  interface ChoiceField extends BaseField {
66
74
  kind: "select" | "multi_select" | "dropdown";
@@ -129,6 +137,64 @@ interface FileUploadField extends BaseField {
129
137
  /** Accepted types, e.g. ["image/*", ".pdf", "video/mp4"]. Empty = anything. */
130
138
  accept?: string[];
131
139
  }
140
+ /**
141
+ * The calculation AST (logic depth P2). A typed tree, never a formula string,
142
+ * and the permanent expression substrate — extending it later means adding
143
+ * ops, never replacing it. v1 is numeric-only: totals and derived values.
144
+ *
145
+ * `if.when` reuses the existing {@link Condition} model verbatim (AND-combined,
146
+ * same ops) — one condition language across visibility, jumps, and calculations.
147
+ *
148
+ * Referenceable operand kinds (v1): `number`, `rating`, `linear_scale`, and
149
+ * `calculated` (chaining). Nothing else coerces implicitly.
150
+ */
151
+ type CalcExpr =
152
+ /** A numeric field ref. Resolves through the same visibility discipline as
153
+ * conditions: a logic-hidden source reads as unanswered (→ null). */
154
+ {
155
+ op: "value";
156
+ fieldId: string;
157
+ } | {
158
+ op: "const";
159
+ value: number;
160
+ }
161
+ /** n-ary, length >= 1. Any null operand → null result (strict propagation). */
162
+ | {
163
+ op: "add" | "mul" | "min" | "max";
164
+ args: CalcExpr[];
165
+ }
166
+ /** Division by zero (and any non-finite result) → null. */
167
+ | {
168
+ op: "sub" | "div";
169
+ left: CalcExpr;
170
+ right: CalcExpr;
171
+ }
172
+ /** Half-away-from-zero at `decimals` (default 0). */
173
+ | {
174
+ op: "round";
175
+ arg: CalcExpr;
176
+ decimals?: number;
177
+ } | {
178
+ op: "if";
179
+ when: Condition[];
180
+ then: CalcExpr;
181
+ else: CalcExpr;
182
+ };
183
+ /**
184
+ * A derived numeric value computed from other answers — never an input. The
185
+ * client recomputes it live for piping/conditions/jumps; the server recomputes
186
+ * it authoritatively on submit, so a tampered client value is ignored by
187
+ * construction. A null result means the field is unanswered (key absent).
188
+ */
189
+ interface CalculatedField extends BaseField {
190
+ kind: "calculated";
191
+ calc: CalcExpr;
192
+ /** Rounds the STORED value, not just the display — one deterministic number
193
+ * everywhere (client preview, server recompute, grid, CSV, webhooks). */
194
+ decimals?: number;
195
+ prefix?: string;
196
+ suffix?: string;
197
+ }
132
198
  /**
133
199
  * A field type Fillo doesn't ship. You define it in code and supply the
134
200
  * renderer via the SDK's `customComponents` map, keyed by `component`. The
@@ -143,7 +209,7 @@ interface CustomField extends BaseField {
143
209
  /** Arbitrary options handed to your component. */
144
210
  config?: Record<string, unknown>;
145
211
  }
146
- type Field = TextField | PhoneField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CustomField;
212
+ type Field = TextField | PhoneField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CalculatedField | CustomField;
147
213
  interface HeadingBlock extends BaseBlock {
148
214
  kind: "heading";
149
215
  text: string;
@@ -493,6 +559,49 @@ interface ValidationResult {
493
559
  */
494
560
  declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
495
561
 
562
+ /**
563
+ * Evaluate a calc expression to a number, or null for "unanswered".
564
+ * `resolve` maps a fieldId to its effective value and must apply the SAME
565
+ * visibility discipline as conditions (a logic-hidden source resolves to
566
+ * undefined) — the shared fixpoint's resolver does exactly that. Semantics:
567
+ * - an unanswered `value` ref → null; numeric strings bridge exactly like
568
+ * condition gt/lt (shared `conditionNumber`), nothing else coerces;
569
+ * - any null operand → null result (strict propagation, no silent zero);
570
+ * - division by zero and non-finite results → null;
571
+ * - `round` is half-away-from-zero at `decimals` (default 0);
572
+ * - `if` runs its `when` through the standard condition evaluator (AND), so
573
+ * unanswered behavior is identical to visibility/jump rules.
574
+ */
575
+ declare function evaluateCalc(expr: CalcExpr, resolve: (fieldId: string) => FieldValue): number | null;
576
+ /**
577
+ * Response data with every calculated field's value written in (null or
578
+ * logic-hidden → key absent, i.e. unanswered) and any client-supplied value
579
+ * for a calculated id dropped — the engine, never the wire, owns these keys.
580
+ * Returns `data` unchanged for a calc-free form.
581
+ *
582
+ * THE canonical evaluator, shared verbatim by the controller (live display,
583
+ * piping, conditions) and the server's submit recompute — the contract's
584
+ * "deterministic, identical evaluation on client and server". Before the
585
+ * joint {visible set, calc values} fixpoint runs, answers are reduced to the
586
+ * canonical form the server stores:
587
+ * - value shape: resolveLogicState reads canonical values (trimmed, coerced,
588
+ * empty-as-unanswered — canonical.ts), so `if.when` conditions match the
589
+ * kept answers, never a divergent live shape;
590
+ * - reachability: an answer on a jump-skipped page reads as unanswered, the
591
+ * same pruning validateResponse applies before the server recompute. The
592
+ * prune below can itself flip a jump or visibility rule, so it iterates to
593
+ * a fixed point — each pass keeps a SUBSET of the previous keys (pruning
594
+ * only ever removes), so the loop is monotone, terminates within the key
595
+ * count, and the client (raw live data) and the server (the once-pruned
596
+ * kept set) walk the same shrinking chain to the SAME view.
597
+ *
598
+ * The pruned view is EVALUATION input only: non-calc answers pass through to
599
+ * the output untouched, so the controller keeps a stale answer behind a
600
+ * flipped jump (navigating back restores it) while its calc display, piping,
601
+ * and jump decisions read it as unanswered — exactly what the server stores.
602
+ */
603
+ declare function computeCalculated(form: FormSchema, data: ResponseData): ResponseData;
604
+
496
605
  declare const FILLO_SCHEMA_VERSION: 1;
497
606
  /** Injected from package.json at build time (tsup define) — never hand-edited. */
498
607
  declare const FILLO_SDK_VERSION: string;
@@ -514,6 +623,17 @@ declare const FILLO_MIN_SDK_VERSION = "0.4.0";
514
623
  * @usefillo/*" error instead of a form that silently can't submit.
515
624
  */
516
625
  declare const FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
626
+ /**
627
+ * The floor served INSTEAD of FILLO_MIN_SDK_VERSION for forms whose live
628
+ * schema contains a calculated field: the first release that ships the kind.
629
+ * An older SDK's zod enum strips the unknown block, so it renders the form
630
+ * WITHOUT the calc row — piping shows blanks and any visibility/jump rule
631
+ * reading the calc id misbehaves. That's wrong-form-behavior, not merely
632
+ * missing chrome, so it fails fast with the "update @usefillo/*" error
633
+ * instead (the Turnstile-floor precedent; the form GET picks the max of the
634
+ * applicable floors).
635
+ */
636
+ declare const FILLO_CALC_MIN_SDK_VERSION = "0.11.0";
517
637
  declare function normalizeSettings(value: unknown): FormSettings;
518
638
  interface SchemaValidationResult {
519
639
  ok: boolean;
@@ -539,6 +659,76 @@ declare function normalizeFormTheme(input: unknown): FormTheme | null;
539
659
  */
540
660
  declare function formatAnswer(field: Field, value: FieldValue): string;
541
661
 
662
+ /**
663
+ * Grouped-number display + parsing for the Number field's `notation:
664
+ * "grouped"` input (design contract: docs/decisions/number-formatting.md).
665
+ * Both helpers are pure, side-effect-free, and DOM-free, so the same code
666
+ * runs in @usefillo/react, @usefillo/dom, and Node. Both are strictly
667
+ * display-only — grouping never touches validation, canonical storage, or
668
+ * the wire (contract decision 2) — and both degrade to canonical text
669
+ * instead of throwing when `Intl` is unavailable.
670
+ */
671
+ /**
672
+ * Locale-grouped display text for a finite number — the blurred value of a
673
+ * `notation: "grouped"` number input. `maximumFractionDigits` is set
674
+ * explicitly to 20 when `decimals` is unset: Intl's own silent default (3)
675
+ * would truncate/round a longer fraction on display, silently disagreeing
676
+ * with the stored value. When `decimals` is set it pins both the minimum and
677
+ * maximum fraction digits — padding to a stable width, the same contract
678
+ * `formatAnswer`'s toFixed follows. Non-finite input and a missing `Intl`
679
+ * both fall back to canonical text; this must never throw mid-keystroke.
680
+ */
681
+ declare function formatGroupedNumber(value: number, opts?: {
682
+ locale?: string;
683
+ decimals?: number;
684
+ }): string;
685
+ /**
686
+ * Canonical text (what `Number()` can parse) for a grouped-number input's raw
687
+ * typed text — this is what `setValue` receives on every keystroke (contract
688
+ * decision 2: the input holds grouped text only while unfocused; `data`
689
+ * always holds canonical numerics). Returns the INPUT UNCHANGED when it can't
690
+ * produce a parseable result — validation flags it from there, same as an
691
+ * unformatted number field today.
692
+ *
693
+ * Derives the locale's group/decimal marks via
694
+ * `Intl.NumberFormat(locale).formatToParts(1234567.8)`, then:
695
+ * - strips group separators — several locales (fr-FR among them) use
696
+ * U+00A0/U+202F no-break spaces, so any whitespace-class character is
697
+ * accepted as a group separator whenever the locale's own is whitespace;
698
+ * - maps the locale decimal mark to ".";
699
+ * - ALWAYS also accepts "." as a decimal mark regardless of locale — see
700
+ * {@link resolveDotGroup} for the one locale family where that collides
701
+ * with "." also being the group separator;
702
+ * - preserves a leading minus;
703
+ * - preserves partial typing states ("1234." stays "1234." — `Number()` can
704
+ * still parse it, and rewriting mid-keystroke fights the respondent).
705
+ *
706
+ * Empty/whitespace input and a missing `Intl` both return the input as-is.
707
+ */
708
+ declare function parseGroupedNumber(text: string, locale?: string): string;
709
+ /**
710
+ * Keystroke-level filter for a formatted number input's raw typed text (the
711
+ * input-quality contract's "optional future: React Aria-style
712
+ * `isValidPartialNumber` keystroke filter", `docs/decisions/input-quality.md`).
713
+ * Permissive on separator PLACEMENT — {@link parseGroupedNumber} and server
714
+ * validation own semantics — this only blocks characters or counts that can
715
+ * never resolve to a number: letters and other symbols, a non-leading "-",
716
+ * or more than one decimal mark.
717
+ *
718
+ * Reuses {@link parseGroupedNumber}'s locale-mark derivation
719
+ * (`Intl.NumberFormat(locale).formatToParts`) and its two special cases: a
720
+ * whitespace-class group separator accepts ANY whitespace character, and
721
+ * where "." is itself the locale's group separator (the de-DE family), "."
722
+ * is unlimited and only the locale's real decimal mark is capped at one.
723
+ *
724
+ * A candidate is validated as a WHOLE string, never character-by-character —
725
+ * a bad paste is rejected in full, not trimmed down to its valid prefix
726
+ * (React Aria's behavior). `Intl`-absent falls back to a minimal
727
+ * digits/"."/"," character class (the leading-"-" rule still applies, since
728
+ * it needs no locale data).
729
+ */
730
+ declare function isValidPartialNumberText(text: string, locale?: string): boolean;
731
+
542
732
  /**
543
733
  * Lightweight, dependency-free phone metadata + helpers powering the SDK's
544
734
  * phone field. Deliberately small: it carries enough to render a country
@@ -546,11 +736,18 @@ declare function formatAnswer(field: Field, value: FieldValue): string;
546
736
  * validation + canonical normalization happen server-side with full
547
737
  * libphonenumber data (bundle size is irrelevant there), so this stays lean and
548
738
  * the stored value is always E.164.
739
+ *
740
+ * Coverage is every ITU dial code (~all of ISO 3166-1): 70 curated entries
741
+ * below carry full formatting metadata; every other country is packed
742
+ * "iso2+dialCode" data with no metadata (formatNational/isPossiblePhone fall
743
+ * back to grouping-in-3s / plain E.164 possibility). Names are resolved at
744
+ * runtime via Intl.DisplayNames — zero hardcoded English name literals.
549
745
  */
550
746
  interface PhoneCountry {
551
747
  /** ISO 3166-1 alpha-2, uppercase ("US"). Also drives the flag emoji. */
552
748
  iso2: string;
553
- /** Display name ("United States"). */
749
+ /** Localized display name, resolved via `Intl.DisplayNames` (falls back
750
+ * to the iso2 code when that API is unavailable). */
554
751
  name: string;
555
752
  /** Country calling code without the "+" ("1", "44", "33"). */
556
753
  dialCode: string;
@@ -561,8 +758,22 @@ interface PhoneCountry {
561
758
  /** Example national number (digits only) for the input placeholder. */
562
759
  example: string;
563
760
  }
564
- /** Curated country metadata, ordered for the picker (broad coverage). */
761
+ /**
762
+ * Every known phone country: the 70 curated entries (rich metadata) then
763
+ * every other ITU dial code (name + dial code only). Order is NOT display
764
+ * order (see PHONE_PICKER_COUNTRIES) — it's the tie-break priority
765
+ * `countryByDialCode` resolves shared codes with, preserved so growing this
766
+ * list from 70 to ~240 doesn't change how any of the original 70 resolve.
767
+ */
565
768
  declare const PHONE_COUNTRIES: PhoneCountry[];
769
+ /**
770
+ * PHONE_COUNTRIES reordered for a picker UI: localized name, Intl.Collator-
771
+ * compared (falls back to iso2 order without Intl.Collator). Kept separate
772
+ * from PHONE_COUNTRIES, whose order countryByDialCode depends on for
773
+ * curated-first tie-breaking — sorting by name would flip those ties (e.g.
774
+ * "Canada" before "United States" would flip which one "+1…" resolves to).
775
+ */
776
+ declare const PHONE_PICKER_COUNTRIES: PhoneCountry[];
566
777
  /** Flag emoji from an ISO-3166 alpha-2 code (regional-indicator letters). */
567
778
  declare function flagEmoji(iso2: string): string;
568
779
  declare function countryByIso(iso2: string | undefined): PhoneCountry | undefined;
@@ -576,19 +787,60 @@ declare function formatNational(country: PhoneCountry | undefined, national: str
576
787
  /** Assemble the canonical E.164 value from a country + national digits. */
577
788
  declare function toE164(country: PhoneCountry, national: string): string;
578
789
  interface ParsedPhone {
579
- /** Best-guess country, if the dial code matched one we know. */
790
+ /** Best-guess country, if the dial code matched one we know. Always unset
791
+ * while `pending`. */
580
792
  country?: PhoneCountry;
581
- /** National significant digits (dial code stripped). */
793
+ /** National significant digits: dial code stripped once a country is
794
+ * known, otherwise every digit typed after "+" (or all digits, with no
795
+ * "+" and no fallback). */
582
796
  national: string;
583
797
  /** Canonical E.164 ("+…"), or "" if there were no digits. */
584
798
  e164: string;
799
+ /**
800
+ * True for a "+"-prefixed value that hasn't resolved a country: no digits
801
+ * at all ("+"), digits that prefix a real dial code without completing
802
+ * one ("+4"), or digits identical to `ParsePhoneOptions.previousDigits` —
803
+ * a bare "+" just prepended to unchanged, stale digits.
804
+ *
805
+ * `national`/`e164` still carry the typed digits (a caller that ignores
806
+ * `pending` degrades gracefully). The renderer should hold off committing
807
+ * a stored value while pending, and redisplay `raw` verbatim instead of
808
+ * `formatNational(country, national)` — there's no country to format
809
+ * against yet, and reformatting a bare "+" into "" is the corruption this
810
+ * field exists to prevent.
811
+ */
812
+ pending?: boolean;
813
+ /** The trimmed input, verbatim. Set only when `pending`. */
814
+ raw?: string;
815
+ }
816
+ interface ParsePhoneOptions {
817
+ /**
818
+ * National digits already in the field before this edit (dial code
819
+ * already stripped — the same shape `parsePhone` returns as `national`).
820
+ * Lets `parsePhone` recognize a bare "+" just prepended to unchanged
821
+ * digits and stay `pending` instead of resolving a country from a
822
+ * coincidental prefix match: without this hint, stale digits from a
823
+ * previous, unrelated number always resolve a country the instant "+"
824
+ * lands in front of them (an old "5551234" would silently become
825
+ * Brazilian the moment "+" is typed, since "55" is Brazil's dial code).
826
+ * Pass the pre-edit digits on every keystroke; once they actually change,
827
+ * resolution proceeds normally on the next call.
828
+ */
829
+ previousDigits?: string;
585
830
  }
586
831
  /**
587
832
  * Parse a stored/typed value into country + national + E.164. Accepts E.164
588
833
  * ("+4915123456789"), a bare international number, or — with `fallback` — a
589
834
  * national number typed without a dial code.
835
+ *
836
+ * See `ParsedPhone.pending`/`ParsePhoneOptions.previousDigits` for the
837
+ * pending-international-entry contract: a "+"-prefixed value that doesn't
838
+ * resolve a dial code (incl. a lone "+") returns pending instead of
839
+ * silently reverting to `fallback` with an emptied value, and
840
+ * `previousDigits` stops stale leftover digits from resolving a country
841
+ * they never meant to represent, just because "+" was prepended.
590
842
  */
591
- declare function parsePhone(value: string, fallback?: PhoneCountry): ParsedPhone;
843
+ declare function parsePhone(value: string, fallback?: PhoneCountry, opts?: ParsePhoneOptions): ParsedPhone;
592
844
  /**
593
845
  * Cheap "could this be a real number?" check used for inline feedback. Length
594
846
  * only — never claims more than it knows; the server does authoritative
@@ -626,8 +878,10 @@ declare function createEmptyForm(title?: string): FormSchema;
626
878
  * defaults — the model only ever supplies the human-meaningful parts.
627
879
  */
628
880
  /**
629
- * Field kinds the generator may use. Excludes hidden/custom/signature: the
630
- * first two are code-only concerns and signature is too niche to draft well.
881
+ * Field kinds the generator may use. Excludes hidden/custom/signature (the
882
+ * first two are code-only concerns and signature is too niche to draft well)
883
+ * and calculated (an LLM can't reliably wire a calc AST to stable field ids —
884
+ * owners add calculations deliberately, in the builder or in code).
631
885
  */
632
886
  declare const DRAFT_KINDS: readonly BlockKind[];
633
887
  /** The LLM-friendly intermediate shape. Everything optional but `kind`. */
@@ -708,6 +962,26 @@ interface PublishedForm {
708
962
  theme: FormTheme | null;
709
963
  /** True when the server says the form cannot accept responses. */
710
964
  closed?: boolean;
965
+ /**
966
+ * Whether a submission would be accepted right now. Absent on older servers
967
+ * — renderers must then keep today's behavior. When false, the default
968
+ * renderers show the not-open state instead of a fillable form.
969
+ */
970
+ accepting?: boolean;
971
+ /**
972
+ * Companion to `accepting` — present only when it is false.
973
+ * `draft`: not published yet; `expired`/`capped`: the unclaimed preview
974
+ * workspace hit its time window or response cap. Storage reasons may be
975
+ * returned for drafts or by older servers; published upload readiness is
976
+ * represented independently by `uploadsAvailable`.
977
+ */
978
+ acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
979
+ /** Whether new file uploads can start right now. Absent on older servers is
980
+ * treated as available; this never changes whether ordinary answers submit. */
981
+ uploadsAvailable?: boolean;
982
+ /** Server-owned per-file ceiling for the active storage lane. Renderers use
983
+ * the lower of this and the field's configured maxFileSizeMb. */
984
+ uploadFileSizeLimitMb?: number;
711
985
  /** Workspace branding state — absent means show the badge (default). */
712
986
  branding?: FormBranding;
713
987
  /** Human-verification challenge to render before submit, when the form
@@ -861,6 +1135,27 @@ interface SyncFormResult {
861
1135
  status?: "draft" | "published";
862
1136
  /** Changes were staged as a draft for a human to publish. */
863
1137
  staged?: boolean;
1138
+ /**
1139
+ * Whether a submission would be accepted right now. Absent on older servers
1140
+ * — renderers must then keep today's behavior. When false, the default
1141
+ * renderers show the not-open state instead of a fillable form.
1142
+ */
1143
+ accepting?: boolean;
1144
+ /**
1145
+ * Companion to `accepting` — present only when it is false.
1146
+ * `draft`: not published yet; `expired`/`capped`: the unclaimed preview
1147
+ * workspace hit its time window or response cap; `storage_required`: the
1148
+ * form needs a connected storage destination before it can go live;
1149
+ * `storage_full`: Fillo's temporary upload allowance is exhausted.
1150
+ */
1151
+ acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
1152
+ /** Whether new file uploads can start right now. This is independent from
1153
+ * response acceptance so a completed file can still be submitted after the
1154
+ * workspace reaches its upload cap. Absent on older servers means available. */
1155
+ uploadsAvailable?: boolean;
1156
+ /** Server-owned per-file ceiling for the active storage lane. Renderers use
1157
+ * the lower of this and the field's configured maxFileSizeMb. */
1158
+ uploadFileSizeLimitMb?: number;
864
1159
  /**
865
1160
  * Server-authoritative live snapshot. Present when the incoming code schema
866
1161
  * is not the version respondents may submit against yet.
@@ -872,7 +1167,23 @@ interface SyncFormResult {
872
1167
  code: string;
873
1168
  message: string;
874
1169
  };
1170
+ /**
1171
+ * Human-readable storage heads-up for the form owner. This can be advisory
1172
+ * while uploads and responses remain available; never use it to gate UI.
1173
+ */
875
1174
  warning?: string;
1175
+ /**
1176
+ * Machine-readable owner advisory for `warning`. Hard unavailability uses
1177
+ * `"storage_required"`; transit threshold advisories use distinct codes.
1178
+ * Point the human at `warningUrl`; use `uploadsAvailable`, not this field,
1179
+ * to gate new file controls.
1180
+ */
1181
+ warningCode?: string;
1182
+ /**
1183
+ * Absolute dashboard URL where a human connects a storage destination.
1184
+ * Present whenever `warningCode` is.
1185
+ */
1186
+ warningUrl?: string;
876
1187
  }
877
1188
  declare class FilloClient {
878
1189
  /** Server origin this client targets, normalized (no trailing slash). */
@@ -1070,6 +1381,15 @@ interface FormControllerOptions {
1070
1381
  * runs; failure sets `submitError` and the respondent can retry.
1071
1382
  */
1072
1383
  resolveFormId?: () => Promise<string>;
1384
+ /**
1385
+ * Surface the REAL {@link resolveFormId} failure (message + machine code)
1386
+ * in `submitError` instead of the respondent-safe "This form is
1387
+ * unavailable." fallback. Dev chrome only: renderers set it from the same
1388
+ * gate as their other developer surfaces (preview prop / dev environment),
1389
+ * so production visitors never see integration details such as keys,
1390
+ * origins, or deployment commands.
1391
+ */
1392
+ verboseResolutionErrors?: boolean;
1073
1393
  /**
1074
1394
  * Host-app account context (identify()): who is filling this form, by your
1075
1395
  * own user id. Sent with the submission and recorded as an unverified
@@ -1220,13 +1540,35 @@ interface AutoSubmitContext {
1220
1540
  }
1221
1541
  declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubmitContext): boolean;
1222
1542
 
1543
+ /**
1544
+ * Pure keyboard-navigation math for a roving-tabindex radiogroup (choice
1545
+ * radios, rating, linear_scale, matrix rows) — shared by both renderers so
1546
+ * the arrow/Home/End handling isn't duplicated and can't drift (audit
1547
+ * P2.3: today's `radiogroupKeyDown`/`bindRadioKeys` copies wrap at the
1548
+ * extremes, have no Home/End, and are RTL-blind).
1549
+ *
1550
+ * No DOM/framework dependency: given the pressed key, the currently active
1551
+ * index, and the option count, this returns the new index to select and
1552
+ * focus, or `null` when `key` isn't a navigation key (the caller falls
1553
+ * through to its own handling, e.g. Space/Enter selection).
1554
+ *
1555
+ * `index` is expected in range (`0`..`length-1`, e.g. from a roving-tabindex
1556
+ * helper that already normalizes "nothing selected" to `0` — matching both
1557
+ * renderers' existing `rovingIndex`). Out-of-range input is still handled
1558
+ * safely: the result is clamped to `[0, length-1]` the same as any other
1559
+ * step, with no special-casing for negative or overflowing starting indexes.
1560
+ */
1561
+ declare function radioGroupStep(key: string, index: number, length: number, opts?: {
1562
+ rtl?: boolean;
1563
+ }): number | null;
1564
+
1223
1565
  /**
1224
1566
  * The styling contract, shared by every renderer: named slots a consumer can
1225
1567
  * attach classes to, and data-* attributes that expose state so utility CSS
1226
1568
  * (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
1227
1569
  * names are public API — renames are breaking; additions are minors.
1228
1570
  */
1229
- declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume", "turnstile"];
1571
+ declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume", "turnstile", "calculated"];
1230
1572
  type FilloSlot = (typeof FILLO_SLOTS)[number];
1231
1573
  /** data-* names emitted alongside the stable fillo-* classes. */
1232
1574
  declare const FILLO_DATA_ATTRS: {
@@ -1311,6 +1653,26 @@ declare const FILLO_THEME_VARS: readonly [{
1311
1653
  }, {
1312
1654
  readonly var: "--fillo-primary-contrast";
1313
1655
  }];
1656
+ /**
1657
+ * Infer `colorScheme` for a `FormTheme` with `background` AND `text` set
1658
+ * but no explicit `colorScheme`, from the background's WCAG relative
1659
+ * luminance — fixes the foot-gun where `theme={{background, text}}` alone
1660
+ * left the light palette's muted/border/control-bg/error/primary-contrast
1661
+ * tokens in place, reading as near-white-on-white (≈1.10:1) on a dark
1662
+ * author-chosen background.
1663
+ *
1664
+ * Explicit `colorScheme` always wins (returned unchanged). Only one of
1665
+ * `background`/`text` set, neither set, or a non-`#rgb`/`#rrggbb`
1666
+ * `background` also pass through unchanged — no inference.
1667
+ *
1668
+ * Returns a `FormTheme`, not just the scheme, so a renderer can drop this
1669
+ * in ahead of its existing code with no other change:
1670
+ * `const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));`
1671
+ * then read `.colorScheme`/`.primary`/… exactly as today — inferred and
1672
+ * explicit schemes cascade through the same shipped
1673
+ * `[data-fillo-color-scheme="dark"]` CSS, so no new CSS is needed.
1674
+ */
1675
+ declare function resolveThemeAppearance(theme: FormTheme | null): FormTheme | null;
1314
1676
 
1315
1677
  /**
1316
1678
  * The default English validation message for a required field left empty.
@@ -1350,6 +1712,13 @@ interface FilloStrings {
1350
1712
  successMessage: string;
1351
1713
  closed: string;
1352
1714
  notLive: string;
1715
+ /** Title of the not-open state for a draft/storage-blocked form in production. */
1716
+ notOpenTitle: string;
1717
+ /** Body of the not-open state. */
1718
+ notOpenBody: string;
1719
+ /** Title of the closed-flavor state (expired/capped workspace);
1720
+ * the body reuses `closed`. */
1721
+ closedTitle: string;
1353
1722
  /** Fallback when a submit fails without a server message. */
1354
1723
  submitFailed: string;
1355
1724
  loadFailedNotFound: string;
@@ -1370,7 +1739,9 @@ declare const DEFAULT_STRINGS: FilloStrings;
1370
1739
  * Field-level and validation strings the default renderers emit. Kept apart
1371
1740
  * from {@link FilloStrings} so parametrized entries can be functions (a
1372
1741
  * translation places the value where its grammar needs it) without widening
1373
- * the documented all-`string` chrome surface.
1742
+ * the documented all-`string` chrome surface. Also the growth point for new
1743
+ * strings in general — unlike `FilloStrings`, nothing depends on this being
1744
+ * an exhaustively-enumerated, closed set.
1374
1745
  */
1375
1746
  interface FilloFieldStrings {
1376
1747
  /** Validation: a required field was left empty. Mirrors REQUIRED_FIELD_MESSAGE. */
@@ -1384,6 +1755,10 @@ interface FilloFieldStrings {
1384
1755
  uploadRetry: string;
1385
1756
  /** Dropzone copy when uploads can't run (no client / preview). */
1386
1757
  uploadsDisabled: string;
1758
+ /** Dropzone copy when the server temporarily refuses new file sessions. */
1759
+ uploadsUnavailable: string;
1760
+ /** An upload request reached the server, but storage could not accept it. */
1761
+ uploadUnavailable: string;
1387
1762
  /** An upload attempt failed with no actionable server message. */
1388
1763
  uploadFailed: string;
1389
1764
  /** Dropzone call to action; `multiple` is true when several files are allowed. */
@@ -1398,6 +1773,24 @@ interface FilloFieldStrings {
1398
1773
  uploadsFailed: (count: number) => string;
1399
1774
  /** Screen-reader status: N uploads completed. */
1400
1775
  filesUploaded: (count: number) => string;
1776
+ /** Live-region announcement while a submit is in flight — for auto-submit
1777
+ * forms, which have no footer/button to show `submitting` on. */
1778
+ submittingAnnouncement: string;
1779
+ /** Heading on the failed-submit error summary (one `role="alert"` summary
1780
+ * linking to fields, GOV.UK pattern — replaces N competing per-field alerts). */
1781
+ errorSummaryTitle: string;
1782
+ /** Live-region announcement after a ranking move: "«label», position n of m". */
1783
+ rankingPosition: (label: string, position: number, count: number) => string;
1784
+ /** Live-region announcement once a phone country-picker selection commits
1785
+ * (focus moves straight to the national input, so nothing else announces it). */
1786
+ phoneCountrySelected: (name: string) => string;
1787
+ /** Live-region announcement of the phone country-picker's filtered result
1788
+ * count as the respondent types in the search box. */
1789
+ phoneResultsCount: (count: number) => string;
1790
+ /** Accessible name/state for an empty signature canvas. */
1791
+ signatureEmpty: string;
1792
+ /** Accessible name/state for a signed signature canvas. */
1793
+ signatureSigned: string;
1401
1794
  }
1402
1795
  declare const DEFAULT_FIELD_STRINGS: FilloFieldStrings;
1403
1796
  /** Everything the default renderers can localize — chrome + field/validation. */
@@ -1419,6 +1812,23 @@ interface SyncedForm {
1419
1812
  status?: "draft" | "published";
1420
1813
  /** Changes were staged as a draft for a human to publish. */
1421
1814
  staged?: boolean;
1815
+ /** Whether a submission would be accepted right now. Absent on older
1816
+ * servers — renderers must then keep the status-based behavior. */
1817
+ accepting?: boolean;
1818
+ /**
1819
+ * Companion to `accepting` — present only when it is false. `draft`: not
1820
+ * published yet; `expired`/`capped`: the unclaimed preview workspace hit its
1821
+ * time window or response cap; `storage_required`: the form needs a
1822
+ * connected storage destination before it can go live; `storage_full`:
1823
+ * Fillo's temporary upload allowance is exhausted.
1824
+ */
1825
+ acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
1826
+ /** Whether new file uploads can start right now. Independent from response
1827
+ * acceptance; absent on older servers means available. */
1828
+ uploadsAvailable?: boolean;
1829
+ /** Server-owned per-file ceiling for the active storage lane. Renderers use
1830
+ * the lower of this and the field's configured maxFileSizeMb. */
1831
+ uploadFileSizeLimitMb?: number;
1422
1832
  /** Server-authoritative live snapshot when local code is not live yet. */
1423
1833
  resolvedSchema?: FormSchema;
1424
1834
  resolvedTheme?: FormTheme | null;
@@ -1428,6 +1838,15 @@ interface SyncedForm {
1428
1838
  message: string;
1429
1839
  };
1430
1840
  warning?: string;
1841
+ /**
1842
+ * Machine-readable owner advisory for `warning`. Hard unavailability uses
1843
+ * `"storage_required"`; advisory thresholds use distinct codes. Use
1844
+ * `uploadsAvailable`, not this field, to decide whether an upload may start.
1845
+ */
1846
+ warningCode?: string;
1847
+ /** Absolute dashboard URL where a human connects a storage destination.
1848
+ * Present whenever `warningCode` is. */
1849
+ warningUrl?: string;
1431
1850
  }
1432
1851
  /**
1433
1852
  * A form whose structure lives in user code. Development and explicit
@@ -1543,6 +1962,37 @@ interface WhenBuilder {
1543
1962
  }
1544
1963
  declare function when(fieldId: string): WhenBuilder;
1545
1964
 
1965
+ /**
1966
+ * The build-time half of the dev check: `NODE_ENV` alone, no hostname
1967
+ * inspection. It evaluates identically on the server and in the browser for
1968
+ * the same bundle, which is what SSR hydration needs — the server has no
1969
+ * `window`, so a hostname-aware check would disagree with the client's first
1970
+ * paint. Renderers use this as the server/hydration snapshot and upgrade to
1971
+ * {@link isLikelyDevEnv} after hydration.
1972
+ */
1973
+ declare function isBuildTimeDevEnv(): boolean;
1974
+ /**
1975
+ * Whether this runtime looks like local development, so renderers can show
1976
+ * actionable dev surfaces (draft banner, missing-client warning, local schema
1977
+ * render) instead of the deliberately quiet production states.
1978
+ *
1979
+ * The build-time `NODE_ENV` signal alone misses real local setups: the
1980
+ * standalone `<script>` bundle has no `process` at all (so it always read as
1981
+ * production), `vite preview` / `next start` serve a production build on
1982
+ * localhost, and some bundlers never define `NODE_ENV`. So a browser whose
1983
+ * hostname is localhost/loopback also counts as development. Real
1984
+ * deployments keep production semantics because they serve from real
1985
+ * hostnames — see {@link isLocalHostname} for why mDNS `*.local` names and
1986
+ * private-LAN addresses are deliberately excluded.
1987
+ *
1988
+ * SSR-safe to CALL (with no `window`, only the `NODE_ENV` check applies), but
1989
+ * NOT hydration-safe for render output: the server pass can't see the page
1990
+ * hostname, so under `next start` on localhost it disagrees with the client.
1991
+ * Render paths should hydrate from {@link isBuildTimeDevEnv} and upgrade to
1992
+ * this check after hydration (see the React renderer's useIsDevEnv()).
1993
+ */
1994
+ declare function isLikelyDevEnv(): boolean;
1995
+
1546
1996
  /**
1547
1997
  * Build initial response data from URL query parameters — Tally-style
1548
1998
  * prefilling. Hidden fields read their configured paramName; every other
@@ -1587,4 +2037,4 @@ declare class Sha1 {
1587
2037
  }
1588
2038
  declare const sha1Base64: (bytes: Uint8Array) => string;
1589
2039
 
1590
- export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type ChallengeConfig, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_CHALLENGE_MIN_SDK_VERSION, FILLO_DATA_ATTRS, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type JumpRule, type LinearScaleField, type MatrixField, type NextPage, type NumberField, PHONE_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type TrustPolicy, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, conditionsMet, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formSchemasEqual, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, isTerminalPage, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, reachableFieldIds, reachableFields, reachablePageIds, reachablePageSequence, resolveNextPage, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };
2040
+ export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CalcExpr, type CalculatedField, type ChallengeConfig, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_FIELD_STRINGS, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_CALC_MIN_SDK_VERSION, FILLO_CHALLENGE_MIN_SDK_VERSION, FILLO_DATA_ATTRS, FILLO_MIN_SDK_VERSION, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, type FilloFieldStrings, FilloJsxError, type FilloRendererStrings, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type JumpRule, type LinearScaleField, type MatrixField, type NextPage, type NumberField, PHONE_COUNTRIES, PHONE_PICKER_COUNTRIES, PHONE_POPOVER_VIEWPORT_GAP, type ParagraphBlock, type ParsePhoneOptions, type ParsedPhone, type PhoneCountry, type PhoneField, type PhonePopoverPlacement, type ProvisionWorkspaceResult, type PublishedForm, REQUIRED_FIELD_MESSAGE, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type TrustPolicy, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, computeCalculated, conditionsMet, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, evaluateCalc, flagEmoji, formSchemasEqual, formatAnswer, formatGroupedNumber, formatNational, isAutoSubmitBlock, isBlockVisible, isBuildTimeDevEnv, isCodeForm, isField, isFilloError, isLikelyDevEnv, isPossiblePhone, isTerminalPage, isValidPartialNumberText, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parseGroupedNumber, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, radioGroupStep, reachableFieldIds, reachableFields, reachablePageIds, reachablePageSequence, resolveNextPage, resolveSlotClass, resolveStrings, resolveText, resolveThemeAppearance, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };