@usefillo/dom 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // src/index.ts
2
2
  import {
3
+ DEFAULT_STRINGS,
4
+ DEFAULT_FIELD_STRINGS,
3
5
  FilloClient,
4
6
  FilloError,
5
7
  PHONE_COUNTRIES,
@@ -12,9 +14,11 @@ import {
12
14
  flagEmoji,
13
15
  formSchemasEqual,
14
16
  formatNational,
17
+ isBuildTimeDevEnv,
15
18
  isCodeForm,
16
19
  isField,
17
20
  isFilloError,
21
+ isLikelyDevEnv,
18
22
  normalizeFormSchema,
19
23
  normalizeFormTheme,
20
24
  parsePhone,
@@ -1040,6 +1044,8 @@ function signature(context) {
1040
1044
  function fileUpload(context) {
1041
1045
  const field = context.field;
1042
1046
  if (field.kind !== "file_upload") return null;
1047
+ const internal = context;
1048
+ const storageBlocked = internal.storageBlocked === true;
1043
1049
  const input = el("input", {
1044
1050
  className: "fillo-input",
1045
1051
  attrs: { id: context.ids.inputId, type: "file", "data-fillo": "control" }
@@ -1047,14 +1053,32 @@ function fileUpload(context) {
1047
1053
  if ((field.maxFiles ?? 1) > 1) input.multiple = true;
1048
1054
  if (field.accept?.length) input.accept = field.accept.join(",");
1049
1055
  const progress = context.uploadProgress?.get(field.id);
1050
- input.disabled = progress !== void 0;
1056
+ input.disabled = progress !== void 0 || storageBlocked;
1051
1057
  applyFieldAria(input, field, context.error, context.ids);
1052
1058
  input.addEventListener("change", () => {
1059
+ if (storageBlocked) return;
1053
1060
  const files = Array.from(input.files ?? []);
1054
1061
  void context.uploadFiles?.(field, files);
1055
1062
  });
1056
1063
  const wrap = el("div");
1057
1064
  wrap.appendChild(input);
1065
+ if (storageBlocked) {
1066
+ const hint = el("p", {
1067
+ className: "fillo-upload-notice",
1068
+ text: internal.storageFixUrl ? "Connect file storage to enable uploads" : DEFAULT_FIELD_STRINGS.uploadsUnavailable,
1069
+ attrs: { role: "status" }
1070
+ });
1071
+ if (internal.storageFixUrl) {
1072
+ hint.append(" \u2014 ");
1073
+ hint.appendChild(
1074
+ el("a", {
1075
+ text: internal.storageFixUrl,
1076
+ attrs: { href: internal.storageFixUrl, target: "_blank", rel: "noopener noreferrer" }
1077
+ })
1078
+ );
1079
+ }
1080
+ wrap.appendChild(hint);
1081
+ }
1058
1082
  if (progress !== void 0) {
1059
1083
  const meter = el("div", {
1060
1084
  className: "fillo-upload-progress",
@@ -1127,6 +1151,7 @@ var DomFormController = class {
1127
1151
  this.target = target;
1128
1152
  this.options = options;
1129
1153
  this.element.className = "fillo-dom-root";
1154
+ if (options.preview === true) warnPreviewInProduction();
1130
1155
  target.replaceChildren(this.element);
1131
1156
  void this.resolve();
1132
1157
  }
@@ -1162,6 +1187,13 @@ var DomFormController = class {
1162
1187
  // Teardown callbacks for open overlays (phone country popover) whose listeners
1163
1188
  // live on window/document and would otherwise leak past a re-render/unmount.
1164
1189
  overlays = /* @__PURE__ */ new Set();
1190
+ /** Dev chrome = every developer-facing surface below (inline schema render,
1191
+ * notices, verbose submit errors, upload pre-emption). `preview` forces it
1192
+ * on off-localhost surfaces — cosmetic only, so correctness (where
1193
+ * submissions go, whether they are accepted) never depends on it. */
1194
+ devChrome() {
1195
+ return this.options.preview === true || isDevEnv();
1196
+ }
1165
1197
  get status() {
1166
1198
  if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
1167
1199
  return this.phase;
@@ -1199,13 +1231,29 @@ var DomFormController = class {
1199
1231
  };
1200
1232
  }
1201
1233
  resolveFormIdFn;
1202
- /** Prod-only: the code form is an unpublished draft — submissions would 404. */
1203
- notLive = false;
1234
+ /** The server refuses submissions right now — render the real form blurred
1235
+ * and display-only beneath the not-open card ("notOpen" for a draft /
1236
+ * storage-blocked form, "closed" for an expired/capped workspace). Doubles
1237
+ * as the display-only gate: while set, the engine is created without a
1238
+ * client/formId/resolver and every interactive entry point no-ops. */
1239
+ notOpenVariant = null;
1204
1240
  /** Exact integration failure retained for custom error/dev surfaces. */
1205
1241
  fatalError = null;
1206
1242
  genericUnavailable = false;
1207
1243
  syncErrorNotice = null;
1208
1244
  stagedNotice = false;
1245
+ /** Dev chrome: the code form is a draft — a visible banner, not console-only. */
1246
+ draftNotice = false;
1247
+ /** Sync's owner-facing storage advisory + dashboard deep-link. It never
1248
+ * substitutes for the explicit uploadsAvailable control state. */
1249
+ syncWarningCode;
1250
+ syncWarningUrl;
1251
+ /** Server-authoritative ability to start a new file upload. */
1252
+ uploadsAvailable;
1253
+ /** Code of the last submit-time resolution failure (form_not_published,
1254
+ * form_schema_changed, …) — the connect-storage deep-link renders only with
1255
+ * the failure it explains, never glued onto transport/challenge errors. */
1256
+ resolutionFailureCode = null;
1209
1257
  async resolve() {
1210
1258
  try {
1211
1259
  const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
@@ -1215,6 +1263,9 @@ var DomFormController = class {
1215
1263
  this.resolveFormIdFn = async () => {
1216
1264
  try {
1217
1265
  const result = await syncCodeForm(client, codeForm, { bypassCache: true });
1266
+ this.syncWarningCode = result.warningCode;
1267
+ this.syncWarningUrl = result.warningUrl;
1268
+ this.uploadsAvailable = result.uploadsAvailable;
1218
1269
  if (result.status === "draft") {
1219
1270
  throw new FilloError(
1220
1271
  "This form is no longer published. Submission was stopped before sending answers.",
@@ -1223,6 +1274,14 @@ var DomFormController = class {
1223
1274
  "form_not_published"
1224
1275
  );
1225
1276
  }
1277
+ if (result.accepting === false) {
1278
+ throw new FilloError(
1279
+ "This form is no longer accepting responses. Submission was stopped before sending answers.",
1280
+ 403,
1281
+ void 0,
1282
+ result.acceptingReason ?? "form_not_accepting"
1283
+ );
1284
+ }
1226
1285
  const authoritative = normalizeFormSchema(result.resolvedSchema ?? codeForm.schema);
1227
1286
  if (!authoritative.ok) {
1228
1287
  throw new FilloError(
@@ -1250,9 +1309,11 @@ var DomFormController = class {
1250
1309
  )
1251
1310
  );
1252
1311
  }
1312
+ this.resolutionFailureCode = null;
1253
1313
  return result.formId;
1254
1314
  } catch (error) {
1255
1315
  const failure = toFilloError(error);
1316
+ this.resolutionFailureCode = failure.code ?? "sync_failed";
1256
1317
  this.reportSubmitSyncFailure(failure);
1257
1318
  throw failure;
1258
1319
  }
@@ -1273,7 +1334,7 @@ var DomFormController = class {
1273
1334
  return;
1274
1335
  }
1275
1336
  if (!client?.key) return;
1276
- if (isDevEnv()) {
1337
+ if (this.devChrome()) {
1277
1338
  this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1278
1339
  } else {
1279
1340
  this.theme = this.options.theme ?? codeForm.theme;
@@ -1290,9 +1351,13 @@ var DomFormController = class {
1290
1351
  "sync_snapshot_required"
1291
1352
  );
1292
1353
  }
1293
- const development = isDevEnv();
1294
- this.notLive = result.status === "draft" && !development;
1354
+ const development = this.devChrome();
1355
+ this.notOpenVariant = development ? null : result.status === "draft" ? "notOpen" : result.accepting === false ? overlayVariant(result.acceptingReason) : null;
1295
1356
  this.stagedNotice = Boolean(result.staged);
1357
+ this.draftNotice = result.status === "draft";
1358
+ this.syncWarningCode = result.warningCode;
1359
+ this.syncWarningUrl = result.warningUrl;
1360
+ this.uploadsAvailable = result.uploadsAvailable;
1296
1361
  if (result.branding) {
1297
1362
  this.fetchedPoweredBy = result.branding.poweredBy;
1298
1363
  this.fetchedBrandLabel = result.branding.label;
@@ -1318,10 +1383,11 @@ var DomFormController = class {
1318
1383
  )
1319
1384
  );
1320
1385
  }
1386
+ const connectStorage = result.warningUrl ? ` \u2014 connect storage to publish: ${result.warningUrl}` : "";
1321
1387
  if (result.staged) {
1322
- console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard`);
1388
+ console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard${connectStorage}`);
1323
1389
  } else if (result.status === "draft") {
1324
- console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published`);
1390
+ console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${connectStorage}`);
1325
1391
  }
1326
1392
  } catch (error) {
1327
1393
  if (this.destroyed) return;
@@ -1341,10 +1407,22 @@ var DomFormController = class {
1341
1407
  this.render();
1342
1408
  const published = await this.options.client.getForm(this.options.formId);
1343
1409
  if (this.destroyed) return;
1344
- this.closed = Boolean(published.closed);
1410
+ if (published.accepting === false) {
1411
+ this.notOpenVariant = overlayVariant(published.acceptingReason);
1412
+ } else {
1413
+ this.closed = Boolean(published.closed);
1414
+ }
1415
+ this.uploadsAvailable = published.uploadsAvailable;
1345
1416
  this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
1346
1417
  this.fetchedBrandLabel = published.branding?.label;
1347
1418
  this.fetchedBrandHref = published.branding?.href;
1419
+ if (published.accepting === false && this.options.renderError) {
1420
+ const message = this.notOpenVariant === "closed" ? "This form is no longer accepting responses." : "This form isn't accepting responses yet.";
1421
+ this.fatalError = new FilloError(message, 403, void 0, published.acceptingReason);
1422
+ this.phase = "error";
1423
+ this.render(this.fatalError);
1424
+ return;
1425
+ }
1348
1426
  this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
1349
1427
  } catch (error) {
1350
1428
  if (this.destroyed) return;
@@ -1360,17 +1438,21 @@ var DomFormController = class {
1360
1438
  this.schema = normalizedSchema;
1361
1439
  this.theme = theme;
1362
1440
  this.formId = formId;
1441
+ const displayOnly = this.notOpenVariant !== null;
1363
1442
  this.engine = coreCreateFormController({
1364
1443
  form: normalizedSchema,
1365
- formId,
1366
- client: this.options.client,
1444
+ formId: displayOnly ? void 0 : formId,
1445
+ client: displayOnly ? void 0 : this.options.client,
1367
1446
  initialData: this.options.initialData,
1368
- onChange: this.options.onChange,
1369
- onSubmitted: this.options.onSubmitted,
1447
+ onChange: displayOnly ? void 0 : this.options.onChange,
1448
+ onSubmitted: displayOnly ? void 0 : this.options.onSubmitted,
1370
1449
  getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
1371
1450
  surface: "default",
1372
- resolveFormId: this.resolveFormIdFn,
1373
- respondent: this.options.respondent
1451
+ resolveFormId: displayOnly ? void 0 : this.resolveFormIdFn,
1452
+ // Dev chrome gets the developer-grade resolution failure (message +
1453
+ // machine code) instead of the respondent-safe "unavailable" fallback.
1454
+ verboseResolutionErrors: this.devChrome(),
1455
+ respondent: displayOnly ? void 0 : this.options.respondent
1374
1456
  });
1375
1457
  let lastStatus = this.engine.getState().status;
1376
1458
  let lastResumed = this.engine.getState().resumedDraft;
@@ -1385,7 +1467,7 @@ var DomFormController = class {
1385
1467
  this.queueRender();
1386
1468
  }
1387
1469
  });
1388
- this.configureDraftFlush(normalizedSchema);
1470
+ if (!displayOnly) this.configureDraftFlush(normalizedSchema);
1389
1471
  this.phase = this.closed ? "closed" : "ready";
1390
1472
  this.render();
1391
1473
  }
@@ -1404,7 +1486,7 @@ var DomFormController = class {
1404
1486
  this.theme = theme;
1405
1487
  this.formId = formId;
1406
1488
  this.engine.setContext({ form: normalizedSchema, formId });
1407
- this.configureDraftFlush(normalizedSchema);
1489
+ if (this.notOpenVariant === null) this.configureDraftFlush(normalizedSchema);
1408
1490
  this.phase = this.closed ? "closed" : "ready";
1409
1491
  this.render();
1410
1492
  }
@@ -1438,24 +1520,25 @@ var DomFormController = class {
1438
1520
  this.setValueInternal(fieldId, value);
1439
1521
  }
1440
1522
  setValueInternal(fieldId, value, opts = {}) {
1523
+ if (this.notOpenVariant) return;
1441
1524
  this.engine?.setValue(fieldId, value);
1442
1525
  if (opts.render !== false) this.queueRender();
1443
1526
  }
1444
1527
  next() {
1445
- if (!this.engine) return;
1528
+ if (!this.engine || this.notOpenVariant) return;
1446
1529
  const before = this.engine.getState().pageIndex;
1447
1530
  this.engine.next();
1448
1531
  this.focusIntent = this.engine.getState().pageIndex !== before ? "page" : "error";
1449
1532
  this.render();
1450
1533
  }
1451
1534
  back() {
1452
- if (!this.engine) return;
1535
+ if (!this.engine || this.notOpenVariant) return;
1453
1536
  this.engine.back();
1454
1537
  this.focusIntent = "page";
1455
1538
  this.render();
1456
1539
  }
1457
1540
  async submit() {
1458
- if (!this.engine || this.phase !== "ready") return;
1541
+ if (!this.engine || this.phase !== "ready" || this.notOpenVariant) return;
1459
1542
  const pending = this.engine.submit();
1460
1543
  if (this.hasErrors()) this.focusIntent = "error";
1461
1544
  this.render();
@@ -1492,7 +1575,7 @@ var DomFormController = class {
1492
1575
  this.engine?.setContext({ respondent });
1493
1576
  }
1494
1577
  async uploadFiles(field, files) {
1495
- if (!this.schema) return;
1578
+ if (!this.schema || this.notOpenVariant) return;
1496
1579
  if (this.uploadProgress.has(field.id)) {
1497
1580
  this.uploadNotices.set(field.id, "Wait for the current upload to finish before adding more files.");
1498
1581
  this.queueRender();
@@ -1585,6 +1668,11 @@ var DomFormController = class {
1585
1668
  this.engine = null;
1586
1669
  this.target.replaceChildren();
1587
1670
  }
1671
+ /** Keep an optional-upload form accepting while pre-empting only file
1672
+ * controls the current definition envelope says the server will refuse. */
1673
+ storageBlocked() {
1674
+ return this.uploadsAvailable === false;
1675
+ }
1588
1676
  /** Add/return the phone popover teardown; called back on re-render or destroy. */
1589
1677
  registerOverlay = (close) => {
1590
1678
  this.overlays.add(close);
@@ -1687,7 +1775,7 @@ var DomFormController = class {
1687
1775
  `[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
1688
1776
  );
1689
1777
  this.syncErrorNotice = error;
1690
- const development = isDevEnv();
1778
+ const development = this.devChrome();
1691
1779
  if (!development || this.options.renderError) {
1692
1780
  this.genericUnavailable = !development;
1693
1781
  this.handleError(error);
@@ -1704,7 +1792,7 @@ var DomFormController = class {
1704
1792
  );
1705
1793
  this.syncErrorNotice = error;
1706
1794
  this.options.onError?.(error);
1707
- if (isDevEnv()) this.render();
1795
+ if (this.devChrome()) this.render();
1708
1796
  }
1709
1797
  /** Submit-time verification failed: keep the form/answers for a safe retry. */
1710
1798
  reportSubmitSyncFailure(error) {
@@ -1747,7 +1835,7 @@ var DomFormController = class {
1747
1835
  if (this.phase === "error" && activeError) {
1748
1836
  const errorView = this.options.renderError?.(activeError) ?? el("div", {
1749
1837
  className: "fillo-form fillo-form--error",
1750
- text: this.genericUnavailable ? "This form is unavailable." : activeError.message
1838
+ text: this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError)
1751
1839
  });
1752
1840
  if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
1753
1841
  if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
@@ -1766,14 +1854,9 @@ var DomFormController = class {
1766
1854
  this.element.appendChild(closed);
1767
1855
  return;
1768
1856
  }
1769
- if (this.notLive) {
1770
- const panel = el("div", {
1771
- className: `fillo-form fillo-form--closed ${this.options.className ?? ""}`,
1772
- attrs: { "data-fillo": "root", "data-state": "closed" }
1773
- });
1774
- themeStyle(panel, this.theme);
1775
- panel.appendChild(el("p", { className: "fillo-closed", text: "This form isn't live yet." }));
1776
- this.element.appendChild(panel);
1857
+ if (this.notOpenVariant && this.schema) {
1858
+ this.element.appendChild(this.renderNotOpen(this.notOpenVariant));
1859
+ if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1777
1860
  return;
1778
1861
  }
1779
1862
  if (!this.schema) return;
@@ -1797,11 +1880,20 @@ var DomFormController = class {
1797
1880
  }
1798
1881
  return;
1799
1882
  }
1800
- if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
1801
- if (this.syncErrorNotice && isDevEnv()) {
1802
- this.element.appendChild(devSyncErrorEl(this.syncErrorNotice));
1883
+ if (this.devChrome()) {
1884
+ appendChildren(
1885
+ this.element,
1886
+ devChromeEls({
1887
+ preview: this.options.preview === true,
1888
+ devNotices: this.options.devNotices,
1889
+ syncError: this.syncErrorNotice,
1890
+ staged: this.stagedNotice,
1891
+ draft: this.draftNotice,
1892
+ warningUrl: this.syncWarningUrl,
1893
+ noClient: !this.options.client
1894
+ })
1895
+ );
1803
1896
  }
1804
- if (this.stagedNotice && isDevEnv()) this.element.appendChild(devStagedEl());
1805
1897
  this.element.appendChild(this.renderForm());
1806
1898
  if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1807
1899
  this.applyFocus(intent, captured);
@@ -1810,27 +1902,71 @@ var DomFormController = class {
1810
1902
  brandingVisible() {
1811
1903
  return this.fetchedPoweredBy ?? true;
1812
1904
  }
1905
+ /** Production not-accepting chrome: the REAL form rendered blurred and
1906
+ * display-only beneath a small card — honest about the state without a dead
1907
+ * grey panel, and impossible to fill. The wrapper is inert + aria-hidden +
1908
+ * pointer-events:none, the preview markup nests every control in a natively
1909
+ * disabled fieldset (see renderForm), and the engine behind it was created
1910
+ * without a client/formId/resolver — so nothing inside can focus, submit,
1911
+ * start an upload session, or touch saved-progress drafts. */
1912
+ renderNotOpen(variant) {
1913
+ const root = el("div", {
1914
+ className: `fillo-form fillo-form--not-open ${this.options.className ?? ""}`,
1915
+ attrs: { "data-fillo": "root", "data-state": "closed" }
1916
+ });
1917
+ themeStyle(root, this.theme);
1918
+ const preview = el("div", {
1919
+ className: "fillo-not-open-preview",
1920
+ attrs: { "aria-hidden": "true", inert: "" }
1921
+ });
1922
+ preview.appendChild(this.renderForm());
1923
+ const card = el("div", {
1924
+ className: "fillo-not-open-card",
1925
+ attrs: { role: "status", "data-fillo": "not-open-card" }
1926
+ });
1927
+ card.appendChild(
1928
+ el("h2", {
1929
+ className: "fillo-not-open-title",
1930
+ text: variant === "closed" ? DEFAULT_STRINGS.closedTitle : DEFAULT_STRINGS.notOpenTitle
1931
+ })
1932
+ );
1933
+ card.appendChild(
1934
+ el("p", {
1935
+ className: "fillo-not-open-body",
1936
+ text: variant === "closed" ? DEFAULT_STRINGS.closed : DEFAULT_STRINGS.notOpenBody
1937
+ })
1938
+ );
1939
+ appendChildren(root, [preview, card]);
1940
+ return root;
1941
+ }
1813
1942
  renderForm() {
1814
1943
  const api = this.api();
1815
- const form = el("form", {
1944
+ const displayOnly = this.notOpenVariant !== null;
1945
+ const form = el(displayOnly ? "div" : "form", {
1816
1946
  className: `fillo-form ${this.options.className ?? ""}`,
1817
1947
  attrs: {
1818
- "data-fillo": "root",
1819
- "data-state": api.status,
1948
+ "data-fillo": displayOnly ? "preview-form" : "root",
1949
+ "data-state": displayOnly ? "preview" : api.status,
1820
1950
  "data-page": String(api.pageIndex + 1)
1821
1951
  }
1822
1952
  });
1823
1953
  form.toggleAttribute("data-last-page", api.isLastPage);
1824
1954
  themeStyle(form, this.theme);
1825
- form.noValidate = true;
1826
1955
  const advance = () => {
1827
1956
  if (api.isLastPage) void this.submit();
1828
1957
  else this.next();
1829
1958
  };
1830
- form.addEventListener("submit", (event) => {
1831
- event.preventDefault();
1832
- advance();
1833
- });
1959
+ if (!displayOnly) {
1960
+ const formEl = form;
1961
+ formEl.noValidate = true;
1962
+ formEl.addEventListener("submit", (event) => {
1963
+ event.preventDefault();
1964
+ advance();
1965
+ });
1966
+ }
1967
+ const content = displayOnly ? form.appendChild(
1968
+ el("fieldset", { className: "fillo-not-open-fields", attrs: { disabled: "" } })
1969
+ ) : form;
1834
1970
  const restoreState = this.engine?.getState();
1835
1971
  if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
1836
1972
  const resume = el("div", {
@@ -1850,7 +1986,7 @@ var DomFormController = class {
1850
1986
  });
1851
1987
  clear.addEventListener("click", () => this.resetDraft());
1852
1988
  resume.appendChild(clear);
1853
- form.appendChild(resume);
1989
+ content.appendChild(resume);
1854
1990
  }
1855
1991
  if (api.pageCount > 1 && this.schema?.settings.showProgress !== false) {
1856
1992
  const progress = el("div", {
@@ -1871,7 +2007,7 @@ var DomFormController = class {
1871
2007
  "--fillo-progress-value",
1872
2008
  `${(api.pageIndex + 1) / api.pageCount * 100}%`
1873
2009
  );
1874
- form.appendChild(progress);
2010
+ content.appendChild(progress);
1875
2011
  }
1876
2012
  if (api.pageIndex === 0) {
1877
2013
  const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
@@ -1889,9 +2025,9 @@ var DomFormController = class {
1889
2025
  })
1890
2026
  );
1891
2027
  }
1892
- form.appendChild(header);
2028
+ content.appendChild(header);
1893
2029
  } else if (api.page.title) {
1894
- form.appendChild(
2030
+ content.appendChild(
1895
2031
  el("h2", {
1896
2032
  className: "fillo-page-title",
1897
2033
  text: api.page.title,
@@ -1904,25 +2040,33 @@ var DomFormController = class {
1904
2040
  const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
1905
2041
  if (rendered) blocks.appendChild(rendered);
1906
2042
  }
1907
- form.appendChild(blocks);
1908
- const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
1909
- hp.style.position = "absolute";
1910
- hp.style.left = "-9999px";
1911
- hp.style.top = "auto";
1912
- hp.style.width = "1px";
1913
- hp.style.height = "1px";
1914
- hp.style.opacity = "0";
1915
- hp.style.overflow = "hidden";
1916
- hp.style.pointerEvents = "none";
1917
- hp.addEventListener("input", () => {
1918
- this.hpValue = hp.value;
1919
- });
1920
- form.appendChild(hp);
2043
+ content.appendChild(blocks);
2044
+ if (!displayOnly) {
2045
+ const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
2046
+ hp.style.position = "absolute";
2047
+ hp.style.left = "-9999px";
2048
+ hp.style.top = "auto";
2049
+ hp.style.width = "1px";
2050
+ hp.style.height = "1px";
2051
+ hp.style.opacity = "0";
2052
+ hp.style.overflow = "hidden";
2053
+ hp.style.pointerEvents = "none";
2054
+ hp.addEventListener("input", () => {
2055
+ this.hpValue = hp.value;
2056
+ });
2057
+ content.appendChild(hp);
2058
+ }
1921
2059
  const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
1922
2060
  if (submitError) {
1923
- form.appendChild(
1924
- el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError })
1925
- );
2061
+ const alert = el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError });
2062
+ const fixUrl = this.devChrome() && this.resolutionFailureCode === "form_not_published" && this.syncWarningCode === "storage_required" ? safeHttpUrl(this.syncWarningUrl) : null;
2063
+ if (fixUrl) {
2064
+ alert.append(" Connect storage: ");
2065
+ alert.appendChild(
2066
+ el("a", { text: fixUrl, attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" } })
2067
+ );
2068
+ }
2069
+ content.appendChild(alert);
1926
2070
  }
1927
2071
  const autoSubmit = api.form.settings.submitMode === "auto";
1928
2072
  const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data));
@@ -1951,7 +2095,7 @@ var DomFormController = class {
1951
2095
  });
1952
2096
  footer.appendChild(submit);
1953
2097
  }
1954
- form.appendChild(footer);
2098
+ content.appendChild(footer);
1955
2099
  }
1956
2100
  return form;
1957
2101
  }
@@ -1982,7 +2126,9 @@ var DomFormController = class {
1982
2126
  activeOtherFields: this.activeOtherFields,
1983
2127
  getValue: () => this.engine?.getState().data[block.id],
1984
2128
  registerOverlay: this.registerOverlay,
1985
- uploadNotice: this.uploadNotices.get(block.id)
2129
+ uploadNotice: this.uploadNotices.get(block.id),
2130
+ storageBlocked: this.storageBlocked(),
2131
+ storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null
1986
2132
  };
1987
2133
  const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
1988
2134
  return renderer?.(context) ?? null;
@@ -2017,13 +2163,16 @@ function poweredByEl(label, href) {
2017
2163
  attrs: { href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer" }
2018
2164
  });
2019
2165
  }
2166
+ function overlayVariant(reason) {
2167
+ return reason === "expired" || reason === "capped" || reason === "storage_full" ? "closed" : "notOpen";
2168
+ }
2020
2169
  function isDevEnv() {
2021
- return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
2170
+ return isLikelyDevEnv();
2022
2171
  }
2023
2172
  function devNoClientEl() {
2024
2173
  return el("div", {
2025
2174
  className: "fillo-devwarning",
2026
- text: "No Fillo client connected \u2014 this form is render-only and won't save responses. Pass a client to collect them in Fillo, or forward with webhooks. (Shown only in development.)",
2175
+ text: "No Fillo client connected \u2014 this form is render-only and won't save responses. Pass a client to collect them in Fillo, or forward with webhooks. (Shown only in development and preview.)",
2027
2176
  attrs: { role: "alert" }
2028
2177
  });
2029
2178
  }
@@ -2041,6 +2190,53 @@ function devStagedEl() {
2041
2190
  attrs: { role: "alert" }
2042
2191
  });
2043
2192
  }
2193
+ function devDraftEl(warningUrl) {
2194
+ const node = el("div", { className: "fillo-devwarning", attrs: { role: "alert" } });
2195
+ node.append(
2196
+ "Draft form \u2014 it renders here, but it won't accept responses until you publish it in your Fillo dashboard. "
2197
+ );
2198
+ const safeUrl = safeHttpUrl(warningUrl);
2199
+ if (safeUrl) {
2200
+ node.append("Connect storage to publish: ");
2201
+ node.appendChild(
2202
+ el("a", { text: safeUrl, attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" } })
2203
+ );
2204
+ node.append(". ");
2205
+ }
2206
+ node.append("(Shown only in development and preview.)");
2207
+ return node;
2208
+ }
2209
+ function previewBadgeEl() {
2210
+ return el("span", {
2211
+ className: "fillo-preview-badge",
2212
+ text: "Preview",
2213
+ attrs: { "data-fillo": "preview-badge" }
2214
+ });
2215
+ }
2216
+ var warnedPreviewInProduction = false;
2217
+ function warnPreviewInProduction() {
2218
+ if (warnedPreviewInProduction || isBuildTimeDevEnv()) return;
2219
+ warnedPreviewInProduction = true;
2220
+ console.warn(
2221
+ "[fillo] `preview` is enabled in a production build. Preview is cosmetic only \u2014 it shows developer chrome and never changes where submissions go or whether they are accepted \u2014 but remove it before respondents see this page."
2222
+ );
2223
+ }
2224
+ function devChromeEls(state) {
2225
+ const nodes = [];
2226
+ if (state.preview) nodes.push(previewBadgeEl());
2227
+ if (state.devNotices === false) return nodes;
2228
+ if (state.syncError) nodes.push(devSyncErrorEl(state.syncError));
2229
+ else if (state.staged) nodes.push(devStagedEl());
2230
+ else if (state.draft) nodes.push(devDraftEl(state.warningUrl));
2231
+ else if (state.noClient) nodes.push(devNoClientEl());
2232
+ return nodes;
2233
+ }
2234
+ function loadFailureMessage(error) {
2235
+ if (error.status === 404) return DEFAULT_STRINGS.loadFailedNotFound;
2236
+ if (error.status === 0) return DEFAULT_STRINGS.loadFailedNetwork;
2237
+ if (error.status === 422) return DEFAULT_STRINGS.renderFailed;
2238
+ return DEFAULT_STRINGS.loadFailed;
2239
+ }
2044
2240
  function toFilloError(error) {
2045
2241
  if (isFilloError(error)) return error;
2046
2242
  return new FilloError(error instanceof Error ? error.message : String(error), 0);
@@ -2066,7 +2262,7 @@ var FilloFormElement = class extends HTMLElementBase {
2066
2262
  mounted = false;
2067
2263
  mountScheduled = false;
2068
2264
  static get observedAttributes() {
2069
- return ["publishable-key", "form-id"];
2265
+ return ["publishable-key", "form-id", "data-preview"];
2070
2266
  }
2071
2267
  connectedCallback() {
2072
2268
  this.scheduleMount();
@@ -2115,6 +2311,14 @@ var FilloFormElement = class extends HTMLElementBase {
2115
2311
  if (this.isConnected) this.mount();
2116
2312
  });
2117
2313
  }
2314
+ /** data-preview is on when present UNLESS its value says off ("false"/"0"):
2315
+ * frameworks stringify booleans onto data-* attributes rather than omitting
2316
+ * them, and a stringified `false` must never enable dev chrome for real
2317
+ * respondents. Disable by removing the attribute or setting it to "false". */
2318
+ previewAttrEnabled() {
2319
+ const value = this.getAttribute("data-preview");
2320
+ return value !== null && value !== "false" && value !== "0";
2321
+ }
2118
2322
  mount() {
2119
2323
  this.instance?.destroy();
2120
2324
  const key = this.getAttribute("publishable-key") ?? void 0;
@@ -2126,6 +2330,12 @@ var FilloFormElement = class extends HTMLElementBase {
2126
2330
  formId,
2127
2331
  theme: this.assignedTheme,
2128
2332
  initialData: this.assignedInitialData,
2333
+ // Same cosmetic-only contract as renderForm's `preview` option: forces
2334
+ // the dev chrome + a visible Preview badge on off-localhost surfaces,
2335
+ // and never changes where submissions go or whether they are accepted.
2336
+ // Value-aware: frameworks stringify booleans onto data-* attributes, so
2337
+ // data-preview="false"/"0" must mean OFF, not presence-implies-on.
2338
+ preview: this.previewAttrEnabled() ? true : void 0,
2129
2339
  onChange: (data) => this.dispatchEvent(new CustomEvent("fillo-change", { detail: { data } })),
2130
2340
  onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
2131
2341
  onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))