@usefillo/core 0.8.0 → 0.10.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/README.md CHANGED
@@ -1,9 +1,23 @@
1
- # @usefillo/core
1
+ <p align="center">
2
+ <a href="https://fillo.so">
3
+ <img src="https://fillo.so/brand/readme-banner.png" alt="Fillo — forms inside your product, with your UI." />
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://fillo.so/docs">Docs</a> ·
9
+ <a href="https://fillo.so/guides">Guides</a> ·
10
+ <a href="https://fillo.so/examples">Examples</a> ·
11
+ <a href="https://fillo.so/changelog">Changelog</a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@usefillo/core"><img src="https://img.shields.io/npm/v/@usefillo/core" alt="npm version" /></a>
16
+ <img src="https://img.shields.io/npm/l/@usefillo/core" alt="MIT license" />
17
+ </p>
2
18
 
3
19
  The framework-agnostic core of [Fillo](https://fillo.so) — forms that render **inside your own product**, with your UI, no iframe.
4
20
 
5
- ### 📚 Full documentation → **[fillo.so/docs](https://fillo.so/docs)**
6
-
7
21
  This package holds the shared foundation: the form schema, validation, the conditional-logic engine, response prefill/piping, and a JS client for provider-aware browser-direct uploads, with resume support where the storage provider offers it. It has **zero framework dependencies**.
8
22
 
9
23
  Most apps don't install this directly — you install a renderer (**[@usefillo/react](https://www.npmjs.com/package/@usefillo/react)** or **[@usefillo/dom](https://www.npmjs.com/package/@usefillo/dom)**), which re-exports everything here you need for embedding. Reach for `@usefillo/core` when you're building your own renderer or working with forms on the server.
@@ -28,9 +42,7 @@ const form = defineForm({
28
42
 
29
43
  Published renderer embeds can fetch by `formId` without a key. Use a client object for custom API origins, uploads, or submissions from your own renderer; add a publishable key only when syncing `defineForm()` schemas from code.
30
44
 
31
- Schema settings include `submitMode: "auto"` for one-tap forms that should submit after a complete discrete answer instead of rendering the first submit button, and `responseLimit: { by: "browser", onRepeat: "keep" }` for browser-scoped duplicate prevention. Themes include `colorScheme: "light" | "dark" | "auto"` for renderer defaults.
32
-
33
- Key exports: `createClient` / `FilloClient`, `defineForm`, `validateResponse`, `validateField`, `visibleBlocks`, and the schema types (`FormSchema`, `Field`, `FieldKind`, `FormTheme`, `ResponseData`, …).
45
+ The full export surface — client methods, schema types, validation, conditional logic, appearance and localization contracts — is documented in the [API reference](https://fillo.so/docs/reference).
34
46
 
35
47
  ## Links
36
48
 
@@ -38,10 +50,3 @@ Key exports: `createClient` / `FilloClient`, `defineForm`, `validateResponse`, `
38
50
  - **Website:** [fillo.so](https://fillo.so)
39
51
 
40
52
  MIT licensed.
41
-
42
- ## Additional authoring surface
43
-
44
- Framework-neutral pieces the renderers share: `defineForm` / `schemaFromJsx`
45
- (the JSX compiler), `when()` condition builder, the `FilloAppearance` styling
46
- contract (slots + data-attribute names), `FilloStrings` localization defaults,
47
- and `createClient({ baseUrl })` for staging/self-hosted targets.
package/dist/index.d.ts CHANGED
@@ -8,6 +8,18 @@ interface Condition {
8
8
  op: ConditionOp;
9
9
  value?: string | number | boolean;
10
10
  }
11
+ /**
12
+ * A single conditional page-flow rule (P1 logic depth). When every condition in
13
+ * `when` matches (AND — the same evaluator as `visibleIf`), navigation leaves
14
+ * the current page for `to`: another page's id, or the literal `"end"` to finish
15
+ * the form early. An empty `when` is an unconditional jump. Rules are evaluated
16
+ * top-to-bottom; the first match wins. See {@link FormPage.next}.
17
+ */
18
+ interface JumpRule {
19
+ when: Condition[];
20
+ /** Target page id, or "end" to finish the form. */
21
+ to: string | "end";
22
+ }
11
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";
12
24
  type ContentKind = "heading" | "paragraph" | "divider";
13
25
  type BlockKind = FieldKind | ContentKind;
@@ -151,6 +163,10 @@ interface FormPage {
151
163
  id: string;
152
164
  title?: string;
153
165
  blocks: Block[];
166
+ /** Conditional page flow (P1 logic depth). Rules are evaluated top-to-bottom;
167
+ * the first whose conditions all match decides the next step. No rule matches
168
+ * → default linear next page. Absent → today's linear behavior. */
169
+ next?: JumpRule[];
154
170
  }
155
171
  /**
156
172
  * Limit repeat responses. Absent = no limit (submit as often as you like).
@@ -185,6 +201,22 @@ interface ResponseLimit {
185
201
  */
186
202
  onRepeat: "keep" | "update";
187
203
  }
204
+ /**
205
+ * Per-form submission-trust policy. Absent = accept everything (today's
206
+ * behavior). See docs/roadmap/07-submission-trust.md.
207
+ */
208
+ interface TrustPolicy {
209
+ /** What to do with a submission whose respondent is NOT HMAC-verified.
210
+ * "allow" (default) accepts it normally; "quarantine" stores it withheld
211
+ * from every downstream consumer until an owner releases it. Declared-agent
212
+ * classes come in a later phase. */
213
+ unverified?: "allow" | "quarantine";
214
+ /** Require a human-verification challenge before accepting a submission.
215
+ * "off" (default) = no challenge; "turnstile" = a Cloudflare Turnstile widget
216
+ * that the SDK renders and the server verifies. Provider-agnostic on purpose.
217
+ * Independent of `unverified` — a form can require both. */
218
+ challenge?: "off" | "turnstile";
219
+ }
188
220
  interface FormSettings {
189
221
  /**
190
222
  * Default "button": respondents submit with the footer button. "auto" hides
@@ -200,6 +232,9 @@ interface FormSettings {
200
232
  /** Limit repeat responses (who counts as the same responder, and what a
201
233
  * repeat does). Absent = no limit. See {@link ResponseLimit}. */
202
234
  responseLimit?: ResponseLimit;
235
+ /** Per-form submission-trust policy. Absent = accept everything (today's
236
+ * behavior). See docs/roadmap/07-submission-trust.md. */
237
+ trust?: TrustPolicy;
203
238
  /** Notify this address on every submission. */
204
239
  notifyEmail?: string;
205
240
  /** Send respondents a receipt (to the first answered email field). */
@@ -351,6 +386,13 @@ interface UploadSession {
351
386
  file?: FileValue;
352
387
  }
353
388
 
389
+ /**
390
+ * All conditions must hold (AND); no conditions = true. THE shared evaluator —
391
+ * both block visibility (`visibleIf`) and page jumps (`JumpRule.when`) run
392
+ * through this over the SAME whole-form fixpoint resolver, so a jump gated by a
393
+ * logic-hidden controller field behaves identically to a visibility rule.
394
+ */
395
+ declare function conditionsMet(conds: Condition[], resolve: (fieldId: string) => FieldValue): boolean;
354
396
  /** All conditions must hold (AND). No conditions = visible. */
355
397
  declare function isBlockVisible(block: Block, data: ResponseData): boolean;
356
398
  declare function visibleBlocks(page: FormPage, data: ResponseData): Block[];
@@ -378,8 +420,57 @@ declare function allFields(form: FormSchema): Field[];
378
420
  * per-visitor key and the server's per-person dedup so both scope identically.
379
421
  */
380
422
  declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
381
- /** Fields currently visible given the response data — the set that gets validated. */
423
+ /** Fields currently visible given the response data. */
382
424
  declare function visibleFields(form: FormSchema, data: ResponseData): Field[];
425
+ /** Where navigation goes when leaving a page. */
426
+ type NextPage = {
427
+ to: string;
428
+ } | {
429
+ end: true;
430
+ } | {
431
+ linear: true;
432
+ };
433
+ /**
434
+ * Evaluate a page's `next` jump rules top-to-bottom against the current data;
435
+ * the first whose conditions all match decides the step ("end" → finish the
436
+ * form). No rule (or no `next`) → the default linear next page. THE single
437
+ * function the client renderer, the server validator, and the funnel share so
438
+ * they always agree on flow.
439
+ */
440
+ declare function resolveNextPage(form: FormSchema, currentPageId: string, data: ResponseData): NextPage;
441
+ /**
442
+ * The ORDERED list of reachable page ids for the given data: walk from
443
+ * `pages[0]` following `resolveNextPage` (linear → next index; jump → target id;
444
+ * end → stop). A rule that points backward could loop, so stop on the first
445
+ * revisit and cap the walk at `pages.length` steps. THE single ordered engine
446
+ * the client renderer, the server validator, and navigation all agree through —
447
+ * a no-jump form yields `[pages[0].id, …, pages[N].id]`, so everything built on
448
+ * it reduces to today's linear behavior. {@link reachablePageIds} is the set of
449
+ * this exact walk (one walk, no divergence).
450
+ */
451
+ declare function reachablePageSequence(form: FormSchema, data: ResponseData): string[];
452
+ /**
453
+ * The page ids reachable for the given data. The unordered set of
454
+ * {@link reachablePageSequence} — one shared walk keeps the ordered navigation
455
+ * and the reachability the validator uses provably in agreement. THE function
456
+ * the client renderer and server validator both use to decide which pages/fields
457
+ * are in play.
458
+ */
459
+ declare function reachablePageIds(form: FormSchema, data: ResponseData): Set<string>;
460
+ /**
461
+ * Whether `pageId` is terminal for the given data — pressing the footer button
462
+ * there submits rather than advancing. True when a matched jump rule resolves to
463
+ * "end", or when the page is the LAST element of the reachable sequence (the
464
+ * last reachable page, including a cycle broken at its revisit, so a backward
465
+ * jump can never loop — the pre-revisit page becomes terminal and Submit
466
+ * appears). For a no-jump form this is exactly "the last page".
467
+ */
468
+ declare function isTerminalPage(form: FormSchema, pageId: string, data: ResponseData): boolean;
469
+ /** Reachable field ids: fields on reachable pages, intersected with visibility. */
470
+ declare function reachableFieldIds(form: FormSchema, data: ResponseData): Set<string>;
471
+ /** Fields that are both reachable AND visible — the set that gets validated and
472
+ * kept on submit. Equals {@link visibleFields} for a form with no jumps. */
473
+ declare function reachableFields(form: FormSchema, data: ResponseData): Field[];
383
474
 
384
475
  /** Validate a single answered value for a field. Returns an error message or null. */
385
476
  declare function validateField(field: Field, value: FieldValue): string | null;
@@ -387,12 +478,18 @@ interface ValidationResult {
387
478
  ok: boolean;
388
479
  /** fieldId -> message for every failing field. */
389
480
  errors: Record<string, string>;
390
- /** Data trimmed to the fields that are visible (hidden answers are dropped). */
481
+ /** Data trimmed to the fields that are reachable + visible (answers to fields
482
+ * hidden by logic OR on pages skipped by a jump/early-end are dropped). */
391
483
  data: ResponseData;
392
484
  }
393
485
  /**
394
- * Validate a full submission against the schema. Only currently-visible fields
395
- * are validated and kept — answers to fields hidden by logic are discarded.
486
+ * Validate a full submission against the schema. Only reachable + currently-
487
+ * visible fields are validated and kept — answers to fields hidden by logic, or
488
+ * on pages a page-jump/early-end skipped, are discarded. A legitimately
489
+ * early-ended submission therefore does NOT 422 on a skipped page's required
490
+ * field. Reachability is computed by the SAME shared engine the client renderer
491
+ * navigates with, so render and validate always agree. A form with no jumps has
492
+ * every page reachable, so this validates exactly as before.
396
493
  */
397
494
  declare function validateResponse(form: FormSchema, data: ResponseData): ValidationResult;
398
495
 
@@ -409,6 +506,14 @@ declare const FILLO_SDK_VERSION: string;
409
506
  * breaks are gated separately by FILLO_SCHEMA_VERSION, so this floor stays low.
410
507
  */
411
508
  declare const FILLO_MIN_SDK_VERSION = "0.4.0";
509
+ /**
510
+ * The floor served INSTEAD of FILLO_MIN_SDK_VERSION for challenge-enabled
511
+ * forms: the first release that ships the Turnstile widget. An older SDK passes
512
+ * the base floor but renders no widget, so the server would reject its every
513
+ * submit — the raised floor makes it fail fast with the clear "update
514
+ * @usefillo/*" error instead of a form that silently can't submit.
515
+ */
516
+ declare const FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
412
517
  declare function normalizeSettings(value: unknown): FormSettings;
413
518
  interface SchemaValidationResult {
414
519
  ok: boolean;
@@ -578,6 +683,18 @@ interface FilloClientOptions {
578
683
  baseUrl?: string;
579
684
  fetch?: typeof fetch;
580
685
  }
686
+ /**
687
+ * Public human-verification challenge config the SDK needs to render a widget.
688
+ * Delivered as a TOP-LEVEL field on the form GET (never inside the schema): the
689
+ * schema's trust policy is server-only and stripped, but the widget needs the
690
+ * PUBLIC site key. Injected server-side from Fillo's env — the SECRET key never
691
+ * leaves the server. Absent = no challenge (render nothing, load no script).
692
+ */
693
+ interface ChallengeConfig {
694
+ provider: "turnstile";
695
+ /** Cloudflare Turnstile PUBLIC site key. Safe to ship to the browser. */
696
+ siteKey: string;
697
+ }
581
698
  interface PublishedForm {
582
699
  id: string;
583
700
  slug: string;
@@ -591,8 +708,28 @@ interface PublishedForm {
591
708
  theme: FormTheme | null;
592
709
  /** True when the server says the form cannot accept responses. */
593
710
  closed?: boolean;
711
+ /**
712
+ * Whether a submission would be accepted right now. Absent on older servers
713
+ * — renderers must then keep today's behavior. When false, the default
714
+ * renderers show the not-open overlay instead of a fillable form.
715
+ */
716
+ accepting?: boolean;
717
+ /**
718
+ * Companion to `accepting` — present only when it is false.
719
+ * `draft`: not published yet; `expired`/`capped`: the unclaimed preview
720
+ * workspace hit its time window or response cap. Storage reasons may be
721
+ * returned for drafts or by older servers; published upload readiness is
722
+ * represented independently by `uploadsAvailable`.
723
+ */
724
+ acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
725
+ /** Whether new file uploads can start right now. Absent on older servers is
726
+ * treated as available; this never changes whether ordinary answers submit. */
727
+ uploadsAvailable?: boolean;
594
728
  /** Workspace branding state — absent means show the badge (default). */
595
729
  branding?: FormBranding;
730
+ /** Human-verification challenge to render before submit, when the form
731
+ * requires one. Absent = no challenge. Carries only the PUBLIC site key. */
732
+ challenge?: ChallengeConfig;
596
733
  }
597
734
  type SubmitResult = {
598
735
  ok: true;
@@ -638,6 +775,13 @@ interface SubmitMeta {
638
775
  * stable user/account id.
639
776
  */
640
777
  respondent?: FilloRespondent;
778
+ /**
779
+ * Human-verification challenge token (e.g. from the Cloudflare Turnstile
780
+ * widget). Present only when the form requires a challenge; the server
781
+ * verifies it and rejects the submit if it is missing or invalid. Never a
782
+ * secret — it is a single-use, server-verifiable proof of the widget solve.
783
+ */
784
+ challengeToken?: string;
641
785
  }
642
786
  /**
643
787
  * The host app's account context for the person filling the form. Passed as
@@ -726,10 +870,32 @@ interface SyncFormResult {
726
870
  formId: string;
727
871
  slug: string;
728
872
  branding?: FormBranding;
873
+ /** Human-verification challenge to render before submit, when the LIVE form
874
+ * requires one (staged changes don't gate until published). Absent = no
875
+ * challenge. Carries only the PUBLIC site key. */
876
+ challenge?: ChallengeConfig;
729
877
  /** Lifecycle on newer servers: a draft can't accept public responses yet. */
730
878
  status?: "draft" | "published";
731
879
  /** Changes were staged as a draft for a human to publish. */
732
880
  staged?: boolean;
881
+ /**
882
+ * Whether a submission would be accepted right now. Absent on older servers
883
+ * — renderers must then keep today's behavior. When false, the default
884
+ * renderers show the not-open overlay instead of a fillable form.
885
+ */
886
+ accepting?: boolean;
887
+ /**
888
+ * Companion to `accepting` — present only when it is false.
889
+ * `draft`: not published yet; `expired`/`capped`: the unclaimed preview
890
+ * workspace hit its time window or response cap; `storage_required`: the
891
+ * form needs a connected storage destination before it can go live;
892
+ * `storage_full`: Fillo's temporary upload allowance is exhausted.
893
+ */
894
+ acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
895
+ /** Whether new file uploads can start right now. This is independent from
896
+ * response acceptance so a completed file can still be submitted after the
897
+ * workspace reaches its upload cap. Absent on older servers means available. */
898
+ uploadsAvailable?: boolean;
733
899
  /**
734
900
  * Server-authoritative live snapshot. Present when the incoming code schema
735
901
  * is not the version respondents may submit against yet.
@@ -741,7 +907,23 @@ interface SyncFormResult {
741
907
  code: string;
742
908
  message: string;
743
909
  };
910
+ /**
911
+ * Human-readable storage heads-up for the form owner. This can be advisory
912
+ * while uploads and responses remain available; never use it to gate UI.
913
+ */
744
914
  warning?: string;
915
+ /**
916
+ * Machine-readable owner advisory for `warning`. Hard unavailability uses
917
+ * `"storage_required"`; transit threshold advisories use distinct codes.
918
+ * Point the human at `warningUrl`; use `uploadsAvailable`, not this field,
919
+ * to gate new file controls.
920
+ */
921
+ warningCode?: string;
922
+ /**
923
+ * Absolute dashboard URL where a human connects a storage destination.
924
+ * Present whenever `warningCode` is.
925
+ */
926
+ warningUrl?: string;
745
927
  }
746
928
  declare class FilloClient {
747
929
  /** Server origin this client targets, normalized (no trailing slash). */
@@ -939,6 +1121,15 @@ interface FormControllerOptions {
939
1121
  * runs; failure sets `submitError` and the respondent can retry.
940
1122
  */
941
1123
  resolveFormId?: () => Promise<string>;
1124
+ /**
1125
+ * Surface the REAL {@link resolveFormId} failure (message + machine code)
1126
+ * in `submitError` instead of the respondent-safe "This form is
1127
+ * unavailable." fallback. Dev chrome only: renderers set it from the same
1128
+ * gate as their other developer surfaces (preview prop / dev environment),
1129
+ * so production visitors never see integration details such as keys,
1130
+ * origins, or deployment commands.
1131
+ */
1132
+ verboseResolutionErrors?: boolean;
942
1133
  /**
943
1134
  * Host-app account context (identify()): who is filling this form, by your
944
1135
  * own user id. Sent with the submission and recorded as an unverified
@@ -946,6 +1137,24 @@ interface FormControllerOptions {
946
1137
  * the respondent can do.
947
1138
  */
948
1139
  respondent?: FilloRespondent;
1140
+ /**
1141
+ * True when the form requires a human-verification challenge (Turnstile).
1142
+ * When set, submit refuses to send until a token is available and always
1143
+ * attaches the token from {@link getChallengeToken}. The server is the real
1144
+ * gate — this only avoids firing a submit the server would reject.
1145
+ */
1146
+ challengeRequired?: boolean;
1147
+ /**
1148
+ * Read the current challenge token (from the rendered widget) at submit time.
1149
+ * Returns undefined until the challenge is solved. Read lazily so an expired
1150
+ * token that was refreshed just before submit is picked up fresh.
1151
+ */
1152
+ getChallengeToken?: () => string | undefined;
1153
+ /**
1154
+ * The server rejected the submission's challenge (stale/replayed/invalid
1155
+ * token). The renderer resets the widget so the human can solve a fresh one.
1156
+ */
1157
+ onChallengeFailed?: () => void;
949
1158
  }
950
1159
  interface FormControllerState {
951
1160
  data: ResponseData;
@@ -1064,6 +1273,8 @@ interface AutoSubmitContext {
1064
1273
  form: FormSchema;
1065
1274
  data: ResponseData;
1066
1275
  status: FormStatus;
1276
+ /** Terminal-aware last-page flag from the controller. Advisory here —
1277
+ * shouldAutoSubmit re-derives terminal from the RESULTING data (see below). */
1067
1278
  isLastPage: boolean;
1068
1279
  uploading: boolean;
1069
1280
  }
@@ -1075,7 +1286,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
1075
1286
  * (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
1076
1287
  * names are public API — renames are breaking; additions are minors.
1077
1288
  */
1078
- 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"];
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"];
1079
1290
  type FilloSlot = (typeof FILLO_SLOTS)[number];
1080
1291
  /** data-* names emitted alongside the stable fillo-* classes. */
1081
1292
  declare const FILLO_DATA_ATTRS: {
@@ -1199,6 +1410,14 @@ interface FilloStrings {
1199
1410
  successMessage: string;
1200
1411
  closed: string;
1201
1412
  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). */
1415
+ notOpenTitle: string;
1416
+ /** Body of the not-open overlay card. */
1417
+ notOpenBody: string;
1418
+ /** Title of the closed-flavor overlay card (expired/capped workspace);
1419
+ * the body reuses `closed`. */
1420
+ closedTitle: string;
1202
1421
  /** Fallback when a submit fails without a server message. */
1203
1422
  submitFailed: string;
1204
1423
  loadFailedNotFound: string;
@@ -1211,6 +1430,8 @@ interface FilloStrings {
1211
1430
  editNotice: string;
1212
1431
  /** The discard action next to the resume notice. */
1213
1432
  resumeStartOver: string;
1433
+ /** Shown when the human-verification widget can't load (script blocked). */
1434
+ challengeUnavailable: string;
1214
1435
  }
1215
1436
  declare const DEFAULT_STRINGS: FilloStrings;
1216
1437
  /**
@@ -1231,6 +1452,8 @@ interface FilloFieldStrings {
1231
1452
  uploadRetry: string;
1232
1453
  /** Dropzone copy when uploads can't run (no client / preview). */
1233
1454
  uploadsDisabled: string;
1455
+ /** Dropzone copy when the server temporarily refuses new file sessions. */
1456
+ uploadsUnavailable: string;
1234
1457
  /** An upload attempt failed with no actionable server message. */
1235
1458
  uploadFailed: string;
1236
1459
  /** Dropzone call to action; `multiple` is true when several files are allowed. */
@@ -1258,10 +1481,28 @@ interface SyncedForm {
1258
1481
  formId: string;
1259
1482
  slug: string;
1260
1483
  branding?: FormBranding;
1484
+ /** Human-verification challenge to render before submit, when the LIVE form
1485
+ * requires one (staged changes don't gate until published). Absent = no
1486
+ * challenge. Carries only the PUBLIC site key. */
1487
+ challenge?: ChallengeConfig;
1261
1488
  /** Lifecycle on newer servers: a draft can't accept public responses yet. */
1262
1489
  status?: "draft" | "published";
1263
1490
  /** Changes were staged as a draft for a human to publish. */
1264
1491
  staged?: boolean;
1492
+ /** Whether a submission would be accepted right now. Absent on older
1493
+ * servers — renderers must then keep the status-based behavior. */
1494
+ accepting?: boolean;
1495
+ /**
1496
+ * Companion to `accepting` — present only when it is false. `draft`: not
1497
+ * published yet; `expired`/`capped`: the unclaimed preview workspace hit its
1498
+ * time window or response cap; `storage_required`: the form needs a
1499
+ * connected storage destination before it can go live; `storage_full`:
1500
+ * Fillo's temporary upload allowance is exhausted.
1501
+ */
1502
+ acceptingReason?: "draft" | "expired" | "capped" | "storage_required" | "storage_full";
1503
+ /** Whether new file uploads can start right now. Independent from response
1504
+ * acceptance; absent on older servers means available. */
1505
+ uploadsAvailable?: boolean;
1265
1506
  /** Server-authoritative live snapshot when local code is not live yet. */
1266
1507
  resolvedSchema?: FormSchema;
1267
1508
  resolvedTheme?: FormTheme | null;
@@ -1271,6 +1512,15 @@ interface SyncedForm {
1271
1512
  message: string;
1272
1513
  };
1273
1514
  warning?: string;
1515
+ /**
1516
+ * Machine-readable owner advisory for `warning`. Hard unavailability uses
1517
+ * `"storage_required"`; advisory thresholds use distinct codes. Use
1518
+ * `uploadsAvailable`, not this field, to decide whether an upload may start.
1519
+ */
1520
+ warningCode?: string;
1521
+ /** Absolute dashboard URL where a human connects a storage destination.
1522
+ * Present whenever `warningCode` is. */
1523
+ warningUrl?: string;
1274
1524
  }
1275
1525
  /**
1276
1526
  * A form whose structure lives in user code. Development and explicit
@@ -1386,6 +1636,37 @@ interface WhenBuilder {
1386
1636
  }
1387
1637
  declare function when(fieldId: string): WhenBuilder;
1388
1638
 
1639
+ /**
1640
+ * The build-time half of the dev check: `NODE_ENV` alone, no hostname
1641
+ * inspection. It evaluates identically on the server and in the browser for
1642
+ * the same bundle, which is what SSR hydration needs — the server has no
1643
+ * `window`, so a hostname-aware check would disagree with the client's first
1644
+ * paint. Renderers use this as the server/hydration snapshot and upgrade to
1645
+ * {@link isLikelyDevEnv} after hydration.
1646
+ */
1647
+ declare function isBuildTimeDevEnv(): boolean;
1648
+ /**
1649
+ * Whether this runtime looks like local development, so renderers can show
1650
+ * actionable dev surfaces (draft banner, missing-client warning, local schema
1651
+ * render) instead of the deliberately quiet production states.
1652
+ *
1653
+ * The build-time `NODE_ENV` signal alone misses real local setups: the
1654
+ * standalone `<script>` bundle has no `process` at all (so it always read as
1655
+ * production), `vite preview` / `next start` serve a production build on
1656
+ * localhost, and some bundlers never define `NODE_ENV`. So a browser whose
1657
+ * hostname is localhost/loopback also counts as development. Real
1658
+ * deployments keep production semantics because they serve from real
1659
+ * hostnames — see {@link isLocalHostname} for why mDNS `*.local` names and
1660
+ * private-LAN addresses are deliberately excluded.
1661
+ *
1662
+ * SSR-safe to CALL (with no `window`, only the `NODE_ENV` check applies), but
1663
+ * NOT hydration-safe for render output: the server pass can't see the page
1664
+ * hostname, so under `next start` on localhost it disagrees with the client.
1665
+ * Render paths should hydrate from {@link isBuildTimeDevEnv} and upgrade to
1666
+ * this check after hydration (see the React renderer's useIsDevEnv()).
1667
+ */
1668
+ declare function isLikelyDevEnv(): boolean;
1669
+
1389
1670
  /**
1390
1671
  * Build initial response data from URL query parameters — Tally-style
1391
1672
  * prefilling. Hidden fields read their configured paramName; every other
@@ -1430,4 +1711,4 @@ declare class Sha1 {
1430
1711
  }
1431
1712
  declare const sha1Base64: (bytes: Uint8Array) => string;
1432
1713
 
1433
- export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, 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_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 LinearScaleField, type MatrixField, 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 UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formSchemasEqual, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, positionPhonePopover, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, visiblePageBlocks, when };
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 };
package/dist/index.js CHANGED
@@ -54,9 +54,13 @@ function evalCondition(cond, resolve) {
54
54
  }
55
55
  }
56
56
  }
57
+ function conditionsMet(conds, resolve) {
58
+ if (conds.length === 0) return true;
59
+ return conds.every((cond) => evalCondition(cond, resolve));
60
+ }
57
61
  function blockVisibleWith(block, resolve) {
58
62
  if (!block.visibleIf || block.visibleIf.length === 0) return true;
59
- return block.visibleIf.every((cond) => evalCondition(cond, resolve));
63
+ return conditionsMet(block.visibleIf, resolve);
60
64
  }
61
65
  function makeResolver(data, scoped, visible) {
62
66
  return (fieldId) => scoped.has(fieldId) && !visible.has(fieldId) ? void 0 : data[fieldId];
@@ -108,6 +112,70 @@ function visibleFields(form, data) {
108
112
  const visible = visibleFieldIds(fields, data);
109
113
  return fields.filter((f) => visible.has(f.id));
110
114
  }
115
+ function formResolver(form, data) {
116
+ const fields = allFields(form);
117
+ const scoped = new Set(fields.map((f) => f.id));
118
+ return makeResolver(data, scoped, visibleFieldIds(fields, data));
119
+ }
120
+ function resolveNextPage(form, currentPageId, data) {
121
+ const page = form.pages.find((p) => p.id === currentPageId);
122
+ const rules = page?.next;
123
+ if (!rules || rules.length === 0) return { linear: true };
124
+ const resolve = formResolver(form, data);
125
+ for (const rule of rules) {
126
+ if (conditionsMet(rule.when, resolve)) {
127
+ return rule.to === "end" ? { end: true } : { to: rule.to };
128
+ }
129
+ }
130
+ return { linear: true };
131
+ }
132
+ function reachablePageSequence(form, data) {
133
+ const seq = [];
134
+ if (form.pages.length === 0) return seq;
135
+ const seen = /* @__PURE__ */ new Set();
136
+ let index = 0;
137
+ for (let steps = 0; steps <= form.pages.length; steps++) {
138
+ const page = form.pages[index];
139
+ if (!page || seen.has(page.id)) break;
140
+ seen.add(page.id);
141
+ seq.push(page.id);
142
+ const nav = resolveNextPage(form, page.id, data);
143
+ if ("end" in nav) break;
144
+ if ("to" in nav) {
145
+ const target = form.pages.findIndex((p) => p.id === nav.to);
146
+ if (target < 0) break;
147
+ index = target;
148
+ } else {
149
+ index += 1;
150
+ if (index >= form.pages.length) break;
151
+ }
152
+ }
153
+ return seq;
154
+ }
155
+ function reachablePageIds(form, data) {
156
+ return new Set(reachablePageSequence(form, data));
157
+ }
158
+ function isTerminalPage(form, pageId, data) {
159
+ if ("end" in resolveNextPage(form, pageId, data)) return true;
160
+ const seq = reachablePageSequence(form, data);
161
+ return seq.length > 0 && seq[seq.length - 1] === pageId;
162
+ }
163
+ function reachableFieldIds(form, data) {
164
+ const reachablePages = reachablePageIds(form, data);
165
+ const visible = visibleFieldIds(allFields(form), data);
166
+ const ids = /* @__PURE__ */ new Set();
167
+ for (const page of form.pages) {
168
+ if (!reachablePages.has(page.id)) continue;
169
+ for (const block of page.blocks) {
170
+ if (isField(block) && visible.has(block.id)) ids.add(block.id);
171
+ }
172
+ }
173
+ return ids;
174
+ }
175
+ function reachableFields(form, data) {
176
+ const ids = reachableFieldIds(form, data);
177
+ return allFields(form).filter((f) => ids.has(f.id));
178
+ }
111
179
 
112
180
  // src/validation.ts
113
181
  import * as z from "zod/mini";
@@ -433,6 +501,9 @@ var DEFAULT_STRINGS = {
433
501
  successMessage: "Your response has been recorded.",
434
502
  closed: "This form is no longer accepting responses.",
435
503
  notLive: "This form isn't live yet.",
504
+ notOpenTitle: "Not open just yet",
505
+ notOpenBody: "This form is still being set up. Check back soon.",
506
+ closedTitle: "Responses are closed",
436
507
  submitFailed: "This form can't submit right now. Please try again in a moment.",
437
508
  loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
438
509
  loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
@@ -440,7 +511,8 @@ var DEFAULT_STRINGS = {
440
511
  renderFailed: "This form could not be rendered.",
441
512
  resumeNotice: "Picked up where you left off.",
442
513
  editNotice: "You're updating your earlier response.",
443
- resumeStartOver: "Start over"
514
+ resumeStartOver: "Start over",
515
+ challengeUnavailable: "The verification check couldn't load. Refresh the page, or check that challenges.cloudflare.com isn't blocked."
444
516
  };
445
517
  var DEFAULT_FIELD_STRINGS = {
446
518
  required: REQUIRED_FIELD_MESSAGE,
@@ -448,6 +520,7 @@ var DEFAULT_FIELD_STRINGS = {
448
520
  alreadyAnswered: "You've already answered this form.",
449
521
  uploadRetry: "Retry",
450
522
  uploadsDisabled: "Uploads are disabled in preview",
523
+ uploadsUnavailable: "Uploads are temporarily unavailable",
451
524
  uploadFailed: "Upload failed \u2014 try again",
452
525
  dropzoneTitle: (multiple) => `Drop ${multiple ? "files" : "a file"} here or click to browse`,
453
526
  dropzoneHint: (maxMb) => `Up to ${maxMb} MB per file`,
@@ -617,7 +690,7 @@ function validateField(field2, value) {
617
690
  }
618
691
  }
619
692
  function validateResponse(form, data) {
620
- const fields = visibleFields(form, data);
693
+ const fields = reachableFields(form, data);
621
694
  const errors = {};
622
695
  const cleaned = {};
623
696
  for (const field2 of fields) {
@@ -688,7 +761,11 @@ var blockSchema = z2.looseObject({
688
761
  var pageSchema = z2.object({
689
762
  id: idSchema,
690
763
  title: z2.optional(z2.string().check(z2.maxLength(500))),
691
- blocks: z2.array(blockSchema).check(z2.maxLength(500))
764
+ blocks: z2.array(blockSchema).check(z2.maxLength(500)),
765
+ // Optional conditional page flow. Kept as unknown here (this schema is strict,
766
+ // so it would otherwise be dropped at parse) and normalized by hand below once
767
+ // the page-id set is known — a jump to a missing page is dropped, never kept.
768
+ next: z2.optional(z2.unknown())
692
769
  });
693
770
  var schemaShape = z2.object({
694
771
  version: z2.literal(1),
@@ -699,8 +776,9 @@ var schemaShape = z2.object({
699
776
  });
700
777
  var MAX_SCHEMA_VERSION = 1;
701
778
  var FILLO_SCHEMA_VERSION = 1;
702
- var FILLO_SDK_VERSION = true ? "0.8.0" : "0.0.0-dev";
779
+ var FILLO_SDK_VERSION = true ? "0.10.0" : "0.0.0-dev";
703
780
  var FILLO_MIN_SDK_VERSION = "0.4.0";
781
+ var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
704
782
  function str(value, max, fallback = "") {
705
783
  return typeof value === "string" ? value.trim().slice(0, max) : fallback;
706
784
  }
@@ -755,6 +833,23 @@ function conditions(value) {
755
833
  });
756
834
  return normalized.length ? normalized : void 0;
757
835
  }
836
+ function jumpRules(value, pageIds, sourceId, allowedFieldIds) {
837
+ if (!Array.isArray(value)) return void 0;
838
+ const rules = value.slice(0, 50).flatMap((item) => {
839
+ if (!item || typeof item !== "object") return [];
840
+ const rec = item;
841
+ const to = typeof rec.to === "string" ? rec.to : void 0;
842
+ if (!to || to !== "end" && !pageIds.has(to)) return [];
843
+ if (to === sourceId) return [];
844
+ const rawWhen = rec.when;
845
+ if (rawWhen !== void 0 && !Array.isArray(rawWhen)) return [];
846
+ const when2 = conditions(rawWhen);
847
+ if (Array.isArray(rawWhen) && rawWhen.length > 0 && when2 === void 0) return [];
848
+ if (when2 && when2.some((c) => !allowedFieldIds.has(c.fieldId))) return [];
849
+ return [{ when: when2 ?? [], to }];
850
+ });
851
+ return rules.length ? rules : void 0;
852
+ }
758
853
  function baseBlock(rec) {
759
854
  return {
760
855
  id: str(rec.id, 128),
@@ -798,6 +893,17 @@ function normalizeResponseLimit(value) {
798
893
  onRepeat
799
894
  };
800
895
  }
896
+ function normalizeTrust(value) {
897
+ if (!value || typeof value !== "object") return void 0;
898
+ const rec = value;
899
+ const unverified = rec.unverified === "allow" || rec.unverified === "quarantine" ? rec.unverified : void 0;
900
+ const challenge = rec.challenge === "turnstile" ? "turnstile" : void 0;
901
+ if (!unverified && !challenge) return void 0;
902
+ return {
903
+ ...unverified ? { unverified } : {},
904
+ ...challenge ? { challenge } : {}
905
+ };
906
+ }
801
907
  function normalizeSettings(value) {
802
908
  const rec = value && typeof value === "object" ? value : {};
803
909
  const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
@@ -809,6 +915,7 @@ function normalizeSettings(value) {
809
915
  redirectUrl: normalizeUrl(rec.redirectUrl),
810
916
  showProgress: bool(rec.showProgress),
811
917
  responseLimit: normalizeResponseLimit(rec.responseLimit),
918
+ trust: normalizeTrust(rec.trust),
812
919
  notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
813
920
  sendReceipt: bool(rec.sendReceipt),
814
921
  saveProgress: bool(rec.saveProgress),
@@ -948,15 +1055,24 @@ function normalizeFormSchema(input) {
948
1055
  if (parsed.data.version > MAX_SCHEMA_VERSION) {
949
1056
  return { ok: false, error: `Unsupported schema version: ${parsed.data.version}` };
950
1057
  }
951
- const pages = parsed.data.pages.map((page) => {
1058
+ const rebuilt = parsed.data.pages.map((page) => {
952
1059
  const blocks = page.blocks.flatMap((raw) => {
953
1060
  const block = normalizeBlock(raw);
954
1061
  return block ? [block] : [];
955
1062
  });
1063
+ return { id: str(page.id, 128), title: optionalStr(page.title, 500), blocks, next: page.next };
1064
+ });
1065
+ const targetIds = new Set(rebuilt.map((p) => p.id));
1066
+ const priorFieldIds = /* @__PURE__ */ new Set();
1067
+ const pages = rebuilt.map((page) => {
1068
+ for (const block of page.blocks) {
1069
+ if (isField(block)) priorFieldIds.add(block.id);
1070
+ }
956
1071
  return cleanObject({
957
- id: str(page.id, 128),
958
- title: optionalStr(page.title, 500),
959
- blocks
1072
+ id: page.id,
1073
+ title: page.title,
1074
+ blocks: page.blocks,
1075
+ next: jumpRules(page.next, targetIds, page.id, new Set(priorFieldIds))
960
1076
  });
961
1077
  });
962
1078
  const pageIds = /* @__PURE__ */ new Set();
@@ -2353,13 +2469,18 @@ function isDraftGone(err) {
2353
2469
  return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
2354
2470
  }
2355
2471
  var DRAFT_DEBOUNCE_MS = 1500;
2472
+ var CHALLENGE_INCOMPLETE_MESSAGE = "Please complete the verification check, then submit.";
2473
+ var CHALLENGE_RETRY_MESSAGE = "That verification didn't go through. Please complete the check again and resubmit.";
2356
2474
  function submitFailureMessage(err) {
2357
2475
  if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
2358
2476
  return "Couldn't reach the server \u2014 check your connection and try again. If this keeps happening, a browser extension, firewall, or the site's Content-Security-Policy may be blocking the request.";
2359
2477
  }
2360
- function syncResolutionFailureMessage(err) {
2478
+ function syncResolutionFailureMessage(err, verbose) {
2361
2479
  if (isFilloError(err)) {
2362
2480
  const status = err.status ?? 0;
2481
+ if (verbose && status > 0 && err.message) {
2482
+ return err.code ? `${err.message} (${err.code})` : err.message;
2483
+ }
2363
2484
  const definitive = status > 0 && status < 500 && status !== 408 && status !== 429;
2364
2485
  if (definitive) return "This form is unavailable.";
2365
2486
  }
@@ -2458,7 +2579,10 @@ function createFormController(options2) {
2458
2579
  // keeps it — never a field whose answer submit would silently drop.
2459
2580
  blocks: visiblePageBlocks(form, page, data),
2460
2581
  isFirstPage: clamped === 0,
2461
- isLastPage: clamped === pageCount - 1,
2582
+ // Terminal-aware: true when the current page is the last reachable page OR
2583
+ // a matched jump rule ends the form here — so the footer reads Submit and
2584
+ // its handler submits. Same shared engine the validator reaches with.
2585
+ isLastPage: isTerminalPage(form, page.id, data),
2462
2586
  uploading: uploadingFields.size > 0,
2463
2587
  submitError,
2464
2588
  restoredSubmission,
@@ -2659,6 +2783,20 @@ function createFormController(options2) {
2659
2783
  else uploadingFields.delete(fieldId);
2660
2784
  notify();
2661
2785
  }
2786
+ function currentSeqPosition(seq) {
2787
+ if (seq.length === 0) return -1;
2788
+ const pageCount = form.pages.length;
2789
+ const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
2790
+ const currentId = form.pages[clamped]?.id;
2791
+ const exact = currentId === void 0 ? -1 : seq.indexOf(currentId);
2792
+ if (exact >= 0) return exact;
2793
+ let fallback = 0;
2794
+ for (let i = 0; i < seq.length; i++) {
2795
+ const idx = form.pages.findIndex((p) => p.id === seq[i]);
2796
+ if (idx >= 0 && idx <= clamped) fallback = i;
2797
+ }
2798
+ return fallback;
2799
+ }
2662
2800
  function next() {
2663
2801
  const pageCount = form.pages.length;
2664
2802
  const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
@@ -2670,14 +2808,31 @@ function createFormController(options2) {
2670
2808
  return;
2671
2809
  }
2672
2810
  }
2673
- pageIndex = Math.min(clamped + 1, pageCount - 1);
2811
+ const current = form.pages[clamped];
2812
+ const seq = reachablePageSequence(form, data);
2813
+ const pos = currentSeqPosition(seq);
2814
+ const terminal = (current ? isTerminalPage(form, current.id, data) : true) || pos < 0 || pos + 1 >= seq.length;
2815
+ if (terminal) {
2816
+ void submit();
2817
+ return;
2818
+ }
2819
+ const nextIndex = form.pages.findIndex((p) => p.id === seq[pos + 1]);
2820
+ pageIndex = nextIndex >= 0 ? nextIndex : Math.min(clamped + 1, pageCount - 1);
2674
2821
  if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
2675
2822
  notify();
2676
2823
  checkpointDraft();
2677
2824
  }
2678
2825
  function back() {
2679
2826
  errors = {};
2680
- pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2827
+ const seq = reachablePageSequence(form, data);
2828
+ const pos = currentSeqPosition(seq);
2829
+ if (pos > 0) {
2830
+ const prevIndex = form.pages.findIndex((p) => p.id === seq[pos - 1]);
2831
+ pageIndex = prevIndex >= 0 ? prevIndex : Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
2832
+ } else if (seq.length > 0) {
2833
+ const firstIndex = form.pages.findIndex((p) => p.id === seq[0]);
2834
+ pageIndex = firstIndex >= 0 ? firstIndex : 0;
2835
+ }
2681
2836
  notify();
2682
2837
  checkpointDraft();
2683
2838
  }
@@ -2701,7 +2856,10 @@ function createFormController(options2) {
2701
2856
  formId = await options2.resolveFormId();
2702
2857
  } catch (err) {
2703
2858
  status = "idle";
2704
- submitError = syncResolutionFailureMessage(err);
2859
+ submitError = syncResolutionFailureMessage(
2860
+ err,
2861
+ options2.verboseResolutionErrors === true
2862
+ );
2705
2863
  notify();
2706
2864
  throw err;
2707
2865
  }
@@ -2710,6 +2868,13 @@ function createFormController(options2) {
2710
2868
  const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
2711
2869
  const submissionKey = visitorKey ?? idempotencyKey;
2712
2870
  const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
2871
+ const challengeToken = options2.challengeRequired ? options2.getChallengeToken?.() : void 0;
2872
+ if (options2.challengeRequired && !challengeToken) {
2873
+ status = "idle";
2874
+ submitError = CHALLENGE_INCOMPLETE_MESSAGE;
2875
+ notify();
2876
+ return;
2877
+ }
2713
2878
  status = "submitting";
2714
2879
  submitError = void 0;
2715
2880
  notify();
@@ -2721,10 +2886,17 @@ function createFormController(options2) {
2721
2886
  surface: options2.surface ?? "headless",
2722
2887
  submissionKey,
2723
2888
  draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
2724
- respondent
2889
+ respondent,
2890
+ challengeToken
2725
2891
  });
2726
2892
  } catch (err) {
2727
2893
  status = "idle";
2894
+ if (isFilloError(err) && err.code === "challenge_failed") {
2895
+ submitError = CHALLENGE_RETRY_MESSAGE;
2896
+ options2.onChallengeFailed?.();
2897
+ notify();
2898
+ return;
2899
+ }
2728
2900
  submitError = submitFailureMessage(err);
2729
2901
  notify();
2730
2902
  throw err;
@@ -2833,7 +3005,7 @@ function needsExplicitSubmit(visible) {
2833
3005
  }
2834
3006
  function shouldAutoSubmit(field2, value, ctx) {
2835
3007
  if (ctx.form.settings.submitMode !== "auto") return false;
2836
- if (!ctx.isLastPage || ctx.status !== "idle" || ctx.uploading) return false;
3008
+ if (ctx.status !== "idle" || ctx.uploading) return false;
2837
3009
  const hasDiscreteAnswer = (() => {
2838
3010
  switch (field2.kind) {
2839
3011
  case "select":
@@ -2851,7 +3023,9 @@ function shouldAutoSubmit(field2, value, ctx) {
2851
3023
  if (!hasDiscreteAnswer) return false;
2852
3024
  if (value === ctx.data[field2.id]) return false;
2853
3025
  const nextData = { ...ctx.data, [field2.id]: value };
2854
- const interactive = visibleFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
3026
+ const page = ctx.form.pages.find((p) => p.blocks.some((b) => b.id === field2.id));
3027
+ if (!page || !isTerminalPage(ctx.form, page.id, nextData)) return false;
3028
+ const interactive = reachableFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
2855
3029
  if (interactive.length !== 1 || interactive[0]?.id !== field2.id) return false;
2856
3030
  return validateResponse(ctx.form, nextData).ok;
2857
3031
  }
@@ -2877,7 +3051,8 @@ var FILLO_SLOTS = [
2877
3051
  "footer",
2878
3052
  "button",
2879
3053
  "success",
2880
- "resume"
3054
+ "resume",
3055
+ "turnstile"
2881
3056
  ];
2882
3057
  var FILLO_DATA_ATTRS = {
2883
3058
  /** Slot name, on every slot element: `data-fillo="control"`. */
@@ -2983,9 +3158,9 @@ function formSchemasEqual(left, right) {
2983
3158
  return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
2984
3159
  }
2985
3160
  var syncTtlMs = (status) => status === "published" ? 36e5 : 6e4;
2986
- var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError);
3161
+ var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError || result.accepting === false);
2987
3162
  function storageKey(client, handle) {
2988
- return `fillo:sync:v2:${client.baseUrl}|${client.key}|${handle}`;
3163
+ return `fillo:sync:v3:${client.baseUrl}|${client.key}|${handle}`;
2989
3164
  }
2990
3165
  function readStoredSync(client, handle, hash) {
2991
3166
  try {
@@ -3002,7 +3177,8 @@ function readStoredSync(client, handle, hash) {
3002
3177
  }
3003
3178
  }
3004
3179
  function writeStoredSync(client, handle, hash, r) {
3005
- if (r.staged || r.resolvedSchema || r.syncError) return;
3180
+ if (r.staged || r.resolvedSchema || r.syncError || r.accepting === false) return;
3181
+ if (r.challenge) return;
3006
3182
  try {
3007
3183
  globalThis.localStorage?.setItem(
3008
3184
  storageKey(client, handle),
@@ -3382,6 +3558,23 @@ function when(fieldId) {
3382
3558
  };
3383
3559
  }
3384
3560
 
3561
+ // src/dev-env.ts
3562
+ function isLocalHostname(hostname) {
3563
+ const host = hostname.toLowerCase();
3564
+ return host === "localhost" || host.endsWith(".localhost") || // Loopback ADDRESSES only — anchored so `127.0.0.1.example.com` stays a
3565
+ // real (production) hostname.
3566
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || host === "::1" || host === "[::1]" || host === "0.0.0.0";
3567
+ }
3568
+ function isBuildTimeDevEnv() {
3569
+ return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
3570
+ }
3571
+ function isLikelyDevEnv() {
3572
+ if (isBuildTimeDevEnv()) return true;
3573
+ if (typeof window === "undefined") return false;
3574
+ const hostname = window.location?.hostname;
3575
+ return typeof hostname === "string" && isLocalHostname(hostname);
3576
+ }
3577
+
3385
3578
  // src/piping.ts
3386
3579
  var TOKEN = /\{\{\s*([\w-]+)\s*\}\}/g;
3387
3580
  function resolveText(text, data, form) {
@@ -3415,6 +3608,7 @@ export {
3415
3608
  DEFAULT_FIELD_STRINGS,
3416
3609
  DEFAULT_STRINGS,
3417
3610
  DRAFT_KINDS,
3611
+ FILLO_CHALLENGE_MIN_SDK_VERSION,
3418
3612
  FILLO_DATA_ATTRS,
3419
3613
  FILLO_MIN_SDK_VERSION,
3420
3614
  FILLO_SCHEMA_VERSION,
@@ -3433,6 +3627,7 @@ export {
3433
3627
  allFields,
3434
3628
  assembleForm,
3435
3629
  codeFormFromJsx,
3630
+ conditionsMet,
3436
3631
  contentHash,
3437
3632
  countryByDialCode,
3438
3633
  countryByIso,
@@ -3450,10 +3645,13 @@ export {
3450
3645
  formatNational,
3451
3646
  isAutoSubmitBlock,
3452
3647
  isBlockVisible,
3648
+ isBuildTimeDevEnv,
3453
3649
  isCodeForm,
3454
3650
  isField,
3455
3651
  isFilloError,
3652
+ isLikelyDevEnv,
3456
3653
  isPossiblePhone,
3654
+ isTerminalPage,
3457
3655
  needsExplicitSubmit,
3458
3656
  normalizeFormSchema,
3459
3657
  normalizeFormTheme,
@@ -3463,6 +3661,11 @@ export {
3463
3661
  positionPhonePopover,
3464
3662
  prefillFromParams,
3465
3663
  provisionWorkspace,
3664
+ reachableFieldIds,
3665
+ reachableFields,
3666
+ reachablePageIds,
3667
+ reachablePageSequence,
3668
+ resolveNextPage,
3466
3669
  resolveSlotClass,
3467
3670
  resolveStrings,
3468
3671
  resolveText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/core",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Form schema, validation, logic engine and JS client for Fillo. Framework-agnostic.",
5
5
  "license": "MIT",
6
6
  "keywords": [