@usefillo/dom 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
@@ -65,7 +65,7 @@ Or drop it in with a single script tag — the bundle exposes a global `Fillo`:
65
65
  </script>
66
66
  ```
67
67
 
68
- The default stylesheet follows system dark mode for unthemed embeds. Pass `theme: { colorScheme: "dark" }` or `"light"` when the host surface is known.
68
+ The default stylesheet inherits the host page's CSS `color-scheme` and font. If your theme switch 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.
69
69
 
70
70
  Every rendered part carries `data-*` state attributes (`data-selected`, `data-invalid`, …) for utility CSS, and the default stylesheet is cascade-layered so your own styles win. On Tailwind v3 or reset-heavy sites import `@usefillo/dom/styles.unlayered.css` instead. Styling contract: [fillo.so/docs/styling](https://fillo.so/docs/styling).
71
71
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Field, FieldValue, FormSchema, FilloClient, ResponseData, FormStatus as FormStatus$1, FormPage, Block, FilloRespondent, CodeForm, FormTheme, ChallengeConfig, FieldKind, FilloError, FormControllerOptions, FormController } from '@usefillo/core';
1
+ import { Field, FieldValue, FormSchema, FilloClient, ResponseData, FormStatus as FormStatus$1, FormPage, Block, FilloRespondent, CodeForm, FormTheme, ChallengeConfig, ChallengeTheme, FieldKind, FilloError, FormControllerOptions, FormController } from '@usefillo/core';
2
2
  export { CodeForm, Field, FieldKind, FieldValue, FileValue, FilloClient, FilloClientOptions, FilloError, FormController, FormControllerOptions, FormControllerState, FormSchema, FormTheme, ProvisionWorkspaceResult, ResponseData, createClient, defineForm, provisionWorkspace } from '@usefillo/core';
3
3
 
4
4
  type FormStatus = FormStatus$1 | "loading" | "closed";
@@ -59,6 +59,18 @@ interface RenderFormBaseOptions {
59
59
  * hosted page does this). Wins over anything the server delivers.
60
60
  */
61
61
  challenge?: ChallengeConfig;
62
+ /**
63
+ * Theme for the human-verification widget. Defaults to "auto" (the
64
+ * visitor's OS preference); apps with their own theme switch pass
65
+ * "light"/"dark" so the widget matches the surrounding form.
66
+ */
67
+ challengeTheme?: ChallengeTheme;
68
+ /**
69
+ * Bridge-mode visibility for the human check. "interaction-only" (default)
70
+ * keeps it invisible unless Cloudflare needs the visitor to act; "always"
71
+ * shows the classic widget box the whole time.
72
+ */
73
+ challengeAppearance?: "always" | "interaction-only";
62
74
  className?: string;
63
75
  components?: Partial<Record<FieldKind, FieldRenderer>>;
64
76
  customComponents?: Record<string, FieldRenderer>;
package/dist/index.js CHANGED
@@ -1662,7 +1662,10 @@ function repeatingGroup(context) {
1662
1662
  removeBtn.toggleAttribute("disabled", !canRemove);
1663
1663
  if (!canRemove) {
1664
1664
  removeBtn.setAttribute("aria-disabled", "true");
1665
- removeBtn.setAttribute("title", `At least ${floor} ${floor === 1 ? "entry" : "entries"} required`);
1665
+ removeBtn.setAttribute(
1666
+ "title",
1667
+ `At least ${floor} ${floor === 1 ? "entry" : "entries"} required`
1668
+ );
1666
1669
  }
1667
1670
  removeBtn.addEventListener("click", () => {
1668
1671
  if (count <= floor) return;
@@ -1758,6 +1761,8 @@ function defaultFieldRenderer(context) {
1758
1761
  }
1759
1762
  var TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
1760
1763
  var TURNSTILE_SCRIPT_ID = "fillo-turnstile-script";
1764
+ var BRIDGE_READY_TIMEOUT_MS = 2e4;
1765
+ var CHALLENGE_CDATA_RE = /^[A-Za-z0-9_-]{1,255}$/;
1761
1766
  var turnstileScriptPromise = null;
1762
1767
  function turnstileGlobal() {
1763
1768
  return globalThis.turnstile;
@@ -1883,6 +1888,15 @@ var DomFormController = class {
1883
1888
  // Invalidated on teardown so a stale load/render callback can't land after
1884
1889
  // a later attempt replaced the container (or double-render into it).
1885
1890
  turnstileAttempt = 0;
1891
+ // Bridge mode (challenge.bridgeUrl): the Fillo-hosted iframe that runs the
1892
+ // widget on Fillo's OWN hostname (so the check works on any embedding
1893
+ // domain) plus its postMessage wiring. Mutually exclusive with the direct
1894
+ // Cloudflare-script fields above; teardownTurnstileWidget() clears both.
1895
+ challengeFrame = null;
1896
+ challengeFrameSrc = null;
1897
+ challengeFrameOrigin = null;
1898
+ challengeMessageHandler = null;
1899
+ challengeWatchdog = null;
1886
1900
  // Clear-then-set (one frame apart) so an identical repeat still re-fires —
1887
1901
  // a same-tick ""→text write can collapse into a no-op net change for AT.
1888
1902
  announce = (text) => {
@@ -1958,6 +1972,8 @@ var DomFormController = class {
1958
1972
  * substitutes for the explicit uploadsAvailable control state. */
1959
1973
  syncWarningCode;
1960
1974
  syncWarningUrl;
1975
+ /** Dashboard form overview containing the Publish action — dev chrome only. */
1976
+ syncFormUrl;
1961
1977
  /** Server-authoritative ability to start a new file upload. */
1962
1978
  uploadsAvailable;
1963
1979
  /** Server-authoritative per-file ceiling for the active storage lane. */
@@ -1983,6 +1999,7 @@ var DomFormController = class {
1983
1999
  const result = await syncCodeForm(client, codeForm, { bypassCache: true });
1984
2000
  this.syncWarningCode = result.warningCode;
1985
2001
  this.syncWarningUrl = result.warningUrl;
2002
+ this.syncFormUrl = result.manageUrl;
1986
2003
  this.uploadsAvailable = result.uploadsAvailable;
1987
2004
  this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
1988
2005
  if (result.status === "draft") {
@@ -2083,6 +2100,7 @@ var DomFormController = class {
2083
2100
  this.draftNotice = result.status === "draft";
2084
2101
  this.syncWarningCode = result.warningCode;
2085
2102
  this.syncWarningUrl = result.warningUrl;
2103
+ this.syncFormUrl = result.manageUrl;
2086
2104
  this.uploadsAvailable = result.uploadsAvailable;
2087
2105
  this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
2088
2106
  this.challenge = this.options.challenge ?? result.challenge;
@@ -2413,7 +2431,7 @@ var DomFormController = class {
2413
2431
  } catch (error) {
2414
2432
  if (this.destroyed || controller.signal.aborted) return;
2415
2433
  const uploadError = toFilloError(error);
2416
- const message = uploadError.status !== void 0 && uploadError.status >= 500 ? DEFAULT_FIELD_STRINGS.uploadUnavailable : uploadError.message || DEFAULT_FIELD_STRINGS.uploadFailed;
2434
+ const message = uploadError.status === 0 || uploadError.status !== void 0 && uploadError.status >= 500 ? DEFAULT_FIELD_STRINGS.uploadUnavailable : DEFAULT_FIELD_STRINGS.uploadFailed;
2417
2435
  const row = this.inFlight.get(key);
2418
2436
  if (row) row.error = message;
2419
2437
  this.announceAlert(message);
@@ -2503,6 +2521,142 @@ var DomFormController = class {
2503
2521
  }
2504
2522
  this.overlays.clear();
2505
2523
  }
2524
+ /** Route the challenge to bridge or direct mode per the server's config.
2525
+ * Bridge (bridgeUrl present — every current server) works on any embedding
2526
+ * domain; direct is the pre-bridge fallback for older self-hosted servers.
2527
+ * Message contract mirrors @usefillo/react's turnstile module; keep both
2528
+ * in sync with apps/web src/app/embed/challenge/route.ts. */
2529
+ ensureChallengeWidget(challenge) {
2530
+ if (challenge.bridgeUrl) this.ensureChallengeBridge(challenge.bridgeUrl);
2531
+ else this.ensureTurnstileWidget(challenge.siteKey);
2532
+ }
2533
+ /** Idempotent per src: mounts the Fillo-hosted bridge iframe into the
2534
+ * persistent container and wires its postMessage token flow. */
2535
+ ensureChallengeBridge(bridgeUrl) {
2536
+ if (typeof window === "undefined") return;
2537
+ let url;
2538
+ try {
2539
+ url = new URL(bridgeUrl);
2540
+ } catch {
2541
+ this.handleChallengeError();
2542
+ return;
2543
+ }
2544
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
2545
+ this.handleChallengeError();
2546
+ return;
2547
+ }
2548
+ url.searchParams.set("origin", window.location.origin);
2549
+ url.searchParams.set("theme", this.options.challengeTheme ?? "auto");
2550
+ if (this.formId && CHALLENGE_CDATA_RE.test(this.formId)) {
2551
+ url.searchParams.set("cdata", this.formId);
2552
+ }
2553
+ url.searchParams.set(
2554
+ "appearance",
2555
+ (this.options.challengeAppearance ?? "interaction-only") === "interaction-only" ? "interaction-only" : "always"
2556
+ );
2557
+ const src = url.toString();
2558
+ if (this.challengeFrame && this.challengeFrameSrc === src) return;
2559
+ this.teardownTurnstileWidget();
2560
+ const attempt = ++this.turnstileAttempt;
2561
+ const frame = el("iframe", {
2562
+ className: "fillo-turnstile-frame",
2563
+ attrs: {
2564
+ src,
2565
+ title: "Human verification",
2566
+ // Cloudflare's normal widget footprint; attributes so host CSS wins.
2567
+ width: "300",
2568
+ height: "65",
2569
+ referrerpolicy: "no-referrer"
2570
+ }
2571
+ });
2572
+ this.challengeFrame = frame;
2573
+ this.challengeFrameSrc = src;
2574
+ this.challengeFrameOrigin = url.origin;
2575
+ this.turnstileContainer.appendChild(frame);
2576
+ this.setChallengeFrameVisible(
2577
+ (this.options.challengeAppearance ?? "interaction-only") !== "interaction-only"
2578
+ );
2579
+ const onMessage = (event) => {
2580
+ if (attempt !== this.turnstileAttempt || this.destroyed) return;
2581
+ if (event.origin !== this.challengeFrameOrigin) return;
2582
+ if (!this.challengeFrame || event.source !== this.challengeFrame.contentWindow) return;
2583
+ const data = event.data;
2584
+ switch (data?.type) {
2585
+ case "fillo:challenge:ready":
2586
+ this.settleChallengeWatchdog();
2587
+ break;
2588
+ case "fillo:challenge:token":
2589
+ this.settleChallengeWatchdog();
2590
+ if (typeof data.token === "string" && data.token.length > 0) {
2591
+ this.handleChallengeToken(data.token);
2592
+ }
2593
+ if ((this.options.challengeAppearance ?? "interaction-only") === "interaction-only") {
2594
+ this.setChallengeFrameVisible(false);
2595
+ }
2596
+ break;
2597
+ case "fillo:challenge:interactive":
2598
+ this.setChallengeFrameVisible(true);
2599
+ break;
2600
+ case "fillo:challenge:interactive-done":
2601
+ if ((this.options.challengeAppearance ?? "interaction-only") === "interaction-only") {
2602
+ this.setChallengeFrameVisible(false);
2603
+ }
2604
+ break;
2605
+ case "fillo:challenge:expired":
2606
+ this.handleChallengeToken(void 0);
2607
+ break;
2608
+ case "fillo:challenge:error":
2609
+ this.settleChallengeWatchdog();
2610
+ this.handleChallengeToken(void 0);
2611
+ this.handleChallengeError();
2612
+ break;
2613
+ }
2614
+ };
2615
+ this.challengeMessageHandler = onMessage;
2616
+ window.addEventListener("message", onMessage);
2617
+ this.challengeWatchdog = window.setTimeout(() => {
2618
+ if (attempt !== this.turnstileAttempt || this.destroyed) return;
2619
+ this.handleChallengeToken(void 0);
2620
+ this.handleChallengeError();
2621
+ }, BRIDGE_READY_TIMEOUT_MS);
2622
+ }
2623
+ /** Collapse/expand the bridge frame (interaction-only mode). The frame stays
2624
+ * mounted either way — height 0 keeps the invisible check running. */
2625
+ setChallengeFrameVisible(visible) {
2626
+ const frame = this.challengeFrame;
2627
+ if (!frame) return;
2628
+ frame.setAttribute("height", visible ? "65" : "0");
2629
+ if (visible) {
2630
+ frame.removeAttribute("aria-hidden");
2631
+ frame.removeAttribute("tabindex");
2632
+ } else {
2633
+ frame.setAttribute("aria-hidden", "true");
2634
+ frame.setAttribute("tabindex", "-1");
2635
+ }
2636
+ this.turnstileContainer.setAttribute(
2637
+ "data-fillo-challenge-visible",
2638
+ visible ? "true" : "false"
2639
+ );
2640
+ }
2641
+ settleChallengeWatchdog() {
2642
+ if (this.challengeWatchdog !== null && typeof window !== "undefined") {
2643
+ window.clearTimeout(this.challengeWatchdog);
2644
+ }
2645
+ this.challengeWatchdog = null;
2646
+ }
2647
+ /** Unwire and remove the bridge frame (listener, watchdog, iframe). Safe to
2648
+ * call when nothing is mounted. */
2649
+ teardownChallengeBridge() {
2650
+ if (this.challengeMessageHandler && typeof window !== "undefined") {
2651
+ window.removeEventListener("message", this.challengeMessageHandler);
2652
+ }
2653
+ this.challengeMessageHandler = null;
2654
+ this.settleChallengeWatchdog();
2655
+ this.challengeFrame?.remove();
2656
+ this.challengeFrame = null;
2657
+ this.challengeFrameSrc = null;
2658
+ this.challengeFrameOrigin = null;
2659
+ }
2506
2660
  /** Idempotent: at most one load+render per armed container (guarded by
2507
2661
  * widget id / in-flight / already-failed); re-arms on a changed site key
2508
2662
  * (mirrors react's effect, keyed on siteKey). */
@@ -2558,7 +2712,14 @@ var DomFormController = class {
2558
2712
  * expiry, or timeout) — not a teardown, the widget stays mounted. */
2559
2713
  resetChallengeWidget() {
2560
2714
  this.challengeToken = void 0;
2561
- if (this.turnstileWidgetId) turnstileGlobal()?.reset(this.turnstileWidgetId);
2715
+ if (this.challengeFrame && this.challengeFrameOrigin) {
2716
+ this.challengeFrame.contentWindow?.postMessage(
2717
+ { type: "fillo:challenge:reset" },
2718
+ this.challengeFrameOrigin
2719
+ );
2720
+ } else if (this.turnstileWidgetId) {
2721
+ turnstileGlobal()?.reset(this.turnstileWidgetId);
2722
+ }
2562
2723
  this.queueRender();
2563
2724
  }
2564
2725
  /** Fully remove any active/pending widget (mirrors react's unmount effect):
@@ -2566,8 +2727,11 @@ var DomFormController = class {
2566
2727
  * fresh container replaces the old one for a pristine next mount. Safe to
2567
2728
  * call when nothing is active. */
2568
2729
  teardownTurnstileWidget() {
2569
- if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight) return;
2730
+ if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight && this.challengeFrame === null) {
2731
+ return;
2732
+ }
2570
2733
  this.turnstileAttempt++;
2734
+ this.teardownChallengeBridge();
2571
2735
  const api = turnstileGlobal();
2572
2736
  if (this.turnstileWidgetId && api) {
2573
2737
  try {
@@ -2835,6 +2999,7 @@ var DomFormController = class {
2835
2999
  staged: this.stagedNotice,
2836
3000
  draft: this.draftNotice,
2837
3001
  warningUrl: this.syncWarningUrl,
3002
+ formUrl: this.syncFormUrl,
2838
3003
  noClient: !this.options.client && !this.options.renderOnly
2839
3004
  })
2840
3005
  );
@@ -3039,8 +3204,8 @@ var DomFormController = class {
3039
3204
  );
3040
3205
  }
3041
3206
  content.appendChild(slot);
3042
- this.ensureTurnstileWidget(this.challenge.siteKey);
3043
- } else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight) {
3207
+ this.ensureChallengeWidget(this.challenge);
3208
+ } else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeFrame !== null) {
3044
3209
  this.teardownTurnstileWidget();
3045
3210
  }
3046
3211
  const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
@@ -3214,25 +3379,45 @@ function devSyncErrorEl(error) {
3214
3379
  attrs: { role: "alert" }
3215
3380
  });
3216
3381
  }
3217
- function devStagedEl() {
3218
- return el("div", {
3219
- className: "fillo-devwarning",
3220
- text: "Code changes are staged. Local preview only. Publish in Fillo before deploying for respondents.",
3221
- attrs: { role: "alert" }
3222
- });
3382
+ function devStagedEl(formUrl) {
3383
+ const node = el("div", { className: "fillo-devwarning", attrs: { role: "alert" } });
3384
+ node.append(
3385
+ "Code changes are staged, not live. This page shows your draft, while respondents still get the live version. "
3386
+ );
3387
+ const publishUrl = safeHttpUrl(formUrl);
3388
+ if (publishUrl) {
3389
+ node.appendChild(
3390
+ el("a", {
3391
+ text: "Review and publish in Fillo",
3392
+ attrs: { href: publishUrl, target: "_blank", rel: "noopener noreferrer" }
3393
+ })
3394
+ );
3395
+ node.append(".");
3396
+ } else {
3397
+ node.append("Review and publish the changes in Fillo.");
3398
+ }
3399
+ return node;
3223
3400
  }
3224
- function devDraftEl(warningUrl) {
3401
+ function devDraftEl(warningUrl, formUrl) {
3225
3402
  const node = el("div", { className: "fillo-devwarning", attrs: { role: "alert" } });
3226
3403
  node.append(
3227
- "Draft form \u2014 it renders here, but it won't accept responses until you publish it in your Fillo dashboard. "
3404
+ "Draft form preview \u2014 the form renders for local testing, but Fillo will reject the submission and save no response until you publish it. "
3228
3405
  );
3229
- const safeUrl = safeHttpUrl(warningUrl);
3230
- if (safeUrl) {
3231
- node.append("Connect storage to publish: ");
3406
+ const storageUrl = safeHttpUrl(warningUrl);
3407
+ const publishUrl = safeHttpUrl(formUrl);
3408
+ if (storageUrl) {
3409
+ node.appendChild(
3410
+ el("a", {
3411
+ text: "Connect storage to publish",
3412
+ attrs: { href: storageUrl, target: "_blank", rel: "noopener noreferrer" }
3413
+ })
3414
+ );
3415
+ node.append(". ");
3416
+ } else if (publishUrl) {
3232
3417
  node.appendChild(
3233
3418
  el("a", {
3234
- text: safeUrl,
3235
- attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" }
3419
+ text: "Open in Fillo to publish",
3420
+ attrs: { href: publishUrl, target: "_blank", rel: "noopener noreferrer" }
3236
3421
  })
3237
3422
  );
3238
3423
  node.append(". ");
@@ -3260,8 +3445,8 @@ function devChromeEls(state) {
3260
3445
  if (state.preview) nodes.push(previewBadgeEl());
3261
3446
  if (state.devNotices === false) return nodes;
3262
3447
  if (state.syncError) nodes.push(devSyncErrorEl(state.syncError));
3263
- else if (state.staged) nodes.push(devStagedEl());
3264
- else if (state.draft) nodes.push(devDraftEl(state.warningUrl));
3448
+ else if (state.staged) nodes.push(devStagedEl(state.formUrl));
3449
+ else if (state.draft) nodes.push(devDraftEl(state.warningUrl, state.formUrl));
3265
3450
  else if (state.noClient) nodes.push(devNoClientEl());
3266
3451
  return nodes;
3267
3452
  }