form-page-builder 2.0.0 → 2.2.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.cjs CHANGED
@@ -21,17 +21,74 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  // src/index.ts
22
22
  var index_exports = {};
23
23
  __export(index_exports, {
24
+ CLIPBOARD_KEY: () => CLIPBOARD_KEY,
24
25
  DARK_THEME: () => DARK_THEME,
25
26
  DEFAULT_THEME: () => DEFAULT_THEME,
27
+ DRAFT_KEY: () => DRAFT_KEY,
26
28
  FormBuilder: () => FormBuilder_default,
29
+ INDEX_KEY: () => INDEX_KEY,
27
30
  default: () => FormBuilder_default,
28
- localStorageAdapter: () => localStorageAdapter
31
+ formKey: () => formKey,
32
+ localStorageAdapter: () => localStorageAdapter,
33
+ parseTemplate: () => parseTemplate,
34
+ savedFormId: () => savedFormId,
35
+ serializeTemplate: () => serializeTemplate
29
36
  });
30
37
  module.exports = __toCommonJS(index_exports);
31
38
 
32
39
  // src/FormBuilder.tsx
33
- var import_react8 = require("react");
34
- var import_lucide_react18 = require("lucide-react");
40
+ var import_react10 = require("react");
41
+ var import_lucide_react19 = require("lucide-react");
42
+
43
+ // src/constants/fieldTypes.ts
44
+ var import_lucide_react = require("lucide-react");
45
+ var FIELD_TYPES = [
46
+ { type: "paragraph", icon: import_lucide_react.FileText, isContent: true, defaultIcon: "FileText" },
47
+ { type: "image", icon: import_lucide_react.Image, isContent: true, isImage: true, defaultIcon: "Type" },
48
+ { type: "button", icon: import_lucide_react.MousePointerClick, isContent: true, defaultIcon: "Type" },
49
+ { type: "input", icon: import_lucide_react.Type, placeholder: true, hasSubtype: true, defaultIcon: "Type" },
50
+ { type: "textarea", icon: import_lucide_react.AlignLeft, placeholder: true, defaultIcon: "AlignLeft" },
51
+ { type: "select", icon: import_lucide_react.ChevronDown, options: true, defaultIcon: "ChevronDown" },
52
+ { type: "radio", icon: import_lucide_react.Circle, options: true, defaultIcon: "Circle" },
53
+ { type: "checkboxGroup", icon: import_lucide_react.ListChecks, options: true, multiValue: true, defaultIcon: "ListChecks" },
54
+ { type: "checkbox", icon: import_lucide_react.CheckSquare, boolean: true, defaultIcon: "CheckSquare" },
55
+ { type: "toggle", icon: import_lucide_react.ToggleLeft, boolean: true, defaultIcon: "ToggleLeft" }
56
+ ];
57
+ var TYPE_MAP = Object.fromEntries(FIELD_TYPES.map((f) => [f.type, f]));
58
+ var CONTENT_TYPES = FIELD_TYPES.filter((f) => f.isContent);
59
+ var FORM_TYPES = FIELD_TYPES.filter((f) => !f.isContent);
60
+ function getMeta(type) {
61
+ return TYPE_MAP[type] || TYPE_MAP.input;
62
+ }
63
+ var LEGACY_INPUT_TYPES = ["text", "email", "phone", "number", "password", "date"];
64
+ var INPUT_SUBTYPES = ["text", "email", "phone", "number", "password", "date", "time"];
65
+ var IMAGE_SHAPES = ["square", "circle", "banner"];
66
+ var FIELD_TYPE_CHROME_KEY = {
67
+ paragraph: "fieldTypeParagraph",
68
+ image: "fieldTypeImage",
69
+ button: "fieldTypeButton",
70
+ input: "fieldTypeInput",
71
+ textarea: "fieldTypeTextarea",
72
+ select: "fieldTypeSelect",
73
+ radio: "fieldTypeRadio",
74
+ checkboxGroup: "fieldTypeCheckboxGroup",
75
+ checkbox: "fieldTypeCheckbox",
76
+ toggle: "fieldTypeToggle"
77
+ };
78
+ var INPUT_SUBTYPE_CHROME_KEY = {
79
+ text: "subtypeText",
80
+ email: "subtypeEmail",
81
+ phone: "subtypePhone",
82
+ number: "subtypeNumber",
83
+ password: "subtypePassword",
84
+ date: "subtypeDate",
85
+ time: "subtypeTime"
86
+ };
87
+ var IMAGE_SHAPE_CHROME_KEY = {
88
+ square: "shapeSquare",
89
+ circle: "shapeCircle",
90
+ banner: "shapeBanner"
91
+ };
35
92
 
36
93
  // src/i18n/languages.ts
37
94
  var DEFAULT_LANGUAGES = [
@@ -72,6 +129,7 @@ var DEFAULT_STRINGS = {
72
129
  tooLarge: (n) => `${n}\u4EE5\u4E0B\u306E\u5024\u3092\u5165\u529B\u3057\u3066\u304F\u3060\u3055\u3044\u3002`
73
130
  }
74
131
  };
132
+ DEFAULT_STRINGS.ja;
75
133
 
76
134
  // src/i18n/chrome.ts
