@usefillo/react 0.16.1 → 0.19.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
@@ -24,6 +24,8 @@ npm i @usefillo/react
24
24
 
25
25
  `react` and `react-dom` (18 or 19) are peer dependencies.
26
26
 
27
+ The default stylesheet inherits the host page's CSS `color-scheme` and font. If your theme provider only toggles a class or data attribute, pass its resolved mode as `theme={{ colorScheme: "light" | "dark" }}`. Use `"auto"` only for a deliberately system-driven page. A fixed hex `background` automatically selects a readable palette unless you explicitly choose one.
28
+
27
29
  ## Write forms as components
28
30
 
29
31
  ```tsx
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentType, ReactNode, ReactElement } from 'react';
3
3
  import * as _usefillo_core from '@usefillo/core';
4
- import { FormSchema, FilloClient, ResponseData, FieldValue, FormPage, Block, FormStatus, FieldKind, Field, CustomField, FormTheme, FilloAppearance, FilloRendererStrings, FilloRespondent, ChallengeConfig, FilloError, CodeForm, FormSettings, Condition, TextField, PhoneField, NumberField, ChoiceField, SelectOption, CheckboxField, RatingField, LinearScaleField, RankingField, SignatureField, DateField, FileUploadField as FileUploadField$1, HiddenField, CalculatedField } from '@usefillo/core';
4
+ import { FormSchema, FilloClient, ResponseData, FieldValue, FormPage, Block, FormStatus, FieldKind, Field, CustomField, FormTheme, FilloAppearance, FilloRendererStrings, FilloRespondent, ChallengeConfig, ChallengeTheme, FilloError, CodeForm, FormSettings, Condition, TextField, PhoneField, NumberField, ChoiceField, SelectOption, CheckboxField, RatingField, LinearScaleField, RankingField, SignatureField, DateField, FileUploadField as FileUploadField$1, HiddenField, CalculatedField, RespondentErrorStrings } from '@usefillo/core';
5
5
  export { CodeForm, Field, FieldValue, FileValue, FilloAppearance, FilloClient, FilloError, FilloJsxError, FilloRendererStrings, FilloSlot, FilloStrings, FormSchema, FormStatus, FormTheme, ProvisionWorkspaceResult, PublishedForm, ResponseData, SlotState, createClient, defineForm, provisionWorkspace, when } from '@usefillo/core';
6
6
 
7
7
  /**
@@ -143,6 +143,18 @@ interface FilloFormBaseProps {
143
143
  * hosted page does this). The SECRET key stays server-side — never passed here.
144
144
  */
145
145
  challenge?: ChallengeConfig;
146
+ /**
147
+ * Theme for the human-verification widget. Defaults to "auto" (the
148
+ * visitor's OS preference); apps with their own theme switch should pass
149
+ * "light"/"dark" so the widget matches the surrounding form.
150
+ */
151
+ challengeTheme?: ChallengeTheme;
152
+ /**
153
+ * Bridge-mode visibility for the human check. "interaction-only" (default)
154
+ * keeps it invisible unless Cloudflare needs the visitor to act; "always"
155
+ * shows the classic widget box the whole time.
156
+ */
157
+ challengeAppearance?: "always" | "interaction-only";
146
158
  /**
147
159
  * Server-owned per-file ceiling when an inline schema skips the normal form
148
160
  * fetch. Fillo's hosted page uses this to preserve its server-rendered fast
@@ -258,7 +270,7 @@ type ContentProps = {
258
270
  /** Inert: rendering one throws; they exist to be read by the compiler. */
259
271
  type Inert<P> = (props: P) => never;
260
272
  interface FilloJsxFormBaseProps extends Omit<FilloFormProps, "form" | "formId" | "client" | "renderOnly" | "skipValidation"> {
261
- /** Workspace handle — the form's identity across syncs. */
273
+ /** Project handle — the form's identity across syncs. */
262
274
  id: string;
263
275
  title?: string;
264
276
  description?: string;
@@ -354,6 +366,8 @@ interface ControllerOptions {
354
366
  * environment) so integration details never reach production visitors.
355
367
  */
356
368
  verboseResolutionErrors?: boolean;
369
+ /** Resolved renderer fallbacks for localized respondent-safe failures. */
370
+ respondentErrorStrings?: Partial<RespondentErrorStrings>;
357
371
  /**
358
372
  * Host-app account context (identify()): who is filling this form, by your
359
373
  * own user id. Recorded with the response as an unverified claim so the
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  useId as useId2,
8
8
  useMemo as useMemo6,
9
9
  useRef as useRef9,
10
- useState as useState6,
10
+ useState as useState7,
11
11
  useSyncExternalStore as useSyncExternalStore2
12
12
  } from "react";
13
13
  import {
@@ -22,6 +22,7 @@ import {
22
22
  normalizeFormTheme,
23
23
  reachableFields,
24
24
  reachablePageSequence,
25
+ respondentErrorStringsFor,
25
26
  resolveThemeAppearance
26
27
  } from "@usefillo/core";
27
28
  import { resolveStrings, slotClass as slotClass3 } from "@usefillo/core";
@@ -104,26 +105,35 @@ function DevNoClientNotice() {
104
105
  " to collect them in Fillo, or forward them with webhooks. (This notice only appears in development and preview.)"
105
106
  ] });
106
107
  }
107
- function DevDraftNotice({ warningUrl }) {
108
- const safeUrl = safeHttpUrl(warningUrl);
108
+ function DevDraftNotice({ warningUrl, formUrl }) {
109
+ const storageUrl = safeHttpUrl(warningUrl);
110
+ const publishUrl = safeHttpUrl(formUrl);
109
111
  return /* @__PURE__ */ jsxs("div", { className: "fillo-devwarning", role: "alert", children: [
110
- /* @__PURE__ */ jsx("strong", { children: "Draft form." }),
111
- " It renders here, but it won\u2019t accept responses until you publish it in your Fillo dashboard.",
112
+ /* @__PURE__ */ jsx("strong", { children: "Draft form preview." }),
113
+ " The form renders for local testing, but Fillo will reject the submission and save no response until you publish it.",
112
114
  " ",
113
- safeUrl && /* @__PURE__ */ jsxs(Fragment, { children: [
114
- "Connect storage to publish:",
115
- " ",
116
- /* @__PURE__ */ jsx("a", { href: safeUrl, target: "_blank", rel: "noopener noreferrer", children: safeUrl }),
115
+ storageUrl ? /* @__PURE__ */ jsxs(Fragment, { children: [
116
+ /* @__PURE__ */ jsx("a", { href: storageUrl, target: "_blank", rel: "noopener noreferrer", children: "Connect storage to publish" }),
117
117
  ".",
118
118
  " "
119
- ] }),
119
+ ] }) : publishUrl ? /* @__PURE__ */ jsxs(Fragment, { children: [
120
+ /* @__PURE__ */ jsx("a", { href: publishUrl, target: "_blank", rel: "noopener noreferrer", children: "Open in Fillo to publish" }),
121
+ ".",
122
+ " "
123
+ ] }) : null,
120
124
  "(This notice only appears in development and preview.)"
121
125
  ] });
122
126
  }
123
- function DevStagedNotice() {
127
+ function DevStagedNotice({ formUrl }) {
128
+ const publishUrl = safeHttpUrl(formUrl);
124
129
  return /* @__PURE__ */ jsxs("div", { className: "fillo-devwarning", role: "alert", children: [
125
- /* @__PURE__ */ jsx("strong", { children: "Code changes are staged." }),
126
- " This local preview is not the live form yet. Publish the changes in Fillo before deploying them for respondents."
130
+ /* @__PURE__ */ jsx("strong", { children: "Code changes are staged, not live." }),
131
+ " This page shows your draft, while respondents still get the live version.",
132
+ " ",
133
+ publishUrl ? /* @__PURE__ */ jsxs(Fragment, { children: [
134
+ /* @__PURE__ */ jsx("a", { href: publishUrl, target: "_blank", rel: "noopener noreferrer", children: "Review and publish in Fillo" }),
135
+ "."
136
+ ] }) : /* @__PURE__ */ jsx(Fragment, { children: "Review and publish the changes in Fillo." })
127
137
  ] });
128
138
  }
129
139
  function DevSyncErrorNotice({ error }) {
@@ -139,7 +149,7 @@ function DevSyncErrorNotice({ error }) {
139
149
  ] });
140
150
  }
141
151
  function DevChrome(props) {
142
- const notice = props.devNotices === false ? null : props.syncError ? /* @__PURE__ */ jsx(DevSyncErrorNotice, { error: props.syncError }) : props.staged ? /* @__PURE__ */ jsx(DevStagedNotice, {}) : props.draft ? /* @__PURE__ */ jsx(DevDraftNotice, { warningUrl: props.warningUrl }) : props.noClient ? /* @__PURE__ */ jsx(DevNoClientNotice, {}) : null;
152
+ const notice = props.devNotices === false ? null : props.syncError ? /* @__PURE__ */ jsx(DevSyncErrorNotice, { error: props.syncError }) : props.staged ? /* @__PURE__ */ jsx(DevStagedNotice, { formUrl: props.formUrl }) : props.draft ? /* @__PURE__ */ jsx(DevDraftNotice, { warningUrl: props.warningUrl, formUrl: props.formUrl }) : props.noClient ? /* @__PURE__ */ jsx(DevNoClientNotice, {}) : null;
143
153
  if (!props.preview && !notice) return null;
144
154
  return /* @__PURE__ */ jsxs(Fragment, { children: [
145
155
  props.preview && /* @__PURE__ */ jsx(PreviewBadge, {}),
@@ -210,6 +220,24 @@ import {
210
220
  function useFilloController(options) {
211
221
  const optsRef = useRef(options);
212
222
  optsRef.current = options;
223
+ const errorCopy = options.respondentErrorStrings;
224
+ const respondentErrorStrings = useMemo2(
225
+ () => ({ ...errorCopy }),
226
+ [
227
+ errorCopy?.submitFailed,
228
+ errorCopy?.loadFailedNotFound,
229
+ errorCopy?.loadFailedNetwork,
230
+ errorCopy?.formUnavailable,
231
+ errorCopy?.formClosed,
232
+ errorCopy?.submitRateLimited,
233
+ errorCopy?.respondentUnrecognized,
234
+ errorCopy?.fileUnavailable,
235
+ errorCopy?.scopeMissing,
236
+ errorCopy?.challengeIncomplete,
237
+ errorCopy?.challengeRetry,
238
+ errorCopy?.reviewAnswers
239
+ ]
240
+ );
213
241
  const storeRef = useRef(null);
214
242
  if (storeRef.current === null) {
215
243
  storeRef.current = createFormController({
@@ -230,6 +258,7 @@ function useFilloController(options) {
230
258
  get verboseResolutionErrors() {
231
259
  return optsRef.current.verboseResolutionErrors;
232
260
  },
261
+ respondentErrorStrings,
233
262
  respondent: options.respondent,
234
263
  // Challenge wiring read through optsRef so the engine (created once) sees
235
264
  // the latest token/reset each submit — the widget solves after mount.
@@ -244,9 +273,17 @@ function useFilloController(options) {
244
273
  form: options.form,
245
274
  formId: options.formId,
246
275
  client: options.client,
247
- respondent: options.respondent
276
+ respondent: options.respondent,
277
+ respondentErrorStrings
248
278
  });
249
- }, [store, options.form, options.formId, options.client, options.respondent]);
279
+ }, [
280
+ store,
281
+ options.form,
282
+ options.formId,
283
+ options.client,
284
+ options.respondent,
285
+ respondentErrorStrings
286
+ ]);
250
287
  useEffect(() => () => store.destroy(), [store]);
251
288
  const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
252
289
  return useMemo2(
@@ -329,6 +366,7 @@ function useCodeFormSync(form, client, onError, options) {
329
366
  settled: false,
330
367
  formId: null,
331
368
  status: null,
369
+ manageUrl: void 0,
332
370
  error: null,
333
371
  noticeError: null,
334
372
  staged: false,
@@ -390,6 +428,7 @@ function useCodeFormSync(form, client, onError, options) {
390
428
  settled: true,
391
429
  formId: r.formId,
392
430
  status: r.status ?? null,
431
+ manageUrl: r.manageUrl,
393
432
  branding: r.branding,
394
433
  challenge: r.challenge,
395
434
  error: null,
@@ -405,7 +444,7 @@ function useCodeFormSync(form, client, onError, options) {
405
444
  warningCode: r.warningCode,
406
445
  warningUrl: r.warningUrl
407
446
  });
408
- const dashboardUrl = `${client.baseUrl || "https://fillo.so"}/forms/${r.formId}`;
447
+ const dashboardUrl = r.manageUrl;
409
448
  if (r.warning) {
410
449
  console.warn(
411
450
  `[fillo] ${r.warning}${r.warningUrl ? ` Connect storage to publish: ${r.warningUrl}` : ""}`
@@ -421,10 +460,14 @@ function useCodeFormSync(form, client, onError, options) {
421
460
  noticeSync(
422
461
  codeForm.id,
423
462
  "staged",
424
- `[fillo] "${codeForm.id}": changes staged as a draft \u2014 the live version remains rendered until you publish them at ${dashboardUrl}`
463
+ `[fillo] "${codeForm.id}": changes staged as a draft \u2014 the live version remains rendered until you publish them${dashboardUrl ? ` at ${dashboardUrl}` : " in Fillo"}`
425
464
  );
426
465
  } else if (r.status === "draft") {
427
- noticeSync(codeForm.id, "draft", `[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published: ${dashboardUrl}`);
466
+ noticeSync(
467
+ codeForm.id,
468
+ "draft",
469
+ `[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${dashboardUrl ? `: ${dashboardUrl}` : " in Fillo"}`
470
+ );
428
471
  }
429
472
  }).catch((err) => {
430
473
  if (cancelled) return;
@@ -451,6 +494,7 @@ function useCodeFormSync(form, client, onError, options) {
451
494
  return synced.key === syncKey && synced.client === client ? {
452
495
  formId: synced.formId,
453
496
  status: synced.status,
497
+ manageUrl: synced.manageUrl,
454
498
  branding: synced.branding,
455
499
  challenge: synced.challenge,
456
500
  error: synced.error,
@@ -1167,8 +1211,9 @@ function formatBytes(bytes) {
1167
1211
  function uploadFailureMessage(error, strings) {
1168
1212
  if (error instanceof Error && error.name === "FilloError") {
1169
1213
  const status = error.status;
1170
- if (status !== void 0 && status >= 500) return strings.uploadUnavailable;
1171
- return error.message || strings.uploadFailed;
1214
+ if (status === 0 || status !== void 0 && status >= 500) {
1215
+ return strings.uploadUnavailable;
1216
+ }
1172
1217
  }
1173
1218
  return strings.uploadFailed;
1174
1219
  }
@@ -1323,7 +1368,10 @@ function FileUploadField({
1323
1368
  filesRef.current = next;
1324
1369
  setValue(next);
1325
1370
  } catch (err) {
1326
- if (mountedRef.current) {
1371
+ if (mountedRef.current && !controller.signal.aborted) {
1372
+ if (err instanceof Error && err.name === "FilloError") {
1373
+ chrome?.onError?.(err);
1374
+ }
1327
1375
  const message = uploadFailureMessage(err, strings);
1328
1376
  setInFlight((prev) => prev.map((f) => f.key === key ? { ...f, error: message } : f));
1329
1377
  }
@@ -2500,10 +2548,12 @@ function FormField({
2500
2548
  }
2501
2549
 
2502
2550
  // src/turnstile.tsx
2503
- import { useEffect as useEffect8, useRef as useRef8 } from "react";
2551
+ import { useEffect as useEffect8, useRef as useRef8, useState as useState6 } from "react";
2504
2552
  import { jsx as jsx7 } from "react/jsx-runtime";
2505
2553
  var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
2506
2554
  var SCRIPT_ID = "fillo-turnstile-script";
2555
+ var BRIDGE_READY_TIMEOUT_MS = 2e4;
2556
+ var CDATA_RE = /^[A-Za-z0-9_-]{1,255}$/;
2507
2557
  var scriptPromise = null;
2508
2558
  function turnstileGlobal() {
2509
2559
  return globalThis.turnstile;
@@ -2552,8 +2602,133 @@ function loadTurnstileScript() {
2552
2602
  });
2553
2603
  return scriptPromise;
2554
2604
  }
2555
- function TurnstileWidget({
2605
+ function TurnstileWidget(props) {
2606
+ return props.bridgeUrl ? /* @__PURE__ */ jsx7(BridgeChallenge, { ...props, bridgeUrl: props.bridgeUrl }) : /* @__PURE__ */ jsx7(DirectChallenge, { ...props });
2607
+ }
2608
+ function BridgeChallenge({
2609
+ bridgeUrl,
2610
+ formId,
2611
+ theme,
2612
+ appearance,
2613
+ onToken,
2614
+ onError,
2615
+ registerReset,
2616
+ className
2617
+ }) {
2618
+ const iframeRef = useRef8(null);
2619
+ const interactionOnly = (appearance ?? "interaction-only") === "interaction-only";
2620
+ const [frame, setFrame] = useState6(null);
2621
+ const [expanded, setExpanded] = useState6(!interactionOnly);
2622
+ const onTokenRef = useRef8(onToken);
2623
+ onTokenRef.current = onToken;
2624
+ const onErrorRef = useRef8(onError);
2625
+ onErrorRef.current = onError;
2626
+ const registerResetRef = useRef8(registerReset);
2627
+ registerResetRef.current = registerReset;
2628
+ useEffect8(() => {
2629
+ if (typeof window === "undefined") return;
2630
+ let url;
2631
+ try {
2632
+ url = new URL(bridgeUrl);
2633
+ } catch {
2634
+ onErrorRef.current?.();
2635
+ return;
2636
+ }
2637
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
2638
+ onErrorRef.current?.();
2639
+ return;
2640
+ }
2641
+ url.searchParams.set("origin", window.location.origin);
2642
+ url.searchParams.set("theme", theme ?? "auto");
2643
+ url.searchParams.set("appearance", interactionOnly ? "interaction-only" : "always");
2644
+ if (formId && CDATA_RE.test(formId)) url.searchParams.set("cdata", formId);
2645
+ setFrame({ src: url.toString(), origin: url.origin });
2646
+ }, [bridgeUrl, formId, theme, interactionOnly]);
2647
+ useEffect8(() => {
2648
+ if (!frame || typeof window === "undefined") return;
2649
+ let settled = false;
2650
+ const watchdog = window.setTimeout(() => {
2651
+ if (!settled) {
2652
+ onTokenRef.current(void 0);
2653
+ onErrorRef.current?.();
2654
+ }
2655
+ }, BRIDGE_READY_TIMEOUT_MS);
2656
+ const settle = () => {
2657
+ settled = true;
2658
+ window.clearTimeout(watchdog);
2659
+ };
2660
+ const onMessage = (event) => {
2661
+ if (event.origin !== frame.origin) return;
2662
+ const target = iframeRef.current;
2663
+ if (!target || event.source !== target.contentWindow) return;
2664
+ const data = event.data;
2665
+ switch (data?.type) {
2666
+ case "fillo:challenge:ready":
2667
+ settle();
2668
+ break;
2669
+ case "fillo:challenge:token":
2670
+ settle();
2671
+ if (typeof data.token === "string" && data.token.length > 0) {
2672
+ onTokenRef.current(data.token);
2673
+ }
2674
+ if (interactionOnly) setExpanded(false);
2675
+ break;
2676
+ case "fillo:challenge:interactive":
2677
+ if (interactionOnly) setExpanded(true);
2678
+ break;
2679
+ case "fillo:challenge:interactive-done":
2680
+ if (interactionOnly) setExpanded(false);
2681
+ break;
2682
+ case "fillo:challenge:expired":
2683
+ onTokenRef.current(void 0);
2684
+ break;
2685
+ case "fillo:challenge:error":
2686
+ settle();
2687
+ onTokenRef.current(void 0);
2688
+ onErrorRef.current?.();
2689
+ break;
2690
+ }
2691
+ };
2692
+ window.addEventListener("message", onMessage);
2693
+ registerResetRef.current?.(() => {
2694
+ onTokenRef.current(void 0);
2695
+ iframeRef.current?.contentWindow?.postMessage(
2696
+ { type: "fillo:challenge:reset" },
2697
+ frame.origin
2698
+ );
2699
+ });
2700
+ return () => {
2701
+ window.clearTimeout(watchdog);
2702
+ window.removeEventListener("message", onMessage);
2703
+ onTokenRef.current(void 0);
2704
+ };
2705
+ }, [frame, interactionOnly]);
2706
+ return /* @__PURE__ */ jsx7(
2707
+ "div",
2708
+ {
2709
+ className,
2710
+ "data-fillo": "turnstile",
2711
+ "data-fillo-challenge-visible": expanded ? "true" : "false",
2712
+ children: frame && /* @__PURE__ */ jsx7(
2713
+ "iframe",
2714
+ {
2715
+ ref: iframeRef,
2716
+ src: frame.src,
2717
+ title: "Human verification",
2718
+ width: 300,
2719
+ height: expanded ? 65 : 0,
2720
+ className: "fillo-turnstile-frame",
2721
+ referrerPolicy: "no-referrer",
2722
+ "aria-hidden": expanded ? void 0 : true,
2723
+ tabIndex: expanded ? void 0 : -1
2724
+ }
2725
+ )
2726
+ }
2727
+ );
2728
+ }
2729
+ function DirectChallenge({
2556
2730
  siteKey,
2731
+ theme,
2557
2732
  onToken,
2558
2733
  onError,
2559
2734
  registerReset,
@@ -2582,7 +2757,7 @@ function TurnstileWidget({
2582
2757
  };
2583
2758
  widgetIdRef.current = api.render(containerRef.current, {
2584
2759
  sitekey: siteKey,
2585
- theme: "auto",
2760
+ theme: theme ?? "auto",
2586
2761
  callback: (token) => onTokenRef.current(token),
2587
2762
  "error-callback": () => {
2588
2763
  onTokenRef.current(void 0);
@@ -2607,15 +2782,8 @@ function TurnstileWidget({
2607
2782
  widgetIdRef.current = null;
2608
2783
  onTokenRef.current(void 0);
2609
2784
  };
2610
- }, [siteKey]);
2611
- return /* @__PURE__ */ jsx7(
2612
- "div",
2613
- {
2614
- ref: containerRef,
2615
- className,
2616
- "data-fillo": "turnstile"
2617
- }
2618
- );
2785
+ }, [siteKey, theme]);
2786
+ return /* @__PURE__ */ jsx7("div", { ref: containerRef, className, "data-fillo": "turnstile" });
2619
2787
  }
2620
2788
 
2621
2789
  // src/FilloForm.tsx
@@ -2680,8 +2848,8 @@ function FilloForm(props) {
2680
2848
  () => renderOnly ? void 0 : props.client ?? (formId ? createClient() : void 0),
2681
2849
  [renderOnly, props.client, formId]
2682
2850
  );
2683
- const [loaded, setLoaded] = useState6(null);
2684
- const [loadFailure, setLoadFailure] = useState6(
2851
+ const [loaded, setLoaded] = useState7(null);
2852
+ const [loadFailure, setLoadFailure] = useState7(
2685
2853
  null
2686
2854
  );
2687
2855
  const devEnv = useIsDevEnv();
@@ -2705,7 +2873,8 @@ function FilloForm(props) {
2705
2873
  resolvedSchema,
2706
2874
  resolvedTheme,
2707
2875
  warningCode: syncedWarningCode,
2708
- warningUrl: syncedWarningUrl
2876
+ warningUrl: syncedWarningUrl,
2877
+ manageUrl: syncedManageUrl
2709
2878
  } = useCodeFormSync(form, client, onError, {
2710
2879
  // An absent client is intentional render-only use. Builder previews and an
2711
2880
  // explicit canonical id also do not need publishable-key resolution.
@@ -2922,6 +3091,7 @@ function FilloForm(props) {
2922
3091
  draftNotice: notPublished,
2923
3092
  stagedNotice: codeChangesStaged,
2924
3093
  syncErrorNotice: syncError ?? syncNoticeError ?? void 0,
3094
+ syncFormUrl: syncedManageUrl,
2925
3095
  uploadsAvailable: codeForm ? syncedUploadsAvailable : currentLoaded?.uploadsAvailable,
2926
3096
  uploadFileSizeLimitMb: codeForm ? syncedUploadFileSizeLimitMb : currentLoaded?.uploadFileSizeLimitMb ?? props.uploadFileSizeLimitMb,
2927
3097
  syncWarningCode: codeForm ? syncedWarningCode : void 0,
@@ -2996,10 +3166,10 @@ function ResolvedForm(props) {
2996
3166
  const challenge = renderOnly ? void 0 : props.challenge;
2997
3167
  const challengeRequired = Boolean(challenge);
2998
3168
  const challengeTokenRef = useRef9(void 0);
2999
- const [resyncWarning, setResyncWarning] = useState6(null);
3000
- const [resolutionFailureCode, setResolutionFailureCode] = useState6(null);
3001
- const [challengeToken, setChallengeToken] = useState6(void 0);
3002
- const [challengeError, setChallengeError] = useState6(false);
3169
+ const [resyncWarning, setResyncWarning] = useState7(null);
3170
+ const [resolutionFailureCode, setResolutionFailureCode] = useState7(null);
3171
+ const [challengeToken, setChallengeToken] = useState7(void 0);
3172
+ const [challengeError, setChallengeError] = useState7(false);
3003
3173
  const resetChallengeRef = useRef9(void 0);
3004
3174
  const handleChallengeToken = useCallback2((token) => {
3005
3175
  challengeTokenRef.current = token;
@@ -3081,6 +3251,7 @@ function ResolvedForm(props) {
3081
3251
  ) : void 0,
3082
3252
  [codeForm, client, props.onError, schema]
3083
3253
  );
3254
+ const strings = resolveStrings(props.strings);
3084
3255
  const api = useFilloController({
3085
3256
  form: schema,
3086
3257
  formId: props.formId,
@@ -3097,6 +3268,7 @@ function ResolvedForm(props) {
3097
3268
  // Dev chrome gets the developer-grade resolution failure (message + code)
3098
3269
  // instead of the respondent-safe "unavailable" fallback.
3099
3270
  verboseResolutionErrors: devChrome,
3271
+ respondentErrorStrings: respondentErrorStringsFor(strings),
3100
3272
  respondent: props.respondent,
3101
3273
  challengeRequired,
3102
3274
  getChallengeToken: () => challengeTokenRef.current,
@@ -3106,7 +3278,6 @@ function ResolvedForm(props) {
3106
3278
  }
3107
3279
  });
3108
3280
  useDraftFlush(api, !displayOnly && !renderOnly);
3109
- const strings = resolveStrings(props.strings);
3110
3281
  const announceRef = useRef9(null);
3111
3282
  const announce = useCallback2((text) => {
3112
3283
  const el = announceRef.current;
@@ -3140,9 +3311,18 @@ function ResolvedForm(props) {
3140
3311
  uploadsAvailable,
3141
3312
  uploadFileSizeLimitMb,
3142
3313
  warningCode,
3143
- warningUrl
3314
+ warningUrl,
3315
+ onError: props.onError
3144
3316
  }),
3145
- [devChrome, renderOnly, uploadsAvailable, uploadFileSizeLimitMb, warningCode, warningUrl]
3317
+ [
3318
+ devChrome,
3319
+ renderOnly,
3320
+ uploadsAvailable,
3321
+ uploadFileSizeLimitMb,
3322
+ warningCode,
3323
+ warningUrl,
3324
+ props.onError
3325
+ ]
3146
3326
  );
3147
3327
  const settings = schema.settings;
3148
3328
  const submitted = api.status === "submitted";
@@ -3243,6 +3423,7 @@ function ResolvedForm(props) {
3243
3423
  staged: props.stagedNotice,
3244
3424
  draft: props.draftNotice,
3245
3425
  warningUrl: props.syncWarningUrl,
3426
+ formUrl: props.syncFormUrl,
3246
3427
  noClient: !props.client && !props.skipValidation && !renderOnly
3247
3428
  }
3248
3429
  ),
@@ -3364,6 +3545,10 @@ function ResolvedForm(props) {
3364
3545
  TurnstileWidget,
3365
3546
  {
3366
3547
  siteKey: challenge.siteKey,
3548
+ bridgeUrl: challenge.bridgeUrl,
3549
+ formId: props.formId,
3550
+ theme: props.challengeTheme,
3551
+ appearance: props.challengeAppearance,
3367
3552
  className: slotClass3("fillo-turnstile", appearance, { slot: "turnstile" }),
3368
3553
  onToken: handleChallengeToken,
3369
3554
  onError: () => setChallengeError(true),
@@ -3531,13 +3716,14 @@ var Fillo = {
3531
3716
  };
3532
3717
 
3533
3718
  // src/provider.tsx
3534
- import { useEffect as useEffect10, useId as useId3, useMemo as useMemo7, useState as useState7 } from "react";
3719
+ import { useEffect as useEffect10, useId as useId3, useMemo as useMemo7, useState as useState8 } from "react";
3535
3720
  import {
3536
3721
  FilloError as FilloError3,
3537
3722
  createClient as createClient2,
3538
3723
  formSchemasEqual as formSchemasEqual2,
3539
3724
  isFilloError as isFilloError3,
3540
3725
  normalizeFormSchema as normalizeFormSchema2,
3726
+ respondentErrorStringsFor as respondentErrorStringsFor2,
3541
3727
  resolveStrings as resolveStrings2,
3542
3728
  syncCodeForm as syncCodeForm3
3543
3729
  } from "@usefillo/core";
@@ -3594,7 +3780,7 @@ function FilloProvider({
3594
3780
  } = useCodeFormSync(form, activeClient, onError, {
3595
3781
  allowUnsynced: Boolean(!activeClient || activeFormId || localPreview)
3596
3782
  });
3597
- const [resyncWarning, setResyncWarning] = useState7(null);
3783
+ const [resyncWarning, setResyncWarning] = useState8(null);
3598
3784
  const rawSchema = codeForm ? resolvedSchema && !devChrome ? resolvedSchema : codeForm.schema : form;
3599
3785
  const normalized = useMemo7(() => normalizeFormSchema2(rawSchema), [rawSchema]);
3600
3786
  if (!normalized.ok) {
@@ -3668,6 +3854,7 @@ function FilloProvider({
3668
3854
  } : void 0,
3669
3855
  [activeClient, codeForm, onError, schema]
3670
3856
  );
3857
+ const resolvedStrings = resolveStrings2(strings);
3671
3858
  const api = useFilloController({
3672
3859
  ...options,
3673
3860
  client: activeClient,
@@ -3678,6 +3865,7 @@ function FilloProvider({
3678
3865
  // Dev chrome (preview or dev env) surfaces the developer-grade resolution
3679
3866
  // failure (message + code); an explicit option wins.
3680
3867
  verboseResolutionErrors: options.verboseResolutionErrors ?? devChrome,
3868
+ respondentErrorStrings: respondentErrorStringsFor2(resolvedStrings),
3681
3869
  // Headless = your markup, no Fillo-rendered layout. Free like every embed
3682
3870
  // method; recorded per response so usage stays measurable.
3683
3871
  surface: "headless"
@@ -3694,7 +3882,8 @@ function FilloProvider({
3694
3882
  uploadsAvailable: freshUploadsAvailable,
3695
3883
  uploadFileSizeLimitMb: freshUploadFileSizeLimitMb,
3696
3884
  warningCode: freshWarningCode,
3697
- warningUrl: freshWarningUrl
3885
+ warningUrl: freshWarningUrl,
3886
+ onError
3698
3887
  }),
3699
3888
  [
3700
3889
  devChrome,
@@ -3702,10 +3891,10 @@ function FilloProvider({
3702
3891
  freshUploadsAvailable,
3703
3892
  freshUploadFileSizeLimitMb,
3704
3893
  freshWarningCode,
3705
- freshWarningUrl
3894
+ freshWarningUrl,
3895
+ onError
3706
3896
  ]
3707
3897
  );
3708
- const resolvedStrings = resolveStrings2(strings);
3709
3898
  if (targetError) {
3710
3899
  if (renderError) return /* @__PURE__ */ jsx10(Fragment4, { children: renderError(targetError) });
3711
3900
  throw targetError;
package/dist/styles.css CHANGED
@@ -20,20 +20,36 @@
20
20
 
21
21
  @layer components {
22
22
  .fillo-form {
23
- color-scheme: light;
24
23
  --fillo-primary: #18181b;
24
+ --fillo-primary: light-dark(#18181b, #fafafa);
25
25
  --fillo-primary-contrast: #ffffff;
26
+ --fillo-primary-contrast: light-dark(#ffffff, #18181b);
26
27
  --fillo-bg: transparent;
27
28
  --fillo-text: #18181b;
29
+ --fillo-text: light-dark(#18181b, #f4f4f5);
28
30
  --fillo-muted: #71717a;
31
+ --fillo-muted: light-dark(#71717a, #a1a1aa);
29
32
  --fillo-border: #e4e4e7;
33
+ --fillo-border: light-dark(#e4e4e7, #3f3f46);
30
34
  --fillo-error: #dc2626;
35
+ --fillo-error: light-dark(#dc2626, #f87171);
31
36
  --fillo-radius: 10px;
32
37
  --fillo-font: inherit;
38
+ /* The form's local type root. Default = inherit the host's font, so the
39
+ form blends in; set it once to pin or scale the WHOLE form — every
40
+ internal size is em-based and proportional to this. Prefer an ABSOLUTE
41
+ value (px): the not-open state nests a preview .fillo-form inside the
42
+ root .fillo-form, and a relative value would compound there. */
43
+ --fillo-font-size: 1em;
33
44
  --fillo-control-bg: #ffffff;
45
+ --fillo-control-bg: light-dark(#ffffff, #18181b);
34
46
 
35
- color-scheme: light;
47
+ /* Unthemed forms follow the host's CSS color-scheme. The first value for
48
+ each token above is a safe light fallback for browsers without
49
+ light-dark(); explicit light/dark and auto remain available below. */
50
+ color-scheme: inherit;
36
51
  font-family: var(--fillo-font);
52
+ font-size: var(--fillo-font-size);
37
53
  color: var(--fillo-text);
38
54
  background: var(--fillo-bg);
39
55
  display: flex;
@@ -46,8 +62,11 @@
46
62
  color-scheme: light;
47
63
  }
48
64
 
65
+ .fillo-form[data-fillo-color-scheme="auto"] {
66
+ color-scheme: light dark;
67
+ }
68
+
49
69
  .fillo-form[data-fillo-color-scheme="dark"] {
50
- color-scheme: dark;
51
70
  --fillo-primary: #fafafa;
52
71
  --fillo-primary-contrast: #18181b;
53
72
  --fillo-text: #f4f4f5;
@@ -60,9 +79,8 @@
60
79
  }
61
80
 
62
81
  @media (prefers-color-scheme: dark) {
63
- .fillo-form:not([data-fillo-color-scheme="light"]):not([data-fillo-has-color-theme]) {
64
- color-scheme: dark;
65
- --fillo-primary: #fafafa;
82
+ .fillo-form[data-fillo-color-scheme="auto"] {
83
+ --fillo-primary: #fafafa;
66
84
  --fillo-primary-contrast: #18181b;
67
85
  --fillo-text: #f4f4f5;
68
86
  --fillo-muted: #a1a1aa;
@@ -75,22 +93,22 @@
75
93
  }
76
94
 
77
95
  .fillo-header { display: flex; flex-direction: column; gap: 0.5rem; }
78
- .fillo-title { font-size: 1.6rem; font-weight: 650; letter-spacing: 0; margin: 0; }
96
+ .fillo-title { font-size: 1.6em; font-weight: 650; letter-spacing: 0; margin: 0; }
79
97
  .fillo-form-description { color: var(--fillo-muted); margin: 0; line-height: 1.55; }
80
- .fillo-page-title { font-size: 1.2rem; font-weight: 600; margin: 0; }
98
+ .fillo-page-title { font-size: 1.2em; font-weight: 600; margin: 0; }
81
99
 
82
100
  .fillo-blocks { display: flex; flex-direction: column; gap: 1.5rem; }
83
101
 
84
102
  /* ---------- Field chrome ---------- */
85
103
  .fillo-field { display: flex; flex-direction: column; gap: 0.45rem; }
86
- .fillo-label { font-weight: 550; font-size: 0.95rem; }
104
+ .fillo-label { font-weight: 550; font-size: 0.95em; }
87
105
  :where(.fillo-field--error) .fillo-label { color: var(--fillo-error); }
88
106
  /* Fillo marks the few optional fields rather than starring the many required
89
107
  ones — so a clean label reads as required by default. */
90
108
  .fillo-optional { color: var(--fillo-muted); font-weight: 400; font-size: 0.85em; }
91
- .fillo-description { color: var(--fillo-muted); font-size: 0.85rem; margin: 0; line-height: 1.5; }
92
- .fillo-error { color: var(--fillo-error); font-size: 0.85rem; font-weight: 500; line-height: 1.4; margin: 0; }
93
- .fillo-submit-error { color: var(--fillo-error); font-size: 0.85rem; font-weight: 500; margin: 0; }
109
+ .fillo-description { color: var(--fillo-muted); font-size: 0.85em; margin: 0; line-height: 1.5; }
110
+ .fillo-error { color: var(--fillo-error); font-size: 0.85em; font-weight: 500; line-height: 1.4; margin: 0; }
111
+ .fillo-submit-error { color: var(--fillo-error); font-size: 0.85em; font-weight: 500; margin: 0; }
94
112
 
95
113
  /* ---------- Inputs ---------- */
96
114
  .fillo-input {
@@ -294,7 +312,7 @@
294
312
  /* Selected marker for icon-mode options — the non-color signal (the native
295
313
  input is visually hidden there, so forced-colors has nothing else). */
296
314
  .fillo-option-check { flex: 0 0 auto; margin-inline-start: auto; color: var(--fillo-primary); }
297
- .fillo-option-label { font-size: 0.95rem; }
315
+ .fillo-option-label { font-size: 1em; }
298
316
 
299
317
  .fillo-option-main {
300
318
  display: flex;
@@ -396,7 +414,7 @@
396
414
  min-width: 0;
397
415
  margin-left: 0;
398
416
  padding: 0.5rem 0.7rem;
399
- font-size: 0.92rem;
417
+ font-size: 1em;
400
418
  background: color-mix(in srgb, var(--fillo-primary) 3%, var(--fillo-control-bg));
401
419
  }
402
420
  .fillo-other-input--block {
@@ -407,7 +425,7 @@
407
425
  /* ---------- Rating ---------- */
408
426
  .fillo-rating { display: flex; gap: 0.25rem; }
409
427
  .fillo-star {
410
- font-size: 1.75rem;
428
+ font-size: 1.75em;
411
429
  line-height: 1;
412
430
  background: none;
413
431
  border: none;
@@ -458,7 +476,7 @@
458
476
  display: flex;
459
477
  justify-content: space-between;
460
478
  color: var(--fillo-muted);
461
- font-size: 0.78rem;
479
+ font-size: 0.78em;
462
480
  margin-top: 0.35rem;
463
481
  }
464
482
 
@@ -481,11 +499,11 @@
481
499
  border-radius: 999px;
482
500
  background: color-mix(in srgb, var(--fillo-primary) 10%, transparent);
483
501
  color: var(--fillo-primary);
484
- font-size: 0.78rem;
502
+ font-size: 0.78em;
485
503
  font-weight: 650;
486
504
  font-variant-numeric: tabular-nums;
487
505
  }
488
- .fillo-ranking-label { flex: 1; font-size: 0.95rem; }
506
+ .fillo-ranking-label { flex: 1; font-size: 1em; }
489
507
  .fillo-ranking-controls { display: flex; gap: 0.2rem; }
490
508
  .fillo-ranking-move {
491
509
  font: inherit;
@@ -504,13 +522,13 @@
504
522
 
505
523
  /* ---------- Matrix ---------- */
506
524
  .fillo-matrix-wrap { overflow-x: auto; }
507
- .fillo-matrix { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
525
+ .fillo-matrix { border-collapse: collapse; width: 100%; font-size: 0.9em; }
508
526
  :where(.fillo-matrix) th, :where(.fillo-matrix) td {
509
527
  padding: 0.55rem 0.7rem;
510
528
  text-align: center;
511
529
  border-bottom: 1px solid var(--fillo-border);
512
530
  }
513
- .fillo-matrix thead th { color: var(--fillo-muted); font-weight: 500; font-size: 0.8rem; }
531
+ .fillo-matrix thead th { color: var(--fillo-muted); font-weight: 500; font-size: 0.89em; }
514
532
  .fillo-matrix tbody th { text-align: start; font-weight: 500; }
515
533
  .fillo-matrix tbody tr:last-child th, .fillo-matrix tbody tr:last-child td { border-bottom: none; }
516
534
  .fillo-matrix-cell:focus-within {
@@ -524,7 +542,7 @@
524
542
  /* A quiet computed-value line, not a control: no border or input chrome —
525
543
  the slightly heavier tabular number is the only emphasis. */
526
544
  .fillo-calculated-value {
527
- font-size: 1.05rem;
545
+ font-size: 1.05em;
528
546
  font-weight: 600;
529
547
  font-variant-numeric: tabular-nums;
530
548
  color: var(--fillo-text);
@@ -553,7 +571,7 @@
553
571
  display: grid;
554
572
  place-items: center;
555
573
  color: var(--fillo-muted);
556
- font-size: 0.85rem;
574
+ font-size: 0.85em;
557
575
  pointer-events: none;
558
576
  }
559
577
  .fillo-signature-clear {
@@ -561,7 +579,7 @@
561
579
  top: 0.4rem;
562
580
  inset-inline-end: 0.4rem;
563
581
  font: inherit;
564
- font-size: 0.75rem;
582
+ font-size: 0.75em;
565
583
  border: 1px solid var(--fillo-border);
566
584
  border-radius: calc(var(--fillo-radius) * 0.6);
567
585
  background: var(--fillo-control-bg);
@@ -580,7 +598,7 @@
580
598
  }
581
599
  .fillo-signature-type-label {
582
600
  color: var(--fillo-muted);
583
- font-size: 0.8rem;
601
+ font-size: 0.8em;
584
602
  }
585
603
  .fillo-signature-type-input {
586
604
  margin-top: 0;
@@ -620,8 +638,8 @@
620
638
  outline: none;
621
639
  }
622
640
  .fillo-dropzone--disabled { cursor: not-allowed; opacity: 0.6; }
623
- .fillo-dropzone-title { font-weight: 550; font-size: 0.95rem; }
624
- .fillo-dropzone-hint { color: var(--fillo-muted); font-size: 0.82rem; }
641
+ .fillo-dropzone-title { font-weight: 550; font-size: 0.95em; }
642
+ .fillo-dropzone-hint { color: var(--fillo-muted); font-size: 0.82em; }
625
643
 
626
644
  .fillo-files { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; margin: 0; padding: 0; }
627
645
  .fillo-file {
@@ -632,7 +650,7 @@
632
650
  border: 1px solid var(--fillo-border);
633
651
  border-radius: var(--fillo-radius);
634
652
  padding: 0.625rem 0.75rem;
635
- font-size: 0.88rem;
653
+ font-size: 0.88em;
636
654
  background: var(--fillo-control-bg);
637
655
  box-sizing: border-box;
638
656
  min-width: 0;
@@ -692,7 +710,7 @@
692
710
  }
693
711
  .fillo-file-error {
694
712
  color: var(--fillo-error);
695
- font-size: 0.82rem;
713
+ font-size: 0.93em;
696
714
  line-height: 1.35;
697
715
  overflow-wrap: anywhere;
698
716
  }
@@ -747,7 +765,7 @@
747
765
  }
748
766
 
749
767
  /* ---------- Content blocks ---------- */
750
- .fillo-heading { font-size: 1.15rem; font-weight: 600; margin: 0.5rem 0 0; }
768
+ .fillo-heading { font-size: 1.15em; font-weight: 600; margin: 0.5rem 0 0; }
751
769
  .fillo-paragraph { color: var(--fillo-muted); line-height: 1.6; margin: 0; }
752
770
  .fillo-divider { border: none; border-top: 1px solid var(--fillo-border); margin: 0.25rem 0; }
753
771
 
@@ -821,11 +839,11 @@
821
839
  padding: 2rem 1rem;
822
840
  text-align: center;
823
841
  }
824
- .fillo-not-open-title { margin: 0; font-size: 1.05rem; font-weight: 650; letter-spacing: 0; }
842
+ .fillo-not-open-title { margin: 0; font-size: 1.05em; font-weight: 650; letter-spacing: 0; }
825
843
  .fillo-not-open-body {
826
844
  max-width: 26rem;
827
845
  margin: 0;
828
- font-size: 0.875rem;
846
+ font-size: 0.875em;
829
847
  color: var(--fillo-muted);
830
848
  line-height: 1.5;
831
849
  }
@@ -849,8 +867,8 @@
849
867
  border-bottom: 2.5px solid var(--fillo-primary-contrast);
850
868
  transform: translateY(-0.09rem) rotate(-45deg);
851
869
  }
852
- .fillo-success-title { margin: 0; font-size: 1.05rem; letter-spacing: 0; }
853
- .fillo-success-message { margin: 0; font-size: 0.875rem; color: var(--fillo-muted); }
870
+ .fillo-success-title { margin: 0; font-size: 1.05em; letter-spacing: 0; }
871
+ .fillo-success-message { margin: 0; font-size: 0.875em; color: var(--fillo-muted); }
854
872
 
855
873
  /* Honeypot — off-screen, never display:none (bots check that) */
856
874
  .fillo-hp {
@@ -911,7 +929,7 @@
911
929
  display: block;
912
930
  margin-top: 1.25rem;
913
931
  text-align: center;
914
- font-size: 0.75rem;
932
+ font-size: 0.75em;
915
933
  font-weight: 500;
916
934
  letter-spacing: 0;
917
935
  color: var(--fillo-muted);
@@ -929,7 +947,7 @@
929
947
  border-radius: var(--fillo-radius);
930
948
  background: #fffbeb;
931
949
  color: #92400e;
932
- font-size: 0.82rem;
950
+ font-size: 0.82em;
933
951
  line-height: 1.45;
934
952
  }
935
953
  .fillo-devwarning code {
@@ -948,7 +966,7 @@
948
966
  border-radius: 999px;
949
967
  background: #fef3c7;
950
968
  color: #92400e;
951
- font-size: 0.7rem;
969
+ font-size: 0.7em;
952
970
  font-weight: 600;
953
971
  letter-spacing: 0.06em;
954
972
  text-transform: uppercase;
@@ -966,7 +984,7 @@
966
984
  border-radius: var(--fillo-radius);
967
985
  background: var(--fillo-control-bg);
968
986
  color: var(--fillo-muted);
969
- font-size: 0.82rem;
987
+ font-size: 0.82em;
970
988
  line-height: 1.45;
971
989
  }
972
990
  .fillo-resume-clear {
@@ -994,10 +1012,19 @@
994
1012
  .fillo-turnstile {
995
1013
  min-height: 65px;
996
1014
  }
1015
+ /* Bridge mode: the widget lives in Fillo's iframe — keep it flush. */
1016
+ .fillo-turnstile-frame {
1017
+ display: block;
1018
+ border: 0;
1019
+ }
1020
+ /* interaction-only: while the check runs invisibly, reserve no space. */
1021
+ .fillo-turnstile[data-fillo-challenge-visible="false"] {
1022
+ min-height: 0;
1023
+ }
997
1024
  .fillo-turnstile-error {
998
1025
  margin: 0;
999
1026
  color: var(--fillo-error);
1000
- font-size: 0.82rem;
1027
+ font-size: 0.82em;
1001
1028
  line-height: 1.45;
1002
1029
  }
1003
1030
 
@@ -1018,7 +1045,7 @@
1018
1045
  justify-content: space-between;
1019
1046
  gap: 0.5rem;
1020
1047
  }
1021
- .fillo-group-instance-title { margin: 0; font-size: 0.95rem; font-weight: 600; }
1048
+ .fillo-group-instance-title { margin: 0; font-size: 0.95em; font-weight: 600; }
1022
1049
  .fillo-group-add {
1023
1050
  align-self: flex-start;
1024
1051
  font: inherit;
@@ -1031,7 +1058,7 @@
1031
1058
  }
1032
1059
  .fillo-group-add:disabled { opacity: 0.5; cursor: default; }
1033
1060
  .fillo-group-remove {
1034
- font-size: 1.1rem;
1061
+ font-size: 1.1em;
1035
1062
  line-height: 1;
1036
1063
  padding: 0 0.2rem;
1037
1064
  border: none;
@@ -20,20 +20,36 @@
20
20
 
21
21
 
22
22
  .fillo-form {
23
- color-scheme: light;
24
23
  --fillo-primary: #18181b;
24
+ --fillo-primary: light-dark(#18181b, #fafafa);
25
25
  --fillo-primary-contrast: #ffffff;
26
+ --fillo-primary-contrast: light-dark(#ffffff, #18181b);
26
27
  --fillo-bg: transparent;
27
28
  --fillo-text: #18181b;
29
+ --fillo-text: light-dark(#18181b, #f4f4f5);
28
30
  --fillo-muted: #71717a;
31
+ --fillo-muted: light-dark(#71717a, #a1a1aa);
29
32
  --fillo-border: #e4e4e7;
33
+ --fillo-border: light-dark(#e4e4e7, #3f3f46);
30
34
  --fillo-error: #dc2626;
35
+ --fillo-error: light-dark(#dc2626, #f87171);
31
36
  --fillo-radius: 10px;
32
37
  --fillo-font: inherit;
38
+ /* The form's local type root. Default = inherit the host's font, so the
39
+ form blends in; set it once to pin or scale the WHOLE form — every
40
+ internal size is em-based and proportional to this. Prefer an ABSOLUTE
41
+ value (px): the not-open state nests a preview .fillo-form inside the
42
+ root .fillo-form, and a relative value would compound there. */
43
+ --fillo-font-size: 1em;
33
44
  --fillo-control-bg: #ffffff;
45
+ --fillo-control-bg: light-dark(#ffffff, #18181b);
34
46
 
35
- color-scheme: light;
47
+ /* Unthemed forms follow the host's CSS color-scheme. The first value for
48
+ each token above is a safe light fallback for browsers without
49
+ light-dark(); explicit light/dark and auto remain available below. */
50
+ color-scheme: inherit;
36
51
  font-family: var(--fillo-font);
52
+ font-size: var(--fillo-font-size);
37
53
  color: var(--fillo-text);
38
54
  background: var(--fillo-bg);
39
55
  display: flex;
@@ -46,8 +62,11 @@
46
62
  color-scheme: light;
47
63
  }
48
64
 
65
+ .fillo-form[data-fillo-color-scheme="auto"] {
66
+ color-scheme: light dark;
67
+ }
68
+
49
69
  .fillo-form[data-fillo-color-scheme="dark"] {
50
- color-scheme: dark;
51
70
  --fillo-primary: #fafafa;
52
71
  --fillo-primary-contrast: #18181b;
53
72
  --fillo-text: #f4f4f5;
@@ -60,9 +79,8 @@
60
79
  }
61
80
 
62
81
  @media (prefers-color-scheme: dark) {
63
- .fillo-form:not([data-fillo-color-scheme="light"]):not([data-fillo-has-color-theme]) {
64
- color-scheme: dark;
65
- --fillo-primary: #fafafa;
82
+ .fillo-form[data-fillo-color-scheme="auto"] {
83
+ --fillo-primary: #fafafa;
66
84
  --fillo-primary-contrast: #18181b;
67
85
  --fillo-text: #f4f4f5;
68
86
  --fillo-muted: #a1a1aa;
@@ -75,22 +93,22 @@
75
93
  }
76
94
 
77
95
  .fillo-header { display: flex; flex-direction: column; gap: 0.5rem; }
78
- .fillo-title { font-size: 1.6rem; font-weight: 650; letter-spacing: 0; margin: 0; }
96
+ .fillo-title { font-size: 1.6em; font-weight: 650; letter-spacing: 0; margin: 0; }
79
97
  .fillo-form-description { color: var(--fillo-muted); margin: 0; line-height: 1.55; }
80
- .fillo-page-title { font-size: 1.2rem; font-weight: 600; margin: 0; }
98
+ .fillo-page-title { font-size: 1.2em; font-weight: 600; margin: 0; }
81
99
 
82
100
  .fillo-blocks { display: flex; flex-direction: column; gap: 1.5rem; }
83
101
 
84
102
  /* ---------- Field chrome ---------- */
85
103
  .fillo-field { display: flex; flex-direction: column; gap: 0.45rem; }
86
- .fillo-label { font-weight: 550; font-size: 0.95rem; }
104
+ .fillo-label { font-weight: 550; font-size: 0.95em; }
87
105
  :where(.fillo-field--error) .fillo-label { color: var(--fillo-error); }
88
106
  /* Fillo marks the few optional fields rather than starring the many required
89
107
  ones — so a clean label reads as required by default. */
90
108
  .fillo-optional { color: var(--fillo-muted); font-weight: 400; font-size: 0.85em; }
91
- .fillo-description { color: var(--fillo-muted); font-size: 0.85rem; margin: 0; line-height: 1.5; }
92
- .fillo-error { color: var(--fillo-error); font-size: 0.85rem; font-weight: 500; line-height: 1.4; margin: 0; }
93
- .fillo-submit-error { color: var(--fillo-error); font-size: 0.85rem; font-weight: 500; margin: 0; }
109
+ .fillo-description { color: var(--fillo-muted); font-size: 0.85em; margin: 0; line-height: 1.5; }
110
+ .fillo-error { color: var(--fillo-error); font-size: 0.85em; font-weight: 500; line-height: 1.4; margin: 0; }
111
+ .fillo-submit-error { color: var(--fillo-error); font-size: 0.85em; font-weight: 500; margin: 0; }
94
112
 
95
113
  /* ---------- Inputs ---------- */
96
114
  .fillo-input {
@@ -294,7 +312,7 @@
294
312
  /* Selected marker for icon-mode options — the non-color signal (the native
295
313
  input is visually hidden there, so forced-colors has nothing else). */
296
314
  .fillo-option-check { flex: 0 0 auto; margin-inline-start: auto; color: var(--fillo-primary); }
297
- .fillo-option-label { font-size: 0.95rem; }
315
+ .fillo-option-label { font-size: 1em; }
298
316
 
299
317
  .fillo-option-main {
300
318
  display: flex;
@@ -396,7 +414,7 @@
396
414
  min-width: 0;
397
415
  margin-left: 0;
398
416
  padding: 0.5rem 0.7rem;
399
- font-size: 0.92rem;
417
+ font-size: 1em;
400
418
  background: color-mix(in srgb, var(--fillo-primary) 3%, var(--fillo-control-bg));
401
419
  }
402
420
  .fillo-other-input--block {
@@ -407,7 +425,7 @@
407
425
  /* ---------- Rating ---------- */
408
426
  .fillo-rating { display: flex; gap: 0.25rem; }
409
427
  .fillo-star {
410
- font-size: 1.75rem;
428
+ font-size: 1.75em;
411
429
  line-height: 1;
412
430
  background: none;
413
431
  border: none;
@@ -458,7 +476,7 @@
458
476
  display: flex;
459
477
  justify-content: space-between;
460
478
  color: var(--fillo-muted);
461
- font-size: 0.78rem;
479
+ font-size: 0.78em;
462
480
  margin-top: 0.35rem;
463
481
  }
464
482
 
@@ -481,11 +499,11 @@
481
499
  border-radius: 999px;
482
500
  background: color-mix(in srgb, var(--fillo-primary) 10%, transparent);
483
501
  color: var(--fillo-primary);
484
- font-size: 0.78rem;
502
+ font-size: 0.78em;
485
503
  font-weight: 650;
486
504
  font-variant-numeric: tabular-nums;
487
505
  }
488
- .fillo-ranking-label { flex: 1; font-size: 0.95rem; }
506
+ .fillo-ranking-label { flex: 1; font-size: 1em; }
489
507
  .fillo-ranking-controls { display: flex; gap: 0.2rem; }
490
508
  .fillo-ranking-move {
491
509
  font: inherit;
@@ -504,13 +522,13 @@
504
522
 
505
523
  /* ---------- Matrix ---------- */
506
524
  .fillo-matrix-wrap { overflow-x: auto; }
507
- .fillo-matrix { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
525
+ .fillo-matrix { border-collapse: collapse; width: 100%; font-size: 0.9em; }
508
526
  :where(.fillo-matrix) th, :where(.fillo-matrix) td {
509
527
  padding: 0.55rem 0.7rem;
510
528
  text-align: center;
511
529
  border-bottom: 1px solid var(--fillo-border);
512
530
  }
513
- .fillo-matrix thead th { color: var(--fillo-muted); font-weight: 500; font-size: 0.8rem; }
531
+ .fillo-matrix thead th { color: var(--fillo-muted); font-weight: 500; font-size: 0.89em; }
514
532
  .fillo-matrix tbody th { text-align: start; font-weight: 500; }
515
533
  .fillo-matrix tbody tr:last-child th, .fillo-matrix tbody tr:last-child td { border-bottom: none; }
516
534
  .fillo-matrix-cell:focus-within {
@@ -524,7 +542,7 @@
524
542
  /* A quiet computed-value line, not a control: no border or input chrome —
525
543
  the slightly heavier tabular number is the only emphasis. */
526
544
  .fillo-calculated-value {
527
- font-size: 1.05rem;
545
+ font-size: 1.05em;
528
546
  font-weight: 600;
529
547
  font-variant-numeric: tabular-nums;
530
548
  color: var(--fillo-text);
@@ -553,7 +571,7 @@
553
571
  display: grid;
554
572
  place-items: center;
555
573
  color: var(--fillo-muted);
556
- font-size: 0.85rem;
574
+ font-size: 0.85em;
557
575
  pointer-events: none;
558
576
  }
559
577
  .fillo-signature-clear {
@@ -561,7 +579,7 @@
561
579
  top: 0.4rem;
562
580
  inset-inline-end: 0.4rem;
563
581
  font: inherit;
564
- font-size: 0.75rem;
582
+ font-size: 0.75em;
565
583
  border: 1px solid var(--fillo-border);
566
584
  border-radius: calc(var(--fillo-radius) * 0.6);
567
585
  background: var(--fillo-control-bg);
@@ -580,7 +598,7 @@
580
598
  }
581
599
  .fillo-signature-type-label {
582
600
  color: var(--fillo-muted);
583
- font-size: 0.8rem;
601
+ font-size: 0.8em;
584
602
  }
585
603
  .fillo-signature-type-input {
586
604
  margin-top: 0;
@@ -620,8 +638,8 @@
620
638
  outline: none;
621
639
  }
622
640
  .fillo-dropzone--disabled { cursor: not-allowed; opacity: 0.6; }
623
- .fillo-dropzone-title { font-weight: 550; font-size: 0.95rem; }
624
- .fillo-dropzone-hint { color: var(--fillo-muted); font-size: 0.82rem; }
641
+ .fillo-dropzone-title { font-weight: 550; font-size: 0.95em; }
642
+ .fillo-dropzone-hint { color: var(--fillo-muted); font-size: 0.82em; }
625
643
 
626
644
  .fillo-files { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; margin: 0; padding: 0; }
627
645
  .fillo-file {
@@ -632,7 +650,7 @@
632
650
  border: 1px solid var(--fillo-border);
633
651
  border-radius: var(--fillo-radius);
634
652
  padding: 0.625rem 0.75rem;
635
- font-size: 0.88rem;
653
+ font-size: 0.88em;
636
654
  background: var(--fillo-control-bg);
637
655
  box-sizing: border-box;
638
656
  min-width: 0;
@@ -692,7 +710,7 @@
692
710
  }
693
711
  .fillo-file-error {
694
712
  color: var(--fillo-error);
695
- font-size: 0.82rem;
713
+ font-size: 0.93em;
696
714
  line-height: 1.35;
697
715
  overflow-wrap: anywhere;
698
716
  }
@@ -747,7 +765,7 @@
747
765
  }
748
766
 
749
767
  /* ---------- Content blocks ---------- */
750
- .fillo-heading { font-size: 1.15rem; font-weight: 600; margin: 0.5rem 0 0; }
768
+ .fillo-heading { font-size: 1.15em; font-weight: 600; margin: 0.5rem 0 0; }
751
769
  .fillo-paragraph { color: var(--fillo-muted); line-height: 1.6; margin: 0; }
752
770
  .fillo-divider { border: none; border-top: 1px solid var(--fillo-border); margin: 0.25rem 0; }
753
771
 
@@ -821,11 +839,11 @@
821
839
  padding: 2rem 1rem;
822
840
  text-align: center;
823
841
  }
824
- .fillo-not-open-title { margin: 0; font-size: 1.05rem; font-weight: 650; letter-spacing: 0; }
842
+ .fillo-not-open-title { margin: 0; font-size: 1.05em; font-weight: 650; letter-spacing: 0; }
825
843
  .fillo-not-open-body {
826
844
  max-width: 26rem;
827
845
  margin: 0;
828
- font-size: 0.875rem;
846
+ font-size: 0.875em;
829
847
  color: var(--fillo-muted);
830
848
  line-height: 1.5;
831
849
  }
@@ -849,8 +867,8 @@
849
867
  border-bottom: 2.5px solid var(--fillo-primary-contrast);
850
868
  transform: translateY(-0.09rem) rotate(-45deg);
851
869
  }
852
- .fillo-success-title { margin: 0; font-size: 1.05rem; letter-spacing: 0; }
853
- .fillo-success-message { margin: 0; font-size: 0.875rem; color: var(--fillo-muted); }
870
+ .fillo-success-title { margin: 0; font-size: 1.05em; letter-spacing: 0; }
871
+ .fillo-success-message { margin: 0; font-size: 0.875em; color: var(--fillo-muted); }
854
872
 
855
873
  /* Honeypot — off-screen, never display:none (bots check that) */
856
874
  .fillo-hp {
@@ -911,7 +929,7 @@
911
929
  display: block;
912
930
  margin-top: 1.25rem;
913
931
  text-align: center;
914
- font-size: 0.75rem;
932
+ font-size: 0.75em;
915
933
  font-weight: 500;
916
934
  letter-spacing: 0;
917
935
  color: var(--fillo-muted);
@@ -929,7 +947,7 @@
929
947
  border-radius: var(--fillo-radius);
930
948
  background: #fffbeb;
931
949
  color: #92400e;
932
- font-size: 0.82rem;
950
+ font-size: 0.82em;
933
951
  line-height: 1.45;
934
952
  }
935
953
  .fillo-devwarning code {
@@ -948,7 +966,7 @@
948
966
  border-radius: 999px;
949
967
  background: #fef3c7;
950
968
  color: #92400e;
951
- font-size: 0.7rem;
969
+ font-size: 0.7em;
952
970
  font-weight: 600;
953
971
  letter-spacing: 0.06em;
954
972
  text-transform: uppercase;
@@ -966,7 +984,7 @@
966
984
  border-radius: var(--fillo-radius);
967
985
  background: var(--fillo-control-bg);
968
986
  color: var(--fillo-muted);
969
- font-size: 0.82rem;
987
+ font-size: 0.82em;
970
988
  line-height: 1.45;
971
989
  }
972
990
  .fillo-resume-clear {
@@ -994,10 +1012,19 @@
994
1012
  .fillo-turnstile {
995
1013
  min-height: 65px;
996
1014
  }
1015
+ /* Bridge mode: the widget lives in Fillo's iframe — keep it flush. */
1016
+ .fillo-turnstile-frame {
1017
+ display: block;
1018
+ border: 0;
1019
+ }
1020
+ /* interaction-only: while the check runs invisibly, reserve no space. */
1021
+ .fillo-turnstile[data-fillo-challenge-visible="false"] {
1022
+ min-height: 0;
1023
+ }
997
1024
  .fillo-turnstile-error {
998
1025
  margin: 0;
999
1026
  color: var(--fillo-error);
1000
- font-size: 0.82rem;
1027
+ font-size: 0.82em;
1001
1028
  line-height: 1.45;
1002
1029
  }
1003
1030
 
@@ -1018,7 +1045,7 @@
1018
1045
  justify-content: space-between;
1019
1046
  gap: 0.5rem;
1020
1047
  }
1021
- .fillo-group-instance-title { margin: 0; font-size: 0.95rem; font-weight: 600; }
1048
+ .fillo-group-instance-title { margin: 0; font-size: 0.95em; font-weight: 600; }
1022
1049
  .fillo-group-add {
1023
1050
  align-self: flex-start;
1024
1051
  font: inherit;
@@ -1031,7 +1058,7 @@
1031
1058
  }
1032
1059
  .fillo-group-add:disabled { opacity: 0.5; cursor: default; }
1033
1060
  .fillo-group-remove {
1034
- font-size: 1.1rem;
1061
+ font-size: 1.1em;
1035
1062
  line-height: 1;
1036
1063
  padding: 0 0.2rem;
1037
1064
  border: none;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/react",
3
- "version": "0.16.1",
3
+ "version": "0.19.0",
4
4
  "description": "Headless React components for embedding Fillo forms natively in your product.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@usefillo/core": "^0.16.1"
46
+ "@usefillo/core": "^0.19.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "react": "^18.0.0 || ^19.0.0",