@usefillo/dom 0.9.0 → 0.11.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,8 +1,11 @@
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,
8
+ PHONE_PICKER_COUNTRIES,
6
9
  countryByIso,
7
10
  countryByTimeZone,
8
11
  createClient,
@@ -11,17 +14,26 @@ import {
11
14
  digitsOnly,
12
15
  flagEmoji,
13
16
  formSchemasEqual,
17
+ formatAnswer,
18
+ formatGroupedNumber,
14
19
  formatNational,
20
+ isBuildTimeDevEnv,
15
21
  isCodeForm,
16
22
  isField,
17
23
  isFilloError,
24
+ isLikelyDevEnv,
25
+ isValidPartialNumberText,
18
26
  normalizeFormSchema,
19
27
  normalizeFormTheme,
28
+ parseGroupedNumber,
20
29
  parsePhone,
21
30
  pipeBlock,
22
31
  positionPhonePopover,
23
32
  provisionWorkspace,
24
33
  needsExplicitSubmit,
34
+ radioGroupStep,
35
+ reachablePageSequence,
36
+ resolveThemeAppearance,
25
37
  shouldAutoSubmit,
26
38
  syncCodeForm,
27
39
  toE164,
@@ -30,6 +42,9 @@ import {
30
42
  var FIELD_INPUT_PREFIX = "fillo-";
31
43
  var domInstanceCounter = 0;
32
44
  var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
45
+ function focusableWithin(node) {
46
+ return node.matches(FOCUSABLE_SELECTOR) ? node : node.querySelector(FOCUSABLE_SELECTOR);
47
+ }
33
48
  function fieldIds(instanceId, fieldId) {
34
49
  const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
35
50
  return {
@@ -48,7 +63,7 @@ function el(tag, options = {}) {
48
63
  return node;
49
64
  }
50
65
  function themeStyle(node, theme) {
51
- const safeTheme = normalizeFormTheme(theme);
66
+ const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));
52
67
  if (!safeTheme) return;
53
68
  if (safeTheme.colorScheme) node.setAttribute("data-fillo-color-scheme", safeTheme.colorScheme);
54
69
  if (safeTheme.primary) node.style.setProperty("--fillo-primary", safeTheme.primary);
@@ -78,6 +93,12 @@ function badgeUrl(href) {
78
93
  function inputValue(value) {
79
94
  return typeof value === "string" || typeof value === "number" ? String(value) : "";
80
95
  }
96
+ function groupedInputValue(value, decimals) {
97
+ const raw = inputValue(value);
98
+ if (raw === "") return raw;
99
+ const n = Number(raw);
100
+ return Number.isFinite(n) ? formatGroupedNumber(n, { decimals }) : raw;
101
+ }
81
102
  function appendChildren(parent, children) {
82
103
  for (const child of children) {
83
104
  if (child) parent.appendChild(child);
@@ -116,12 +137,11 @@ function applyFieldAria(node, field, error, ids) {
116
137
  }
117
138
  function bindRadioKeys(group, values, current, select) {
118
139
  group.addEventListener("keydown", (event) => {
119
- const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
120
- const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
121
- if (!forward && !backward) return;
122
- event.preventDefault();
140
+ const rtl = getComputedStyle(group).direction === "rtl";
123
141
  const index = current === null ? -1 : values.indexOf(current);
124
- const next = index === -1 ? forward ? 0 : values.length - 1 : (index + (forward ? 1 : -1) + values.length) % values.length;
142
+ const next = radioGroupStep(event.key, index, values.length, { rtl });
143
+ if (next === null) return;
144
+ event.preventDefault();
125
145
  const value = values[next];
126
146
  if (value === void 0) return;
127
147
  select(value);
@@ -157,29 +177,84 @@ function shell(field, error, child, ids) {
157
177
  el("p", {
158
178
  className: "fillo-error",
159
179
  text: error,
160
- attrs: { role: "alert", id: ids.errorId, "data-fillo": "error" }
180
+ attrs: { id: ids.errorId, "data-fillo": "error" }
161
181
  })
162
182
  );
163
183
  return wrap;
164
184
  }
165
185
  function textInput(type, context) {
186
+ const field = context.field;
187
+ const numberField = field.kind === "number" ? field : void 0;
188
+ const grouped = numberField?.notation === "grouped";
189
+ const formatted = grouped || Boolean(numberField?.prefix) || Boolean(numberField?.suffix);
166
190
  const input = el("input", {
167
191
  className: "fillo-input",
168
- attrs: { id: context.ids.inputId, type, "data-fillo": "control" }
192
+ attrs: { id: context.ids.inputId, type: formatted ? "text" : type, "data-fillo": "control" }
169
193
  });
170
- input.value = inputValue(context.value);
171
- if (context.field.placeholder) input.placeholder = context.field.placeholder;
172
- if ("maxLength" in context.field && context.field.maxLength !== void 0) {
173
- input.maxLength = context.field.maxLength;
194
+ if (formatted) input.setAttribute("inputmode", "decimal");
195
+ if (type === "email") input.setAttribute("autocomplete", "email");
196
+ else if (type === "url") input.setAttribute("autocomplete", "url");
197
+ input.value = grouped ? groupedInputValue(context.value, numberField?.decimals) : inputValue(context.value);
198
+ if (field.placeholder) input.placeholder = field.placeholder;
199
+ if ("maxLength" in field && field.maxLength !== void 0) {
200
+ input.maxLength = field.maxLength;
201
+ }
202
+ if (numberField && !formatted) {
203
+ if (numberField.min !== void 0) input.min = String(numberField.min);
204
+ if (numberField.max !== void 0) input.max = String(numberField.max);
205
+ }
206
+ if (formatted) {
207
+ let lastValid = input.value;
208
+ let lastCaret = input.value.length;
209
+ const rejectEdit = () => {
210
+ input.value = lastValid;
211
+ input.setSelectionRange(lastCaret, lastCaret);
212
+ };
213
+ if (grouped) {
214
+ input.addEventListener("input", () => {
215
+ if (!isValidPartialNumberText(input.value)) return rejectEdit();
216
+ lastValid = input.value;
217
+ lastCaret = input.selectionStart ?? input.value.length;
218
+ context.setValue(parseGroupedNumber(input.value), { render: false });
219
+ });
220
+ input.addEventListener("change", () => {
221
+ if (!isValidPartialNumberText(input.value)) return rejectEdit();
222
+ lastValid = input.value;
223
+ context.setValue(parseGroupedNumber(input.value));
224
+ });
225
+ input.addEventListener("blur", () => {
226
+ const live = context.getValue?.() ?? context.value;
227
+ input.value = groupedInputValue(live, numberField?.decimals);
228
+ lastValid = input.value;
229
+ lastCaret = input.value.length;
230
+ });
231
+ } else {
232
+ input.addEventListener("input", () => {
233
+ if (!isValidPartialNumberText(input.value)) return rejectEdit();
234
+ lastValid = input.value;
235
+ lastCaret = input.selectionStart ?? input.value.length;
236
+ context.setValue(input.value, { render: false });
237
+ });
238
+ input.addEventListener("change", () => {
239
+ if (!isValidPartialNumberText(input.value)) return rejectEdit();
240
+ lastValid = input.value;
241
+ context.setValue(input.value);
242
+ });
243
+ }
244
+ } else {
245
+ input.addEventListener("input", () => context.setValue(input.value, { render: false }));
246
+ input.addEventListener("change", () => context.setValue(input.value));
174
247
  }
175
- if (context.field.kind === "number") {
176
- if (context.field.min !== void 0) input.min = String(context.field.min);
177
- if (context.field.max !== void 0) input.max = String(context.field.max);
248
+ applyFieldAria(input, field, context.error, context.ids);
249
+ let control = input;
250
+ if (numberField && (numberField.prefix || numberField.suffix)) {
251
+ control = appendChildren(el("div", { className: "fillo-number" }), [
252
+ numberField.prefix ? el("span", { className: "fillo-number-prefix", text: numberField.prefix }) : null,
253
+ input,
254
+ numberField.suffix ? el("span", { className: "fillo-number-suffix", text: numberField.suffix }) : null
255
+ ]);
178
256
  }
179
- input.addEventListener("input", () => context.setValue(input.value, { render: false }));
180
- input.addEventListener("change", () => context.setValue(input.value));
181
- applyFieldAria(input, context.field, context.error, context.ids);
182
- return shell(context.field, context.error, input, context.ids);
257
+ return shell(field, context.error, control, context.ids);
183
258
  }
184
259
  function domLocaleCountry() {
185
260
  if (typeof navigator === "undefined") return void 0;
@@ -237,8 +312,9 @@ function phoneInput(context) {
237
312
  }
238
313
  });
239
314
  applyFieldAria(input, context.field, context.error, context.ids);
315
+ const liveValue = () => context.getValue?.() ?? context.value;
240
316
  const nationalFromValue = () => {
241
- const digits = digitsOnly(inputValue(context.value));
317
+ const digits = digitsOnly(inputValue(liveValue()));
242
318
  return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
243
319
  };
244
320
  const syncFlag = () => {
@@ -251,6 +327,7 @@ function phoneInput(context) {
251
327
  input.placeholder = formatNational(country, country.example);
252
328
  };
253
329
  const commit = (nat) => context.setValue(nat ? toE164(country, nat) : "", { render: false });
330
+ const commitRaw = (raw) => context.setValue(raw, { render: false });
254
331
  syncFlag();
255
332
  syncInput();
256
333
  input.addEventListener("input", () => {
@@ -258,7 +335,11 @@ function phoneInput(context) {
258
335
  const caret = input.selectionStart ?? raw.length;
259
336
  const before = digitsOnly(raw.slice(0, caret)).length;
260
337
  if (raw.trimStart().startsWith("+")) {
261
- const p = parsePhone(raw);
338
+ const p = parsePhone(raw, country, { previousDigits: nationalFromValue() });
339
+ if (p.pending) {
340
+ commitRaw(p.raw ?? raw);
341
+ return;
342
+ }
262
343
  if (p.country) {
263
344
  country = p.country;
264
345
  syncFlag();
@@ -271,11 +352,13 @@ function phoneInput(context) {
271
352
  setCaretAfterDigits(input, before);
272
353
  });
273
354
  const registerOverlay = context.registerOverlay;
355
+ const announce = context.announce;
274
356
  let popover = null;
275
357
  let active = 0;
276
- let matches = PHONE_COUNTRIES;
358
+ let matches = PHONE_PICKER_COUNTRIES;
277
359
  let stopPopoverLayout = null;
278
360
  let unregisterOverlay = null;
361
+ let resultsTimer;
279
362
  const listId = `${context.ids.inputId}-countries`;
280
363
  const syncPopoverLayout = () => {
281
364
  if (!popover) return;
@@ -294,13 +377,26 @@ function phoneInput(context) {
294
377
  window.removeEventListener("scroll", update, true);
295
378
  };
296
379
  };
380
+ const onFocusOut = (event) => {
381
+ const related = event.relatedTarget;
382
+ if (related) {
383
+ if (!countryWrap.contains(related)) closePopover();
384
+ return;
385
+ }
386
+ queueMicrotask(() => {
387
+ if (popover && !countryWrap.contains(document.activeElement)) closePopover();
388
+ });
389
+ };
297
390
  const closePopover = () => {
391
+ clearTimeout(resultsTimer);
298
392
  stopPopoverLayout?.();
299
393
  stopPopoverLayout = null;
300
394
  popover?.remove();
301
395
  popover = null;
302
396
  flagBtn.setAttribute("aria-expanded", "false");
397
+ flagBtn.removeAttribute("aria-controls");
303
398
  document.removeEventListener("mousedown", onOutside);
399
+ countryWrap.removeEventListener("focusout", onFocusOut);
304
400
  unregisterOverlay?.();
305
401
  unregisterOverlay = null;
306
402
  };
@@ -314,9 +410,10 @@ function phoneInput(context) {
314
410
  commit(nationalFromValue());
315
411
  syncInput();
316
412
  closePopover();
413
+ announce?.(DEFAULT_FIELD_STRINGS.phoneCountrySelected(c.name));
317
414
  input.focus();
318
415
  };
319
- const openPopover = () => {
416
+ const openPopover = (seed) => {
320
417
  const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
321
418
  const search = el("input", {
322
419
  className: "fillo-phone-search",
@@ -326,9 +423,11 @@ function phoneInput(context) {
326
423
  "aria-expanded": "true",
327
424
  "aria-controls": listId,
328
425
  "aria-autocomplete": "list",
426
+ "aria-label": "Search country or code",
329
427
  placeholder: "Search country or code"
330
428
  }
331
429
  });
430
+ if (seed) search.value = seed;
332
431
  const list = el("ul", {
333
432
  className: "fillo-phone-list",
334
433
  attrs: { role: "listbox", id: listId, "aria-label": "Country" }
@@ -336,7 +435,14 @@ function phoneInput(context) {
336
435
  const renderList = () => {
337
436
  list.replaceChildren();
338
437
  if (matches.length === 0) {
339
- list.appendChild(el("li", { className: "fillo-phone-empty", text: "No matches" }));
438
+ list.appendChild(
439
+ el("li", {
440
+ className: "fillo-phone-empty",
441
+ text: "No matches",
442
+ attrs: { role: "option", "aria-disabled": "true" }
443
+ })
444
+ );
445
+ search.removeAttribute("aria-activedescendant");
340
446
  syncPopoverLayout();
341
447
  return;
342
448
  }
@@ -373,21 +479,28 @@ function phoneInput(context) {
373
479
  const filter = (q) => {
374
480
  const query = q.trim().toLowerCase();
375
481
  const qDigits = query.replace(/[^\d]/g, "");
376
- matches = !query ? PHONE_COUNTRIES : PHONE_COUNTRIES.filter(
482
+ matches = !query ? PHONE_PICKER_COUNTRIES : PHONE_PICKER_COUNTRIES.filter(
377
483
  (c) => c.name.toLowerCase().includes(query) || c.iso2.toLowerCase() === query || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
378
484
  );
379
485
  active = 0;
380
486
  renderList();
487
+ clearTimeout(resultsTimer);
488
+ resultsTimer = setTimeout(
489
+ () => announce?.(DEFAULT_FIELD_STRINGS.phoneResultsCount(matches.length)),
490
+ 300
491
+ );
381
492
  };
382
493
  search.addEventListener("input", () => filter(search.value));
383
494
  search.addEventListener("keydown", (e) => {
384
495
  if (e.key === "ArrowDown") {
385
496
  e.preventDefault();
497
+ if (matches.length === 0) return;
386
498
  active = Math.min(active + 1, matches.length - 1);
387
499
  syncActive();
388
500
  search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
389
501
  } else if (e.key === "ArrowUp") {
390
502
  e.preventDefault();
503
+ if (matches.length === 0) return;
391
504
  active = Math.max(active - 1, 0);
392
505
  syncActive();
393
506
  search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
@@ -404,16 +517,40 @@ function phoneInput(context) {
404
517
  popover = pop;
405
518
  countryWrap.appendChild(pop);
406
519
  flagBtn.setAttribute("aria-expanded", "true");
407
- matches = PHONE_COUNTRIES;
408
- active = Math.max(0, PHONE_COUNTRIES.indexOf(country));
409
- renderList();
520
+ flagBtn.setAttribute("aria-controls", listId);
521
+ if (seed) {
522
+ filter(seed);
523
+ } else {
524
+ matches = PHONE_PICKER_COUNTRIES;
525
+ active = Math.max(0, PHONE_PICKER_COUNTRIES.indexOf(country));
526
+ renderList();
527
+ }
410
528
  bindPopoverLayout();
411
529
  syncActive();
412
530
  document.addEventListener("mousedown", onOutside);
531
+ countryWrap.addEventListener("focusout", onFocusOut);
413
532
  unregisterOverlay = registerOverlay?.(closePopover) ?? null;
414
- requestAnimationFrame(() => search.focus());
533
+ requestAnimationFrame(() => {
534
+ search.focus();
535
+ if (seed) {
536
+ const end = search.value.length;
537
+ search.setSelectionRange(end, end);
538
+ }
539
+ });
415
540
  };
416
541
  flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
542
+ flagBtn.addEventListener("keydown", (event) => {
543
+ if (popover) return;
544
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
545
+ event.preventDefault();
546
+ openPopover();
547
+ return;
548
+ }
549
+ if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
550
+ event.preventDefault();
551
+ openPopover(event.key);
552
+ }
553
+ });
417
554
  appendChildren(wrap, [countryWrap, input]);
418
555
  return shell(context.field, context.error, wrap, context.ids);
419
556
  }
@@ -459,7 +596,12 @@ function singleChoice(context) {
459
596
  appendChildren(label, [
460
597
  input,
461
598
  optionIcon(option.icon),
462
- el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
599
+ el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
600
+ // Icon-mode hides the native input visually (CSS), so an SVG recolor
601
+ // was the only selected-state signal — add a decorative shape signal
602
+ // that survives forced-colors (audit P1.2; state still lives on the
603
+ // input, this marker is non-authoritative).
604
+ option.icon && value === option.id ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
463
605
  ]);
464
606
  wrap.appendChild(label);
465
607
  }
@@ -478,13 +620,16 @@ function singleChoice(context) {
478
620
  appendChildren(label, [
479
621
  appendChildren(el("span", { className: "fillo-option-main" }), [
480
622
  input,
481
- el("span", { className: "fillo-option-label", text: "Other", attrs: { "data-fillo": "optionLabel" } })
623
+ el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
482
624
  ])
483
625
  ]);
484
626
  if (otherActive) {
485
- const other = el("input", { className: "fillo-input fillo-other-input", attrs: { type: "text" } });
627
+ const other = el("input", {
628
+ className: "fillo-input fillo-other-input",
629
+ attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
630
+ });
486
631
  other.value = isOther ? value : "";
487
- other.placeholder = "Your answer";
632
+ other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
488
633
  other.addEventListener("input", () => {
489
634
  activeOtherFields?.add(field.id);
490
635
  context.setValue(other.value, { render: false });
@@ -532,7 +677,8 @@ function multiChoice(context) {
532
677
  appendChildren(label, [
533
678
  input,
534
679
  optionIcon(option.icon),
535
- el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
680
+ el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
681
+ option.icon && selected.includes(option.id) ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
536
682
  ]);
537
683
  wrap.appendChild(label);
538
684
  }
@@ -558,13 +704,16 @@ function multiChoice(context) {
558
704
  appendChildren(label, [
559
705
  appendChildren(el("span", { className: "fillo-option-main" }), [
560
706
  input,
561
- el("span", { className: "fillo-option-label", text: "Other", attrs: { "data-fillo": "optionLabel" } })
707
+ el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
562
708
  ])
563
709
  ]);
564
710
  if (active) {
565
- const other = el("input", { className: "fillo-input fillo-other-input", attrs: { type: "text" } });
711
+ const other = el("input", {
712
+ className: "fillo-input fillo-other-input",
713
+ attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
714
+ });
566
715
  other.value = otherText ?? "";
567
- other.placeholder = "Your answer";
716
+ other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
568
717
  other.addEventListener("input", () => {
569
718
  activeOtherFields?.add(field.id);
570
719
  const rest = getSelected().filter((value) => knownIds.has(value));
@@ -605,7 +754,7 @@ function dropdown(context) {
605
754
  for (const option of displayOptions(field)) {
606
755
  select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
607
756
  }
608
- if (field.allowOther) select.appendChild(el("option", { text: "Other...", attrs: { value: other } }));
757
+ if (field.allowOther) select.appendChild(el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } }));
609
758
  select.value = otherActive ? other : value;
610
759
  select.addEventListener("change", () => {
611
760
  if (select.value === other) {
@@ -622,10 +771,10 @@ function dropdown(context) {
622
771
  if (otherActive) {
623
772
  const other2 = el("input", {
624
773
  className: "fillo-input fillo-other-input fillo-other-input--block",
625
- attrs: { type: "text" }
774
+ attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
626
775
  });
627
776
  other2.value = isOther ? value : "";
628
- other2.placeholder = "Your answer";
777
+ other2.placeholder = DEFAULT_STRINGS.otherPlaceholder;
629
778
  other2.addEventListener("input", () => {
630
779
  activeOtherFields?.add(field.id);
631
780
  context.setValue(other2.value, { render: false });
@@ -666,14 +815,11 @@ function checkbox(context) {
666
815
  className: "fillo-toggle-input",
667
816
  attrs: {
668
817
  id: context.ids.inputId,
669
- type: "checkbox",
670
- role: "switch",
671
- "aria-checked": String(context.value === true)
818
+ type: "checkbox"
672
819
  }
673
820
  });
674
821
  input2.checked = context.value === true;
675
822
  input2.addEventListener("change", () => {
676
- input2.setAttribute("aria-checked", String(input2.checked));
677
823
  context.setValue(input2.checked);
678
824
  });
679
825
  applyFieldAria(input2, context.field, context.error, context.ids);
@@ -698,7 +844,7 @@ function checkbox(context) {
698
844
  el("p", {
699
845
  className: "fillo-error",
700
846
  text: context.error,
701
- attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
847
+ attrs: { id: context.ids.errorId, "data-fillo": "error" }
702
848
  })
703
849
  );
704
850
  return wrap2;
@@ -769,7 +915,10 @@ function rating(context) {
769
915
  for (const [index, n] of values.entries()) {
770
916
  const button = el("button", {
771
917
  className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
772
- text: "\u2605",
918
+ // Shape signal, not just color, so the selected state survives
919
+ // forced-colors (audit P1.2): filled "★" once selected, hollow "☆"
920
+ // otherwise.
921
+ text: n <= current ? "\u2605" : "\u2606",
773
922
  attrs: {
774
923
  type: "button",
775
924
  role: "radio",
@@ -780,7 +929,10 @@ function rating(context) {
780
929
  }
781
930
  });
782
931
  button.toggleAttribute("data-selected", n <= current);
783
- button.addEventListener("click", () => context.setValue(n === current ? null : n));
932
+ button.addEventListener("click", (event) => {
933
+ if (event.detail === 0 && n === current) return;
934
+ context.setValue(n === current ? null : n);
935
+ });
784
936
  wrap.appendChild(button);
785
937
  }
786
938
  bindRadioKeys(wrap, values, selected, (value) => context.setValue(value));
@@ -826,7 +978,10 @@ function linearScale(context) {
826
978
  }
827
979
  });
828
980
  button.toggleAttribute("data-selected", context.value === n);
829
- button.addEventListener("click", () => context.setValue(context.value === n ? null : n));
981
+ button.addEventListener("click", (event) => {
982
+ if (event.detail === 0 && context.value === n) return;
983
+ context.setValue(context.value === n ? null : n);
984
+ });
830
985
  steps.appendChild(button);
831
986
  }
832
987
  bindRadioKeys(steps, values, current, (value) => context.setValue(value));
@@ -844,6 +999,7 @@ function linearScale(context) {
844
999
  function ranking(context) {
845
1000
  const field = context.field;
846
1001
  if (field.kind !== "ranking") return null;
1002
+ const announce = context.announce;
847
1003
  const answered = Array.isArray(context.value) ? context.value : [];
848
1004
  const order = [
849
1005
  ...answered.filter((id) => field.options.some((option) => option.id === id)),
@@ -870,18 +1026,31 @@ function ranking(context) {
870
1026
  const next = [...order];
871
1027
  [next[index], next[target]] = [next[target], next[index]];
872
1028
  context.setValue(next);
1029
+ announce?.(DEFAULT_FIELD_STRINGS.rankingPosition(option.label, target + 1, order.length));
873
1030
  };
874
1031
  const up = el("button", {
875
1032
  className: "fillo-ranking-move",
876
- text: "Up",
877
- attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "up" }
1033
+ text: "\u2191",
1034
+ attrs: {
1035
+ type: "button",
1036
+ "data-fillo-rank-opt": id,
1037
+ "data-fillo-rank-dir": "up",
1038
+ // Per-row label (audit P1.5 [D]: every row read "Up"/"Down" alike);
1039
+ // wording matches @usefillo/react's.
1040
+ "aria-label": `Move ${option.label} up`
1041
+ }
878
1042
  });
879
1043
  up.toggleAttribute("disabled", index === 0);
880
1044
  up.addEventListener("click", () => move(-1));
881
1045
  const down = el("button", {
882
1046
  className: "fillo-ranking-move",
883
- text: "Down",
884
- attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "down" }
1047
+ text: "\u2193",
1048
+ attrs: {
1049
+ type: "button",
1050
+ "data-fillo-rank-opt": id,
1051
+ "data-fillo-rank-dir": "down",
1052
+ "aria-label": `Move ${option.label} down`
1053
+ }
885
1054
  });
886
1055
  down.toggleAttribute("disabled", index === order.length - 1);
887
1056
  down.addEventListener("click", () => move(1));
@@ -909,17 +1078,40 @@ function matrix(context) {
909
1078
  table.appendChild(thead);
910
1079
  const tbody = document.createElement("tbody");
911
1080
  for (const row of field.rows) {
1081
+ const rowLabelId = `${context.ids.inputId}-${row.id}`;
1082
+ const answered = answers[row.id] !== void 0;
1083
+ const rowInvalid = Boolean(context.error) && Boolean(field.required) && !answered;
1084
+ const radioId = (col) => `${context.ids.inputId}-${row.id}-${col.id}`;
912
1085
  const tr = document.createElement("tr");
913
- tr.appendChild(el("th", { text: row.label, attrs: { scope: "row" } }));
1086
+ const th = el("th", { attrs: { scope: "row" } });
1087
+ const rowGroup = el("div", {
1088
+ attrs: {
1089
+ role: "radiogroup",
1090
+ "aria-labelledby": `${context.ids.labelId} ${rowLabelId}`,
1091
+ "aria-owns": field.columns.map(radioId).join(" ")
1092
+ }
1093
+ });
1094
+ rowGroup.appendChild(el("span", { text: row.label, attrs: { id: rowLabelId } }));
1095
+ th.appendChild(rowGroup);
1096
+ tr.appendChild(th);
914
1097
  for (const col of field.columns) {
915
- const td = document.createElement("td");
1098
+ const td = el("td", { attrs: { "data-label": col.label } });
1099
+ const cellLabel = el("label", { className: "fillo-matrix-cell" });
916
1100
  const input = el("input", {
917
1101
  className: "fillo-option-input",
918
- attrs: { type: "radio", name: `${context.ids.name}-${row.id}`, "aria-label": `${row.label}: ${col.label}` }
1102
+ attrs: {
1103
+ id: radioId(col),
1104
+ type: "radio",
1105
+ name: `${context.ids.name}-${row.id}`,
1106
+ "aria-label": `${row.label}: ${col.label}`
1107
+ }
919
1108
  });
1109
+ if (field.required) input.setAttribute("aria-required", "true");
1110
+ if (rowInvalid) input.setAttribute("aria-invalid", "true");
920
1111
  input.checked = answers[row.id] === col.id;
921
1112
  input.addEventListener("change", () => context.setValue({ ...answers, [row.id]: col.id }));
922
- td.appendChild(input);
1113
+ cellLabel.appendChild(input);
1114
+ td.appendChild(cellLabel);
923
1115
  tr.appendChild(td);
924
1116
  }
925
1117
  tbody.appendChild(tr);
@@ -943,7 +1135,7 @@ function signature(context) {
943
1135
  const pad = el("div", { className: "fillo-signature", attrs: { "data-fillo": "control" } });
944
1136
  const canvas = el("canvas", {
945
1137
  className: "fillo-signature-canvas",
946
- attrs: { "aria-hidden": "true" }
1138
+ attrs: { role: "img" }
947
1139
  });
948
1140
  const clear = el("button", {
949
1141
  className: "fillo-signature-clear",
@@ -956,6 +1148,13 @@ function signature(context) {
956
1148
  const ctx = canvas.getContext("2d");
957
1149
  let drawing = false;
958
1150
  let hasInk = typeof context.value === "string" && context.value.startsWith("data:image/");
1151
+ const syncSignatureLabel = () => {
1152
+ canvas.setAttribute(
1153
+ "aria-label",
1154
+ hasInk ? DEFAULT_FIELD_STRINGS.signatureSigned : DEFAULT_FIELD_STRINGS.signatureEmpty
1155
+ );
1156
+ };
1157
+ syncSignatureLabel();
959
1158
  if (hasInk && ctx) {
960
1159
  const image = new Image();
961
1160
  image.onload = () => ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
@@ -967,8 +1166,7 @@ function signature(context) {
967
1166
  attrs: {
968
1167
  id: context.ids.inputId,
969
1168
  type: "text",
970
- autocomplete: "name",
971
- placeholder: "Type your full name to sign"
1169
+ autocomplete: "name"
972
1170
  }
973
1171
  });
974
1172
  applyFieldAria(typed, context.field, context.error, context.ids);
@@ -988,6 +1186,7 @@ function signature(context) {
988
1186
  hint.style.display = "";
989
1187
  context.setValue(null, { render: false });
990
1188
  }
1189
+ syncSignatureLabel();
991
1190
  };
992
1191
  typed.addEventListener("input", () => drawTyped(typed.value));
993
1192
  const point = (event) => {
@@ -1002,6 +1201,7 @@ function signature(context) {
1002
1201
  drawing = true;
1003
1202
  hasInk = true;
1004
1203
  hint.style.display = "none";
1204
+ syncSignatureLabel();
1005
1205
  if (typed.value) typed.value = "";
1006
1206
  canvas.setPointerCapture(event.pointerId);
1007
1207
  const p = point(event);
@@ -1029,57 +1229,164 @@ function signature(context) {
1029
1229
  hasInk = false;
1030
1230
  hint.style.display = "";
1031
1231
  typed.value = "";
1232
+ syncSignatureLabel();
1032
1233
  context.setValue(null, { render: false });
1033
1234
  });
1034
1235
  appendChildren(pad, [canvas, hint, clear]);
1035
1236
  const typeWrap = el("div", { className: "fillo-signature-type" });
1036
- typeWrap.appendChild(typed);
1237
+ const typeLabel = el("label", {
1238
+ className: "fillo-signature-type-label",
1239
+ text: "Or type your full name to sign",
1240
+ attrs: { for: context.ids.inputId }
1241
+ });
1242
+ appendChildren(typeWrap, [typeLabel, typed]);
1037
1243
  appendChildren(wrap, [pad, typeWrap]);
1038
1244
  return shell(context.field, context.error, wrap, context.ids);
1039
1245
  }
1246
+ function formatBytes(bytes) {
1247
+ if (bytes < 1024) return `${bytes} B`;
1248
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
1249
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
1250
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
1251
+ }
1252
+ function fileActionBtn(className, label, text, onClick) {
1253
+ const btn = el("button", { className, text, attrs: { type: "button", "aria-label": label } });
1254
+ btn.addEventListener("click", onClick);
1255
+ return btn;
1256
+ }
1040
1257
  function fileUpload(context) {
1041
1258
  const field = context.field;
1042
1259
  if (field.kind !== "file_upload") return null;
1260
+ const internal = context;
1261
+ const storageBlocked = internal.storageBlocked === true;
1043
1262
  const input = el("input", {
1044
1263
  className: "fillo-input",
1045
1264
  attrs: { id: context.ids.inputId, type: "file", "data-fillo": "control" }
1046
1265
  });
1047
1266
  if ((field.maxFiles ?? 1) > 1) input.multiple = true;
1048
1267
  if (field.accept?.length) input.accept = field.accept.join(",");
1049
- const progress = context.uploadProgress?.get(field.id);
1050
- input.disabled = progress !== void 0;
1268
+ input.disabled = storageBlocked;
1051
1269
  applyFieldAria(input, field, context.error, context.ids);
1052
1270
  input.addEventListener("change", () => {
1271
+ if (storageBlocked) return;
1053
1272
  const files = Array.from(input.files ?? []);
1273
+ input.value = "";
1054
1274
  void context.uploadFiles?.(field, files);
1055
1275
  });
1056
1276
  const wrap = el("div");
1057
1277
  wrap.appendChild(input);
1058
- if (progress !== void 0) {
1059
- const meter = el("div", {
1060
- className: "fillo-upload-progress",
1061
- attrs: {
1062
- role: "progressbar",
1063
- "aria-label": `Uploading ${field.label}`,
1064
- "aria-valuemin": "0",
1065
- "aria-valuemax": "100",
1066
- "aria-valuenow": String(Math.round(progress * 100))
1067
- }
1278
+ if (storageBlocked) {
1279
+ const hint = el("p", {
1280
+ className: "fillo-upload-notice",
1281
+ text: internal.storageFixUrl ? "Connect file storage to enable uploads" : DEFAULT_FIELD_STRINGS.uploadsUnavailable
1068
1282
  });
1069
- meter.appendChild(el("div", { className: "fillo-upload-progress-fill" }));
1070
- meter.firstElementChild?.style.setProperty("width", `${Math.round(progress * 100)}%`);
1071
- wrap.appendChild(meter);
1072
- }
1073
- const value = Array.isArray(context.value) ? context.value : [];
1074
- for (const file of value) {
1075
- wrap.appendChild(el("p", { className: "fillo-description", text: `${file.name} (${Math.round(file.size / 1024)} KB)` }));
1076
- }
1077
- const notice = context.uploadNotice;
1078
- if (notice) {
1079
- wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice, attrs: { role: "status" } }));
1283
+ if (internal.storageFixUrl) {
1284
+ hint.append(" \u2014 ");
1285
+ hint.appendChild(
1286
+ el("a", {
1287
+ text: internal.storageFixUrl,
1288
+ attrs: { href: internal.storageFixUrl, target: "_blank", rel: "noopener noreferrer" }
1289
+ })
1290
+ );
1291
+ }
1292
+ wrap.appendChild(hint);
1293
+ }
1294
+ const done = Array.isArray(context.value) ? context.value : [];
1295
+ const rows = internal.uploadRows ?? [];
1296
+ const maxFiles = field.maxFiles ?? 1;
1297
+ const remaining = maxFiles - done.length - rows.filter((row) => row.error === void 0).length;
1298
+ if (done.length > 0 || rows.length > 0) {
1299
+ const list = el("ul", { className: "fillo-files" });
1300
+ for (const file of done) {
1301
+ const li = el("li", { className: "fillo-file fillo-file--done" });
1302
+ appendChildren(li, [
1303
+ el("span", { className: "fillo-file-name", text: file.name }),
1304
+ el("span", { className: "fillo-file-meta", text: formatBytes(file.size) }),
1305
+ el("span", { className: "fillo-sr-only", text: "Uploaded" }),
1306
+ fileActionBtn(
1307
+ "fillo-file-remove",
1308
+ `Remove ${file.name}`,
1309
+ "\xD7",
1310
+ () => context.setValue(done.filter((f) => f.fileId !== file.fileId))
1311
+ )
1312
+ ]);
1313
+ list.appendChild(li);
1314
+ }
1315
+ for (const row of rows) {
1316
+ const failed = row.error !== void 0;
1317
+ const li = el("li", {
1318
+ className: `fillo-file${failed ? " fillo-file--failed" : ""}`,
1319
+ attrs: failed ? {} : {
1320
+ role: "progressbar",
1321
+ "aria-label": `Uploading ${row.name}`,
1322
+ "aria-valuemin": "0",
1323
+ "aria-valuemax": "100",
1324
+ "aria-valuenow": String(Math.round(row.fraction * 100))
1325
+ }
1326
+ });
1327
+ li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1328
+ if (failed) {
1329
+ li.appendChild(el("span", { className: "fillo-file-meta", text: row.error ?? "" }));
1330
+ if (remaining > 0) {
1331
+ li.appendChild(
1332
+ fileActionBtn(
1333
+ "fillo-file-retry",
1334
+ `${DEFAULT_FIELD_STRINGS.uploadRetry} ${row.name}`,
1335
+ DEFAULT_FIELD_STRINGS.uploadRetry,
1336
+ () => internal.uploadAction?.(row.key, "retry")
1337
+ )
1338
+ );
1339
+ }
1340
+ li.appendChild(fileActionBtn("fillo-file-remove", "Dismiss", "\xD7", () => internal.uploadAction?.(row.key, "dismiss")));
1341
+ } else {
1342
+ const bar = el("span", { className: "fillo-progress-bar" });
1343
+ bar.style.width = `${row.fraction * 100}%`;
1344
+ appendChildren(li, [
1345
+ el("span", { className: "fillo-file-meta", text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}` }),
1346
+ appendChildren(el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }), [bar]),
1347
+ fileActionBtn("fillo-file-remove", `Cancel ${row.name}`, "\xD7", () => internal.uploadAction?.(row.key, "cancel"))
1348
+ ]);
1349
+ }
1350
+ list.appendChild(li);
1351
+ }
1352
+ wrap.appendChild(list);
1080
1353
  }
1354
+ const notice = internal.uploadNotice;
1355
+ if (notice) wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice }));
1081
1356
  return shell(field, context.error, wrap, context.ids);
1082
1357
  }
1358
+ function calculated(context) {
1359
+ const field = context.field;
1360
+ const wrap = el("div", {
1361
+ className: "fillo-field fillo-field--calculated fillo-calculated",
1362
+ attrs: { "data-fillo": "calculated", "data-field": field.id, "data-kind": field.kind }
1363
+ });
1364
+ wrap.appendChild(
1365
+ el("label", {
1366
+ className: "fillo-label",
1367
+ text: field.label,
1368
+ attrs: { id: context.ids.labelId, for: context.ids.inputId, "data-fillo": "label" }
1369
+ })
1370
+ );
1371
+ if (field.description)
1372
+ wrap.appendChild(
1373
+ el("p", {
1374
+ className: "fillo-description",
1375
+ text: field.description,
1376
+ attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
1377
+ })
1378
+ );
1379
+ const value = context.value;
1380
+ const answered = value !== void 0 && value !== null && value !== "";
1381
+ const output = el("output", {
1382
+ className: `fillo-calculated-value${answered ? "" : " fillo-calculated-value--empty"}`,
1383
+ text: answered ? formatAnswer(field, value) : "\u2014",
1384
+ attrs: { id: context.ids.inputId }
1385
+ });
1386
+ if (field.description) output.setAttribute("aria-describedby", context.ids.descriptionId);
1387
+ wrap.appendChild(output);
1388
+ return wrap;
1389
+ }
1083
1390
  var shuffleCache = /* @__PURE__ */ new Map();
1084
1391
  function displayOptions(field) {
1085
1392
  if (!field.shuffleOptions) return field.options;
@@ -1114,7 +1421,8 @@ var DEFAULT_COMPONENTS = {
1114
1421
  ranking,
1115
1422
  matrix,
1116
1423
  signature,
1117
- file_upload: fileUpload
1424
+ file_upload: fileUpload,
1425
+ calculated
1118
1426
  };
1119
1427
  function defaultFieldRenderer(context) {
1120
1428
  if (context.field.kind === "hidden") return null;
@@ -1122,17 +1430,82 @@ function defaultFieldRenderer(context) {
1122
1430
  const renderer = DEFAULT_COMPONENTS[context.field.kind];
1123
1431
  return renderer ? renderer(context) : null;
1124
1432
  }
1433
+ var TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
1434
+ var TURNSTILE_SCRIPT_ID = "fillo-turnstile-script";
1435
+ var turnstileScriptPromise = null;
1436
+ function turnstileGlobal() {
1437
+ return globalThis.turnstile;
1438
+ }
1439
+ function loadTurnstileScript() {
1440
+ if (typeof window === "undefined" || typeof document === "undefined") {
1441
+ return Promise.reject(new Error("Turnstile needs a browser"));
1442
+ }
1443
+ if (turnstileGlobal()) return Promise.resolve();
1444
+ if (turnstileScriptPromise) return turnstileScriptPromise;
1445
+ turnstileScriptPromise = new Promise((resolve, reject) => {
1446
+ const onReady = () => {
1447
+ if (turnstileGlobal()) resolve();
1448
+ else reject(new Error("Turnstile loaded without its global"));
1449
+ };
1450
+ const existing = document.getElementById(TURNSTILE_SCRIPT_ID);
1451
+ if (existing) {
1452
+ if (turnstileGlobal()) {
1453
+ resolve();
1454
+ return;
1455
+ }
1456
+ existing.addEventListener("load", onReady, { once: true });
1457
+ existing.addEventListener(
1458
+ "error",
1459
+ () => reject(new Error("Turnstile script failed to load")),
1460
+ { once: true }
1461
+ );
1462
+ return;
1463
+ }
1464
+ const script = document.createElement("script");
1465
+ script.id = TURNSTILE_SCRIPT_ID;
1466
+ script.src = TURNSTILE_SCRIPT_SRC;
1467
+ script.async = true;
1468
+ script.defer = true;
1469
+ script.addEventListener("load", onReady, { once: true });
1470
+ script.addEventListener(
1471
+ "error",
1472
+ () => {
1473
+ script.remove();
1474
+ turnstileScriptPromise = null;
1475
+ reject(new Error("Turnstile script failed to load"));
1476
+ },
1477
+ { once: true }
1478
+ );
1479
+ document.head.appendChild(script);
1480
+ });
1481
+ return turnstileScriptPromise;
1482
+ }
1483
+ function newTurnstileContainer() {
1484
+ return el("div", { className: "fillo-turnstile", attrs: { "data-fillo": "turnstile" } });
1485
+ }
1125
1486
  var DomFormController = class {
1126
1487
  constructor(target, options) {
1127
1488
  this.target = target;
1128
1489
  this.options = options;
1129
1490
  this.element.className = "fillo-dom-root";
1130
- target.replaceChildren(this.element);
1491
+ if (options.preview === true) warnPreviewInProduction();
1492
+ target.replaceChildren(this.element, this.statusEl, this.alertEl);
1131
1493
  void this.resolve();
1132
1494
  }
1133
1495
  target;
1134
1496
  options;
1135
1497
  element = el("div");
1498
+ // Persistent live-region channels (audit P2.1): siblings of `element`
1499
+ // (never inside it), so the full-tree `element.replaceChildren()` every
1500
+ // render() does can never tear them down or recreate them.
1501
+ statusEl = el("span", {
1502
+ className: "fillo-sr-only",
1503
+ attrs: { role: "status", "aria-live": "polite", "data-fillo": "announce" }
1504
+ });
1505
+ alertEl = el("span", {
1506
+ className: "fillo-sr-only",
1507
+ attrs: { role: "alert", "data-fillo": "announce-alert" }
1508
+ });
1136
1509
  instanceId = `d${++domInstanceCounter}`;
1137
1510
  schema = null;
1138
1511
  theme;
@@ -1152,9 +1525,12 @@ var DomFormController = class {
1152
1525
  hpValue = "";
1153
1526
  liveSubmissionHandled = false;
1154
1527
  activeOtherFields = /* @__PURE__ */ new Set();
1155
- uploadProgress = /* @__PURE__ */ new Map();
1156
- uploadControllers = /* @__PURE__ */ new Map();
1157
- // Per-field "some files weren't added" notices (over-limit selections).
1528
+ // Per-file upload tracking (audit P0.3), keyed by a synthetic per-file key —
1529
+ // replaces the old per-field aggregate progress number. Concurrent uploads
1530
+ // within one field are independent rows, each cancellable/retryable alone.
1531
+ inFlight = /* @__PURE__ */ new Map();
1532
+ // Per-field soft capacity/busy notices ("N files weren't added", "N files
1533
+ // exceed the limit") — plain text; announce() carries the live semantics.
1158
1534
  uploadNotices = /* @__PURE__ */ new Map();
1159
1535
  // Where to move focus on the next render: "error" after a failed submit/next,
1160
1536
  // "page" after navigating, null → restore the pre-render active control.
@@ -1162,6 +1538,44 @@ var DomFormController = class {
1162
1538
  // Teardown callbacks for open overlays (phone country popover) whose listeners
1163
1539
  // live on window/document and would otherwise leak past a re-render/unmount.
1164
1540
  overlays = /* @__PURE__ */ new Set();
1541
+ // Human-verification challenge (Turnstile). Token read lazily by the
1542
+ // engine's getChallengeToken at submit time (react's ref, equivalent
1543
+ // freshest-solve contract). `turnstileContainer` is the node Cloudflare
1544
+ // renders its iframe into — created once and RE-ATTACHED (moved, never
1545
+ // recreated) into the tree every render, same hoist precedent as
1546
+ // statusEl/alertEl above, so the iframe survives replaceChildren() instead
1547
+ // of reloading on every keystroke.
1548
+ challenge;
1549
+ challengeToken;
1550
+ challengeError = false;
1551
+ turnstileContainer = newTurnstileContainer();
1552
+ turnstileWidgetId = null;
1553
+ turnstileSiteKey;
1554
+ turnstileLoadInFlight = false;
1555
+ // Invalidated on teardown so a stale load/render callback can't land after
1556
+ // a later attempt replaced the container (or double-render into it).
1557
+ turnstileAttempt = 0;
1558
+ // Clear-then-set (one frame apart) so an identical repeat still re-fires —
1559
+ // a same-tick ""→text write can collapse into a no-op net change for AT.
1560
+ announce = (text) => {
1561
+ this.statusEl.textContent = "";
1562
+ requestAnimationFrame(() => {
1563
+ this.statusEl.textContent = text;
1564
+ });
1565
+ };
1566
+ announceAlert = (text) => {
1567
+ this.alertEl.textContent = "";
1568
+ requestAnimationFrame(() => {
1569
+ this.alertEl.textContent = text;
1570
+ });
1571
+ };
1572
+ /** Dev chrome = every developer-facing surface below (inline schema render,
1573
+ * notices, verbose submit errors, upload pre-emption). `preview` forces it
1574
+ * on off-localhost surfaces — cosmetic only, so correctness (where
1575
+ * submissions go, whether they are accepted) never depends on it. */
1576
+ devChrome() {
1577
+ return this.options.preview === true || isDevEnv();
1578
+ }
1165
1579
  get status() {
1166
1580
  if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
1167
1581
  return this.phase;
@@ -1199,15 +1613,34 @@ var DomFormController = class {
1199
1613
  };
1200
1614
  }
1201
1615
  resolveFormIdFn;
1202
- /** Prod-only: the code form is an unpublished draft — submissions would 404. */
1203
- notLive = false;
1616
+ /** The server refuses submissions right now — render a not-open state backed
1617
+ * by the real form in display-only mode ("notOpen" for a draft /
1618
+ * storage-blocked form, "closed" for an expired/capped workspace). Doubles
1619
+ * as the display-only gate: while set, the engine is created without a
1620
+ * client/formId/resolver and every interactive entry point no-ops. */
1621
+ notOpenVariant = null;
1204
1622
  /** Exact integration failure retained for custom error/dev surfaces. */
1205
1623
  fatalError = null;
1206
1624
  genericUnavailable = false;
1207
1625
  syncErrorNotice = null;
1208
1626
  stagedNotice = false;
1627
+ /** Dev chrome: the code form is a draft — a visible banner, not console-only. */
1628
+ draftNotice = false;
1629
+ /** Sync's owner-facing storage advisory + dashboard deep-link. It never
1630
+ * substitutes for the explicit uploadsAvailable control state. */
1631
+ syncWarningCode;
1632
+ syncWarningUrl;
1633
+ /** Server-authoritative ability to start a new file upload. */
1634
+ uploadsAvailable;
1635
+ /** Server-authoritative per-file ceiling for the active storage lane. */
1636
+ uploadFileSizeLimitMb;
1637
+ /** Code of the last submit-time resolution failure (form_not_published,
1638
+ * form_schema_changed, …) — the connect-storage deep-link renders only with
1639
+ * the failure it explains, never glued onto transport/challenge errors. */
1640
+ resolutionFailureCode = null;
1209
1641
  async resolve() {
1210
1642
  try {
1643
+ this.challenge = this.options.challenge;
1211
1644
  const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
1212
1645
  if (codeForm) {
1213
1646
  const client = this.options.client;
@@ -1215,6 +1648,10 @@ var DomFormController = class {
1215
1648
  this.resolveFormIdFn = async () => {
1216
1649
  try {
1217
1650
  const result = await syncCodeForm(client, codeForm, { bypassCache: true });
1651
+ this.syncWarningCode = result.warningCode;
1652
+ this.syncWarningUrl = result.warningUrl;
1653
+ this.uploadsAvailable = result.uploadsAvailable;
1654
+ this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
1218
1655
  if (result.status === "draft") {
1219
1656
  throw new FilloError(
1220
1657
  "This form is no longer published. Submission was stopped before sending answers.",
@@ -1223,6 +1660,14 @@ var DomFormController = class {
1223
1660
  "form_not_published"
1224
1661
  );
1225
1662
  }
1663
+ if (result.accepting === false) {
1664
+ throw new FilloError(
1665
+ "This form is no longer accepting responses. Submission was stopped before sending answers.",
1666
+ 403,
1667
+ void 0,
1668
+ result.acceptingReason ?? "form_not_accepting"
1669
+ );
1670
+ }
1226
1671
  const authoritative = normalizeFormSchema(result.resolvedSchema ?? codeForm.schema);
1227
1672
  if (!authoritative.ok) {
1228
1673
  throw new FilloError(
@@ -1250,9 +1695,11 @@ var DomFormController = class {
1250
1695
  )
1251
1696
  );
1252
1697
  }
1698
+ this.resolutionFailureCode = null;
1253
1699
  return result.formId;
1254
1700
  } catch (error) {
1255
1701
  const failure = toFilloError(error);
1702
+ this.resolutionFailureCode = failure.code ?? "sync_failed";
1256
1703
  this.reportSubmitSyncFailure(failure);
1257
1704
  throw failure;
1258
1705
  }
@@ -1273,7 +1720,7 @@ var DomFormController = class {
1273
1720
  return;
1274
1721
  }
1275
1722
  if (!client?.key) return;
1276
- if (isDevEnv()) {
1723
+ if (this.devChrome()) {
1277
1724
  this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1278
1725
  } else {
1279
1726
  this.theme = this.options.theme ?? codeForm.theme;
@@ -1290,9 +1737,15 @@ var DomFormController = class {
1290
1737
  "sync_snapshot_required"
1291
1738
  );
1292
1739
  }
1293
- const development = isDevEnv();
1294
- this.notLive = result.status === "draft" && !development;
1740
+ const development = this.devChrome();
1741
+ this.notOpenVariant = development ? null : result.status === "draft" ? "notOpen" : result.accepting === false ? overlayVariant(result.acceptingReason) : null;
1295
1742
  this.stagedNotice = Boolean(result.staged);
1743
+ this.draftNotice = result.status === "draft";
1744
+ this.syncWarningCode = result.warningCode;
1745
+ this.syncWarningUrl = result.warningUrl;
1746
+ this.uploadsAvailable = result.uploadsAvailable;
1747
+ this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
1748
+ this.challenge = this.options.challenge ?? result.challenge;
1296
1749
  if (result.branding) {
1297
1750
  this.fetchedPoweredBy = result.branding.poweredBy;
1298
1751
  this.fetchedBrandLabel = result.branding.label;
@@ -1318,10 +1771,11 @@ var DomFormController = class {
1318
1771
  )
1319
1772
  );
1320
1773
  }
1774
+ const connectStorage = result.warningUrl ? ` \u2014 connect storage to publish: ${result.warningUrl}` : "";
1321
1775
  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`);
1776
+ 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
1777
  } 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`);
1778
+ console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${connectStorage}`);
1325
1779
  }
1326
1780
  } catch (error) {
1327
1781
  if (this.destroyed) return;
@@ -1341,10 +1795,24 @@ var DomFormController = class {
1341
1795
  this.render();
1342
1796
  const published = await this.options.client.getForm(this.options.formId);
1343
1797
  if (this.destroyed) return;
1344
- this.closed = Boolean(published.closed);
1798
+ if (published.accepting === false) {
1799
+ this.notOpenVariant = overlayVariant(published.acceptingReason);
1800
+ } else {
1801
+ this.closed = Boolean(published.closed);
1802
+ }
1803
+ this.uploadsAvailable = published.uploadsAvailable;
1804
+ this.uploadFileSizeLimitMb = published.uploadFileSizeLimitMb;
1345
1805
  this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
1346
1806
  this.fetchedBrandLabel = published.branding?.label;
1347
1807
  this.fetchedBrandHref = published.branding?.href;
1808
+ this.challenge = this.options.challenge ?? published.challenge;
1809
+ if (published.accepting === false && this.options.renderError) {
1810
+ const message = this.notOpenVariant === "closed" ? "This form is no longer accepting responses." : "This form isn't accepting responses yet.";
1811
+ this.fatalError = new FilloError(message, 403, void 0, published.acceptingReason);
1812
+ this.phase = "error";
1813
+ this.render(this.fatalError);
1814
+ return;
1815
+ }
1348
1816
  this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
1349
1817
  } catch (error) {
1350
1818
  if (this.destroyed) return;
@@ -1360,17 +1828,28 @@ var DomFormController = class {
1360
1828
  this.schema = normalizedSchema;
1361
1829
  this.theme = theme;
1362
1830
  this.formId = formId;
1831
+ const displayOnly = this.notOpenVariant !== null;
1363
1832
  this.engine = coreCreateFormController({
1364
1833
  form: normalizedSchema,
1365
- formId,
1366
- client: this.options.client,
1834
+ formId: displayOnly ? void 0 : formId,
1835
+ client: displayOnly ? void 0 : this.options.client,
1367
1836
  initialData: this.options.initialData,
1368
- onChange: this.options.onChange,
1369
- onSubmitted: this.options.onSubmitted,
1837
+ onChange: displayOnly ? void 0 : this.options.onChange,
1838
+ onSubmitted: displayOnly ? void 0 : this.options.onSubmitted,
1370
1839
  getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
1371
1840
  surface: "default",
1372
- resolveFormId: this.resolveFormIdFn,
1373
- respondent: this.options.respondent
1841
+ resolveFormId: displayOnly ? void 0 : this.resolveFormIdFn,
1842
+ // Dev chrome gets the developer-grade resolution failure (message +
1843
+ // machine code) instead of the respondent-safe "unavailable" fallback.
1844
+ verboseResolutionErrors: this.devChrome(),
1845
+ respondent: displayOnly ? void 0 : this.options.respondent,
1846
+ // Challenge (Turnstile) — wired as react's useFilloController wires the
1847
+ // core controller: challengeRequired captured once here (forced off for
1848
+ // a not-open preview, which never submits regardless);
1849
+ // getChallengeToken/onChallengeFailed stay live via `this`.
1850
+ challengeRequired: displayOnly ? false : Boolean(this.challenge),
1851
+ getChallengeToken: () => this.challengeToken,
1852
+ onChallengeFailed: () => this.resetChallengeWidget()
1374
1853
  });
1375
1854
  let lastStatus = this.engine.getState().status;
1376
1855
  let lastResumed = this.engine.getState().resumedDraft;
@@ -1379,13 +1858,15 @@ var DomFormController = class {
1379
1858
  const state = this.engine?.getState();
1380
1859
  if (!state) return;
1381
1860
  if (state.status !== lastStatus || state.resumedDraft !== lastResumed || state.editingPrevious !== lastEditing) {
1861
+ if (state.editingPrevious && !lastEditing) this.announce(DEFAULT_STRINGS.editNotice);
1862
+ else if (state.resumedDraft && !lastResumed) this.announce(DEFAULT_STRINGS.resumeNotice);
1382
1863
  lastStatus = state.status;
1383
1864
  lastResumed = state.resumedDraft;
1384
1865
  lastEditing = state.editingPrevious;
1385
1866
  this.queueRender();
1386
1867
  }
1387
1868
  });
1388
- this.configureDraftFlush(normalizedSchema);
1869
+ if (!displayOnly) this.configureDraftFlush(normalizedSchema);
1389
1870
  this.phase = this.closed ? "closed" : "ready";
1390
1871
  this.render();
1391
1872
  }
@@ -1404,7 +1885,7 @@ var DomFormController = class {
1404
1885
  this.theme = theme;
1405
1886
  this.formId = formId;
1406
1887
  this.engine.setContext({ form: normalizedSchema, formId });
1407
- this.configureDraftFlush(normalizedSchema);
1888
+ if (this.notOpenVariant === null) this.configureDraftFlush(normalizedSchema);
1408
1889
  this.phase = this.closed ? "closed" : "ready";
1409
1890
  this.render();
1410
1891
  }
@@ -1438,26 +1919,31 @@ var DomFormController = class {
1438
1919
  this.setValueInternal(fieldId, value);
1439
1920
  }
1440
1921
  setValueInternal(fieldId, value, opts = {}) {
1922
+ if (this.notOpenVariant) return;
1441
1923
  this.engine?.setValue(fieldId, value);
1442
1924
  if (opts.render !== false) this.queueRender();
1443
1925
  }
1444
1926
  next() {
1445
- if (!this.engine) return;
1927
+ if (!this.engine || this.notOpenVariant) return;
1446
1928
  const before = this.engine.getState().pageIndex;
1447
1929
  this.engine.next();
1448
1930
  this.focusIntent = this.engine.getState().pageIndex !== before ? "page" : "error";
1449
1931
  this.render();
1450
1932
  }
1451
1933
  back() {
1452
- if (!this.engine) return;
1934
+ if (!this.engine || this.notOpenVariant) return;
1453
1935
  this.engine.back();
1454
1936
  this.focusIntent = "page";
1455
1937
  this.render();
1456
1938
  }
1457
1939
  async submit() {
1458
- if (!this.engine || this.phase !== "ready") return;
1940
+ if (!this.engine || this.phase !== "ready" || this.notOpenVariant) return;
1459
1941
  const pending = this.engine.submit();
1460
- if (this.hasErrors()) this.focusIntent = "error";
1942
+ if (this.hasErrors()) {
1943
+ this.focusIntent = "error";
1944
+ } else if (this.engine.getState().status === "submitting") {
1945
+ this.announce(DEFAULT_FIELD_STRINGS.submittingAnnouncement);
1946
+ }
1461
1947
  this.render();
1462
1948
  try {
1463
1949
  await pending;
@@ -1491,18 +1977,87 @@ var DomFormController = class {
1491
1977
  this.options.respondent = respondent;
1492
1978
  this.engine?.setContext({ respondent });
1493
1979
  }
1494
- async uploadFiles(field, files) {
1495
- if (!this.schema) return;
1496
- if (this.uploadProgress.has(field.id)) {
1497
- this.uploadNotices.set(field.id, "Wait for the current upload to finish before adding more files.");
1980
+ /** This field's active (non-failed) in-flight count — room math and the
1981
+ * setUploading(field, …) flip both key off it. A failed row holds no slot
1982
+ * (it must not strand the field at permanently-full). */
1983
+ fieldActive(fieldId) {
1984
+ let n = 0;
1985
+ for (const row of this.inFlight.values()) if (row.fieldId === fieldId && row.error === void 0) n++;
1986
+ return n;
1987
+ }
1988
+ uploadRowsFor(fieldId) {
1989
+ const rows = [];
1990
+ for (const [key, row] of this.inFlight) {
1991
+ if (row.fieldId === fieldId) rows.push({ key, name: row.name, size: row.size, fraction: row.fraction, error: row.error });
1992
+ }
1993
+ return rows;
1994
+ }
1995
+ /** Cancel/retry/dismiss one row by its per-file key (audit P0.3) — the
1996
+ * per-file counterpart of setValueInternal, called from the rendered
1997
+ * Cancel/Retry/Dismiss buttons via InternalFieldRenderContext. */
1998
+ uploadAction = (key, action) => {
1999
+ const row = this.inFlight.get(key);
2000
+ if (!row) return;
2001
+ if (action === "cancel") {
2002
+ row.controller.abort();
2003
+ this.inFlight.delete(key);
2004
+ } else if (action === "dismiss") {
2005
+ this.inFlight.delete(key);
2006
+ } else {
2007
+ this.inFlight.delete(key);
2008
+ void this.startUpload(row.fieldId, row.file, row.maxFiles);
2009
+ }
2010
+ if (this.fieldActive(row.fieldId) === 0) this.engine?.setUploading(row.fieldId, false);
2011
+ this.queueRender();
2012
+ };
2013
+ async startUpload(fieldId, file, maxFiles) {
2014
+ const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
2015
+ const controller = new AbortController();
2016
+ this.inFlight.set(key, { fieldId, name: file.name, size: file.size, fraction: 0, file, controller, maxFiles });
2017
+ this.engine?.setUploading(fieldId, true);
2018
+ this.queueRender();
2019
+ try {
2020
+ const value = this.options.client && this.formId ? await this.options.client.uploadFile(this.formId, file, {
2021
+ fieldId,
2022
+ signal: controller.signal,
2023
+ onProgress: (progress) => {
2024
+ const row = this.inFlight.get(key);
2025
+ if (!row) return;
2026
+ row.fraction = progress.fraction;
2027
+ this.queueRender();
2028
+ }
2029
+ }) : {
2030
+ fileId: `local:${file.name}:${file.size}`,
2031
+ name: file.name,
2032
+ size: file.size,
2033
+ mime: file.type || "application/octet-stream"
2034
+ };
2035
+ if (this.destroyed) return;
2036
+ this.inFlight.delete(key);
2037
+ const current = maxFiles > 1 && Array.isArray(this.mutableData[fieldId]) ? this.mutableData[fieldId] : [];
2038
+ this.setValueInternal(fieldId, [...current, value]);
2039
+ } catch (error) {
2040
+ if (this.destroyed || controller.signal.aborted) return;
2041
+ const uploadError = toFilloError(error);
2042
+ const message = uploadError.status !== void 0 && uploadError.status >= 500 ? DEFAULT_FIELD_STRINGS.uploadUnavailable : uploadError.message || DEFAULT_FIELD_STRINGS.uploadFailed;
2043
+ const row = this.inFlight.get(key);
2044
+ if (row) row.error = message;
2045
+ this.announceAlert(message);
2046
+ this.options.onError?.(uploadError);
2047
+ } finally {
2048
+ if (this.fieldActive(fieldId) === 0) this.engine?.setUploading(fieldId, false);
1498
2049
  this.queueRender();
1499
- return;
1500
2050
  }
2051
+ }
2052
+ async uploadFiles(field, files) {
2053
+ if (!this.schema || this.notOpenVariant) return;
1501
2054
  const maxFiles = field.maxFiles ?? 1;
1502
- const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
1503
- const room = Math.max(0, maxFiles - existing.length);
2055
+ const existingCount = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? this.mutableData[field.id].length : 0;
2056
+ const room = Math.max(0, maxFiles - existingCount - this.fieldActive(field.id));
1504
2057
  const withinLimit = files.slice(0, room);
1505
- const maxBytes = (field.maxFileSizeMb ?? 500) * 1024 * 1024;
2058
+ const configuredMaxMb = field.maxFileSizeMb ?? 500;
2059
+ const maxFileSizeMb = this.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, this.uploadFileSizeLimitMb) : configuredMaxMb;
2060
+ const maxBytes = maxFileSizeMb * 1024 * 1024;
1506
2061
  const selected = withinLimit.filter((file) => file.size <= maxBytes);
1507
2062
  const dropped = files.length - withinLimit.length;
1508
2063
  const oversized = withinLimit.length - selected.length;
@@ -1515,13 +2070,12 @@ var DomFormController = class {
1515
2070
  }
1516
2071
  if (oversized > 0) {
1517
2072
  notices.push(
1518
- `${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${field.maxFileSizeMb ?? 500} MB limit.`
2073
+ `${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
1519
2074
  );
1520
2075
  }
1521
- this.uploadNotices.set(
1522
- field.id,
1523
- notices.join(" ")
1524
- );
2076
+ const text = notices.join(" ");
2077
+ this.uploadNotices.set(field.id, text);
2078
+ this.announce(text);
1525
2079
  } else {
1526
2080
  this.uploadNotices.delete(field.id);
1527
2081
  }
@@ -1529,62 +2083,24 @@ var DomFormController = class {
1529
2083
  this.queueRender();
1530
2084
  return;
1531
2085
  }
1532
- const controller = new AbortController();
1533
- this.uploadControllers.set(field.id, controller);
1534
- this.uploadProgress.set(field.id, 0);
1535
- this.engine?.setUploading(field.id, true);
1536
- this.queueRender();
1537
- try {
1538
- const values = [];
1539
- for (const [index, file] of selected.entries()) {
1540
- if (this.options.client && this.formId) {
1541
- values.push(
1542
- await this.options.client.uploadFile(this.formId, file, {
1543
- fieldId: field.id,
1544
- signal: controller.signal,
1545
- onProgress: (progress) => {
1546
- this.uploadProgress.set(field.id, (index + progress.fraction) / selected.length);
1547
- this.queueRender();
1548
- }
1549
- })
1550
- );
1551
- } else {
1552
- values.push({
1553
- fileId: `local:${file.name}:${file.size}`,
1554
- name: file.name,
1555
- size: file.size,
1556
- mime: file.type || "application/octet-stream"
1557
- });
1558
- }
1559
- }
1560
- if (this.destroyed) return;
1561
- this.setValueInternal(field.id, [...existing, ...values]);
1562
- } catch (error) {
1563
- if (!this.destroyed && !controller.signal.aborted) {
1564
- const uploadError = toFilloError(error);
1565
- this.uploadNotices.set(field.id, uploadError.message || "Upload failed \u2014 try again");
1566
- this.options.onError?.(uploadError);
1567
- }
1568
- } finally {
1569
- if (this.uploadControllers.get(field.id) === controller) {
1570
- this.uploadControllers.delete(field.id);
1571
- }
1572
- this.uploadProgress.delete(field.id);
1573
- this.engine?.setUploading(field.id, false);
1574
- this.queueRender();
1575
- }
2086
+ await Promise.all(selected.map((file) => this.startUpload(field.id, file, maxFiles)));
1576
2087
  }
1577
2088
  destroy() {
1578
2089
  this.destroyed = true;
1579
2090
  this.teardownDraftFlush();
1580
2091
  this.closeOverlays();
1581
- this.uploadControllers.forEach((controller) => controller.abort());
1582
- this.uploadControllers.clear();
1583
- this.uploadProgress.clear();
2092
+ this.teardownTurnstileWidget();
2093
+ for (const row of this.inFlight.values()) row.controller.abort();
2094
+ this.inFlight.clear();
1584
2095
  this.engine?.destroy();
1585
2096
  this.engine = null;
1586
2097
  this.target.replaceChildren();
1587
2098
  }
2099
+ /** Keep an optional-upload form accepting while pre-empting only file
2100
+ * controls the current definition envelope says the server will refuse. */
2101
+ storageBlocked() {
2102
+ return this.uploadsAvailable === false;
2103
+ }
1588
2104
  /** Add/return the phone popover teardown; called back on re-render or destroy. */
1589
2105
  registerOverlay = (close) => {
1590
2106
  this.overlays.add(close);
@@ -1600,6 +2116,83 @@ var DomFormController = class {
1600
2116
  }
1601
2117
  this.overlays.clear();
1602
2118
  }
2119
+ /** Idempotent: at most one load+render per armed container (guarded by
2120
+ * widget id / in-flight / already-failed); re-arms on a changed site key
2121
+ * (mirrors react's effect, keyed on siteKey). */
2122
+ ensureTurnstileWidget(siteKey) {
2123
+ if (this.turnstileSiteKey !== siteKey) {
2124
+ this.teardownTurnstileWidget();
2125
+ this.turnstileSiteKey = siteKey;
2126
+ }
2127
+ if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError) return;
2128
+ this.turnstileLoadInFlight = true;
2129
+ const attempt = ++this.turnstileAttempt;
2130
+ loadTurnstileScript().then(() => {
2131
+ if (this.destroyed || attempt !== this.turnstileAttempt) return;
2132
+ const api = turnstileGlobal();
2133
+ if (!api) {
2134
+ this.turnstileLoadInFlight = false;
2135
+ this.handleChallengeError();
2136
+ return;
2137
+ }
2138
+ this.turnstileWidgetId = api.render(this.turnstileContainer, {
2139
+ sitekey: siteKey,
2140
+ theme: "auto",
2141
+ callback: (token) => this.handleChallengeToken(token),
2142
+ "error-callback": () => {
2143
+ this.handleChallengeToken(void 0);
2144
+ this.handleChallengeError();
2145
+ },
2146
+ "expired-callback": () => this.resetChallengeWidget(),
2147
+ "timeout-callback": () => this.resetChallengeWidget()
2148
+ });
2149
+ this.turnstileLoadInFlight = false;
2150
+ }).catch(() => {
2151
+ if (this.destroyed || attempt !== this.turnstileAttempt) return;
2152
+ this.turnstileLoadInFlight = false;
2153
+ this.handleChallengeError();
2154
+ });
2155
+ }
2156
+ handleChallengeToken(token) {
2157
+ this.challengeToken = token;
2158
+ if (token) this.challengeError = false;
2159
+ this.queueRender();
2160
+ }
2161
+ /** Surfaces via `.fillo-turnstile-error` AND the assertive channel — the
2162
+ * paragraph mounts fresh with the tree, and a live region announces
2163
+ * mutations, not initial content (same reasoning as the announce channels). */
2164
+ handleChallengeError() {
2165
+ this.challengeError = true;
2166
+ this.announceAlert(DEFAULT_STRINGS.challengeUnavailable);
2167
+ this.queueRender();
2168
+ }
2169
+ /** Re-arm the SAME widget instance for a fresh solve (server rejection,
2170
+ * expiry, or timeout) — not a teardown, the widget stays mounted. */
2171
+ resetChallengeWidget() {
2172
+ this.challengeToken = void 0;
2173
+ if (this.turnstileWidgetId) turnstileGlobal()?.reset(this.turnstileWidgetId);
2174
+ this.queueRender();
2175
+ }
2176
+ /** Fully remove any active/pending widget (mirrors react's unmount effect):
2177
+ * the script stays cached, but the widget and its token are gone, and a
2178
+ * fresh container replaces the old one for a pristine next mount. Safe to
2179
+ * call when nothing is active. */
2180
+ teardownTurnstileWidget() {
2181
+ if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight) return;
2182
+ this.turnstileAttempt++;
2183
+ const api = turnstileGlobal();
2184
+ if (this.turnstileWidgetId && api) {
2185
+ try {
2186
+ api.remove(this.turnstileWidgetId);
2187
+ } catch {
2188
+ }
2189
+ }
2190
+ this.turnstileWidgetId = null;
2191
+ this.turnstileLoadInFlight = false;
2192
+ this.challengeError = false;
2193
+ this.turnstileContainer = newTurnstileContainer();
2194
+ this.challengeToken = void 0;
2195
+ }
1603
2196
  captureFocus() {
1604
2197
  if (typeof document === "undefined") return null;
1605
2198
  const active = document.activeElement;
@@ -1657,9 +2250,13 @@ var DomFormController = class {
1657
2250
  applyFocus(intent, captured) {
1658
2251
  if (typeof document === "undefined") return;
1659
2252
  if (intent === "error") {
2253
+ const summary = this.element.querySelector(".fillo-error-summary");
2254
+ if (summary) {
2255
+ summary.focus();
2256
+ return;
2257
+ }
1660
2258
  const invalid = this.element.querySelector('[aria-invalid="true"]');
1661
- const target = invalid?.matches(FOCUSABLE_SELECTOR) ? invalid : invalid?.querySelector(FOCUSABLE_SELECTOR);
1662
- target?.focus?.();
2259
+ invalid && focusableWithin(invalid)?.focus();
1663
2260
  return;
1664
2261
  }
1665
2262
  if (intent === "page") {
@@ -1687,7 +2284,7 @@ var DomFormController = class {
1687
2284
  `[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
1688
2285
  );
1689
2286
  this.syncErrorNotice = error;
1690
- const development = isDevEnv();
2287
+ const development = this.devChrome();
1691
2288
  if (!development || this.options.renderError) {
1692
2289
  this.genericUnavailable = !development;
1693
2290
  this.handleError(error);
@@ -1704,7 +2301,7 @@ var DomFormController = class {
1704
2301
  );
1705
2302
  this.syncErrorNotice = error;
1706
2303
  this.options.onError?.(error);
1707
- if (isDevEnv()) this.render();
2304
+ if (this.devChrome()) this.render();
1708
2305
  }
1709
2306
  /** Submit-time verification failed: keep the form/answers for a safe retry. */
1710
2307
  reportSubmitSyncFailure(error) {
@@ -1728,6 +2325,8 @@ var DomFormController = class {
1728
2325
  this.focusIntent = null;
1729
2326
  const captured = intent ? null : this.captureFocus();
1730
2327
  this.closeOverlays();
2328
+ const willRenderForm = this.phase === "ready" && this.engine?.getState().status !== "submitted";
2329
+ if (!willRenderForm) this.teardownTurnstileWidget();
1731
2330
  this.element.replaceChildren();
1732
2331
  if (this.phase === "loading") {
1733
2332
  const loading = el("div", {
@@ -1747,7 +2346,7 @@ var DomFormController = class {
1747
2346
  if (this.phase === "error" && activeError) {
1748
2347
  const errorView = this.options.renderError?.(activeError) ?? el("div", {
1749
2348
  className: "fillo-form fillo-form--error",
1750
- text: this.genericUnavailable ? "This form is unavailable." : activeError.message
2349
+ text: this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError)
1751
2350
  });
1752
2351
  if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
1753
2352
  if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
@@ -1766,14 +2365,9 @@ var DomFormController = class {
1766
2365
  this.element.appendChild(closed);
1767
2366
  return;
1768
2367
  }
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);
2368
+ if (this.notOpenVariant && this.schema) {
2369
+ this.element.appendChild(this.renderNotOpen(this.notOpenVariant));
2370
+ if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1777
2371
  return;
1778
2372
  }
1779
2373
  if (!this.schema) return;
@@ -1783,8 +2377,6 @@ var DomFormController = class {
1783
2377
  const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
1784
2378
  success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
1785
2379
  success.setAttribute("data-state", success.getAttribute("data-state") ?? "submitted");
1786
- success.setAttribute("role", success.getAttribute("role") ?? "status");
1787
- success.setAttribute("aria-live", success.getAttribute("aria-live") ?? "polite");
1788
2380
  if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
1789
2381
  themeStyle(success, this.theme);
1790
2382
  this.element.appendChild(success);
@@ -1793,15 +2385,27 @@ var DomFormController = class {
1793
2385
  this.liveSubmissionHandled = true;
1794
2386
  const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
1795
2387
  if (redirect && typeof window !== "undefined") window.location.assign(redirect);
1796
- else success.focus();
2388
+ else {
2389
+ success.focus();
2390
+ this.announce(success.textContent ?? "");
2391
+ }
1797
2392
  }
1798
2393
  return;
1799
2394
  }
1800
- if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
1801
- if (this.syncErrorNotice && isDevEnv()) {
1802
- this.element.appendChild(devSyncErrorEl(this.syncErrorNotice));
2395
+ if (this.devChrome()) {
2396
+ appendChildren(
2397
+ this.element,
2398
+ devChromeEls({
2399
+ preview: this.options.preview === true,
2400
+ devNotices: this.options.devNotices,
2401
+ syncError: this.syncErrorNotice,
2402
+ staged: this.stagedNotice,
2403
+ draft: this.draftNotice,
2404
+ warningUrl: this.syncWarningUrl,
2405
+ noClient: !this.options.client
2406
+ })
2407
+ );
1803
2408
  }
1804
- if (this.stagedNotice && isDevEnv()) this.element.appendChild(devStagedEl());
1805
2409
  this.element.appendChild(this.renderForm());
1806
2410
  if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1807
2411
  this.applyFocus(intent, captured);
@@ -1810,49 +2414,100 @@ var DomFormController = class {
1810
2414
  brandingVisible() {
1811
2415
  return this.fetchedPoweredBy ?? true;
1812
2416
  }
2417
+ /** Production not-accepting chrome: a calm state backed by the real form
2418
+ * rendered display-only — honest about the state and impossible to fill.
2419
+ * The default CSS hides the preview; hosts without it still get the layered
2420
+ * safety contract. The wrapper is inert + aria-hidden +
2421
+ * pointer-events:none, the preview markup nests every control in a natively
2422
+ * disabled fieldset (see renderForm), and the engine behind it was created
2423
+ * without a client/formId/resolver — so nothing inside can focus, submit,
2424
+ * start an upload session, or touch saved-progress drafts. */
2425
+ renderNotOpen(variant) {
2426
+ const root = el("div", {
2427
+ className: `fillo-form fillo-form--not-open ${this.options.className ?? ""}`,
2428
+ attrs: { "data-fillo": "root", "data-state": "closed" }
2429
+ });
2430
+ themeStyle(root, this.theme);
2431
+ const preview = el("div", {
2432
+ className: "fillo-not-open-preview",
2433
+ attrs: { "aria-hidden": "true", inert: "" }
2434
+ });
2435
+ preview.appendChild(this.renderForm());
2436
+ const card = el("div", {
2437
+ className: "fillo-not-open-card",
2438
+ attrs: { role: "status", "data-fillo": "not-open-card" }
2439
+ });
2440
+ card.appendChild(
2441
+ el("h2", {
2442
+ className: "fillo-not-open-title",
2443
+ text: variant === "closed" ? DEFAULT_STRINGS.closedTitle : DEFAULT_STRINGS.notOpenTitle
2444
+ })
2445
+ );
2446
+ card.appendChild(
2447
+ el("p", {
2448
+ className: "fillo-not-open-body",
2449
+ text: variant === "closed" ? DEFAULT_STRINGS.closed : DEFAULT_STRINGS.notOpenBody
2450
+ })
2451
+ );
2452
+ appendChildren(root, [preview, card]);
2453
+ return root;
2454
+ }
1813
2455
  renderForm() {
1814
2456
  const api = this.api();
1815
- const form = el("form", {
2457
+ const displayOnly = this.notOpenVariant !== null;
2458
+ const challengeRequired = Boolean(this.challenge);
2459
+ const showChallenge = !displayOnly && challengeRequired && api.isLastPage;
2460
+ const form = el(displayOnly ? "div" : "form", {
1816
2461
  className: `fillo-form ${this.options.className ?? ""}`,
1817
2462
  attrs: {
1818
- "data-fillo": "root",
1819
- "data-state": api.status,
2463
+ "data-fillo": displayOnly ? "preview-form" : "root",
2464
+ "data-state": displayOnly ? "preview" : api.status,
1820
2465
  "data-page": String(api.pageIndex + 1)
1821
2466
  }
1822
2467
  });
1823
2468
  form.toggleAttribute("data-last-page", api.isLastPage);
1824
2469
  themeStyle(form, this.theme);
1825
- form.noValidate = true;
1826
2470
  const advance = () => {
1827
2471
  if (api.isLastPage) void this.submit();
1828
2472
  else this.next();
1829
2473
  };
1830
- form.addEventListener("submit", (event) => {
1831
- event.preventDefault();
1832
- advance();
1833
- });
2474
+ if (!displayOnly) {
2475
+ const formEl = form;
2476
+ formEl.noValidate = true;
2477
+ formEl.addEventListener("submit", (event) => {
2478
+ event.preventDefault();
2479
+ advance();
2480
+ });
2481
+ }
2482
+ const content = displayOnly ? form.appendChild(
2483
+ el("fieldset", { className: "fillo-not-open-fields", attrs: { disabled: "" } })
2484
+ ) : form;
1834
2485
  const restoreState = this.engine?.getState();
1835
2486
  if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
1836
2487
  const resume = el("div", {
1837
2488
  className: "fillo-resume",
1838
- attrs: { role: "status", "data-fillo": "resume" }
2489
+ attrs: { "data-fillo": "resume" }
1839
2490
  });
1840
2491
  resume.appendChild(
1841
2492
  el("span", {
1842
2493
  className: "fillo-resume-text",
1843
- text: restoreState.editingPrevious ? "You're updating your earlier response." : "Picked up where you left off."
2494
+ text: restoreState.editingPrevious ? DEFAULT_STRINGS.editNotice : DEFAULT_STRINGS.resumeNotice
1844
2495
  })
1845
2496
  );
1846
2497
  const clear = el("button", {
1847
2498
  className: "fillo-resume-clear",
1848
- text: "Start over",
2499
+ text: DEFAULT_STRINGS.resumeStartOver,
1849
2500
  attrs: { type: "button" }
1850
2501
  });
1851
2502
  clear.addEventListener("click", () => this.resetDraft());
1852
2503
  resume.appendChild(clear);
1853
- form.appendChild(resume);
2504
+ content.appendChild(resume);
1854
2505
  }
1855
2506
  if (api.pageCount > 1 && this.schema?.settings.showProgress !== false) {
2507
+ const seq = reachablePageSequence(api.form, api.data);
2508
+ const total = Math.max(seq.length, 1);
2509
+ const pos = seq.indexOf(api.page.id);
2510
+ const step = pos >= 0 ? pos + 1 : Math.min(api.pageIndex + 1, total);
1856
2511
  const progress = el("div", {
1857
2512
  className: "fillo-progress-track",
1858
2513
  attrs: {
@@ -1860,8 +2515,8 @@ var DomFormController = class {
1860
2515
  "data-fillo": "progress",
1861
2516
  "aria-label": api.form.title || "Form progress",
1862
2517
  "aria-valuemin": "1",
1863
- "aria-valuemax": String(api.pageCount),
1864
- "aria-valuenow": String(api.pageIndex + 1)
2518
+ "aria-valuemax": String(total),
2519
+ "aria-valuenow": String(step)
1865
2520
  }
1866
2521
  });
1867
2522
  progress.appendChild(
@@ -1869,9 +2524,9 @@ var DomFormController = class {
1869
2524
  );
1870
2525
  progress.firstElementChild?.style.setProperty(
1871
2526
  "--fillo-progress-value",
1872
- `${(api.pageIndex + 1) / api.pageCount * 100}%`
2527
+ `${step / total * 100}%`
1873
2528
  );
1874
- form.appendChild(progress);
2529
+ content.appendChild(progress);
1875
2530
  }
1876
2531
  if (api.pageIndex === 0) {
1877
2532
  const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
@@ -1889,9 +2544,9 @@ var DomFormController = class {
1889
2544
  })
1890
2545
  );
1891
2546
  }
1892
- form.appendChild(header);
2547
+ content.appendChild(header);
1893
2548
  } else if (api.page.title) {
1894
- form.appendChild(
2549
+ content.appendChild(
1895
2550
  el("h2", {
1896
2551
  className: "fillo-page-title",
1897
2552
  text: api.page.title,
@@ -1899,33 +2554,65 @@ var DomFormController = class {
1899
2554
  })
1900
2555
  );
1901
2556
  }
2557
+ const summary = this.renderErrorSummary(api);
2558
+ if (summary) content.appendChild(summary);
1902
2559
  const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
1903
2560
  for (const block of api.blocks) {
1904
2561
  const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
1905
2562
  if (rendered) blocks.appendChild(rendered);
1906
2563
  }
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);
2564
+ content.appendChild(blocks);
2565
+ if (!displayOnly) {
2566
+ const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
2567
+ hp.style.position = "absolute";
2568
+ hp.style.left = "-9999px";
2569
+ hp.style.top = "auto";
2570
+ hp.style.width = "1px";
2571
+ hp.style.height = "1px";
2572
+ hp.style.opacity = "0";
2573
+ hp.style.overflow = "hidden";
2574
+ hp.style.pointerEvents = "none";
2575
+ hp.addEventListener("input", () => {
2576
+ this.hpValue = hp.value;
2577
+ });
2578
+ content.appendChild(hp);
2579
+ }
2580
+ if (showChallenge) {
2581
+ const slot = el("div", {
2582
+ className: "fillo-turnstile-slot",
2583
+ attrs: { "data-fillo": "turnstile-slot" }
2584
+ });
2585
+ slot.appendChild(this.turnstileContainer);
2586
+ if (this.challengeError) {
2587
+ slot.appendChild(
2588
+ el("p", {
2589
+ className: "fillo-turnstile-error",
2590
+ attrs: { role: "alert" },
2591
+ text: DEFAULT_STRINGS.challengeUnavailable
2592
+ })
2593
+ );
2594
+ }
2595
+ content.appendChild(slot);
2596
+ this.ensureTurnstileWidget(this.challenge.siteKey);
2597
+ } else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight) {
2598
+ this.teardownTurnstileWidget();
2599
+ }
1921
2600
  const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
1922
2601
  if (submitError) {
1923
- form.appendChild(
1924
- el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError })
1925
- );
2602
+ const alert = el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError });
2603
+ const fixUrl = this.devChrome() && this.resolutionFailureCode === "form_not_published" && this.syncWarningCode === "storage_required" ? safeHttpUrl(this.syncWarningUrl) : null;
2604
+ if (fixUrl) {
2605
+ alert.append(" Connect storage: ");
2606
+ alert.appendChild(
2607
+ el("a", { text: fixUrl, attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" } })
2608
+ );
2609
+ }
2610
+ content.appendChild(alert);
1926
2611
  }
1927
2612
  const autoSubmit = api.form.settings.submitMode === "auto";
1928
- const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data));
2613
+ const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data)) || // A challenge needs an explicit Submit even on an otherwise button-less
2614
+ // auto-submit form — you can't one-tap past a human check.
2615
+ showChallenge;
1929
2616
  const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
1930
2617
  if (showFooter) {
1931
2618
  const footer = el("footer", { className: "fillo-footer", attrs: { "data-fillo": "footer" } });
@@ -1944,17 +2631,58 @@ var DomFormController = class {
1944
2631
  text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
1945
2632
  attrs: { type: "submit", "data-fillo": "button" }
1946
2633
  });
1947
- submit.toggleAttribute("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
2634
+ submit.toggleAttribute(
2635
+ "disabled",
2636
+ api.status === "submitting" || this.engine?.getState().uploading === true || // Wait for the human check before enabling submit (never "Next").
2637
+ // The server rejects a tokenless submit regardless — this is UX only.
2638
+ showChallenge && !this.challengeToken
2639
+ );
1948
2640
  submit.addEventListener("click", (event) => {
1949
2641
  event.preventDefault();
1950
2642
  advance();
1951
2643
  });
1952
2644
  footer.appendChild(submit);
1953
2645
  }
1954
- form.appendChild(footer);
2646
+ content.appendChild(footer);
1955
2647
  }
1956
2648
  return form;
1957
2649
  }
2650
+ /** GOV.UK error summary (audit P2.8): scoped to the CURRENTLY RENDERED
2651
+ * page — a failed submit already jumps to the first page with a visible
2652
+ * error, so every link resolves to something in the live tree. Per-field
2653
+ * messages lose their own role="alert"; this is the one alert. */
2654
+ renderErrorSummary(api) {
2655
+ const fields = api.blocks.filter(isField).filter((field) => api.errors[field.id]);
2656
+ if (fields.length === 0) return null;
2657
+ const box = el("div", {
2658
+ className: "fillo-error-summary",
2659
+ attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
2660
+ });
2661
+ box.appendChild(
2662
+ el("p", { className: "fillo-error-summary-title", text: DEFAULT_FIELD_STRINGS.errorSummaryTitle })
2663
+ );
2664
+ const list = el("ul");
2665
+ for (const field of fields) {
2666
+ const link = el("a", {
2667
+ text: `${field.label}: ${api.errors[field.id]}`,
2668
+ attrs: { href: `#${fieldIds(this.instanceId, field.id).inputId}` }
2669
+ });
2670
+ link.addEventListener("click", (event) => {
2671
+ event.preventDefault();
2672
+ const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
2673
+ (node) => node.getAttribute("data-field") === field.id
2674
+ );
2675
+ if (!fieldEl) return;
2676
+ const invalid = fieldEl.querySelector('[aria-invalid="true"]') ?? fieldEl;
2677
+ focusableWithin(invalid)?.focus();
2678
+ });
2679
+ const item = el("li");
2680
+ item.appendChild(link);
2681
+ list.appendChild(item);
2682
+ }
2683
+ box.appendChild(list);
2684
+ return box;
2685
+ }
1958
2686
  renderBlock(block, api) {
1959
2687
  if (!isField(block)) return renderContent(block);
1960
2688
  const setValue = (value, options) => {
@@ -1966,7 +2694,7 @@ var DomFormController = class {
1966
2694
  // dom's status union adds render phases; non-"idle" is a no anyway.
1967
2695
  status: api.status,
1968
2696
  isLastPage: api.isLastPage,
1969
- uploading: this.uploadProgress.size > 0
2697
+ uploading: this.engine?.getState().uploading === true
1970
2698
  };
1971
2699
  if (shouldAutoSubmit(block, value, autoCtx)) void this.submit();
1972
2700
  };
@@ -1978,11 +2706,16 @@ var DomFormController = class {
1978
2706
  api,
1979
2707
  setValue,
1980
2708
  uploadFiles: (field, files) => this.uploadFiles(field, files),
1981
- uploadProgress: this.uploadProgress,
2709
+ uploadRows: this.uploadRowsFor(block.id),
2710
+ uploadAction: this.uploadAction,
1982
2711
  activeOtherFields: this.activeOtherFields,
1983
2712
  getValue: () => this.engine?.getState().data[block.id],
1984
2713
  registerOverlay: this.registerOverlay,
1985
- uploadNotice: this.uploadNotices.get(block.id)
2714
+ uploadNotice: this.uploadNotices.get(block.id),
2715
+ storageBlocked: this.storageBlocked(),
2716
+ storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
2717
+ announce: this.announce,
2718
+ announceAlert: this.announceAlert
1986
2719
  };
1987
2720
  const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
1988
2721
  return renderer?.(context) ?? null;
@@ -2003,7 +2736,7 @@ function defaultSuccess(schema) {
2003
2736
  const success = el("div", { className: "fillo-form fillo-form--success" });
2004
2737
  const inner = el("div", { className: "fillo-success" });
2005
2738
  appendChildren(inner, [
2006
- el("div", { className: "fillo-success-mark", text: "\u2713", attrs: { "aria-hidden": "true" } }),
2739
+ el("div", { className: "fillo-success-mark", attrs: { "aria-hidden": "true" } }),
2007
2740
  el("h2", { className: "fillo-success-title", text: schema.settings.successTitle ?? "Thanks!" }),
2008
2741
  el("p", { className: "fillo-success-message", text: schema.settings.successMessage ?? "Your response has been recorded." })
2009
2742
  ]);
@@ -2017,13 +2750,16 @@ function poweredByEl(label, href) {
2017
2750
  attrs: { href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer" }
2018
2751
  });
2019
2752
  }
2753
+ function overlayVariant(reason) {
2754
+ return reason === "expired" || reason === "capped" || reason === "storage_full" ? "closed" : "notOpen";
2755
+ }
2020
2756
  function isDevEnv() {
2021
- return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
2757
+ return isLikelyDevEnv();
2022
2758
  }
2023
2759
  function devNoClientEl() {
2024
2760
  return el("div", {
2025
2761
  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.)",
2762
+ 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
2763
  attrs: { role: "alert" }
2028
2764
  });
2029
2765
  }
@@ -2041,6 +2777,53 @@ function devStagedEl() {
2041
2777
  attrs: { role: "alert" }
2042
2778
  });
2043
2779
  }
2780
+ function devDraftEl(warningUrl) {
2781
+ const node = el("div", { className: "fillo-devwarning", attrs: { role: "alert" } });
2782
+ node.append(
2783
+ "Draft form \u2014 it renders here, but it won't accept responses until you publish it in your Fillo dashboard. "
2784
+ );
2785
+ const safeUrl = safeHttpUrl(warningUrl);
2786
+ if (safeUrl) {
2787
+ node.append("Connect storage to publish: ");
2788
+ node.appendChild(
2789
+ el("a", { text: safeUrl, attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" } })
2790
+ );
2791
+ node.append(". ");
2792
+ }
2793
+ node.append("(Shown only in development and preview.)");
2794
+ return node;
2795
+ }
2796
+ function previewBadgeEl() {
2797
+ return el("span", {
2798
+ className: "fillo-preview-badge",
2799
+ text: "Preview",
2800
+ attrs: { "data-fillo": "preview-badge" }
2801
+ });
2802
+ }
2803
+ var warnedPreviewInProduction = false;
2804
+ function warnPreviewInProduction() {
2805
+ if (warnedPreviewInProduction || isBuildTimeDevEnv()) return;
2806
+ warnedPreviewInProduction = true;
2807
+ console.warn(
2808
+ "[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."
2809
+ );
2810
+ }
2811
+ function devChromeEls(state) {
2812
+ const nodes = [];
2813
+ if (state.preview) nodes.push(previewBadgeEl());
2814
+ if (state.devNotices === false) return nodes;
2815
+ if (state.syncError) nodes.push(devSyncErrorEl(state.syncError));
2816
+ else if (state.staged) nodes.push(devStagedEl());
2817
+ else if (state.draft) nodes.push(devDraftEl(state.warningUrl));
2818
+ else if (state.noClient) nodes.push(devNoClientEl());
2819
+ return nodes;
2820
+ }
2821
+ function loadFailureMessage(error) {
2822
+ if (error.status === 404) return DEFAULT_STRINGS.loadFailedNotFound;
2823
+ if (error.status === 0) return DEFAULT_STRINGS.loadFailedNetwork;
2824
+ if (error.status === 422) return DEFAULT_STRINGS.renderFailed;
2825
+ return DEFAULT_STRINGS.loadFailed;
2826
+ }
2044
2827
  function toFilloError(error) {
2045
2828
  if (isFilloError(error)) return error;
2046
2829
  return new FilloError(error instanceof Error ? error.message : String(error), 0);
@@ -2066,7 +2849,7 @@ var FilloFormElement = class extends HTMLElementBase {
2066
2849
  mounted = false;
2067
2850
  mountScheduled = false;
2068
2851
  static get observedAttributes() {
2069
- return ["publishable-key", "form-id"];
2852
+ return ["publishable-key", "form-id", "data-preview"];
2070
2853
  }
2071
2854
  connectedCallback() {
2072
2855
  this.scheduleMount();
@@ -2115,6 +2898,14 @@ var FilloFormElement = class extends HTMLElementBase {
2115
2898
  if (this.isConnected) this.mount();
2116
2899
  });
2117
2900
  }
2901
+ /** data-preview is on when present UNLESS its value says off ("false"/"0"):
2902
+ * frameworks stringify booleans onto data-* attributes rather than omitting
2903
+ * them, and a stringified `false` must never enable dev chrome for real
2904
+ * respondents. Disable by removing the attribute or setting it to "false". */
2905
+ previewAttrEnabled() {
2906
+ const value = this.getAttribute("data-preview");
2907
+ return value !== null && value !== "false" && value !== "0";
2908
+ }
2118
2909
  mount() {
2119
2910
  this.instance?.destroy();
2120
2911
  const key = this.getAttribute("publishable-key") ?? void 0;
@@ -2126,6 +2917,12 @@ var FilloFormElement = class extends HTMLElementBase {
2126
2917
  formId,
2127
2918
  theme: this.assignedTheme,
2128
2919
  initialData: this.assignedInitialData,
2920
+ // Same cosmetic-only contract as renderForm's `preview` option: forces
2921
+ // the dev chrome + a visible Preview badge on off-localhost surfaces,
2922
+ // and never changes where submissions go or whether they are accepted.
2923
+ // Value-aware: frameworks stringify booleans onto data-* attributes, so
2924
+ // data-preview="false"/"0" must mean OFF, not presence-implies-on.
2925
+ preview: this.previewAttrEnabled() ? true : void 0,
2129
2926
  onChange: (data) => this.dispatchEvent(new CustomEvent("fillo-change", { detail: { data } })),
2130
2927
  onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
2131
2928
  onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))