77
135
  var CHROME = {
@@ -147,16 +205,33 @@ var CHROME = {
147
205
  formJson: "Form JSON",
148
206
  copy: "Copy",
149
207
  copied: "Copied",
208
+ copyTemplate: "Copy template",
209
+ pasteTemplate: "Paste template",
210
+ copyTemplateTitle: "Copy this form as a template you can paste into another builder",
211
+ pasteTemplateTitle: "Replace this form with the copied template",
212
+ pasteTemplateConfirm: "Replace the current form with the copied template? This can't be undone.",
150
213
  noTemplates: 'No templates saved yet. Click "Save" in the toolbar to store this form here.',
214
+ noTemplatesReadonly: "No templates available yet.",
151
215
  updated: "Updated",
152
216
  open: "Open",
217
+ applyTemplate: "Apply",
153
218
  saveForm: "Save as template",
154
219
  formName: "Template name",
155
220
  saveToLibrary: "Save template",
156
221
  loadingDraft: "Loading your draft...",
157
- templatesLimitReached: "You can save up to 5 templates. Delete one to save another.",
222
+ templatesLimitReached: (max) => `You can save up to ${max} template${max === 1 ? "" : "s"}. Delete one to save another.`,
223
+ templatesLimitTitle: "Template limit reached",
224
+ confirm: "Confirm",
225
+ cancel: "Cancel",
226
+ dismiss: "OK",
227
+ confirmReplace: "Replace",
158
228
  current: "current",
159
229
  customColor: "Custom color",
230
+ templateLabel: (name) => `Template: ${name}`,
231
+ templateEdited: "Edited",
232
+ templateLoading: "Loading template...",
233
+ templateLoadFailed: "Couldn't load that template.",
234
+ templateSaveFailed: "Couldn't save the template.",
160
235
  fieldsCount: (n) => `${n} field${n === 1 ? "" : "s"}`,
161
236
  fieldTypeInput: "Input",
162
237
  fieldTypeTextarea: "Textarea",
@@ -283,16 +358,33 @@ var CHROME = {
283
358
  formJson: "\u30D5\u30A9\u30FC\u30E0JSON",
284
359
  copy: "\u30B3\u30D4\u30FC",
285
360
  copied: "\u30B3\u30D4\u30FC\u3057\u307E\u3057\u305F",
361
+ copyTemplate: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u30B3\u30D4\u30FC",
362
+ pasteTemplate: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u8CBC\u308A\u4ED8\u3051",
363
+ copyTemplateTitle: "\u3053\u306E\u30D5\u30A9\u30FC\u30E0\u3092\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3068\u3057\u3066\u30B3\u30D4\u30FC\u3057\u3001\u4ED6\u306E\u30D3\u30EB\u30C0\u30FC\u306B\u8CBC\u308A\u4ED8\u3051\u307E\u3059",
364
+ pasteTemplateTitle: "\u30B3\u30D4\u30FC\u3057\u305F\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3067\u3053\u306E\u30D5\u30A9\u30FC\u30E0\u3092\u7F6E\u304D\u63DB\u3048\u307E\u3059",
365
+ pasteTemplateConfirm: "\u73FE\u5728\u306E\u30D5\u30A9\u30FC\u30E0\u3092\u30B3\u30D4\u30FC\u3057\u305F\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3067\u7F6E\u304D\u63DB\u3048\u307E\u3059\u304B\uFF1F\u3053\u306E\u64CD\u4F5C\u306F\u53D6\u308A\u6D88\u305B\u307E\u305B\u3093\u3002",
286
366
  noTemplates: "\u307E\u3060\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u3042\u308A\u307E\u305B\u3093\u3002\u30C4\u30FC\u30EB\u30D0\u30FC\u306E\u300C\u4FDD\u5B58\u300D\u3092\u30AF\u30EA\u30C3\u30AF\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
367
+ noTemplatesReadonly: "\u5229\u7528\u3067\u304D\u308B\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u304C\u307E\u3060\u3042\u308A\u307E\u305B\u3093\u3002",
287
368
  updated: "\u66F4\u65B0\u65E5\u6642",
288
369
  open: "\u958B\u304F",
370
+ applyTemplate: "\u9069\u7528",
289
371
  saveForm: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3068\u3057\u3066\u4FDD\u5B58",
290
372
  formName: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u540D",
291
373
  saveToLibrary: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u4FDD\u5B58",
292
374
  loadingDraft: "\u4E0B\u66F8\u304D\u3092\u8AAD\u307F\u8FBC\u3093\u3067\u3044\u307E\u3059...",
293
- templatesLimitReached: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306F5\u4EF6\u307E\u3067\u4FDD\u5B58\u3067\u304D\u307E\u3059\u30021\u4EF6\u524A\u9664\u3057\u3066\u304B\u3089\u4FDD\u5B58\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
375
+ templatesLimitReached: (max) => `\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306F${max}\u4EF6\u307E\u3067\u4FDD\u5B58\u3067\u304D\u307E\u3059\u30021\u4EF6\u524A\u9664\u3057\u3066\u304B\u3089\u4FDD\u5B58\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
376
+ templatesLimitTitle: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u4E0A\u9650\u306B\u9054\u3057\u307E\u3057\u305F",
377
+ confirm: "\u78BA\u5B9A",
378
+ cancel: "\u30AD\u30E3\u30F3\u30BB\u30EB",
379
+ dismiss: "OK",
380
+ confirmReplace: "\u7F6E\u304D\u63DB\u3048\u308B",
294
381
  current: "\u73FE\u5728\u7DE8\u96C6\u4E2D",
295
382
  customColor: "\u30AB\u30B9\u30BF\u30E0\u30AB\u30E9\u30FC",
383
+ templateLabel: (name) => `\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8: ${name}`,
384
+ templateEdited: "\u7DE8\u96C6\u6E08\u307F",
385
+ templateLoading: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u8AAD\u307F\u8FBC\u307F\u4E2D...",
386
+ templateLoadFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u8AAD\u307F\u8FBC\u3081\u307E\u305B\u3093\u3067\u3057\u305F\u3002",
387
+ templateSaveFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002",
296
388
  fieldsCount: (n) => `${n}\u4EF6\u306E\u30D5\u30A3\u30FC\u30EB\u30C9`,
297
389
  fieldTypeInput: "\u5165\u529B",
298
390
  fieldTypeTextarea: "\u30C6\u30AD\u30B9\u30C8\u30A8\u30EA\u30A2",
@@ -348,6 +440,7 @@ var CHROME = {
348
440
  spacingTicketPadding: "\u30D5\u30A3\u30FC\u30EB\u30C9\u5185\u4F59\u767D"
349
441
  }
350
442
  };
443
+ CHROME.ja;
351
444
 
352
445
  // src/lib/bilingual.ts
353
446
  function bi(en = "", ja = "") {
@@ -420,56 +513,6 @@ var localStorageAdapter = {
420
513
  }
421
514
  };
422
515
 
423
- // src/constants/fieldTypes.ts
424
- var import_lucide_react = require("lucide-react");
425
- var FIELD_TYPES = [
426
- { type: "paragraph", icon: import_lucide_react.FileText, isContent: true, defaultIcon: "FileText" },
427
- { type: "image", icon: import_lucide_react.Image, isContent: true, isImage: true, defaultIcon: "Type" },
428
- { type: "button", icon: import_lucide_react.MousePointerClick, isContent: true, defaultIcon: "Type" },
429
- { type: "input", icon: import_lucide_react.Type, placeholder: true, hasSubtype: true, defaultIcon: "Type" },
430
- { type: "textarea", icon: import_lucide_react.AlignLeft, placeholder: true, defaultIcon: "AlignLeft" },
431
- { type: "select", icon: import_lucide_react.ChevronDown, options: true, defaultIcon: "ChevronDown" },
432
- { type: "radio", icon: import_lucide_react.Circle, options: true, defaultIcon: "Circle" },
433
- { type: "checkboxGroup", icon: import_lucide_react.ListChecks, options: true, multiValue: true, defaultIcon: "ListChecks" },
434
- { type: "checkbox", icon: import_lucide_react.CheckSquare, boolean: true, defaultIcon: "CheckSquare" },
435
- { type: "toggle", icon: import_lucide_react.ToggleLeft, boolean: true, defaultIcon: "ToggleLeft" }
436
- ];
437
- var TYPE_MAP = Object.fromEntries(FIELD_TYPES.map((f) => [f.type, f]));
438
- var CONTENT_TYPES = FIELD_TYPES.filter((f) => f.isContent);
439
- var FORM_TYPES = FIELD_TYPES.filter((f) => !f.isContent);
440
- function getMeta(type) {
441
- return TYPE_MAP[type] || TYPE_MAP.input;
442
- }
443
- var LEGACY_INPUT_TYPES = ["text", "email", "phone", "number", "password", "date"];
444
- var INPUT_SUBTYPES = ["text", "email", "phone", "number", "password", "date", "time"];
445
- var IMAGE_SHAPES = ["square", "circle", "banner"];
446
- var FIELD_TYPE_CHROME_KEY = {
447
- paragraph: "fieldTypeParagraph",
448
- image: "fieldTypeImage",
449
- button: "fieldTypeButton",
450
- input: "fieldTypeInput",
451
- textarea: "fieldTypeTextarea",
452
- select: "fieldTypeSelect",
453
- radio: "fieldTypeRadio",
454
- checkboxGroup: "fieldTypeCheckboxGroup",
455
- checkbox: "fieldTypeCheckbox",
456
- toggle: "fieldTypeToggle"
457
- };
458
- var INPUT_SUBTYPE_CHROME_KEY = {
459
- text: "subtypeText",
460
- email: "subtypeEmail",
461
- phone: "subtypePhone",
462
- number: "subtypeNumber",
463
- password: "subtypePassword",
464
- date: "subtypeDate",
465
- time: "subtypeTime"
466
- };
467
- var IMAGE_SHAPE_CHROME_KEY = {
468
- square: "shapeSquare",
469
- circle: "shapeCircle",
470
- banner: "shapeBanner"
471
- };
472
-
473
516
  // src/lib/id.ts
474
517
  var genFormId = () => `form_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
475
518
  var genSectionId = () => `section_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
@@ -633,10 +676,32 @@ function migrateDocument(raw) {
633
676
  return { title, themeOverrides, sections };
634
677
  }
635
678
 
679
+ // src/lib/template.ts
680
+ var TEMPLATE_FORMAT = 1;
681
+ function serializeTemplate(document2) {
682
+ const envelope = { __fpb: "template", v: TEMPLATE_FORMAT, document: document2 };
683
+ return JSON.stringify(envelope);
684
+ }
685
+ function parseTemplate(raw) {
686
+ if (!raw) return null;
687
+ let parsed;
688
+ try {
689
+ parsed = JSON.parse(raw);
690
+ } catch {
691
+ return null;
692
+ }
693
+ if (!parsed || typeof parsed !== "object") return null;
694
+ const obj = parsed;
695
+ const candidate = obj.__fpb === "template" && obj.document ? obj.document : obj;
696
+ return migrateDocument(candidate);
697
+ }
698
+
636
699
  // src/lib/features.ts
700
+ var DEFAULT_MAX_TEMPLATES = 5;
637
701
  var DEFAULT_FEATURES = {
638
702
  naming: true,
639
- templates: true,
703
+ templates: { enabled: true, manage: true, max: DEFAULT_MAX_TEMPLATES },
704
+ templateClipboard: true,
640
705
  newForm: true,
641
706
  autosave: true,
642
707
  jsonView: true,
@@ -647,14 +712,24 @@ var DEFAULT_FEATURES = {
647
712
  contentBlocks: true,
648
713
  fieldTypes: true,
649
714
  sections: true,
650
- dragReorder: true
715
+ sectionBackground: true,
716
+ dragReorder: true,
717
+ deviceToggle: true
651
718
  };
719
+ function resolveTemplates(t2) {
720
+ if (t2 === false) return { enabled: false, manage: false, max: DEFAULT_MAX_TEMPLATES };
721
+ if (t2 === void 0 || t2 === true) return { enabled: true, manage: true, max: DEFAULT_MAX_TEMPLATES };
722
+ return { enabled: true, manage: t2.manage ?? true, max: t2.max ?? DEFAULT_MAX_TEMPLATES };
723
+ }
652
724
  function resolveFeatures(features) {
653
725
  return {
654
726
  ...DEFAULT_FEATURES,
655
727
  ...features,
728
+ templates: resolveTemplates(features?.templates),
656
729
  contentBlocks: Array.isArray(features?.contentBlocks) ? new Set(features.contentBlocks) : features?.contentBlocks ?? DEFAULT_FEATURES.contentBlocks,
657
- fieldTypes: Array.isArray(features?.fieldTypes) ? new Set(features.fieldTypes) : features?.fieldTypes ?? DEFAULT_FEATURES.fieldTypes
730
+ fieldTypes: Array.isArray(features?.fieldTypes) ? new Set(features.fieldTypes) : features?.fieldTypes ?? DEFAULT_FEATURES.fieldTypes,
731
+ // Unset → follow `sections`, so an existing `sections: false` still hides the picker.
732
+ sectionBackground: features?.sectionBackground ?? features?.sections ?? DEFAULT_FEATURES.sectionBackground
658
733
  };
659
734
  }
660
735
  function isContentBlockEnabled(features, type) {
@@ -684,17 +759,17 @@ var DEFAULT_THEME = {
684
759
 
685
760
  // src/hooks/useTheme.ts
686
761
  function useTheme(themeOverrideProp) {
687
- const baseTheme = {
762
+ const [themeOverrides, setThemeOverrides] = (0, import_react.useState)({});
763
+ const baseTheme = (0, import_react.useMemo)(() => ({
688
764
  ...DEFAULT_THEME,
689
765
  ...themeOverrideProp || {},
690
766
  layout: { ...DEFAULT_THEME.layout, ...themeOverrideProp && themeOverrideProp.layout || {} }
691
- };
692
- const [themeOverrides, setThemeOverrides] = (0, import_react.useState)({});
693
- const theme = {
767
+ }), [themeOverrideProp]);
768
+ const theme = (0, import_react.useMemo)(() => ({
694
769
  ...baseTheme,
695
770
  ...themeOverrides,
696
771
  layout: { ...baseTheme.layout, ...themeOverrides.layout || {} }
697
- };
772
+ }), [baseTheme, themeOverrides]);
698
773
  function updateThemeColor(key, value) {
699
774
  setThemeOverrides((prev) => ({ ...prev, [key]: value }));
700
775
  }
@@ -893,35 +968,49 @@ var import_react3 = require("react");
893
968
  var DRAFT_KEY = "form-page-builder:draft";
894
969
  var INDEX_KEY = "form-page-builder:index";
895
970
  var formKey = (id) => `form-page-builder:saved:${id}`;
971
+ var savedFormId = (key) => key.startsWith("form-page-builder:saved:") ? key.slice("form-page-builder:saved:".length) : null;
972
+ var CLIPBOARD_KEY = "form-page-builder:clipboard";
896
973
 
897
974
  // src/hooks/usePersistence.ts
898
- var MAX_TEMPLATES = 5;
975
+ function snapshotOf(d) {
976
+ return JSON.stringify({ title: d.title, themeOverrides: d.themeOverrides, sections: d.sections });
977
+ }
899
978
  function usePersistence({
900
979
  storage,
901
980
  autosave,
981
+ templateMax,
982
+ templateManage,
902
983
  language,
903
984
  chrome,
904
- document,
985
+ document: document2,
905
986
  initialDocument,
906
987
  onLoadDocument,
907
988
  onLoadThemeOverrides,
908
989
  onTitleChange,
909
990
  onNewForm,
991
+ onTemplateChange,
910
992
  ensureActiveSection
911
993
  }) {
912
994
  const [currentFormId, setCurrentFormId] = (0, import_react3.useState)(null);
913
995
  const [loadingDraft, setLoadingDraft] = (0, import_react3.useState)(true);
914
996
  const [saveState, setSaveState] = (0, import_react3.useState)("idle");
997
+ const [templateState, setTemplateState] = (0, import_react3.useState)("idle");
915
998
  const [savedForms, setSavedForms] = (0, import_react3.useState)([]);
916
999
  const [saveAsPrompt, setSaveAsPrompt] = (0, import_react3.useState)(null);
1000
+ const [notice, setNotice] = (0, import_react3.useState)(null);
917
1001
  const autosaveTimer = (0, import_react3.useRef)(null);
918
1002
  const hasLoadedOnce = (0, import_react3.useRef)(false);
1003
+ const savedSnapshot = (0, import_react3.useRef)(null);
1004
+ function flashSaved() {
1005
+ setTemplateState("saved");
1006
+ setTimeout(() => setTemplateState((s) => s === "saved" ? "idle" : s), 1500);
1007
+ }
919
1008
  async function refreshLibrary() {
920
1009
  try {
921
1010
  const raw = await storage.get(INDEX_KEY);
922
1011
  const list = raw ? JSON.parse(raw) : [];
923
1012
  setSavedForms(list.sort((a, b) => b.updatedAt - a.updatedAt));
924
- } catch (err) {
1013
+ } catch {
925
1014
  setSavedForms([]);
926
1015
  }
927
1016
  }
@@ -945,12 +1034,21 @@ function usePersistence({
945
1034
  onLoadDocument(doc);
946
1035
  onLoadThemeOverrides(doc.themeOverrides);
947
1036
  }
948
- if (parsed.currentFormId) setCurrentFormId(parsed.currentFormId);
1037
+ if (parsed.currentFormId && templateManage) {
1038
+ setCurrentFormId(parsed.currentFormId);
1039
+ try {
1040
+ const tplRaw = await storage.get(formKey(parsed.currentFormId));
1041
+ const tplDoc = tplRaw ? migrateDocument(JSON.parse(tplRaw)) : null;
1042
+ savedSnapshot.current = tplDoc ? snapshotOf(tplDoc) : null;
1043
+ } catch {
1044
+ savedSnapshot.current = null;
1045
+ }
1046
+ }
949
1047
  } else {
950
1048
  ensureActiveSection();
951
1049
  }
952
1050
  }
953
- } catch (err) {
1051
+ } catch {
954
1052
  ensureActiveSection();
955
1053
  } finally {
956
1054
  setLoadingDraft(false);
@@ -965,86 +1063,125 @@ function usePersistence({
965
1063
  if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
966
1064
  autosaveTimer.current = setTimeout(async () => {
967
1065
  try {
968
- await storage.set(DRAFT_KEY, JSON.stringify({ ...document, currentFormId }));
1066
+ await storage.set(DRAFT_KEY, JSON.stringify({ ...document2, currentFormId }));
969
1067
  setSaveState("saved");
970
- } catch (err) {
1068
+ } catch {
971
1069
  setSaveState("error");
972
1070
  }
973
1071
  }, 600);
974
1072
  return () => {
975
1073
  if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
976
1074
  };
977
- }, [document.title, document.themeOverrides, document.sections, currentFormId]);
1075
+ }, [document2.title, document2.themeOverrides, document2.sections, currentFormId]);
978
1076
  async function saveAs(name) {
979
- if (savedForms.length >= MAX_TEMPLATES) {
980
- alert(chrome.templatesLimitReached);
1077
+ if (!templateManage) return;
1078
+ if (savedForms.length >= templateMax) {
1079
+ setSaveAsPrompt(null);
1080
+ setNotice({ title: chrome.templatesLimitTitle, message: chrome.templatesLimitReached(templateMax) });
981
1081
  return;
982
1082
  }
983
1083
  const id = genFormId();
984
1084
  const now = Date.now();
1085
+ setTemplateState("saving");
985
1086
  try {
986
1087
  const newTitle = bi(name, "");
987
- await storage.set(formKey(id), JSON.stringify({ ...document, title: newTitle, id, updatedAt: now }));
1088
+ const docToSave = { ...document2, title: newTitle };
1089
+ await storage.set(formKey(id), JSON.stringify({ ...docToSave, id, updatedAt: now }));
988
1090
  const next = [...savedForms, { id, title: name, updatedAt: now }];
989
1091
  await storage.set(INDEX_KEY, JSON.stringify(next));
990
1092
  setSavedForms(next);
991
1093
  setCurrentFormId(id);
992
1094
  onTitleChange(newTitle);
993
1095
  setSaveAsPrompt(null);
994
- } catch (err) {
995
- alert("Couldn't save the form. Please try again.");
1096
+ savedSnapshot.current = snapshotOf(docToSave);
1097
+ flashSaved();
1098
+ onTemplateChange?.({ id, title: name, source: "new" });
1099
+ } catch {
1100
+ setTemplateState("error");
996
1101
  }
997
1102
  }
998
1103
  async function saveExisting() {
1104
+ if (!templateManage) return;
999
1105
  if (!currentFormId) {
1000
- setSaveAsPrompt({ open: true, suggestedName: t(document.title, language) });
1106
+ setSaveAsPrompt({ open: true, suggestedName: t(document2.title, language) });
1001
1107
  return;
1002
1108
  }
1003
1109
  const now = Date.now();
1110
+ setTemplateState("saving");
1004
1111
  try {
1005
- await storage.set(formKey(currentFormId), JSON.stringify({ ...document, id: currentFormId, updatedAt: now }));
1006
- const next = savedForms.map((f) => f.id === currentFormId ? { ...f, title: t(document.title, "en"), updatedAt: now } : f);
1112
+ await storage.set(formKey(currentFormId), JSON.stringify({ ...document2, id: currentFormId, updatedAt: now }));
1113
+ const next = savedForms.map((f) => f.id === currentFormId ? { ...f, title: t(document2.title, "en"), updatedAt: now } : f);
1007
1114
  setSavedForms(next);
1008
1115
  await storage.set(INDEX_KEY, JSON.stringify(next));
1009
- } catch (err) {
1010
- alert("Couldn't save the form. Please try again.");
1116
+ savedSnapshot.current = snapshotOf(document2);
1117
+ flashSaved();
1118
+ onTemplateChange?.({ id: currentFormId, title: t(document2.title, language), source: "saved" });
1119
+ } catch {
1120
+ setTemplateState("error");
1011
1121
  }
1012
1122
  }
1013
1123
  async function loadForm(id) {
1124
+ setTemplateState("loading");
1014
1125
  try {
1015
1126
  const raw = await storage.get(formKey(id));
1016
- if (!raw) return;
1127
+ if (!raw) {
1128
+ setTemplateState("error");
1129
+ return false;
1130
+ }
1017
1131
  const doc = migrateDocument(JSON.parse(raw));
1018
- if (!doc) return;
1132
+ if (!doc) {
1133
+ setTemplateState("error");
1134
+ return false;
1135
+ }
1019
1136
  onLoadDocument(doc);
1020
1137
  onLoadThemeOverrides(doc.themeOverrides);
1021
- setCurrentFormId(id);
1022
- } catch (err) {
1023
- alert("Couldn't load that form.");
1138
+ const boundId = templateManage ? id : null;
1139
+ setCurrentFormId(boundId);
1140
+ savedSnapshot.current = boundId ? snapshotOf(doc) : null;
1141
+ setTemplateState("idle");
1142
+ onTemplateChange?.({ id, title: t(doc.title, language), source: "applied" });
1143
+ return true;
1144
+ } catch {
1145
+ setTemplateState("error");
1146
+ return false;
1024
1147
  }
1025
1148
  }
1026
1149
  async function deleteForm(id) {
1150
+ if (!templateManage) return;
1151
+ const removed = savedForms.find((f) => f.id === id);
1027
1152
  try {
1028
1153
  await storage.delete(formKey(id));
1029
1154
  const next = savedForms.filter((f) => f.id !== id);
1030
1155
  setSavedForms(next);
1031
1156
  await storage.set(INDEX_KEY, JSON.stringify(next));
1032
- if (currentFormId === id) setCurrentFormId(null);
1033
- } catch (err) {
1157
+ if (currentFormId === id) {
1158
+ setCurrentFormId(null);
1159
+ savedSnapshot.current = null;
1160
+ }
1161
+ onTemplateChange?.({ id: currentFormId === id ? null : id, title: removed?.title ?? "", source: "deleted" });
1162
+ } catch {
1163
+ setTemplateState("error");
1034
1164
  }
1035
1165
  }
1036
1166
  function newForm() {
1037
1167
  onNewForm();
1038
1168
  setCurrentFormId(null);
1169
+ savedSnapshot.current = null;
1170
+ setTemplateState("idle");
1039
1171
  }
1172
+ const activeTemplate = currentFormId ? savedForms.find((f) => f.id === currentFormId) ?? null : null;
1173
+ const isTemplateDirty = savedSnapshot.current !== null && snapshotOf(document2) !== savedSnapshot.current;
1040
1174
  function dismissSaveAsPrompt() {
1041
1175
  setSaveAsPrompt(null);
1042
1176
  }
1043
1177
  return {
1044
1178
  loadingDraft,
1045
1179
  saveState,
1180
+ templateState,
1046
1181
  savedForms,
1047
1182
  currentFormId,
1183
+ activeTemplateTitle: activeTemplate?.title ?? null,
1184
+ isTemplateDirty,
1048
1185
  saveAs,
1049
1186
  saveExisting,
1050
1187
  loadForm,
@@ -1052,15 +1189,64 @@ function usePersistence({
1052
1189
  refreshLibrary,
1053
1190
  newForm,
1054
1191
  saveAsPrompt,
1055
- dismissSaveAsPrompt
1192
+ dismissSaveAsPrompt,
1193
+ notice,
1194
+ dismissNotice: () => setNotice(null)
1056
1195
  };
1057
1196
  }
1058
1197
 
1059
- // src/hooks/useDragReorder.ts
1198
+ // src/hooks/useTemplateClipboard.ts
1060
1199
  var import_react4 = require("react");
1200
+ var sameTabListeners = /* @__PURE__ */ new Set();
1201
+ function notifySameTab() {
1202
+ sameTabListeners.forEach((fn) => fn());
1203
+ }
1204
+ function readKey(key) {
1205
+ try {
1206
+ return typeof window !== "undefined" && window.localStorage ? window.localStorage.getItem(key) : null;
1207
+ } catch {
1208
+ return null;
1209
+ }
1210
+ }
1211
+ function useTemplateClipboard(clipboardKey = CLIPBOARD_KEY) {
1212
+ const keyRef = (0, import_react4.useRef)(clipboardKey);
1213
+ keyRef.current = clipboardKey;
1214
+ const [hasClipboard, setHasClipboard] = (0, import_react4.useState)(false);
1215
+ const refresh = (0, import_react4.useCallback)(() => {
1216
+ setHasClipboard(parseTemplate(readKey(keyRef.current)) !== null);
1217
+ }, []);
1218
+ (0, import_react4.useEffect)(() => {
1219
+ refresh();
1220
+ const onStorage = (e) => {
1221
+ if (e.key === null || e.key === keyRef.current) refresh();
1222
+ };
1223
+ sameTabListeners.add(refresh);
1224
+ window.addEventListener("storage", onStorage);
1225
+ return () => {
1226
+ sameTabListeners.delete(refresh);
1227
+ window.removeEventListener("storage", onStorage);
1228
+ };
1229
+ }, [refresh]);
1230
+ const copyTemplate = (0, import_react4.useCallback)((document2) => {
1231
+ const str = serializeTemplate(document2);
1232
+ try {
1233
+ window.localStorage?.setItem(keyRef.current, str);
1234
+ } catch {
1235
+ }
1236
+ notifySameTab();
1237
+ refresh();
1238
+ }, [refresh]);
1239
+ const readTemplate = (0, import_react4.useCallback)(() => {
1240
+ return parseTemplate(readKey(keyRef.current));
1241
+ }, []);
1242
+ return { hasClipboard, copyTemplate, readTemplate };
1243
+ }
1244
+
1245
+ // src/hooks/useDragReorder.ts
1246
+ var import_react5 = require("react");
1061
1247
  function useDragReorder(reorderWithinSection) {
1062
- const dragRef = (0, import_react4.useRef)({ sectionId: null, index: null });
1063
- const [dragOverKey, setDragOverKey] = (0, import_react4.useState)(null);
1248
+ const dragRef = (0, import_react5.useRef)({ sectionId: null, index: null });
1249
+ const [dragOverKey, setDragOverKey] = (0, import_react5.useState)(null);
1064
1250
  function getDropZoneHandlers(sectionId, index) {
1065
1251
  const dragKey = `${sectionId}:${index}`;
1066
1252
  return {
@@ -1094,7 +1280,7 @@ var import_lucide_react2 = require("lucide-react");
1094
1280
  var styles = {
1095
1281
  app: { fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", background: "var(--fb-canvas)", borderRadius: 12, overflow: "hidden", border: "1px solid var(--fb-border)", color: "var(--fb-ink)", display: "flex", flexDirection: "column" },
1096
1282
  toolbar: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "var(--fb-space-toolbar) 16px", borderBottom: "1px solid var(--fb-border)", background: "var(--fb-surface)", flexWrap: "wrap", gap: 8, position: "relative" },
1097
- toolbarLeft: { display: "flex", alignItems: "center", gap: 10 },
1283
+ toolbarLeft: { display: "flex", alignItems: "center", gap: 10, minWidth: 0, flexShrink: 1 },
1098
1284
  logoMark: { width: 26, height: 26, borderRadius: 7, background: "var(--fb-primary)", color: "#fff", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'JetBrains Mono', monospace", letterSpacing: "-0.5px" },
1099
1285
  titleInput: { border: "none", outline: "none", fontSize: 14, fontWeight: 600, background: "transparent", color: "var(--fb-ink)", padding: "4px 6px", borderRadius: 6, minWidth: 160 },
1100
1286
  toolbarRight: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
@@ -1104,6 +1290,8 @@ var styles = {
1104
1290
  ghostBtn: { display: "flex", alignItems: "center", gap: 6, padding: "6px 12px", borderRadius: 7, border: "1px solid var(--fb-border)", background: "var(--fb-surface)", color: "var(--fb-ink)", fontSize: 13, fontWeight: 500 },
1105
1291
  primaryBtn: { display: "flex", alignItems: "center", gap: 6, padding: "6px 13px", borderRadius: 7, border: "1px solid var(--fb-primary)", background: "var(--fb-primary)", color: "#fff", fontSize: 13, fontWeight: 600 },
1106
1292
  saveStatus: { display: "flex", alignItems: "center", gap: 5, fontSize: 12, color: "var(--fb-muted)", minWidth: 60 },
1293
+ templateTag: { display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--fb-muted)", minWidth: 0, flexShrink: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
1294
+ toolbarIconBtn: { display: "flex", alignItems: "center", justifyContent: "center", padding: "6px 9px", borderRadius: 7, border: "1px solid var(--fb-border)", background: "var(--fb-surface)", color: "var(--fb-ink)" },
1107
1295
  countBadge: { fontSize: 10.5, fontWeight: 700, background: "var(--fb-primary-soft)", color: "var(--fb-primary)", borderRadius: 9, padding: "1px 6px", marginLeft: 2 },
1108
1296
  loadingScreen: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", color: "var(--fb-muted)" },
1109
1297
  libraryBody: { padding: "var(--fb-space-panel)", overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 },
@@ -1240,21 +1428,36 @@ function Toolbar({
1240
1428
  languages,
1241
1429
  mode,
1242
1430
  saveState,
1431
+ templateState,
1432
+ activeTemplateTitle,
1433
+ templateDirty,
1243
1434
  chrome,
1244
1435
  features,
1245
1436
  savedFormsCount,
1437
+ clipboardReady,
1438
+ templateCopied,
1246
1439
  onTitleChange,
1247
1440
  onLanguageChange,
1248
1441
  onModeChange,
1249
1442
  onNewForm,
1250
1443
  onOpenLibrary,
1251
1444
  onSaveExisting,
1252
- onOpenJson
1445
+ onOpenJson,
1446
+ onCopyTemplate,
1447
+ onPasteTemplate
1253
1448
  }) {
1254
1449
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: styles.toolbar, children: [
1255
1450
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: styles.toolbarLeft, children: [
1256
1451
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: styles.logoMark, children: "FB" }),
1257
- features.naming ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { value: t(title, language), onChange: (e) => onTitleChange(e.target.value), style: styles.titleInput, "aria-label": "Form title" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.titleInput, children: t(title, language) })
1452
+ features.naming ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { value: t(title, language), onChange: (e) => onTitleChange(e.target.value), style: styles.titleInput, "aria-label": "Form title" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.titleInput, children: t(title, language) }),
1453
+ templateState === "loading" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: styles.saveStatus, children: [
1454
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Loader2, { size: 12, className: "spin" }),
1455
+ " ",
1456
+ chrome.templateLoading
1457
+ ] }) : activeTemplateTitle && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "fb-template-tag", style: styles.templateTag, title: chrome.templateLabel(activeTemplateTitle), children: [
1458
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis" }, children: chrome.templateLabel(activeTemplateTitle) }),
1459
+ templateDirty && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.currentBadge, children: chrome.templateEdited })
1460
+ ] })
1258
1461
  ] }),
1259
1462
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: styles.toolbarRight, children: [
1260
1463
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: styles.saveStatus, children: [
@@ -1287,20 +1490,20 @@ function Toolbar({
1287
1490
  chrome.preview
1288
1491
  ] })
1289
1492
  ] }),
1290
- (features.newForm || features.templates) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: styles.toolbarDivider }),
1493
+ (features.newForm || features.templates.enabled) && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: styles.toolbarDivider }),
1291
1494
  features.newForm && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.ghostBtn, onClick: onNewForm, title: chrome.startNewForm, children: [
1292
1495
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.FilePlus2, { size: 14 }),
1293
1496
  " ",
1294
1497
  chrome.newForm
1295
1498
  ] }),
1296
- features.templates && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1499
+ features.templates.enabled && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1297
1500
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.ghostBtn, onClick: onOpenLibrary, title: chrome.openTemplatesTitle, children: [
1298
1501
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.FolderOpen, { size: 14 }),
1299
1502
  " ",
1300
1503
  chrome.templates,
1301
1504
  savedFormsCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: styles.countBadge, children: savedFormsCount })
1302
1505
  ] }),
1303
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.primaryBtn, onClick: onSaveExisting, title: chrome.saveToLibraryTitle, children: [
1506
+ features.templates.manage && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { style: styles.primaryBtn, onClick: onSaveExisting, title: chrome.saveToLibraryTitle, children: [
1304
1507
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Save, { size: 14 }),
1305
1508
  " ",
1306
1509
  chrome.save
@@ -1310,13 +1513,17 @@ function Toolbar({
1310
1513
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Code2, { size: 14 }),
1311
1514
  " ",
1312
1515
  chrome.viewJson
1516
+ ] }),
1517
+ features.templateClipboard && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1518
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: styles.toolbarIconBtn, onClick: onCopyTemplate, title: chrome.copyTemplateTitle, "aria-label": chrome.copyTemplate, children: templateCopied ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.Check, { size: 14 }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.ClipboardCopy, { size: 14 }) }),
1519
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: styles.toolbarIconBtn, onClick: onPasteTemplate, disabled: !clipboardReady, title: chrome.pasteTemplateTitle, "aria-label": chrome.pasteTemplate, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_lucide_react2.ClipboardPaste, { size: 14 }) })
1313
1520
  ] })
1314
1521
  ] })
1315
1522
  ] });
1316
1523
  }
1317
1524
 
1318
1525
  // src/components/Palette.tsx
1319
- var import_react5 = require("react");
1526
+ var import_react6 = require("react");
1320
1527
  var import_lucide_react5 = require("lucide-react");
1321
1528
 
1322
1529
  // src/components/Segmented.tsx
@@ -1411,11 +1618,13 @@ function Palette({
1411
1618
  theme,
1412
1619
  updateThemeColor,
1413
1620
  updateThemeLayout,
1414
- resetTheme
1621
+ resetTheme,
1622
+ fieldCount
1415
1623
  }) {
1416
- const [tab, setTab] = (0, import_react5.useState)("blocks");
1624
+ const [tab, setTab] = (0, import_react6.useState)("blocks");
1417
1625
  const contentTypes = CONTENT_TYPES.filter((f) => isContentBlockEnabled(features, f.type));
1418
1626
  const formTypes = FORM_TYPES.filter((f) => isFieldTypeEnabled(features, f.type));
1627
+ const atFieldCap = typeof features.maxFields === "number" && fieldCount >= features.maxFields;
1419
1628
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "fb-palette", style: styles.palette, children: [
1420
1629
  features.design && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { marginBottom: 14 }, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1421
1630
  Segmented,
@@ -1458,7 +1667,8 @@ function Palette({
1458
1667
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { ...styles.panelHeading, marginTop: 16 }, children: chrome.formFields }),
1459
1668
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: styles.paletteList, children: formTypes.map((f) => {
1460
1669
  const Icon = f.icon;
1461
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("button", { style: styles.paletteItem, onClick: () => onAddField(f.type), children: [
1670
+ const itemStyle = atFieldCap ? { ...styles.paletteItem, opacity: 0.45, cursor: "not-allowed" } : styles.paletteItem;
1671
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("button", { type: "button", disabled: atFieldCap, style: itemStyle, onClick: () => onAddField(f.type), children: [
1462
1672
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(Icon, { size: 16, color: "var(--fb-primary)" }),
1463
1673
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { children: chrome[FIELD_TYPE_CHROME_KEY[f.type]] }),
1464
1674
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_lucide_react5.Plus, { size: 13, color: "var(--fb-muted)", style: { marginLeft: "auto" } })
@@ -1796,7 +2006,7 @@ function SectionCard({
1796
2006
  ),
1797
2007
  section.collapsed && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { style: styles.miniBadge, children: chrome.fieldsCount(section.fields.length) }),
1798
2008
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { style: styles.sectionHeaderActions, onClick: (e) => e.stopPropagation(), children: [
1799
- features.sections && !section.collapsed && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { style: styles.swatchRow, children: [
2009
+ features.sectionBackground && !section.collapsed && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { style: styles.swatchRow, children: [
1800
2010
  SECTION_BG_SWATCHES.map((c) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "button", title: c || chrome.none, style: { ...styles.swatchBtn, background: c || "var(--fb-canvas)", ...section.background === c ? styles.swatchBtnActive : {} }, onClick: () => onUpdateBackground(c) }, c || "none")),
1801
2011
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("input", { type: "color", title: chrome.customColor, value: /^#/.test(section.background) ? section.background : "#ffffff", onChange: (e) => onUpdateBackground(e.target.value), style: styles.colorPickerInput })
1802
2012
  ] }),
@@ -2117,7 +2327,7 @@ function InspectorBody({ selected, language, chrome, features, onUpdateField, on
2117
2327
  }
2118
2328
 
2119
2329
  // src/components/PreviewPane.tsx
2120
- var import_react6 = require("react");
2330
+ var import_react7 = require("react");
2121
2331
  var import_lucide_react14 = require("lucide-react");
2122
2332
 
2123
2333
  // src/lib/validate.ts
@@ -2165,11 +2375,11 @@ function collectFieldValues(fields) {
2165
2375
 
2166
2376
  // src/components/PreviewPane.tsx
2167
2377
  var import_jsx_runtime14 = require("react/jsx-runtime");
2168
- function PreviewPane({ title, sections, onFieldChange, language, strings, chrome, baseMaxWidth, onSubmit }) {
2169
- const [device, setDevice] = (0, import_react6.useState)("laptop");
2170
- const [errors, setErrors] = (0, import_react6.useState)({});
2171
- const [submitted, setSubmitted] = (0, import_react6.useState)(null);
2172
- const [attemptedButtonId, setAttemptedButtonId] = (0, import_react6.useState)(null);
2378
+ function PreviewPane({ title, sections, onFieldChange, language, strings, chrome, baseMaxWidth, features, onSubmit }) {
2379
+ const [device, setDevice] = (0, import_react7.useState)("laptop");
2380
+ const [errors, setErrors] = (0, import_react7.useState)({});
2381
+ const [submitted, setSubmitted] = (0, import_react7.useState)(null);
2382
+ const [attemptedButtonId, setAttemptedButtonId] = (0, import_react7.useState)(null);
2173
2383
  const deviceOptions = buildDeviceOptions(baseMaxWidth, chrome);
2174
2384
  const maxWidth = (deviceOptions.find((d) => d.value === device) || deviceOptions[0]).maxWidth;
2175
2385
  const allFields = sections.flatMap((s) => s.fields);
@@ -2228,7 +2438,7 @@ function PreviewPane({ title, sections, onFieldChange, language, strings, chrome
2228
2438
  }
2229
2439
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: styles.previewWrap, children: [
2230
2440
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: { width: "100%", maxWidth, display: "flex", flexDirection: "column", alignItems: "center" }, children: [
2231
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { style: styles.previewToolbar, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Segmented, { options: deviceOptions, value: device, onChange: setDevice }) }),
2441
+ features.deviceToggle && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { style: styles.previewToolbar, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Segmented, { options: deviceOptions, value: device, onChange: setDevice }) }),
2232
2442
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { style: { ...styles.previewCard, maxWidth, width: "100%" }, children: [
2233
2443
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("h2", { style: styles.previewTitle, children: t(title, language) }),
2234
2444
  allFields.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { style: { color: "var(--fb-muted)", fontSize: 14 }, children: strings.addFieldsHint }),
@@ -2274,17 +2484,66 @@ function PreviewPane({ title, sections, onFieldChange, language, strings, chrome
2274
2484
 
2275
2485
  // src/components/modals/JsonModal.tsx
2276
2486
  var import_lucide_react15 = require("lucide-react");
2487
+
2488
+ // src/hooks/useModalA11y.ts
2489
+ var import_react8 = require("react");
2490
+ var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
2491
+ function useModalA11y(onClose) {
2492
+ const panelRef = (0, import_react8.useRef)(null);
2493
+ const onCloseRef = (0, import_react8.useRef)(onClose);
2494
+ onCloseRef.current = onClose;
2495
+ (0, import_react8.useEffect)(() => {
2496
+ const opener = document.activeElement;
2497
+ const panel = panelRef.current;
2498
+ const focusables = () => Array.from(panel?.querySelectorAll(FOCUSABLE) ?? []);
2499
+ if (panel && !panel.contains(document.activeElement)) {
2500
+ (panel.querySelector("[autofocus]") ?? focusables()[0] ?? panel).focus();
2501
+ }
2502
+ const onKeyDown = (e) => {
2503
+ if (e.key === "Escape") {
2504
+ e.stopPropagation();
2505
+ onCloseRef.current();
2506
+ return;
2507
+ }
2508
+ if (e.key !== "Tab") return;
2509
+ const items = focusables();
2510
+ if (items.length === 0) {
2511
+ e.preventDefault();
2512
+ return;
2513
+ }
2514
+ const first = items[0];
2515
+ const last = items[items.length - 1];
2516
+ const active = document.activeElement;
2517
+ if (e.shiftKey && (active === first || !panel?.contains(active))) {
2518
+ e.preventDefault();
2519
+ last.focus();
2520
+ } else if (!e.shiftKey && active === last) {
2521
+ e.preventDefault();
2522
+ first.focus();
2523
+ }
2524
+ };
2525
+ document.addEventListener("keydown", onKeyDown, true);
2526
+ return () => {
2527
+ document.removeEventListener("keydown", onKeyDown, true);
2528
+ opener?.focus?.();
2529
+ };
2530
+ }, []);
2531
+ return panelRef;
2532
+ }
2533
+
2534
+ // src/components/modals/JsonModal.tsx
2277
2535
  var import_jsx_runtime15 = require("react/jsx-runtime");
2278
2536
  function JsonModal({ chrome, jsonString, copied, onCopy, onClose }) {
2279
- return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: styles.modal, onClick: (e) => e.stopPropagation(), children: [
2537
+ const panelRef = useModalA11y(onClose);
2538
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { ref: panelRef, style: styles.modal, role: "dialog", "aria-modal": "true", "aria-label": chrome.formJson, onClick: (e) => e.stopPropagation(), children: [
2280
2539
  /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: styles.modalHeader, children: [
2281
2540
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: chrome.formJson }),
2282
2541
  /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { style: { display: "flex", gap: 8 }, children: [
2283
- /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("button", { style: styles.ghostBtn, onClick: onCopy, children: [
2542
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("button", { type: "button", style: styles.ghostBtn, onClick: onCopy, children: [
2284
2543
  copied ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.Check, { size: 14 }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.ClipboardCopy, { size: 14 }),
2285
2544
  copied ? chrome.copied : chrome.copy
2286
2545
  ] }),
2287
- /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("button", { style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.X, { size: 16 }) })
2546
+ /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_lucide_react15.X, { size: 16 }) })
2288
2547
  ] })
2289
2548
  ] }),
2290
2549
  /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("pre", { style: styles.jsonPre, children: jsonString })
@@ -2294,55 +2553,97 @@ function JsonModal({ chrome, jsonString, copied, onCopy, onClose }) {
2294
2553
  // src/components/modals/TemplatesModal.tsx
2295
2554
  var import_lucide_react16 = require("lucide-react");
2296
2555
  var import_jsx_runtime16 = require("react/jsx-runtime");
2297
- function TemplatesModal({ chrome, savedForms, currentFormId, onOpen, onDelete, onClose }) {
2298
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.modal, onClick: (e) => e.stopPropagation(), children: [
2556
+ function TemplatesModal({ chrome, savedForms, currentFormId, manage, templateState, onOpen, onDelete, onClose }) {
2557
+ const busy = templateState === "loading";
2558
+ const panelRef = useModalA11y(onClose);
2559
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { ref: panelRef, style: styles.modal, role: "dialog", "aria-modal": "true", "aria-label": chrome.templates, onClick: (e) => e.stopPropagation(), children: [
2299
2560
  /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.modalHeader, children: [
2300
2561
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: chrome.templates }),
2301
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.X, { size: 16 }) })
2562
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.X, { size: 16 }) })
2302
2563
  ] }),
2303
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.libraryBody, children: savedForms.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.inspectorEmpty, children: chrome.noTemplates }) : savedForms.map((f) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRow, children: [
2304
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { minWidth: 0 }, children: [
2305
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowTitle, children: [
2306
- f.title,
2307
- f.id === currentFormId && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: styles.currentBadge, children: chrome.current })
2564
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryBody, children: [
2565
+ savedForms.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: styles.inspectorEmpty, children: manage ? chrome.noTemplates : chrome.noTemplatesReadonly }) : savedForms.map((f) => /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRow, children: [
2566
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { minWidth: 0 }, children: [
2567
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowTitle, children: [
2568
+ f.title,
2569
+ f.id === currentFormId && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: styles.currentBadge, children: chrome.current })
2570
+ ] }),
2571
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowMeta, children: [
2572
+ chrome.updated,
2573
+ " ",
2574
+ new Date(f.updatedAt).toLocaleString()
2575
+ ] })
2308
2576
  ] }),
2309
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: styles.libraryRowMeta, children: [
2310
- chrome.updated,
2311
- " ",
2312
- new Date(f.updatedAt).toLocaleString()
2577
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { display: "flex", gap: 6, flexShrink: 0 }, children: [
2578
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", style: styles.ghostBtn, disabled: busy, onClick: () => onOpen(f.id), children: [
2579
+ busy && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.Loader2, { size: 12, className: "spin" }),
2580
+ " ",
2581
+ manage ? chrome.open : chrome.applyTemplate
2582
+ ] }),
2583
+ manage && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { type: "button", style: { ...styles.iconBtn, ...styles.iconBtnDanger }, disabled: busy, title: chrome.delete, onClick: () => onDelete(f.id), children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.Trash2, { size: 13 }) })
2313
2584
  ] })
2314
- ] }),
2315
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { display: "flex", gap: 6, flexShrink: 0 }, children: [
2316
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { style: styles.ghostBtn, onClick: () => onOpen(f.id), children: chrome.open }),
2317
- /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("button", { style: { ...styles.iconBtn, ...styles.iconBtnDanger }, title: chrome.delete, onClick: () => onDelete(f.id), children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.Trash2, { size: 13 }) })
2585
+ ] }, f.id)),
2586
+ templateState === "error" && /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { style: { ...styles.libraryRowMeta, color: "var(--fb-danger)", display: "flex", alignItems: "center", gap: 5, marginTop: 4 }, children: [
2587
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_lucide_react16.AlertCircle, { size: 12 }),
2588
+ " ",
2589
+ chrome.templateLoadFailed
2318
2590
  ] })
2319
- ] }, f.id)) })
2591
+ ] })
2320
2592
  ] }) });
2321
2593
  }
2322
2594
 
2323
2595
  // src/components/modals/SaveAsModal.tsx
2324
- var import_react7 = require("react");
2596
+ var import_react9 = require("react");
2325
2597
  var import_lucide_react17 = require("lucide-react");
2326
2598
  var import_jsx_runtime17 = require("react/jsx-runtime");
2327
- function SaveAsModal({ chrome, suggestedName, onSave, onClose }) {
2328
- const [name, setName] = (0, import_react7.useState)(suggestedName);
2329
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: { ...styles.modal, maxWidth: 380 }, onClick: (e) => e.stopPropagation(), children: [
2599
+ function SaveAsModal({ chrome, suggestedName, templateState, onSave, onClose }) {
2600
+ const [name, setName] = (0, import_react9.useState)(suggestedName);
2601
+ const saving = templateState === "saving";
2602
+ const canSave = !!name.trim() && !saving;
2603
+ const panelRef = useModalA11y(onClose);
2604
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { ref: panelRef, style: { ...styles.modal, maxWidth: 380 }, role: "dialog", "aria-modal": "true", "aria-label": chrome.saveForm, onClick: (e) => e.stopPropagation(), children: [
2330
2605
  /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: styles.modalHeader, children: [
2331
2606
  /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: chrome.saveForm }),
2332
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.X, { size: 16 }) })
2607
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.X, { size: 16 }) })
2333
2608
  ] }),
2334
2609
  /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: { padding: 16 }, children: [
2335
2610
  /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("label", { style: styles.propLabel, children: chrome.formName }),
2336
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("input", { autoFocus: true, style: styles.propInput, value: name, onChange: (e) => setName(e.target.value), onKeyDown: (e) => e.key === "Enter" && name.trim() && onSave(name.trim()) }),
2337
- /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("button", { style: { ...styles.primaryBtn, width: "100%", justifyContent: "center", marginTop: 14 }, disabled: !name.trim(), onClick: () => onSave(name.trim()), children: [
2338
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.Save, { size: 14 }),
2611
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("input", { autoFocus: true, "aria-label": chrome.formName, style: styles.propInput, value: name, onChange: (e) => setName(e.target.value), onKeyDown: (e) => e.key === "Enter" && canSave && onSave(name.trim()) }),
2612
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("button", { type: "button", style: { ...styles.primaryBtn, width: "100%", justifyContent: "center", marginTop: 14 }, disabled: !canSave, onClick: () => onSave(name.trim()), children: [
2613
+ saving ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.Loader2, { size: 14, className: "spin" }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.Save, { size: 14 }),
2339
2614
  " ",
2340
2615
  chrome.saveToLibrary
2616
+ ] }),
2617
+ templateState === "error" && /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { style: { fontSize: 12, color: "var(--fb-danger)", display: "flex", alignItems: "center", gap: 5, marginTop: 10 }, children: [
2618
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_lucide_react17.AlertCircle, { size: 12 }),
2619
+ " ",
2620
+ chrome.templateSaveFailed
2341
2621
  ] })
2342
2622
  ] })
2343
2623
  ] }) });
2344
2624
  }
2345
2625
 
2626
+ // src/components/modals/ConfirmModal.tsx
2627
+ var import_lucide_react18 = require("lucide-react");
2628
+ var import_jsx_runtime18 = require("react/jsx-runtime");
2629
+ function ConfirmModal({ chrome, title, message, onConfirm, confirmLabel, tone = "default", onClose }) {
2630
+ const panelRef = useModalA11y(onClose);
2631
+ const confirmStyle = tone === "danger" ? { ...styles.primaryBtn, background: "var(--fb-danger)", borderColor: "var(--fb-danger)" } : styles.primaryBtn;
2632
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { style: styles.modalOverlay, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { ref: panelRef, style: { ...styles.modal, maxWidth: 380 }, role: "alertdialog", "aria-modal": "true", "aria-label": title, onClick: (e) => e.stopPropagation(), children: [
2633
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { style: styles.modalHeader, children: [
2634
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { style: { fontWeight: 600, fontSize: 14 }, children: title }),
2635
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", style: styles.iconBtn, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.X, { size: 16 }) })
2636
+ ] }),
2637
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { style: { padding: 16 }, children: [
2638
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { style: { margin: 0, fontSize: 13, lineHeight: 1.5, color: "var(--fb-ink)" }, children: message }),
2639
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 16 }, children: onConfirm ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(import_jsx_runtime18.Fragment, { children: [
2640
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", autoFocus: true, style: styles.ghostBtn, onClick: onClose, children: chrome.cancel }),
2641
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", style: confirmStyle, onClick: onConfirm, children: confirmLabel ?? chrome.confirm })
2642
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", autoFocus: true, style: styles.primaryBtn, onClick: onClose, children: chrome.dismiss }) })
2643
+ ] })
2644
+ ] }) });
2645
+ }
2646
+
2346
2647
  // src/styles/globalCss.ts
2347
2648
  var css = `
2348
2649
  * { box-sizing: border-box; }
@@ -2350,20 +2651,14 @@ var css = `
2350
2651
  input, textarea, select { font-family: inherit; }
2351
2652
  @keyframes fb-spin { to { transform: rotate(360deg); } }
2352
2653
  .spin { animation: fb-spin 0.8s linear infinite; }
2353
- /* Caps the widget at the viewport height so it scrolls internally rather
2354
- than growing past whatever space its host container gives it; the
2355
- second declaration upgrades to the dynamic viewport unit on browsers
2356
- that support it (mobile browser chrome resizing the viewport), and is
2357
- simply ignored by ones that don't. */
2654
+ /* Cap at viewport height so the widget scrolls internally; dvh upgrade is ignored where unsupported. */
2358
2655
  .fb-root { max-height: 100vh; max-height: 100dvh; }
2359
2656
 
2360
- /* Below this width the fixed 200px Palette + 270px Inspector columns
2361
- don't leave room for a usable Canvas, so instead of stacking all
2362
- three in flow (which buries Canvas under a tall Palette), Palette
2363
- and Inspector become narrow drawers over Canvas (which stays
2364
- partially visible behind them), toggled by .fb-mobile-bar's two
2365
- buttons, tracked by .fb-work-area's data-mobile-panel attribute,
2366
- and dismissible via .fb-mobile-backdrop. */
2657
+ /* Toolbar's active-template label is nice-to-have \u2014 drop it first when width is tight. */
2658
+ @media (max-width: 600px) { .fb-template-tag { display: none; } }
2659
+
2660
+ /* Under 720px: Palette/Inspector become drawers over Canvas, toggled by .fb-mobile-bar,
2661
+ tracked by .fb-work-area[data-mobile-panel], dismissed via .fb-mobile-backdrop. */
2367
2662
  .fb-mobile-bar { display: none; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--fb-border); background: var(--fb-surface); flex-shrink: 0; }
2368
2663
  .fb-mobile-btn { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 7px; border: 1px solid var(--fb-border); background: var(--fb-surface); color: var(--fb-muted); font-size: 12.5px; font-weight: 600; }
2369
2664
  .fb-mobile-btn[aria-pressed="true"] { border-color: var(--fb-primary); background: var(--fb-primary-soft); color: var(--fb-primary); }
@@ -2389,8 +2684,8 @@ var css = `
2389
2684
  `;
2390
2685
 
2391
2686
  // src/FormBuilder.tsx
2392
- var import_jsx_runtime18 = require("react/jsx-runtime");
2393
- var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2687
+ var import_jsx_runtime19 = require("react/jsx-runtime");
2688
+ var FormBuilder = (0, import_react10.forwardRef)(function FormBuilder2({
2394
2689
  theme: themeOverrideProp,
2395
2690
  language: languageOverride,
2396
2691
  languages = DEFAULT_LANGUAGES,
@@ -2399,24 +2694,40 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2399
2694
  features: featuresProp,
2400
2695
  storage: storageProp,
2401
2696
  onSubmit,
2402
- initialDocument
2697
+ initialDocument,
2698
+ initialMode,
2699
+ onModeChange,
2700
+ onTemplateChange,
2701
+ templateClipboardKey
2403
2702
  }, ref) {
2404
2703
  const features = resolveFeatures(featuresProp);
2405
2704
  const storage = storageProp ?? localStorageAdapter;
2406
2705
  const { theme, updateThemeColor, updateThemeLayout, resetTheme, replaceThemeOverrides, themeOverrides } = useTheme(themeOverrideProp);
2407
- const [language, setLanguage] = (0, import_react8.useState)(languageOverride || languages[0]?.code || "en");
2706
+ const [language, setLanguage] = (0, import_react10.useState)(languageOverride || languages[0]?.code || "en");
2408
2707
  const strings = { ...DEFAULT_STRINGS[language] || DEFAULT_STRINGS.en, ...stringsOverride && stringsOverride[language] || {} };
2409
2708
  const chrome = { ...CHROME[language] || CHROME.en, ...chromeOverride && chromeOverride[language] || {} };
2410
2709
  const doc = useFormDocument({ language, chrome });
2411
2710
  const drag = useDragReorder(doc.reorderWithinSection);
2412
- const [mode, setMode] = (0, import_react8.useState)("build");
2413
- const [showJson, setShowJson] = (0, import_react8.useState)(false);
2414
- const [showLibrary, setShowLibrary] = (0, import_react8.useState)(false);
2415
- const [copied, setCopied] = (0, import_react8.useState)(false);
2416
- const [mobilePanel, setMobilePanel] = (0, import_react8.useState)("none");
2711
+ const [mode, setModeState] = (0, import_react10.useState)(initialMode ?? "build");
2712
+ const setMode = (next) => {
2713
+ setModeState(next);
2714
+ onModeChange?.(next);
2715
+ };
2716
+ (0, import_react10.useEffect)(() => {
2717
+ onModeChange?.(mode);
2718
+ }, []);
2719
+ const [showJson, setShowJson] = (0, import_react10.useState)(false);
2720
+ const [showLibrary, setShowLibrary] = (0, import_react10.useState)(false);
2721
+ const [copied, setCopied] = (0, import_react10.useState)(false);
2722
+ const [templateCopied, setTemplateCopied] = (0, import_react10.useState)(false);
2723
+ const [pastePrompt, setPastePrompt] = (0, import_react10.useState)(false);
2724
+ const clipboard = useTemplateClipboard(templateClipboardKey);
2725
+ const [mobilePanel, setMobilePanel] = (0, import_react10.useState)("none");
2417
2726
  const persistence = usePersistence({
2418
2727
  storage,
2419
2728
  autosave: features.autosave,
2729
+ templateMax: features.templates.max,
2730
+ templateManage: features.templates.manage,
2420
2731
  language,
2421
2732
  chrome,
2422
2733
  document: { title: doc.title, themeOverrides, sections: doc.sections },
@@ -2428,16 +2739,17 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2428
2739
  doc.resetToBlank();
2429
2740
  resetTheme();
2430
2741
  },
2742
+ onTemplateChange,
2431
2743
  ensureActiveSection: () => doc.setActiveSectionId((prev) => prev ?? doc.sections[0]?.id ?? null)
2432
2744
  });
2433
- const jsonDoc = {
2745
+ const jsonDoc = (0, import_react10.useMemo)(() => ({
2434
2746
  version: 5,
2435
2747
  title: doc.title,
2436
2748
  theme,
2437
2749
  themeOverrides,
2438
2750
  sections: doc.sections.map((s) => ({ id: s.id, title: s.title, background: s.background, collapsed: s.collapsed, fields: s.fields }))
2439
- };
2440
- const jsonString = JSON.stringify(jsonDoc, null, 2);
2751
+ }), [doc.title, doc.sections, theme, themeOverrides]);
2752
+ const jsonString = (0, import_react10.useMemo)(() => JSON.stringify(jsonDoc, null, 2), [jsonDoc]);
2441
2753
  function copyJson() {
2442
2754
  navigator.clipboard.writeText(jsonString).then(() => {
2443
2755
  setCopied(true);
@@ -2445,17 +2757,31 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2445
2757
  }).catch(() => {
2446
2758
  });
2447
2759
  }
2448
- (0, import_react8.useImperativeHandle)(ref, () => ({
2760
+ function applyDocument(migrated) {
2761
+ if (!migrated) return false;
2762
+ doc.loadDocument(migrated);
2763
+ replaceThemeOverrides(migrated.themeOverrides);
2764
+ return true;
2765
+ }
2766
+ function copyTemplate() {
2767
+ clipboard.copyTemplate(jsonDoc);
2768
+ setTemplateCopied(true);
2769
+ setTimeout(() => setTemplateCopied(false), 1500);
2770
+ }
2771
+ function pasteTemplate() {
2772
+ if (clipboard.readTemplate()) setPastePrompt(true);
2773
+ }
2774
+ (0, import_react10.useImperativeHandle)(ref, () => ({
2449
2775
  getDocument: () => jsonDoc,
2450
2776
  exportJson: () => jsonString,
2451
2777
  loadDocument: (raw) => {
2452
- const migrated = migrateDocument(raw);
2453
- if (!migrated) return;
2454
- doc.loadDocument(migrated);
2455
- replaceThemeOverrides(migrated.themeOverrides);
2456
- }
2778
+ applyDocument(migrateDocument(raw));
2779
+ },
2780
+ getTemplate: () => ({ __fpb: "template", v: TEMPLATE_FORMAT, document: jsonDoc }),
2781
+ loadTemplate: (input) => applyDocument(parseTemplate(typeof input === "string" ? input : JSON.stringify(input)))
2457
2782
  // eslint-disable-next-line react-hooks/exhaustive-deps
2458
2783
  }), [jsonDoc, jsonString]);
2784
+ const fieldCount = doc.sections.reduce((n, s) => n + s.fields.filter((f) => !getMeta(f.type).isContent).length, 0);
2459
2785
  const activeSectionIdx = doc.sections.findIndex((s) => s.id === doc.activeSection?.id);
2460
2786
  const activeSectionLabel = t(doc.activeSection?.title, language) || `#${activeSectionIdx + 1}`;
2461
2787
  const rootStyle = {
@@ -2479,9 +2805,9 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2479
2805
  "--fb-space-panel": `${theme.layout.panelPadding}px`,
2480
2806
  "--fb-space-ticket": `${theme.layout.ticketPadding}px`
2481
2807
  };
2482
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-root", style: rootStyle, children: [
2483
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("style", { children: css }),
2484
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2808
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-root", style: rootStyle, children: [
2809
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("style", { children: css }),
2810
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2485
2811
  Toolbar,
2486
2812
  {
2487
2813
  title: doc.title,
@@ -2489,24 +2815,31 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2489
2815
  languages,
2490
2816
  mode,
2491
2817
  saveState: persistence.saveState,
2818
+ templateState: persistence.templateState,
2819
+ activeTemplateTitle: persistence.activeTemplateTitle,
2820
+ templateDirty: persistence.isTemplateDirty,
2492
2821
  chrome,
2493
2822
  features,
2494
2823
  savedFormsCount: persistence.savedForms.length,
2824
+ clipboardReady: clipboard.hasClipboard,
2825
+ templateCopied,
2495
2826
  onTitleChange: doc.updateTitle,
2496
2827
  onLanguageChange: setLanguage,
2497
2828
  onModeChange: setMode,
2498
2829
  onNewForm: persistence.newForm,
2499
2830
  onOpenLibrary: () => setShowLibrary(true),
2500
2831
  onSaveExisting: persistence.saveExisting,
2501
- onOpenJson: () => setShowJson(true)
2832
+ onOpenJson: () => setShowJson(true),
2833
+ onCopyTemplate: copyTemplate,
2834
+ onPasteTemplate: pasteTemplate
2502
2835
  }
2503
2836
  ),
2504
- persistence.loadingDraft ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { style: styles.loadingScreen, children: [
2505
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.Loader2, { size: 20, className: "spin" }),
2506
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { style: { marginTop: 8, fontSize: 13, color: "var(--fb-muted)" }, children: chrome.loadingDraft })
2507
- ] }) : mode === "build" ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-work-area", style: styles.workArea, "data-mobile-panel": mobilePanel, children: [
2508
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-mobile-bar", children: [
2509
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
2837
+ persistence.loadingDraft ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { style: styles.loadingScreen, children: [
2838
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react19.Loader2, { size: 20, className: "spin" }),
2839
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { style: { marginTop: 8, fontSize: 13, color: "var(--fb-muted)" }, children: chrome.loadingDraft })
2840
+ ] }) : mode === "build" ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-work-area", style: styles.workArea, "data-mobile-panel": mobilePanel, children: [
2841
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-mobile-bar", children: [
2842
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
2510
2843
  "button",
