@usefillo/core 0.10.0 → 0.12.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 (3) hide show
  1. package/dist/index.d.ts +367 -20
  2. package/dist/index.js +778 -143
  3. package/package.json +2 -1
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,17 @@ 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
+ /** Thousand separators in the SDK input. Display-only; never reaches the
70
+ * wire, the server, or exports. Named by the GROUP separator:
71
+ * - `"grouped"`: detect from the respondent's browser locale;
72
+ * - `"grouped-comma"`: fixed comma groups, dot decimal — `1,234.56`;
73
+ * - `"grouped-dot"`: fixed dot groups, comma decimal — `1.234,56`. */
74
+ notation?: "grouped" | "grouped-comma" | "grouped-dot";
64
75
  }
65
76
  interface ChoiceField extends BaseField {
66
77
  kind: "select" | "multi_select" | "dropdown";
@@ -129,6 +140,64 @@ interface FileUploadField extends BaseField {
129
140
  /** Accepted types, e.g. ["image/*", ".pdf", "video/mp4"]. Empty = anything. */
130
141
  accept?: string[];
131
142
  }
143
+ /**
144
+ * The calculation AST (logic depth P2). A typed tree, never a formula string,
145
+ * and the permanent expression substrate — extending it later means adding
146
+ * ops, never replacing it. v1 is numeric-only: totals and derived values.
147
+ *
148
+ * `if.when` reuses the existing {@link Condition} model verbatim (AND-combined,
149
+ * same ops) — one condition language across visibility, jumps, and calculations.
150
+ *
151
+ * Referenceable operand kinds (v1): `number`, `rating`, `linear_scale`, and
152
+ * `calculated` (chaining). Nothing else coerces implicitly.
153
+ */
154
+ type CalcExpr =
155
+ /** A numeric field ref. Resolves through the same visibility discipline as
156
+ * conditions: a logic-hidden source reads as unanswered (→ null). */
157
+ {
158
+ op: "value";
159
+ fieldId: string;
160
+ } | {
161
+ op: "const";
162
+ value: number;
163
+ }
164
+ /** n-ary, length >= 1. Any null operand → null result (strict propagation). */
165
+ | {
166
+ op: "add" | "mul" | "min" | "max";
167
+ args: CalcExpr[];
168
+ }
169
+ /** Division by zero (and any non-finite result) → null. */
170
+ | {
171
+ op: "sub" | "div";
172
+ left: CalcExpr;
173
+ right: CalcExpr;
174
+ }
175
+ /** Half-away-from-zero at `decimals` (default 0). */
176
+ | {
177
+ op: "round";
178
+ arg: CalcExpr;
179
+ decimals?: number;
180
+ } | {
181
+ op: "if";
182
+ when: Condition[];
183
+ then: CalcExpr;
184
+ else: CalcExpr;
185
+ };
186
+ /**
187
+ * A derived numeric value computed from other answers — never an input. The
188
+ * client recomputes it live for piping/conditions/jumps; the server recomputes
189
+ * it authoritatively on submit, so a tampered client value is ignored by
190
+ * construction. A null result means the field is unanswered (key absent).
191
+ */
192
+ interface CalculatedField extends BaseField {
193
+ kind: "calculated";
194
+ calc: CalcExpr;
195
+ /** Rounds the STORED value, not just the display — one deterministic number
196
+ * everywhere (client preview, server recompute, grid, CSV, webhooks). */
197
+ decimals?: number;
198
+ prefix?: string;
199
+ suffix?: string;
200
+ }
132
201
  /**
133
202
  * A field type Fillo doesn't ship. You define it in code and supply the
134
203
  * renderer via the SDK's `customComponents` map, keyed by `component`. The
@@ -143,7 +212,7 @@ interface CustomField extends BaseField {
143
212
  /** Arbitrary options handed to your component. */
144
213
  config?: Record<string, unknown>;
145
214
  }
