@usefillo/core 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -67,6 +67,8 @@ interface RatingField extends BaseField {
67
67
  kind: "rating";
68
68
  /** Number of steps, default 5. */
69
69
  max?: number;
70
+ /** Optional analysis meaning. CSAT requires a 1–5 rating. */
71
+ insightsMetric?: "csat";
70
72
  }
71
73
  interface DateField extends BaseField {
72
74
  kind: "date";
@@ -79,6 +81,8 @@ interface LinearScaleField extends BaseField {
79
81
  max?: number;
80
82
  minLabel?: string;
81
83
  maxLabel?: string;
84
+ /** Optional analysis meaning. NPS requires 0–10; CSAT requires 1–5. */
85
+ insightsMetric?: "csat" | "nps";
82
86
  }
83
87
  interface RankingField extends BaseField {
84
88
  kind: "ranking";
@@ -148,6 +152,39 @@ interface FormPage {
148
152
  title?: string;
149
153
  blocks: Block[];
150
154
  }
155
+ /**
156
+ * Limit repeat responses. Absent = no limit (submit as often as you like).
157
+ */
158
+ interface ResponseLimit {
159
+ /**
160
+ * Who counts as the same responder:
161
+ * - "browser": same device, anonymous — the SDK remembers this browser
162
+ * answered and sends a de-duplication key (ratings, polls, "was this
163
+ * helpful").
164
+ * - "field": the person self-identifies by answering `field` (an email or
165
+ * phone field). A self-claim — unverified, soft dedup — so the hosted link
166
+ * and anonymous embeds can still dedupe.
167
+ * - "identify": the identify() respondent (HMAC-verifiable). The strong
168
+ * path; only embedded surfaces that call identify() are recognized.
169
+ */
170
+ by: "browser" | "field" | "identify";
171
+ /** The email/phone field whose answer identifies the person. `by: "field"` only. */
172
+ field?: string;
173
+ /**
174
+ * Optionally sub-scope the limit by a field's answer (usually a hidden
175
+ * article/product/order id): "one response per responder PER that value" —
176
+ * e.g. one 👍 per visitor per article from a single shared form.
177
+ */
178
+ scopeField?: string;
179
+ /**
180
+ * A repeat from the same responder: "keep" answers with the standing
181
+ * response (the first answer stands); "update" edits it in place (re-anchored
182
+ * to the current schema, `response.updated` webhook, previous answers
183
+ * prefilled for a verified respondent). "update" applies to `by: "identify"`
184
+ * only — a self-claim/browser must not overwrite another person's response.
185
+ */
186
+ onRepeat: "keep" | "update";
187
+ }
151
188
  interface FormSettings {
152
189
  /**
153
190
  * Default "button": respondents submit with the footer button. "auto" hides
@@ -160,17 +197,49 @@ interface FormSettings {
160
197
  successMessage?: string;
161
198
  redirectUrl?: string;
162
199
  showProgress?: boolean;
163
- /**
164
- * Default "multiple": a browser can submit as often as it wants. Use
165
- * "once_per_visitor" for tiny docs/article feedback where the SDK should
166
- * remember this browser has already answered and send a server-side
167
- * de-duplication key with the response.
168
- */
169
- submissionLimit?: "multiple" | "once_per_visitor";
200
+ /** Limit repeat responses (who counts as the same responder, and what a
201
+ * repeat does). Absent = no limit. See {@link ResponseLimit}. */
202
+ responseLimit?: ResponseLimit;
170
203
  /** Notify this address on every submission. */
171
204
  notifyEmail?: string;
172
205
  /** Send respondents a receipt (to the first answered email field). */
173
206
  sendReceipt?: boolean;
207
+ /**
208
+ * Save respondents' in-progress answers to Fillo (default off). The SDK
209
+ * autosaves as they type and restores on return, so long forms survive
210
+ * reloads and tab closes. Off by default because it stores answers before
211
+ * the respondent chooses to submit — the form owner opts in.
212
+ */
213
+ saveProgress?: boolean;
214
+ /**
215
+ * Let workspace members read the answers a respondent has typed but not yet
216
+ * submitted (default off; requires saveProgress). This exposes pre-submission
217
+ * content to the owner's team, so it's a separate opt-in with its own consent
218
+ * framing — disclose it in your privacy policy. Respondents still see the
219
+ * "progress saved" notice. The content is visible only in the authenticated
220
+ * dashboard; the draft token stays the only public read capability.
221
+ */
222
+ draftAnswersVisible?: boolean;
223
+ /**
224
+ * Email a respondent one "pick up where you left off" link when they leave a
225
+ * form idle (default off; requires saveProgress). Sent at most once per
226
+ * draft, to an address they entered or their verified account email — never
227
+ * with any answer content. Owner opt-in.
228
+ */
229
+ resumeEmails?: boolean;
230
+ /**
231
+ * Where a resume link should land for an embedded form (http(s) only). The
232
+ * draft reference travels in the URL fragment, which the SDK adopts on load;
233
+ * defaults to the hosted /f page when unset.
234
+ */
235
+ resumeUrl?: string;
236
+ /**
237
+ * Email the form's notification address a daily digest of who dropped off —
238
+ * abandoned and open draft counts, where people stalled, and verified
239
+ * respondents by name (default off; requires notifyEmail). Never any answer
240
+ * content or resume links.
241
+ */
242
+ draftDigest?: boolean;
174
243
  }
175
244
  interface FormSchema {
176
245
  version: 1;
@@ -226,6 +295,10 @@ type UploadStatus = "pending" | "uploading" | "complete" | "aborted";
226
295
  * - "gdrive": direct to a Google Drive resumable session URL (Content-Range
227
296
  * protocol).
228
297
  * - "s3-put": a single PUT to a presigned S3/R2 URL — straight to the bucket.
298
+ * Retained for sessions created by older Fillo servers.
299
+ * - "s3-multipart": resumable S3/R2 multipart upload. The client asks Fillo
300
+ * for one short-lived UploadPart URL at a time; only the server can assemble
301
+ * the parts into an object.
229
302
  * - "box": direct to Box with a folder-scoped upload token — small files via a
230
303
  * single multipart POST, large files via Box's chunked session (the client
231
304
  * computes the SHA-1 digests Box requires). The server commits/verifies in the
@@ -237,6 +310,8 @@ type UploadTransport = {
237
310
  } | {
238
311
  type: "s3-put";
239
312
  uploadUrl: string;
313
+ } | {
314
+ type: "s3-multipart";
240
315
  } | {
241
316
  type: "box";
242
317
  mode: "simple";
@@ -279,8 +354,30 @@ interface UploadSession {
279
354
  /** All conditions must hold (AND). No conditions = visible. */
280
355
  declare function isBlockVisible(block: Block, data: ResponseData): boolean;
281
356
  declare function visibleBlocks(page: FormPage, data: ResponseData): Block[];
357
+ /**
358
+ * Blocks to render on `page`, resolved against the WHOLE-FORM visibility
359
+ * fixpoint rather than just this page's own fields. A field whose `visibleIf`
360
+ * references an answer on another page must appear/validate here exactly when
361
+ * the server would keep that answer — and the server (validateResponse →
362
+ * visibleFields) uses the whole-form fixpoint, hiding any controlling field
363
+ * that is itself logic-hidden. Scoping per page instead reads a stale answer
364
+ * behind a hidden cross-page trigger as still-answered, so the client would
365
+ * render (and validate) a field whose answer the server then silently drops.
366
+ * Use this for anything that must agree with validateResponse; the per-page
367
+ * `visibleBlocks` remains for callers holding only a single page.
368
+ */
369
+ declare function visiblePageBlocks(form: FormSchema, page: FormPage, data: ResponseData): Block[];
282
370
  /** Every field in the form (across pages), in order. */
283
371
  declare function allFields(form: FormSchema): Field[];
372
+ /**
373
+ * The scope key for a response limit: the string form of the answer to
374
+ * `settings.responseLimit.scopeField`, or null when there is no scope field or the
375
+ * answer is not a usable scalar. A non-scalar answer (checkbox boolean,
376
+ * multi_select/ranking array, matrix object) means "no scope" — the limit
377
+ * spans the whole form rather than silently mis-bucketing. Shared by the SDK's
378
+ * per-visitor key and the server's per-person dedup so both scope identically.
379
+ */
380
+ declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
284
381
  /** Fields currently visible given the response data — the set that gets validated. */
285
382
  declare function visibleFields(form: FormSchema, data: ResponseData): Field[];
286
383
 
@@ -302,6 +399,17 @@ declare function validateResponse(form: FormSchema, data: ResponseData): Validat
302
399
  declare const FILLO_SCHEMA_VERSION: 1;
303
400
  /** Injected from package.json at build time (tsup define) — never hand-edited. */
304
401
  declare const FILLO_SDK_VERSION: string;
402
+ /**
403
+ * The oldest published @usefillo/* SDK that can still render a form the current
404
+ * server serves. This is a DELIBERATE floor — bump it BY HAND only when a
405
+ * genuinely wire-breaking change ships (a new required request/response field an
406
+ * old SDK can't produce or read). It must never be tied to FILLO_SDK_VERSION:
407
+ * the server used to serve its own build version as the min, so every release
408
+ * 426'd every customer still on an older pinned SDK. Field-kind/schema-shape
409
+ * breaks are gated separately by FILLO_SCHEMA_VERSION, so this floor stays low.
410
+ */
411
+ declare const FILLO_MIN_SDK_VERSION = "0.4.0";
412
+ declare function normalizeSettings(value: unknown): FormSettings;
305
413
  interface SchemaValidationResult {
306
414
  ok: boolean;
307
415
  /** Present when ok — a normalized, structurally-valid schema. */
@@ -382,6 +490,18 @@ declare function parsePhone(value: string, fallback?: PhoneCountry): ParsedPhone
382
490
  * validation. `country` (when known) tightens the accepted lengths.
383
491
  */
384
492
  declare function isPossiblePhone(value: string): boolean;
493
+ /** Whether the country-picker popover opens below or above its trigger. */
494
+ type PhonePopoverPlacement = "below" | "above";
495
+ /** Minimum gap kept between the popover and the viewport edge, in px. */
496
+ declare const PHONE_POPOVER_VIEWPORT_GAP = 8;
497
+ /**
498
+ * Decide whether the country-picker popover should open below or above its
499
+ * anchor and size it to fit the viewport, writing the result to CSS custom
500
+ * properties on the popover. Pure DOM math with no framework assumptions, so
501
+ * the React and vanilla renderers share one implementation instead of drifting
502
+ * copies. Returns "below" when there is nothing to position (SSR / detached).
503
+ */
504
+ declare function positionPhonePopover(anchor: HTMLElement | null, popover: HTMLElement | null): PhonePopoverPlacement;
385
505
 
386
506
  /** Display metadata for every block kind — drives the builder palette. */
387
507
  declare const BLOCK_KIND_META: Record<BlockKind, {
@@ -416,6 +536,7 @@ interface FieldSpec {
416
536
  max?: number;
417
537
  minLabel?: string;
418
538
  maxLabel?: string;
539
+ insightsMetric?: "csat" | "nps";
419
540
  rows?: string[];
420
541
  columns?: string[];
421
542
  placeholder?: string;
@@ -478,6 +599,8 @@ type SubmitResult = {
478
599
  responseId: string;
479
600
  /** True when the API accepted the request as an already-recorded visitor response. */
480
601
  duplicate?: boolean;
602
+ /** True when an update-in-place limit (responseLimit onRepeat "update") updated the person's living response. */
603
+ updated?: boolean;
481
604
  errors?: undefined;
482
605
  } | {
483
606
  ok: false;
@@ -496,9 +619,74 @@ interface SubmitMeta {
496
619
  surface?: "default" | "headless";
497
620
  /**
498
621
  * Browser-scoped de-duplication key sent when a form opts into
499
- * settings.submissionLimit = "once_per_visitor".
622
+ * settings.responseLimit.by = "browser".
500
623
  */
501
624
  submissionKey?: string;
625
+ /**
626
+ * Saved-progress draft this submission completes (forms with
627
+ * settings.saveProgress). The server deletes the draft with the response
628
+ * commit so it can't be resumed after submitting.
629
+ */
630
+ draft?: {
631
+ id: string;
632
+ token: string;
633
+ };
634
+ /**
635
+ * Host-app account context for this respondent (identify()). Recorded with
636
+ * the response and shown in the dashboard/webhooks as a CLAIM from the
637
+ * embedding page — it is metadata, not authentication. `id` is your own
638
+ * stable user/account id.
639
+ */
640
+ respondent?: FilloRespondent;
641
+ }
642
+ /**
643
+ * The host app's account context for the person filling the form. Passed as
644
+ * the `respondent` option on FilloForm / FilloProvider / renderForm /
645
+ * createFormController; Fillo keys responses to it so the dashboard,
646
+ * webhooks, and integrations can say WHO answered.
647
+ */
648
+ interface FilloRespondent {
649
+ /** Your stable user/account id — the identity key within your workspace. */
650
+ id: string;
651
+ email?: string;
652
+ name?: string;
653
+ /** Small primitive facts (plan, role, region…) — not a data warehouse. */
654
+ traits?: Record<string, string | number | boolean>;
655
+ /**
656
+ * Identity verification (optional): hex HMAC-SHA256 of `id`, computed on
657
+ * YOUR server with the workspace identity secret from Fillo settings. Once
658
+ * the workspace holds a secret, Fillo records identity only with a valid
659
+ * hash — never compute this in the browser or the secret leaks.
660
+ */
661
+ hash?: string;
662
+ }
663
+ /** Wire shape of a saved-progress draft (settings.saveProgress forms). */
664
+ interface ResponseDraft {
665
+ id: string;
666
+ formId: string;
667
+ /** Partial answers exactly as last saved — validated only at submit. */
668
+ data: ResponseData;
669
+ /** 0-based page the respondent was on when the draft was last saved. */
670
+ page: number;
671
+ /** A freshly rotated bearer, returned only when adopting a resume link — the
672
+ * URL token is spent, so this is what future saves must use. */
673
+ token?: string;
674
+ }
675
+ interface CreatedDraft {
676
+ id: string;
677
+ /**
678
+ * Per-draft bearer, returned once at creation; sent back as
679
+ * X-Fillo-Draft-Token on every read/save/delete of this draft.
680
+ */
681
+ token: string;
682
+ /** ISO timestamp; the server slides it forward on every save. */
683
+ expiresAt?: string;
684
+ /**
685
+ * True when a VERIFIED identity picked up its existing draft from another
686
+ * device — the token was rotated to this caller, and the draft's answers
687
+ * were left untouched (fetch them with getDraft to restore).
688
+ */
689
+ existing?: boolean;
502
690
  }
503
691
  interface UploadProgress {
504
692
  uploadedBytes: number;
@@ -514,14 +702,23 @@ interface UploadFileOptions {
514
702
  sessionId?: string;
515
703
  /** Ownership token for the resumed session (from the original create call). */
516
704
  uploadToken?: string;
705
+ /** Persist this handle if the host wants reload-safe resumability. */
706
+ onSession?: (handle: {
707
+ sessionId: string;
708
+ uploadToken?: string;
709
+ }) => void;
517
710
  }
518
711
  declare class FilloError extends Error {
519
712
  status?: number | undefined;
520
713
  /** Server-suggested wait (from a 429's Retry-After header), seconds. */
521
714
  retryAfterSec?: number | undefined;
715
+ /** Stable machine-readable API error code, when the server provides one. */
716
+ code?: string | undefined;
522
717
  constructor(message: string, status?: number | undefined,
523
718
  /** Server-suggested wait (from a 429's Retry-After header), seconds. */
524
- retryAfterSec?: number | undefined);
719
+ retryAfterSec?: number | undefined,
720
+ /** Stable machine-readable API error code, when the server provides one. */
721
+ code?: string | undefined);
525
722
  }
526
723
  /** Duck-typed — `instanceof` breaks when two SDK copies end up in one bundle. */
527
724
  declare function isFilloError(err: unknown): err is FilloError;
@@ -533,6 +730,17 @@ interface SyncFormResult {
533
730
  status?: "draft" | "published";
534
731
  /** Changes were staged as a draft for a human to publish. */
535
732
  staged?: boolean;
733
+ /**
734
+ * Server-authoritative live snapshot. Present when the incoming code schema
735
+ * is not the version respondents may submit against yet.
736
+ */
737
+ resolvedSchema?: FormSchema;
738
+ resolvedTheme?: FormTheme | null;
739
+ /** Non-fatal integration problem; the resolved live snapshot remains usable. */
740
+ syncError?: {
741
+ code: string;
742
+ message: string;
743
+ };
536
744
  warning?: string;
537
745
  }
538
746
  declare class FilloClient {
@@ -547,12 +755,30 @@ declare class FilloClient {
547
755
  /** Fetch a published form definition by id or slug. */
548
756
  getForm(idOrSlug: string): Promise<PublishedForm>;
549
757
  /**
550
- * Upsert a code-defined form into the workspace identified by the client's
551
- * publishable key. Returns the canonical form id used for submissions.
758
+ * Resolve a code-defined form through the workspace identified by the
759
+ * client's publishable key. Depending on workspace policy, changed content
760
+ * may be staged for review or resolved to the authoritative live snapshot.
761
+ * Returns the canonical form id used for submissions.
552
762
  */
553
763
  syncForm(handle: string, schema: FormSchema, theme?: FormTheme | null): Promise<SyncFormResult>;
554
764
  /** Submit a response. Returns per-field errors instead of throwing on validation failure. */
555
765
  submit(formId: string, data: ResponseData, meta?: SubmitMeta): Promise<SubmitResult>;
766
+ /**
767
+ * The identified person's own living response on an upsert-mode form —
768
+ * used to prefill their answers for editing. Requires a VERIFIED identity
769
+ * ({id, hash}); anything else 404s (returned as null), because an
770
+ * unverified read would hand anyone's answers to any page script.
771
+ *
772
+ * `scopeValue` scopes the lookup for forms whose response limit is keyed to
773
+ * a field (responseLimit.scopeField) — e.g. one response per article. When
774
+ * the form is scoped, the server returns 404 unless the matching scope value
775
+ * is sent, so a page loaded for article B never prefills article A's answers.
776
+ * Optional and back-compatible: omit it for unscoped forms.
777
+ */
778
+ fetchOwnResponse(formId: string, respondent: FilloRespondent, scopeValue?: string): Promise<{
779
+ responseId: string;
780
+ data: ResponseData;
781
+ } | null>;
556
782
  /**
557
783
  * Open a respondent session for funnel analysis. Fire-and-forget: returns the
558
784
  * session id, or null if tracking is unavailable (never blocks the form).
@@ -563,14 +789,40 @@ declare class FilloClient {
563
789
  furthestPage?: number;
564
790
  completed?: boolean;
565
791
  }): void;
792
+ /**
793
+ * Create a saved-progress draft (forms with settings.saveProgress). Returns
794
+ * the draft id and its ownership token — the token is shown only once.
795
+ */
796
+ createDraft(formId: string, body: {
797
+ data: ResponseData;
798
+ page?: number;
799
+ respondent?: FilloRespondent;
800
+ }): Promise<CreatedDraft>;
801
+ /** Fetch a draft to restore. 404s once expired, consumed, or deleted. When
802
+ * `adopt` is set (resume-link adoption), the server rotates the bearer and
803
+ * returns the new `token` — the URL token becomes single-use. */
804
+ getDraft(draftId: string, token: string, adopt?: boolean): Promise<ResponseDraft>;
805
+ /**
806
+ * Overwrite a draft's answers/page and slide its expiry. `keepalive` lets a
807
+ * tab-close flush outlive the page; browsers reject keepalive bodies over
808
+ * ~64KB, in which case this save is simply lost — the debounced autosaves
809
+ * every few keystrokes are the real persistence, the flush is a bonus.
810
+ */
811
+ saveDraft(draftId: string, token: string, body: {
812
+ data: ResponseData;
813
+ page?: number;
814
+ }, opts?: {
815
+ keepalive?: boolean;
816
+ }): Promise<void>;
817
+ /** Discard a draft (the "Start over" path). */
818
+ deleteDraft(draftId: string, token: string): Promise<void>;
566
819
  /** Current state of an upload session — used to resume after interruption. */
567
- getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
820
+ getUploadSession(sessionId: string, token?: string, signal?: AbortSignal): Promise<UploadSession>;
568
821
  /**
569
- * Resumable chunked upload. Creates (or resumes) a session, streams the file
570
- * chunk by chunk with progress callbacks, and finalizes into a FileValue that
571
- * goes into the response data. Survives flaky connections: each chunk is
572
- * retried, and on repeated failure the true offset is re-queried so no byte
573
- * is sent twice or skipped.
822
+ * Provider-aware browser-direct upload. Creates a session, uses the storage
823
+ * transport selected by the server, reports progress, and finalizes into a
824
+ * FileValue. Resumable providers can continue an existing session; one-shot
825
+ * transports are retried according to their own safe semantics.
574
826
  *
575
827
  * Depending on the form's storage settings the server picks a transport:
576
828
  * direct-to-Google-Drive resumable
@@ -589,7 +841,7 @@ declare class FilloClient {
589
841
  /**
590
842
  * Caller signal (if any) combined with a fresh per-request timeout. Created per
591
843
  * fetch so each retry attempt gets its own deadline; the caller's own abort
592
- * still drives the resumable-upload cancel semantics.
844
+ * still drives upload cancellation across every provider transport.
593
845
  */
594
846
  private uploadSignal;
595
847
  /** Retry a request with exponential backoff; never retries an aborted upload. */
@@ -601,6 +853,13 @@ declare class FilloClient {
601
853
  * queries Drive for the authoritative offset).
602
854
  */
603
855
  private driveUploadLoop;
856
+ /**
857
+ * S3-compatible multipart protocol. Fillo owns the provider upload id and is
858
+ * the only party allowed to complete it; the browser receives only narrowly
859
+ * scoped UploadPart URLs. That keeps uploads resumable and means an old or
860
+ * slow browser request cannot materialize an object after server-side abort.
861
+ */
862
+ private s3MultipartUploadLoop;
604
863
  /**
605
864
  * S3-compatible single PUT to a presigned URL — bytes go straight to the
606
865
  * bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
@@ -612,13 +871,13 @@ interface ProvisionWorkspaceResult {
612
871
  /** Publishable key (pk_…) for the new workspace. Pass to createClient({ key }). */
613
872
  key: string;
614
873
  organizationId: string;
615
- /** A claim link was emailed here so the owner can take ownership later. */
874
+ /** The private workspace link is emailed directly; `url` stays null. */
616
875
  claim: {
617
876
  url: string | null;
618
877
  email: string | null;
619
878
  sent: boolean;
620
879
  };
621
- /** Caps applied until the workspace is claimed. */
880
+ /** Caps applied until the workspace is saved to an account. */
622
881
  limits: {
623
882
  responses: number;
624
883
  expiresAt: string;
@@ -626,9 +885,9 @@ interface ProvisionWorkspaceResult {
626
885
  }
627
886
  /**
628
887
  * Provision a Fillo workspace from an email — no signup — and get a publishable
629
- * key back, so a form can collect real responses immediately. A claim link is
630
- * emailed to `email`; until someone claims it the workspace runs as a capped
631
- * preview (limited responses, for a limited time). Built for setup automation,
888
+ * key back, so a form can collect real responses immediately. A private
889
+ * workspace link is emailed to `email`; until someone saves it to an account,
890
+ * the workspace runs as a capped preview (limited responses, for a limited time). Built for setup automation,
632
891
  * e.g. a coding agent wiring Fillo into an app during integration.
633
892
  *
634
893
  * const { key } = await provisionWorkspace({ email: "you@co.com" });
@@ -674,12 +933,19 @@ interface FormControllerOptions {
674
933
  */
675
934
  surface?: "default" | "headless";
676
935
  /**
677
- * Resolve the submission target at submit time when `formId` is still
678
- * unset — e.g. re-run a code-form sync that failed at mount. Answers are
679
- * held (never dropped) while it runs; failure sets `submitError` and the
680
- * respondent can retry.
936
+ * Resolve/verify the submission target immediately before submit. Code-form
937
+ * renderers use this to recover a missing target and to detect a live schema
938
+ * change after a cached mount. Answers are held (never dropped) while it
939
+ * runs; failure sets `submitError` and the respondent can retry.
681
940
  */
682
941
  resolveFormId?: () => Promise<string>;
942
+ /**
943
+ * Host-app account context (identify()): who is filling this form, by your
944
+ * own user id. Sent with the submission and recorded as an unverified
945
+ * claim — it changes what the dashboard/webhooks can tell you, never what
946
+ * the respondent can do.
947
+ */
948
+ respondent?: FilloRespondent;
683
949
  }
684
950
  interface FormControllerState {
685
951
  data: ResponseData;
@@ -697,6 +963,48 @@ interface FormControllerState {
697
963
  uploading: boolean;
698
964
  /** Human-readable message for the last failed submit; cleared on edit/retry. */
699
965
  submitError?: string;
966
+ /**
967
+ * True when `status` is "submitted" because the once-per-visitor gate
968
+ * restored a previous visit's response, not because a submit happened in
969
+ * this controller instance. Renderers use it to skip one-time "just
970
+ * submitted" reactions (moving focus to the success screen, redirecting) —
971
+ * otherwise every remount of an already-answered form replays them.
972
+ */
973
+ restoredSubmission: boolean;
974
+ /**
975
+ * True when a saved-progress draft (settings.saveProgress) was restored
976
+ * into this fill — answers and/or page position came from a previous
977
+ * visit. Renderers use it to show a "continuing where you left off"
978
+ * notice with a Start over action (resetDraft).
979
+ */
980
+ resumedDraft: boolean;
981
+ /**
982
+ * True when an update-in-place limit prefilled the VERIFIED respondent's own
983
+ * previous answers — submitting updates that response in place. Renderers
984
+ * show an "updating your earlier response" notice.
985
+ */
986
+ editingPrevious: boolean;
987
+ /**
988
+ * True when the last submit was accepted as an already-recorded response
989
+ * rather than a new one (the server returns this only for a VERIFIED
990
+ * identify() repeat on a keep-mode form). Renderers show an "already
991
+ * answered" message on the success screen instead of implying a fresh
992
+ * submission — otherwise a repeat visitor's new answers look saved when the
993
+ * server kept the original.
994
+ */
995
+ duplicateSubmission: boolean;
996
+ /**
997
+ * True when the last submit UPDATED the person's existing response in place
998
+ * (responseLimit onRepeat "update"), rather than creating a new one.
999
+ */
1000
+ updatedSubmission: boolean;
1001
+ /**
1002
+ * True when a resume link (#fillo-draft=…) could not be adopted because it
1003
+ * was expired, already used, or not this browser's — so no progress was
1004
+ * restored. Renderers surface a "that link expired — start again" notice
1005
+ * instead of showing a silently blank form.
1006
+ */
1007
+ resumeLinkFailed: boolean;
700
1008
  }
701
1009
  interface FormController {
702
1010
  /** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
@@ -721,7 +1029,21 @@ interface FormController {
721
1029
  form?: FormSchema;
722
1030
  formId?: string;
723
1031
  client?: FilloClient;
1032
+ /** Late-bind identify() context — host sessions often resolve after mount. */
1033
+ respondent?: FilloRespondent;
724
1034
  }): void;
1035
+ /**
1036
+ * Persist any unsaved draft progress right now (settings.saveProgress
1037
+ * forms). Renderers call this on pagehide/visibility-hidden so the last
1038
+ * keystrokes survive a tab close; a no-op when there's nothing to save.
1039
+ */
1040
+ flushDraft(): void;
1041
+ /**
1042
+ * Discard the saved draft and reset to a fresh fill: answers back to
1043
+ * initialData + URL prefill, first page, errors cleared. The "Start over"
1044
+ * action next to the resume notice.
1045
+ */
1046
+ resetDraft(): void;
725
1047
  /** Drop all listeners. */
726
1048
  destroy(): void;
727
1049
  }
@@ -753,7 +1075,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
753
1075
  * (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
754
1076
  * names are public API — renames are breaking; additions are minors.
755
1077
  */
756
- declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success"];
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"];
757
1079
  type FilloSlot = (typeof FILLO_SLOTS)[number];
758
1080
  /** data-* names emitted alongside the stable fillo-* classes. */
759
1081
  declare const FILLO_DATA_ATTRS: {
@@ -840,10 +1162,23 @@ declare const FILLO_THEME_VARS: readonly [{
840
1162
  }];
841
1163
 
842
1164
  /**
843
- * Every visitor-facing string the default renderers emit, overridable as a
844
- * unit so a localized site never shows stray English at the submit moment.
1165
+ * The default English validation message for a required field left empty.
1166
+ * `validateField` returns this exact sentinel (core has no strings context),
1167
+ * and the React render layer swaps it for `strings.required` so a localized
1168
+ * site can translate it. Keep it identical to `DEFAULT_FIELD_STRINGS.required`.
1169
+ */
1170
+ declare const REQUIRED_FIELD_MESSAGE = "This field is required";
1171
+ /**
1172
+ * The form-chrome strings the default renderers emit, overridable as a unit so
1173
+ * a localized site never shows stray English at the submit moment.
845
1174
  * Schema-authored text (labels, descriptions, success copy set in settings)
846
1175
  * always wins over these defaults.
1176
+ *
1177
+ * Field-level and validation copy (required message, upload field, duplicate/
1178
+ * resume notices) lives in {@link FilloFieldStrings} — split out so this
1179
+ * documented, all-`string` surface stays stable while parametrized field
1180
+ * strings can be functions. The renderers resolve both together
1181
+ * ({@link FilloRendererStrings}); the `strings` prop overrides either.
847
1182
  */
848
1183
  interface FilloStrings {
849
1184
  back: string;
@@ -870,9 +1205,53 @@ interface FilloStrings {
870
1205
  loadFailedNetwork: string;
871
1206
  loadFailed: string;
872
1207
  renderFailed: string;
1208
+ /** Saved-progress notice when a draft restored earlier answers. */
1209
+ resumeNotice: string;
1210
+ /** Upsert-mode notice when the person's previous response was prefilled. */
1211
+ editNotice: string;
1212
+ /** The discard action next to the resume notice. */
1213
+ resumeStartOver: string;
873
1214
  }
874
1215
  declare const DEFAULT_STRINGS: FilloStrings;
875
- declare function resolveStrings(overrides?: Partial<FilloStrings>): FilloStrings;
1216
+ /**
1217
+ * Field-level and validation strings the default renderers emit. Kept apart
1218
+ * from {@link FilloStrings} so parametrized entries can be functions (a
1219
+ * translation places the value where its grammar needs it) without widening
1220
+ * the documented all-`string` chrome surface.
1221
+ */
1222
+ interface FilloFieldStrings {
1223
+ /** Validation: a required field was left empty. Mirrors REQUIRED_FIELD_MESSAGE. */
1224
+ required: string;
1225
+ /** Notice when a spent/expired resume link couldn't restore progress. */
1226
+ resumeLinkExpired: string;
1227
+ /** Success-screen message when a verified identity re-submits and the form
1228
+ * keeps the first answer (a visible duplicate, not a fresh response). */
1229
+ alreadyAnswered: string;
1230
+ /** Retry control on a failed upload row. */
1231
+ uploadRetry: string;
1232
+ /** Dropzone copy when uploads can't run (no client / preview). */
1233
+ uploadsDisabled: string;
1234
+ /** An upload attempt failed with no actionable server message. */
1235
+ uploadFailed: string;
1236
+ /** Dropzone call to action; `multiple` is true when several files are allowed. */
1237
+ dropzoneTitle: (multiple: boolean) => string;
1238
+ /** Dropzone hint stating the per-file size limit in MB. */
1239
+ dropzoneHint: (maxMb: number) => string;
1240
+ /** A file exceeded the per-file MB limit before upload started. */
1241
+ fileTooLarge: (maxMb: number) => string;
1242
+ /** Screen-reader status: N uploads in progress. */
1243
+ filesUploading: (count: number) => string;
1244
+ /** Screen-reader status: N uploads failed. */
1245
+ uploadsFailed: (count: number) => string;
1246
+ /** Screen-reader status: N uploads completed. */
1247
+ filesUploaded: (count: number) => string;
1248
+ }
1249
+ declare const DEFAULT_FIELD_STRINGS: FilloFieldStrings;
1250
+ /** Everything the default renderers can localize — chrome + field/validation. */
1251
+ type FilloRendererStrings = FilloStrings & FilloFieldStrings;
1252
+ /** Merge overrides over the built-in chrome + field defaults. Accepts a partial
1253
+ * of the full renderer surface so the `strings` prop can override either set. */
1254
+ declare function resolveStrings(overrides?: Partial<FilloRendererStrings>): FilloRendererStrings;
876
1255
 
877
1256
  /** Result of syncing a code-defined form: its canonical id, slug, and branding. */
878
1257
  interface SyncedForm {
@@ -883,12 +1262,21 @@ interface SyncedForm {
883
1262
  status?: "draft" | "published";
884
1263
  /** Changes were staged as a draft for a human to publish. */
885
1264
  staged?: boolean;
1265
+ /** Server-authoritative live snapshot when local code is not live yet. */
1266
+ resolvedSchema?: FormSchema;
1267
+ resolvedTheme?: FormTheme | null;
1268
+ /** Non-fatal integration problem; resolvedSchema remains safe to render. */
1269
+ syncError?: {
1270
+ code: string;
1271
+ message: string;
1272
+ };
886
1273
  warning?: string;
887
1274
  }
888
1275
  /**
889
- * A form whose structure lives in user code. Framework renderers can show it
890
- * immediately, then sync it into a Fillo workspace when a publishable key is
891
- * present so responses, uploads, webhooks, and exports work normally.
1276
+ * A form whose structure lives in user code. Development and explicit
1277
+ * render-only usage can show it immediately; production renderers with a
1278
+ * publishable key first resolve the canonical form from Fillo so responses,
1279
+ * uploads, webhooks, and exports stay bound to the published version.
892
1280
  */
893
1281
  interface CodeForm {
894
1282
  /** Stable handle, unique in the workspace. */
@@ -909,10 +1297,16 @@ declare function isCodeForm(form: unknown): form is CodeForm;
909
1297
  /** Stable djb2 hash of a string — used to key code-form sync by content. */
910
1298
  declare function contentHash(input: string): string;
911
1299
  /**
912
- * Sync a code-defined form into a workspace at most once per session for the
913
- * same client, handle, schema, and theme. `bypassCache` forces the network —
914
- * used by submit-time resolution, where a stale cached formId must not be
915
- * trusted over a fresh sync.
1300
+ * Compare normalized schema JSON while ignoring object-key order (Postgres
1301
+ * jsonb and custom API proxies may reorder keys). Array order remains part of
1302
+ * the form definition because it controls pages, blocks, and options.
1303
+ */
1304
+ declare function formSchemasEqual(left: FormSchema, right: FormSchema): boolean;
1305
+ /**
1306
+ * Resolve a code-defined form with in-flight dedupe and bounded stable-result
1307
+ * caching (published 1h, draft 60s). Staged/live-fallback results are evicted
1308
+ * once settled so an SPA can observe publication without a hard reload.
1309
+ * `bypassCache` forces the network for submit-time compatibility checks.
916
1310
  */
917
1311
  declare function syncCodeForm(client: FilloClient, form: CodeForm, opts?: {
918
1312
  bypassCache?: boolean;
@@ -1036,4 +1430,4 @@ declare class Sha1 {
1036
1430
  }
1037
1431
  declare const sha1Base64: (bytes: Uint8Array) => string;
1038
1432
 
1039
- 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 CustomField, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, 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, FilloJsxError, 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, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type ProvisionWorkspaceResult, type PublishedForm, type RankingField, type RatingField, type ResponseData, 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, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, when };
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 };