2511
2844
  {
2512
2845
  type: "button",
@@ -2514,13 +2847,13 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2514
2847
  "aria-pressed": mobilePanel === "palette",
2515
2848
  onClick: () => setMobilePanel((p) => p === "palette" ? "none" : "palette"),
2516
2849
  children: [
2517
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.Menu, { size: 14 }),
2850
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react19.Menu, { size: 14 }),
2518
2851
  " ",
2519
2852
  chrome.paletteTabBlocks
2520
2853
  ]
2521
2854
  }
2522
2855
  ),
2523
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
2856
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
2524
2857
  "button",
2525
2858
  {
2526
2859
  type: "button",
@@ -2528,16 +2861,16 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2528
2861
  "aria-pressed": mobilePanel === "inspector",
2529
2862
  onClick: () => setMobilePanel((p) => p === "inspector" ? "none" : "inspector"),
2530
2863
  children: [
2531
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(import_lucide_react18.SlidersHorizontal, { size: 14 }),
2864
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(import_lucide_react19.SlidersHorizontal, { size: 14 }),
2532
2865
  " ",
2533
2866
  chrome.properties
2534
2867
  ]
2535
2868
  }
2536
2869
  )
2537
2870
  ] }),
2538
- /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "fb-canvas-area", style: styles.workArea, children: [
2539
- mobilePanel !== "none" && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "fb-mobile-backdrop", onClick: () => setMobilePanel("none") }),
2540
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2871
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "fb-canvas-area", style: styles.workArea, children: [
2872
+ mobilePanel !== "none" && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "fb-mobile-backdrop", onClick: () => setMobilePanel("none") }),
2873
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2541
2874
  Palette,