146
- type Field = TextField | PhoneField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CustomField;
215
+ type Field = TextField | PhoneField | NumberField | ChoiceField | CheckboxField | RatingField | LinearScaleField | RankingField | MatrixField | SignatureField | DateField | FileUploadField | HiddenField | CalculatedField | CustomField;
147
216
  interface HeadingBlock extends BaseBlock {
148
217
  kind: "heading";
149
218
  text: string;
@@ -493,6 +562,49 @@ interface ValidationResult {
493
562
  */
494
563
  declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
495
564
 
565
+ /**
566
+ * Evaluate a calc expression to a number, or null for "unanswered".
567
+ * `resolve` maps a fieldId to its effective value and must apply the SAME
568
+ * visibility discipline as conditions (a logic-hidden source resolves to
569
+ * undefined) — the shared fixpoint's resolver does exactly that. Semantics:
570
+ * - an unanswered `value` ref → null; numeric strings bridge exactly like
571
+ * condition gt/lt (shared `conditionNumber`), nothing else coerces;
572
+ * - any null operand → null result (strict propagation, no silent zero);
573
+ * - division by zero and non-finite results → null;
574
+ * - `round` is half-away-from-zero at `decimals` (default 0);
575
+ * - `if` runs its `when` through the standard condition evaluator (AND), so
576
+ * unanswered behavior is identical to visibility/jump rules.
577
+ */
578
+ declare function evaluateCalc(expr: CalcExpr, resolve: (fieldId: string) => FieldValue): number | null;
579
+ /**
580
+ * Response data with every calculated field's value written in (null or
581
+ * logic-hidden → key absent, i.e. unanswered) and any client-supplied value
582
+ * for a calculated id dropped — the engine, never the wire, owns these keys.
583
+ * Returns `data` unchanged for a calc-free form.
584
+ *
585
+ * THE canonical evaluator, shared verbatim by the controller (live display,
586
+ * piping, conditions) and the server's submit recompute — the contract's
587
+ * "deterministic, identical evaluation on client and server". Before the
588
+ * joint {visible set, calc values} fixpoint runs, answers are reduced to the
589
+ * canonical form the server stores:
590
+ * - value shape: resolveLogicState reads canonical values (trimmed, coerced,
591
+ * empty-as-unanswered — canonical.ts), so `if.when` conditions match the
592
+ * kept answers, never a divergent live shape;
593
+ * - reachability: an answer on a jump-skipped page reads as unanswered, the
594
+ * same pruning validateResponse applies before the server recompute. The
595
+ * prune below can itself flip a jump or visibility rule, so it iterates to
596
+ * a fixed point — each pass keeps a SUBSET of the previous keys (pruning
597
+ * only ever removes), so the loop is monotone, terminates within the key
598
+ * count, and the client (raw live data) and the server (the once-pruned
599
+ * kept set) walk the same shrinking chain to the SAME view.
600
+ *
601
+ * The pruned view is EVALUATION input only: non-calc answers pass through to
602
+ * the output untouched, so the controller keeps a stale answer behind a
603
+ * flipped jump (navigating back restores it) while its calc display, piping,
604
+ * and jump decisions read it as unanswered — exactly what the server stores.
605
+ */
606
+ declare function computeCalculated(form: FormSchema, data: ResponseData): ResponseData;
607
+
496
608
  declare const FILLO_SCHEMA_VERSION: 1;
497
609
  /** Injected from package.json at build time (tsup define) — never hand-edited. */
498
610
  declare const FILLO_SDK_VERSION: string;
@@ -514,6 +626,17 @@ declare const FILLO_MIN_SDK_VERSION = "0.4.0";
514
626
  * @usefillo/*" error instead of a form that silently can't submit.
515
627
  */
516
628
  declare const FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
629
+ /**
630
+ * The floor served INSTEAD of FILLO_MIN_SDK_VERSION for forms whose live
631
+ * schema contains a calculated field: the first release that ships the kind.
632
+ * An older SDK's zod enum strips the unknown block, so it renders the form
633
+ * WITHOUT the calc row — piping shows blanks and any visibility/jump rule
634
+ * reading the calc id misbehaves. That's wrong-form-behavior, not merely
635
+ * missing chrome, so it fails fast with the "update @usefillo/*" error
636
+ * instead (the Turnstile-floor precedent; the form GET picks the max of the
637
+ * applicable floors).
638
+ */
639
+ declare const FILLO_CALC_MIN_SDK_VERSION = "0.11.0";
517
640
  declare function normalizeSettings(value: unknown): FormSettings;
518
641
  interface SchemaValidationResult {
519
642
  ok: boolean;
@@ -539,6 +662,90 @@ declare function normalizeFormTheme(input: unknown): FormTheme | null;
539
662
  */
540
663
  declare function formatAnswer(field: Field, value: FieldValue): string;
541
664
 
665
+ /**
666
+ * Grouped-number display + parsing for the Number field's `notation` input
667
+ * — browser-locale detection (`"grouped"`) or an author-fixed style
668
+ * (`"grouped-comma"` = `1,234.56`, `"grouped-dot"` = `1.234,56`); design
669
+ * contract: docs/decisions/number-formatting.md. All helpers are pure,
670
+ * side-effect-free, and DOM-free, so the same code runs in @usefillo/react,
671
+ * @usefillo/dom, and Node. All are strictly display-only — grouping never
672
+ * touches validation, canonical storage, or the wire (contract decision 2)
673
+ * — and all degrade to canonical text instead of throwing when `Intl` is
674
+ * unavailable.
675
+ */
676
+ /**
677
+ * The `Intl` locale a `notation` value pins the separators to — the single
678
+ * source of the notation→locale map, consumed by BOTH renderers (never
679
+ * duplicate it). Notation values are named by the GROUP separator:
680
+ * - `"grouped"` (and unset): `undefined` — detect from the respondent's
681
+ * browser locale, exactly the pre-fixed-style behavior;
682
+ * - `"grouped-comma"`: `"en-US"` — comma groups, dot decimal, `1,234.56`;
683
+ * - `"grouped-dot"`: `"de-DE"` — dot groups, comma decimal, `1.234,56`.
684
+ */
685
+ declare function localeForNotation(notation?: "grouped" | "grouped-comma" | "grouped-dot"): string | undefined;
686
+ /**
687
+ * Locale-grouped display text for a finite number — the blurred value of a
688
+ * `notation: "grouped"` number input. `maximumFractionDigits` is set
689
+ * explicitly to 20 when `decimals` is unset: Intl's own silent default (3)
690
+ * would truncate/round a longer fraction on display, silently disagreeing
691
+ * with the stored value. When `decimals` is set it pins both the minimum and
692
+ * maximum fraction digits — padding to a stable width, the same contract
693
+ * `formatAnswer`'s toFixed follows. Non-finite input and a missing `Intl`
694
+ * both fall back to canonical text; this must never throw mid-keystroke.
695
+ */
696
+ declare function formatGroupedNumber(value: number, opts?: {
697
+ locale?: string;
698
+ decimals?: number;
699
+ }): string;
700
+ /**
701
+ * Canonical text (what `Number()` can parse) for a grouped-number input's raw
702
+ * typed text — this is what `setValue` receives on every keystroke (contract
703
+ * decision 2: the input holds grouped text only while unfocused; `data`
704
+ * always holds canonical numerics). Returns the INPUT UNCHANGED when it can't
705
+ * produce a parseable result — validation flags it from there, same as an
706
+ * unformatted number field today.
707
+ *
708
+ * Derives the locale's group/decimal marks via
709
+ * `Intl.NumberFormat(locale).formatToParts(1234567.8)`, then:
710
+ * - strips group separators — several locales (fr-FR among them) use
711
+ * U+00A0/U+202F no-break spaces, so any whitespace-class character is
712
+ * accepted as a group separator whenever the locale's own is whitespace;
713
+ * - maps the locale decimal mark to ".";
714
+ * - ALWAYS also accepts "." as a decimal mark regardless of locale — see
715
+ * {@link resolveAmbiguousGrouping} for the locale families where a "." or
716
+ * "," group separator collides with a decimal reading (a "," group run
717
+ * that isn't exact 3-digit chunking reads as a decimal, so "12,5" under a
718
+ * comma-grouping locale is 12.5, never a silently-stripped 125);
719
+ * - preserves a leading minus;
720
+ * - preserves partial typing states ("1234." stays "1234." — `Number()` can
721
+ * still parse it, and rewriting mid-keystroke fights the respondent).
722
+ *
723
+ * Empty/whitespace input and a missing `Intl` both return the input as-is.
724
+ */
725
+ declare function parseGroupedNumber(text: string, locale?: string): string;
726
+ /**
727
+ * Keystroke-level filter for a formatted number input's raw typed text (the
728
+ * input-quality contract's "optional future: React Aria-style
729
+ * `isValidPartialNumber` keystroke filter", `docs/decisions/input-quality.md`).
730
+ * Permissive on separator PLACEMENT — {@link parseGroupedNumber} and server
731
+ * validation own semantics — this only blocks characters or counts that can
732
+ * never resolve to a number: letters and other symbols, a non-leading "-",
733
+ * or more than one decimal mark.
734
+ *
735
+ * Reuses {@link parseGroupedNumber}'s locale-mark derivation
736
+ * (`Intl.NumberFormat(locale).formatToParts`) and its two special cases: a
737
+ * whitespace-class group separator accepts ANY whitespace character, and
738
+ * where "." is itself the locale's group separator (the de-DE family), "."
739
+ * is unlimited and only the locale's real decimal mark is capped at one.
740
+ *
741
+ * A candidate is validated as a WHOLE string, never character-by-character —
742
+ * a bad paste is rejected in full, not trimmed down to its valid prefix
743
+ * (React Aria's behavior). `Intl`-absent falls back to a minimal
744
+ * digits/"."/"," character class (the leading-"-" rule still applies, since
745
+ * it needs no locale data).
746
+ */
747
+ declare function isValidPartialNumberText(text: string, locale?: string): boolean;
748
+
542
749
  /**
543
750
  * Lightweight, dependency-free phone metadata + helpers powering the SDK's
544
751
  * phone field. Deliberately small: it carries enough to render a country
@@ -546,11 +753,18 @@ declare function formatAnswer(field: Field, value: FieldValue): string;
546
753
  * validation + canonical normalization happen server-side with full
547
754
  * libphonenumber data (bundle size is irrelevant there), so this stays lean and
548
755
  * the stored value is always E.164.
756
+ *
757
+ * Coverage is every ITU dial code (~all of ISO 3166-1): 70 curated entries
758
+ * below carry full formatting metadata; every other country is packed
759
+ * "iso2+dialCode" data with no metadata (formatNational/isPossiblePhone fall
760
+ * back to grouping-in-3s / plain E.164 possibility). Names are resolved at
761
+ * runtime via Intl.DisplayNames — zero hardcoded English name literals.
549
762
  */
550
763
  interface PhoneCountry {
551
764
  /** ISO 3166-1 alpha-2, uppercase ("US"). Also drives the flag emoji. */
552
765
  iso2: string;
553
- /** Display name ("United States"). */
766
+ /** Localized display name, resolved via `Intl.DisplayNames` (falls back
767
+ * to the iso2 code when that API is unavailable). */
554
768
  name: string;
555
769
  /** Country calling code without the "+" ("1", "44", "33"). */
556
770
  dialCode: string;
@@ -561,8 +775,22 @@ interface PhoneCountry {
561
775
  /** Example national number (digits only) for the input placeholder. */
562
776
  example: string;
563
777
  }
564
- /** Curated country metadata, ordered for the picker (broad coverage). */
778
+ /**
779
+ * Every known phone country: the 70 curated entries (rich metadata) then
780
+ * every other ITU dial code (name + dial code only). Order is NOT display
781
+ * order (see PHONE_PICKER_COUNTRIES) — it's the tie-break priority
782
+ * `countryByDialCode` resolves shared codes with, preserved so growing this
783
+ * list from 70 to ~240 doesn't change how any of the original 70 resolve.
784
+ */
565
785
  declare const PHONE_COUNTRIES: PhoneCountry[];
786
+ /**
787
+ * PHONE_COUNTRIES reordered for a picker UI: localized name, Intl.Collator-
788
+ * compared (falls back to iso2 order without Intl.Collator). Kept separate
789
+ * from PHONE_COUNTRIES, whose order countryByDialCode depends on for
790
+ * curated-first tie-breaking — sorting by name would flip those ties (e.g.
791
+ * "Canada" before "United States" would flip which one "+1…" resolves to).
792
+ */
793
+ declare const PHONE_PICKER_COUNTRIES: PhoneCountry[];
566
794
  /** Flag emoji from an ISO-3166 alpha-2 code (regional-indicator letters). */
567
795
  declare function flagEmoji(iso2: string): string;
568
796
  declare function countryByIso(iso2: string | undefined): PhoneCountry | undefined;
@@ -576,19 +804,60 @@ declare function formatNational(country: PhoneCountry | undefined, national: str
576
804
  /** Assemble the canonical E.164 value from a country + national digits. */
577
805
  declare function toE164(country: PhoneCountry, national: string): string;
578
806
  interface ParsedPhone {
579
- /** Best-guess country, if the dial code matched one we know. */
807
+ /** Best-guess country, if the dial code matched one we know. Always unset
808
+ * while `pending`. */
580
809
  country?: PhoneCountry;
581
- /** National significant digits (dial code stripped). */
810
+ /** National significant digits: dial code stripped once a country is
811
+ * known, otherwise every digit typed after "+" (or all digits, with no
812
+ * "+" and no fallback). */
582
813
  national: string;
583
814
  /** Canonical E.164 ("+…"), or "" if there were no digits. */
584
815
  e164: string;
816
+ /**
817
+ * True for a "+"-prefixed value that hasn't resolved a country: no digits
818
+ * at all ("+"), digits that prefix a real dial code without completing
819
+ * one ("+4"), or digits identical to `ParsePhoneOptions.previousDigits` —
820
+ * a bare "+" just prepended to unchanged, stale digits.
821
+ *
822
+ * `national`/`e164` still carry the typed digits (a caller that ignores
823
+ * `pending` degrades gracefully). The renderer should hold off committing
824
+ * a stored value while pending, and redisplay `raw` verbatim instead of
825
+ * `formatNational(country, national)` — there's no country to format
826
+ * against yet, and reformatting a bare "+" into "" is the corruption this
827
+ * field exists to prevent.
828
+ */
829
+ pending?: boolean;
830
+ /** The trimmed input, verbatim. Set only when `pending`. */
831
+ raw?: string;
832
+ }
833
+ interface ParsePhoneOptions {
834
+ /**
835
+ * National digits already in the field before this edit (dial code
836
+ * already stripped — the same shape `parsePhone` returns as `national`).
837
+ * Lets `parsePhone` recognize a bare "+" just prepended to unchanged
838
+ * digits and stay `pending` instead of resolving a country from a
839
+ * coincidental prefix match: without this hint, stale digits from a
840
+ * previous, unrelated number always resolve a country the instant "+"
841
+ * lands in front of them (an old "5551234" would silently become
842
+ * Brazilian the moment "+" is typed, since "55" is Brazil's dial code).
843
+ * Pass the pre-edit digits on every keystroke; once they actually change,
844
+ * resolution proceeds normally on the next call.
845
+ */
846
+ previousDigits?: string;
585
847
  }
586
848
  /**
587
849
  * Parse a stored/typed value into country + national + E.164. Accepts E.164
588
850
  * ("+4915123456789"), a bare international number, or — with `fallback` — a
589
851
  * national number typed without a dial code.
852
+ *
853
+ * See `ParsedPhone.pending`/`ParsePhoneOptions.previousDigits` for the
854
+ * pending-international-entry contract: a "+"-prefixed value that doesn't
855
+ * resolve a dial code (incl. a lone "+") returns pending instead of
856
+ * silently reverting to `fallback` with an emptied value, and
857
+ * `previousDigits` stops stale leftover digits from resolving a country
858
+ * they never meant to represent, just because "+" was prepended.
590
859
  */
591
- declare function parsePhone(value: string, fallback?: PhoneCountry): ParsedPhone;
860
+ declare function parsePhone(value: string, fallback?: PhoneCountry, opts?: ParsePhoneOptions): ParsedPhone;
592
861
  /**
593
862
  * Cheap "could this be a real number?" check used for inline feedback. Length
594
863
  * only — never claims more than it knows; the server does authoritative
@@ -626,8 +895,10 @@ declare function createEmptyForm(title?: string): FormSchema;
626
895
  * defaults — the model only ever supplies the human-meaningful parts.
627
896
  */
628
897
  /**
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.
898
+ * Field kinds the generator may use. Excludes hidden/custom/signature (the
899
+ * first two are code-only concerns and signature is too niche to draft well)
900
+ * and calculated (an LLM can't reliably wire a calc AST to stable field ids —
901
+ * owners add calculations deliberately, in the builder or in code).
631
902
  */
632
903
  declare const DRAFT_KINDS: readonly BlockKind[];
633
904
  /** The LLM-friendly intermediate shape. Everything optional but `kind`. */
@@ -711,7 +982,7 @@ interface PublishedForm {
711
982
  /**
712
983
  * Whether a submission would be accepted right now. Absent on older servers
713
984
  * — renderers must then keep today's behavior. When false, the default
714
- * renderers show the not-open overlay instead of a fillable form.
985
+ * renderers show the not-open state instead of a fillable form.
715
986
  */
716
987
  accepting?: boolean;
717
988
  /**
@@ -725,6 +996,9 @@ interface PublishedForm {
725
996
  /** Whether new file uploads can start right now. Absent on older servers is
726
997
  * treated as available; this never changes whether ordinary answers submit. */
727
998
  uploadsAvailable?: boolean;
999
+ /** Server-owned per-file ceiling for the active storage lane. Renderers use
1000
+ * the lower of this and the field's configured maxFileSizeMb. */
1001
+ uploadFileSizeLimitMb?: number;
728
1002
  /** Workspace branding state — absent means show the badge (default). */
729
1003
  branding?: FormBranding;
730
1004
  /** Human-verification challenge to render before submit, when the form
@@ -881,7 +1155,7 @@ interface SyncFormResult {
881
1155
  /**
882
1156
  * Whether a submission would be accepted right now. Absent on older servers
883
1157
  * — renderers must then keep today's behavior. When false, the default
884
- * renderers show the not-open overlay instead of a fillable form.
1158
+ * renderers show the not-open state instead of a fillable form.
885
1159
  */
886
1160
  accepting?: boolean;
887
1161
  /**
@@ -896,6 +1170,9 @@ interface SyncFormResult {
896
1170
  * response acceptance so a completed file can still be submitted after the
897
1171
  * workspace reaches its upload cap. Absent on older servers means available. */
898
1172
  uploadsAvailable?: boolean;
1173
+ /** Server-owned per-file ceiling for the active storage lane. Renderers use
1174
+ * the lower of this and the field's configured maxFileSizeMb. */
1175
+ uploadFileSizeLimitMb?: number;
899
1176
  /**
900
1177
  * Server-authoritative live snapshot. Present when the incoming code schema
901
1178
  * is not the version respondents may submit against yet.
@@ -1102,7 +1379,9 @@ interface FormControllerOptions {
1102
1379
  getHoneypot?: () => string;
1103
1380
  /**
1104
1381
  * @internal Let preview page navigation move forward without validating the
1105
- * current page. Submission still validates, and renderers must not use this
1382
+ * current page. Submission still validates every answerable field; a
1383
+ * transportless preview ignores required file-upload fields because its
1384
+ * disabled picker cannot possibly satisfy them. Renderers must not use this
1106
1385
  * option to change visitor-facing behavior such as branding.
1107
1386
  */
1108
1387
  skipValidation?: boolean;
@@ -1280,13 +1559,35 @@ interface AutoSubmitContext {
1280
1559
  }
1281
1560
  declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubmitContext): boolean;
1282
1561
 
1562
+ /**
1563
+ * Pure keyboard-navigation math for a roving-tabindex radiogroup (choice
1564
+ * radios, rating, linear_scale, matrix rows) — shared by both renderers so
1565
+ * the arrow/Home/End handling isn't duplicated and can't drift (audit
1566
+ * P2.3: today's `radiogroupKeyDown`/`bindRadioKeys` copies wrap at the
1567
+ * extremes, have no Home/End, and are RTL-blind).
1568
+ *
1569
+ * No DOM/framework dependency: given the pressed key, the currently active
1570
+ * index, and the option count, this returns the new index to select and
1571
+ * focus, or `null` when `key` isn't a navigation key (the caller falls
1572
+ * through to its own handling, e.g. Space/Enter selection).
1573
+ *
1574
+ * `index` is expected in range (`0`..`length-1`, e.g. from a roving-tabindex
1575
+ * helper that already normalizes "nothing selected" to `0` — matching both
1576
+ * renderers' existing `rovingIndex`). Out-of-range input is still handled
1577
+ * safely: the result is clamped to `[0, length-1]` the same as any other
1578
+ * step, with no special-casing for negative or overflowing starting indexes.
1579
+ */
1580
+ declare function radioGroupStep(key: string, index: number, length: number, opts?: {
1581
+ rtl?: boolean;
1582
+ }): number | null;
1583
+
1283
1584
  /**
1284
1585
  * The styling contract, shared by every renderer: named slots a consumer can
1285
1586
  * attach classes to, and data-* attributes that expose state so utility CSS
1286
1587
  * (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
1287
1588
  * names are public API — renames are breaking; additions are minors.
1288
1589
  */
1289
- 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"];
1590
+ 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"];
1290
1591
  type FilloSlot = (typeof FILLO_SLOTS)[number];
1291
1592
  /** data-* names emitted alongside the stable fillo-* classes. */
1292
1593
  declare const FILLO_DATA_ATTRS: {
@@ -1371,6 +1672,26 @@ declare const FILLO_THEME_VARS: readonly [{
1371
1672
  }, {
1372
1673
  readonly var: "--fillo-primary-contrast";
1373
1674
  }];
1675
+ /**
1676
+ * Infer `colorScheme` for a `FormTheme` with `background` AND `text` set
1677
+ * but no explicit `colorScheme`, from the background's WCAG relative
1678
+ * luminance — fixes the foot-gun where `theme={{background, text}}` alone
1679
+ * left the light palette's muted/border/control-bg/error/primary-contrast
1680
+ * tokens in place, reading as near-white-on-white (≈1.10:1) on a dark
1681
+ * author-chosen background.
1682
+ *
1683
+ * Explicit `colorScheme` always wins (returned unchanged). Only one of
1684
+ * `background`/`text` set, neither set, or a non-`#rgb`/`#rrggbb`
1685
+ * `background` also pass through unchanged — no inference.
1686
+ *
1687
+ * Returns a `FormTheme`, not just the scheme, so a renderer can drop this
1688
+ * in ahead of its existing code with no other change:
1689
+ * `const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));`
1690
+ * then read `.colorScheme`/`.primary`/… exactly as today — inferred and
1691
+ * explicit schemes cascade through the same shipped
1692
+ * `[data-fillo-color-scheme="dark"]` CSS, so no new CSS is needed.
1693
+ */
1694
+ declare function resolveThemeAppearance(theme: FormTheme | null): FormTheme | null;
1374
1695
 
1375
1696
  /**
1376
1697
  * The default English validation message for a required field left empty.
@@ -1410,12 +1731,11 @@ interface FilloStrings {
1410
1731
  successMessage: string;
1411
1732
  closed: string;
1412
1733
  notLive: string;
1413
- /** Title of the not-open overlay card (a draft/storage-blocked form rendered
1414
- * in production shows the real form blurred beneath it). */
1734
+ /** Title of the not-open state for a draft/storage-blocked form in production. */
1415
1735
  notOpenTitle: string;
1416
- /** Body of the not-open overlay card. */
1736
+ /** Body of the not-open state. */
1417
1737
  notOpenBody: string;
1418
- /** Title of the closed-flavor overlay card (expired/capped workspace);
1738
+ /** Title of the closed-flavor state (expired/capped workspace);
1419
1739
  * the body reuses `closed`. */
1420
1740
  closedTitle: string;
1421
1741
  /** Fallback when a submit fails without a server message. */
@@ -1438,7 +1758,9 @@ declare const DEFAULT_STRINGS: FilloStrings;
1438
1758
  * Field-level and validation strings the default renderers emit. Kept apart
1439
1759
  * from {@link FilloStrings} so parametrized entries can be functions (a
1440
1760
  * translation places the value where its grammar needs it) without widening
1441
- * the documented all-`string` chrome surface.
1761
+ * the documented all-`string` chrome surface. Also the growth point for new
1762
+ * strings in general — unlike `FilloStrings`, nothing depends on this being
1763
+ * an exhaustively-enumerated, closed set.
1442
1764
  */
1443
1765
  interface FilloFieldStrings {
1444
1766
  /** Validation: a required field was left empty. Mirrors REQUIRED_FIELD_MESSAGE. */
@@ -1450,10 +1772,14 @@ interface FilloFieldStrings {
1450
1772
  alreadyAnswered: string;
1451
1773
  /** Retry control on a failed upload row. */
1452
1774
  uploadRetry: string;
1453
- /** Dropzone copy when uploads can't run (no client / preview). */
1775
+ /** Dropzone copy when uploads can't run because the form isn't connected. */
1454
1776
  uploadsDisabled: string;
1777
+ /** Dropzone copy for an explicit render-only preview, which never has transport. */
1778
+ uploadsRenderOnly: string;
1455
1779
  /** Dropzone copy when the server temporarily refuses new file sessions. */
1456
1780
  uploadsUnavailable: string;
1781
+ /** An upload request reached the server, but storage could not accept it. */
1782
+ uploadUnavailable: string;
1457
1783
  /** An upload attempt failed with no actionable server message. */
1458
1784
  uploadFailed: string;
1459
1785
  /** Dropzone call to action; `multiple` is true when several files are allowed. */
@@ -1468,6 +1794,24 @@ interface FilloFieldStrings {
1468
1794
  uploadsFailed: (count: number) => string;
1469
1795
  /** Screen-reader status: N uploads completed. */
1470
1796
  filesUploaded: (count: number) => string;
1797
+ /** Live-region announcement while a submit is in flight — for auto-submit
1798
+ * forms, which have no footer/button to show `submitting` on. */
1799
+ submittingAnnouncement: string;
1800
+ /** Heading on the failed-submit error summary (one `role="alert"` summary
1801
+ * linking to fields, GOV.UK pattern — replaces N competing per-field alerts). */
1802
+ errorSummaryTitle: string;
1803
+ /** Live-region announcement after a ranking move: "«label», position n of m". */
1804
+ rankingPosition: (label: string, position: number, count: number) => string;
1805
+ /** Live-region announcement once a phone country-picker selection commits
1806
+ * (focus moves straight to the national input, so nothing else announces it). */
1807
+ phoneCountrySelected: (name: string) => string;
1808
+ /** Live-region announcement of the phone country-picker's filtered result
1809
+ * count as the respondent types in the search box. */
1810
+ phoneResultsCount: (count: number) => string;
1811
+ /** Accessible name/state for an empty signature canvas. */
1812
+ signatureEmpty: string;
1813
+ /** Accessible name/state for a signed signature canvas. */
1814
+ signatureSigned: string;
1471
1815
  }
1472
1816
  declare const DEFAULT_FIELD_STRINGS: FilloFieldStrings;
1473
1817
  /** Everything the default renderers can localize — chrome + field/validation. */
@@ -1503,6 +1847,9 @@ interface SyncedForm {
1503
1847
  /** Whether new file uploads can start right now. Independent from response
1504
1848
  * acceptance; absent on older servers means available. */
1505
1849
  uploadsAvailable?: boolean;
1850
+ /** Server-owned per-file ceiling for the active storage lane. Renderers use
1851
+ * the lower of this and the field's configured maxFileSizeMb. */
1852
+ uploadFileSizeLimitMb?: number;
1506
1853
  /** Server-authoritative live snapshot when local code is not live yet. */
1507
1854
  resolvedSchema?: FormSchema;
1508
1855
  resolvedTheme?: FormTheme | null;
@@ -1711,4 +2058,4 @@ declare class Sha1 {
1711
2058
  }
1712
2059
  declare const sha1Base64: (bytes: Uint8Array) => string;
1713
2060
 
1714
- 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, isBuildTimeDevEnv, isCodeForm, isField, isFilloError, isLikelyDevEnv, 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 };
2061
+ 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, localeForNotation, 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 };