2542
2875
  {
2543
2876
  activeSectionLabel,
@@ -2547,13 +2880,14 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2547
2880
  setMobilePanel("none");
2548
2881
  },
2549
2882
  features,
2883
+ fieldCount,
2550
2884
  theme,
2551
2885
  updateThemeColor,
2552
2886
  updateThemeLayout,
2553
2887
  resetTheme
2554
2888
  }
2555
2889
  ),
2556
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2890
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2557
2891
  Canvas,
2558
2892
  {
2559
2893
  sections: doc.sections,
@@ -2585,7 +2919,7 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2585
2919
  getDragHandleProps: drag.getDragHandleProps
2586
2920
  }
2587
2921
  ),
2588
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2922
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2589
2923
  Inspector,
2590
2924
  {
2591
2925
  selected: doc.selected,
@@ -2600,7 +2934,7 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2600
2934
  }
2601
2935
  )
2602
2936
  ] })
2603
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2937
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2604
2938
  PreviewPane,
2605
2939
  {
2606
2940
  title: doc.title,
@@ -2610,32 +2944,59 @@ var FormBuilder = (0, import_react8.forwardRef)(function FormBuilder2({
2610
2944
  strings,
2611
2945
  chrome,
2612
2946
  baseMaxWidth: theme.layout.maxWidth,
2947
+ features,
2613
2948
  onSubmit
2614
2949
  }
2615
2950
  ),
2616
- showJson && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(JsonModal, { chrome, jsonString, copied, onCopy: copyJson, onClose: () => setShowJson(false) }),
2617
- showLibrary && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2951
+ showJson && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(JsonModal, { chrome, jsonString, copied, onCopy: copyJson, onClose: () => setShowJson(false) }),
2952
+ showLibrary && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2618
2953
  TemplatesModal,
2619
2954
  {
2620
2955
  chrome,
2621
2956
  savedForms: persistence.savedForms,
2622
2957
  currentFormId: persistence.currentFormId,
2958
+ manage: features.templates.manage,
2959
+ templateState: persistence.templateState,
2623
2960
  onOpen: async (id) => {
2624
- await persistence.loadForm(id);
2625
- setShowLibrary(false);
2961
+ if (await persistence.loadForm(id)) setShowLibrary(false);
2626
2962
  },
2627
2963
  onDelete: persistence.deleteForm,
2628
2964
  onClose: () => setShowLibrary(false)
2629
2965
  }
2630
2966
  ),
2631
- persistence.saveAsPrompt && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
2967
+ persistence.saveAsPrompt && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2632
2968
  SaveAsModal,
2633
2969
  {
2634
2970
  chrome,
2635
2971
  suggestedName: persistence.saveAsPrompt.suggestedName,
2972
+ templateState: persistence.templateState,
2636
2973
  onSave: persistence.saveAs,
2637
2974
  onClose: persistence.dismissSaveAsPrompt
2638
2975
  }
2976
+ ),
2977
+ pastePrompt && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2978
+ ConfirmModal,
2979
+ {
2980
+ chrome,
2981
+ title: chrome.pasteTemplate,
2982
+ message: chrome.pasteTemplateConfirm,
2983
+ confirmLabel: chrome.confirmReplace,
2984
+ tone: "danger",
2985
+ onConfirm: () => {
2986
+ applyDocument(clipboard.readTemplate());
2987
+ setPastePrompt(false);
2988
+ },
2989
+ onClose: () => setPastePrompt(false)
2990
+ }
2991
+ ),
2992
+ persistence.notice && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
2993
+ ConfirmModal,
2994
+ {
2995
+ chrome,
2996
+ title: persistence.notice.title,
2997
+ message: persistence.notice.message,
2998
+ onClose: persistence.dismissNotice
2999
+ }
2639
3000
  )
2640
3001
  ] });
2641
3002
  });
@@ -2657,9 +3018,16 @@ var DARK_THEME = {
2657
3018
  };
2658
3019
  // Annotate the CommonJS export names for ESM import in node:
2659
3020
  0 && (module.exports = {
3021
+ CLIPBOARD_KEY,
2660
3022
  DARK_THEME,
2661
3023
  DEFAULT_THEME,
3024
+ DRAFT_KEY,
2662
3025
  FormBuilder,
2663
- localStorageAdapter
3026
+ INDEX_KEY,
3027
+ formKey,
3028
+ localStorageAdapter,
3029
+ parseTemplate,
3030
+ savedFormId,
3031
+ serializeTemplate
2664
3032
  });
2665
3033
  //# sourceMappingURL=index.cjs.map