@marktiderman/genesis-forms 0.1.1 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +121 -17
  2. package/conformance/{after-class-back-after-a-jump.scenario.json → event-feedback-back-after-a-jump.scenario.json} +21 -21
  3. package/conformance/{after-class-contact-yes-completed.scenario.json → event-feedback-contact-yes-completed.scenario.json} +21 -21
  4. package/conformance/{after-class-contact-yes-requires-name-and-email.scenario.json → event-feedback-contact-yes-requires-name-and-email.scenario.json} +19 -19
  5. package/conformance/{after-class-happy-path.scenario.json → event-feedback-happy-path.scenario.json} +24 -24
  6. package/conformance/{after-class-invalid-email-rejected.scenario.json → event-feedback-invalid-email-rejected.scenario.json} +19 -19
  7. package/conformance/{after-class-low-rating-skips-went-well.scenario.json → event-feedback-low-rating-skips-went-well.scenario.json} +21 -21
  8. package/conformance/{after-class-server-drops-unknown-and-hidden-answers.scenario.json → event-feedback-server-drops-unknown-and-hidden-answers.scenario.json} +22 -22
  9. package/conformance/{after-class-server-ignores-a-client-sent-coach.scenario.json → event-feedback-server-ignores-a-client-sent-owner.scenario.json} +24 -24
  10. package/conformance/{monthly-pulse-default-ending.scenario.json → monthly-check-in-default-ending.scenario.json} +6 -6
  11. package/conformance/{monthly-pulse-promoter-ending-via-jump.scenario.json → monthly-check-in-promoter-ending-via-jump.scenario.json} +6 -6
  12. package/dist/destinations.cjs +1211 -0
  13. package/dist/destinations.cjs.map +1 -0
  14. package/dist/destinations.d.cts +465 -0
  15. package/dist/destinations.d.ts +465 -0
  16. package/dist/destinations.js +1169 -0
  17. package/dist/destinations.js.map +1 -0
  18. package/dist/{engine-LsPRTzt3.d.cts → engine-CPLtKVew.d.cts} +7 -6
  19. package/dist/{engine-Cp5t2_Am.d.ts → engine-DOrPgFDw.d.ts} +7 -6
  20. package/dist/index.cjs +410 -24
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +122 -18
  23. package/dist/index.d.ts +122 -18
  24. package/dist/index.js +399 -25
  25. package/dist/index.js.map +1 -1
  26. package/dist/server.cjs +350 -29
  27. package/dist/server.cjs.map +1 -1
  28. package/dist/server.d.cts +13 -12
  29. package/dist/server.d.ts +13 -12
  30. package/dist/server.js +350 -29
  31. package/dist/server.js.map +1 -1
  32. package/dist/testing.cjs +354 -29
  33. package/dist/testing.cjs.map +1 -1
  34. package/dist/testing.d.cts +4 -3
  35. package/dist/testing.d.ts +4 -3
  36. package/dist/testing.js +354 -29
  37. package/dist/testing.js.map +1 -1
  38. package/dist/{validate-j8Z188mF.d.cts → types-BBKBQ8sp.d.cts} +9 -29
  39. package/dist/{validate-j8Z188mF.d.ts → types-BBKBQ8sp.d.ts} +9 -29
  40. package/dist/validate-BXXqi0fq.d.cts +30 -0
  41. package/dist/validate-BjNwF1UE.d.ts +30 -0
  42. package/package.json +14 -2
package/dist/index.js CHANGED
@@ -60,6 +60,211 @@ function isFormEvent(value) {
60
60
  return typeof value.field === "string" && (value.value === void 0 || isJson(value.value)) && (value.source === void 0 || value.source === "prefill");
61
61
  }
62
62
 
63
+ // src/structured.ts
64
+ var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
65
+ function intProp(field2, name, fallback, floor = 1) {
66
+ const v = field2.props?.[name];
67
+ return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : fallback;
68
+ }
69
+ function optionalIntProp(field2, name, floor = 0) {
70
+ const v = field2.props?.[name];
71
+ return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : void 0;
72
+ }
73
+ var blank = (v) => typeof v !== "string" || v.trim() === "";
74
+ var blankText = (v) => typeof v === "string" && v.trim() === "";
75
+ var emptyList = (value, isBlank) => value === void 0 || value === null || Array.isArray(value) && value.every(isBlank);
76
+ var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
77
+ function listLimits(field2) {
78
+ return { min: optionalIntProp(field2, "min") ?? 0, max: intProp(field2, "max", 10), itemMaxLength: intProp(field2, "itemMaxLength", 500) };
79
+ }
80
+ var listItems = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
81
+ var MAX_TABLE_COLUMNS = 6;
82
+ var MAX_TABLE_CELL_LENGTH = 2e3;
83
+ function tableColumns(field2) {
84
+ const raw = field2.props?.columns;
85
+ if (!Array.isArray(raw)) return [];
86
+ const seen = /* @__PURE__ */ new Set();
87
+ const out = [];
88
+ for (const c of raw) {
89
+ if (!isObject(c) || typeof c.key !== "string" || !c.key || seen.has(c.key)) continue;
90
+ seen.add(c.key);
91
+ const column = { key: c.key, label: typeof c.label === "string" ? c.label : c.key };
92
+ if (c.long === true) column.long = true;
93
+ out.push(column);
94
+ }
95
+ return out;
96
+ }
97
+ function tableLimits(field2) {
98
+ return { minRows: optionalIntProp(field2, "minRows") ?? 0, maxRows: intProp(field2, "maxRows", 3) };
99
+ }
100
+ var filledRows = (value) => Array.isArray(value) ? value.filter((r) => isObject(r) && Object.values(r).some((c) => !blank(c))) : [];
101
+ var MAX_TIMESTAMP_SECONDS = 86400;
102
+ function formatTimestamp(seconds) {
103
+ const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
104
+ const h = Math.floor(total / 3600);
105
+ const m = Math.floor(total % 3600 / 60);
106
+ const s = String(total % 60).padStart(2, "0");
107
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
108
+ }
109
+ function parseTimestamp(text) {
110
+ const t = text.trim();
111
+ if (!t) return null;
112
+ const parts = t.split(":");
113
+ if (parts.length > 3) return null;
114
+ if (parts.length === 1) {
115
+ if (!/^\d+(\.\d+)?$/.test(t)) return null;
116
+ const n = Number(t);
117
+ return n <= MAX_TIMESTAMP_SECONDS ? n : null;
118
+ }
119
+ const last = parts.length - 1;
120
+ let total = 0;
121
+ for (let i = 0; i < parts.length; i++) {
122
+ const p = parts[i];
123
+ const ok = i === 0 ? /^\d+$/.test(p) : i === last ? /^\d{2}(\.\d+)?$/.test(p) : /^\d{2}$/.test(p);
124
+ if (!ok) return null;
125
+ const n = Number(p);
126
+ if (i > 0 && n >= 60) return null;
127
+ total = total * 60 + n;
128
+ }
129
+ return total <= MAX_TIMESTAMP_SECONDS ? total : null;
130
+ }
131
+ var isTimestamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_TIMESTAMP_SECONDS;
132
+ var notesWithText = (value) => Array.isArray(value) ? value.filter((n) => isObject(n) && !blank(n.text)) : [];
133
+ var MAX_HIGHLIGHT_NOTE_LENGTH = 1e3;
134
+ var MAX_HIGHLIGHT_QUOTE_LENGTH = 2e3;
135
+ var HIGHLIGHT_NOTE_MESSAGE = "Add a note to each highlight.";
136
+ var sourceText = (field2) => typeof field2.props?.text === "string" && field2.props.text ? field2.props.text : void 0;
137
+ var STRUCTURED_FIELD_TYPES = ["list", "table", "media-notes", "text-highlights"];
138
+ var structuredFieldTypes = [
139
+ {
140
+ type: "list",
141
+ label: "List",
142
+ category: "text",
143
+ value: { json: true },
144
+ // logic.ts `contains` on an array is exact item equality, same as multi-choice, so it is offered.
145
+ operators: ["contains", "answered", "empty"],
146
+ a11y: { semantics: "group" },
147
+ isEmpty: (value) => emptyList(value, blankText),
148
+ validate: (value, field2) => {
149
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) return "Enter a list of answers.";
150
+ const { min, max, itemMaxLength } = listLimits(field2);
151
+ if (value.some((v) => v.trim().length > itemMaxLength)) return `Keep each item under ${itemMaxLength} characters.`;
152
+ const count = listItems(value).length;
153
+ if (count < min) return `Add at least ${plural(min, "item")}.`;
154
+ if (count > max) return `Add at most ${plural(max, "item")}.`;
155
+ return null;
156
+ },
157
+ normalize: (value) => Array.isArray(value) ? listItems(value).map((v) => v.trim()) : value,
158
+ summarize: (value) => listItems(value).map((v) => v.trim()).join("; "),
159
+ samples: (field2) => {
160
+ const { min, max } = listLimits(field2);
161
+ return [Array.from({ length: Math.min(Math.max(1, min), max) }, (_, i) => `Item ${i + 1}`)];
162
+ }
163
+ },
164
+ {
165
+ type: "table",
166
+ label: "Table",
167
+ category: "advanced",
168
+ value: { json: true },
169
+ operators: ["answered", "empty"],
170
+ a11y: { semantics: "group" },
171
+ isEmpty: (value) => emptyList(value, (r) => isObject(r) && Object.values(r).every(blankText)),
172
+ validate: (value, field2) => {
173
+ const columns = tableColumns(field2);
174
+ if (!columns.length) return "This question has no columns.";
175
+ if (!Array.isArray(value) || !value.every(isObject)) return "Fill in the rows.";
176
+ const keys = new Set(columns.map((c) => c.key));
177
+ for (const row of value) {
178
+ for (const [key, cell] of Object.entries(row)) {
179
+ if (!keys.has(key)) return "An answer names a column this question doesn't have.";
180
+ if (typeof cell !== "string") return "Fill in the rows with text.";
181
+ if (cell.trim().length > MAX_TABLE_CELL_LENGTH) return `Keep each answer under ${MAX_TABLE_CELL_LENGTH} characters.`;
182
+ }
183
+ }
184
+ const { minRows, maxRows } = tableLimits(field2);
185
+ const count = filledRows(value).length;
186
+ if (count < minRows) return `Fill in at least ${plural(minRows, "row")}.`;
187
+ if (count > maxRows) return `Fill in at most ${plural(maxRows, "row")}.`;
188
+ return null;
189
+ },
190
+ normalize: (value) => Array.isArray(value) ? filledRows(value).map((row) => {
191
+ const out = {};
192
+ for (const [key, cell] of Object.entries(row)) if (typeof cell === "string") out[key] = cell.trim();
193
+ return out;
194
+ }) : value,
195
+ summarize: (value, field2) => {
196
+ const columns = tableColumns(field2);
197
+ return filledRows(value).map((row) => columns.filter((c) => !blank(row[c.key])).map((c) => `${c.label}: ${row[c.key].trim()}`).join(" \xB7 ")).join(" / ");
198
+ },
199
+ samples: (field2) => {
200
+ const columns = tableColumns(field2);
201
+ if (!columns.length) return [];
202
+ const { minRows, maxRows } = tableLimits(field2);
203
+ const row = Object.fromEntries(columns.map((c) => [c.key, "sample"]));
204
+ return [Array.from({ length: Math.min(Math.max(1, minRows), maxRows) }, () => ({ ...row }))];
205
+ }
206
+ },
207
+ {
208
+ type: "media-notes",
209
+ label: "Notes at moments",
210
+ category: "media",
211
+ value: { json: true },
212
+ operators: ["answered", "empty"],
213
+ a11y: { semantics: "group" },
214
+ isEmpty: (value) => emptyList(value, (n) => isObject(n) && blankText(n.text)),
215
+ validate: (value, field2) => {
216
+ if (!Array.isArray(value)) return "Add notes.";
217
+ const textMaxLength = intProp(field2, "textMaxLength", 1e3);
218
+ for (const note of value) {
219
+ if (!isObject(note) || typeof note.text !== "string") return "Add notes.";
220
+ if (!isTimestamp(note.at)) return "Each note needs a time.";
221
+ if (note.text.trim().length > textMaxLength) return `Keep each note under ${textMaxLength} characters.`;
222
+ }
223
+ const maxNotes = intProp(field2, "maxNotes", 20);
224
+ return notesWithText(value).length > maxNotes ? `Add at most ${plural(maxNotes, "note")}.` : null;
225
+ },
226
+ normalize: (value) => Array.isArray(value) ? notesWithText(value).map((n) => ({ at: n.at, text: n.text.trim() })).sort((a, b) => a.at - b.at) : value,
227
+ summarize: (value) => notesWithText(value).map((n) => ({ at: typeof n.at === "number" ? n.at : 0, text: n.text.trim() })).sort((a, b) => a.at - b.at).map((n) => `${formatTimestamp(n.at)} ${n.text}`).join("; "),
228
+ samples: () => [[{ at: 0, text: "sample" }]]
229
+ },
230
+ {
231
+ type: "text-highlights",
232
+ label: "Highlights",
233
+ category: "media",
234
+ value: { json: true },
235
+ operators: ["answered", "empty"],
236
+ a11y: { semantics: "group" },
237
+ isEmpty: (value) => emptyList(value, () => false),
238
+ validate: (value, field2) => {
239
+ if (!Array.isArray(value)) return "Highlight a passage.";
240
+ const maxHighlights = intProp(field2, "maxHighlights", 20);
241
+ if (value.length > maxHighlights) return `Add at most ${plural(maxHighlights, "highlight")}.`;
242
+ const text = sourceText(field2);
243
+ for (const h of value) {
244
+ if (!isObject(h) || !Number.isInteger(h.start) || !Number.isInteger(h.end) || typeof h.quote !== "string" || typeof h.note !== "string") {
245
+ return "Highlight a passage of the text.";
246
+ }
247
+ const start = h.start;
248
+ const end = h.end;
249
+ if (start < 0 || end <= start || h.quote.length !== end - start) return "Highlight a passage of the text.";
250
+ if (end - start > MAX_HIGHLIGHT_QUOTE_LENGTH) return `Highlight under ${MAX_HIGHLIGHT_QUOTE_LENGTH} characters at a time.`;
251
+ if (text !== void 0 && (end > text.length || text.slice(start, end) !== h.quote)) return "A highlight doesn't match the text.";
252
+ if (h.note.trim() === "") return HIGHLIGHT_NOTE_MESSAGE;
253
+ if (h.note.trim().length > MAX_HIGHLIGHT_NOTE_LENGTH) return `Keep each note under ${MAX_HIGHLIGHT_NOTE_LENGTH} characters.`;
254
+ }
255
+ return null;
256
+ },
257
+ normalize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => ({ start: h.start, end: h.end, quote: h.quote, note: String(h.note).trim() })).sort((a, b) => a.start - b.start || a.end - b.end) : value,
258
+ summarize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => `"${String(h.quote)}" \u2014 ${String(h.note).trim()}`).join("; ") : "",
259
+ samples: (field2) => {
260
+ const text = sourceText(field2);
261
+ if (text === void 0) return [[{ start: 0, end: 6, quote: "sample", note: "sample" }]];
262
+ const end = Math.min(text.length, 20);
263
+ return [[{ start: 0, end, quote: text.slice(0, end), note: "sample" }]];
264
+ }
265
+ }
266
+ ];
267
+
63
268
  // src/fields.ts
64
269
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
65
270
  var globalScope = globalThis;
@@ -247,7 +452,7 @@ var builtins = [
247
452
  summarize: (value) => value ? "Yes" : "No"
248
453
  }
249
454
  ];
250
- for (const manifest of builtins) registerFieldType(manifest);
455
+ for (const manifest of [...builtins, ...structuredFieldTypes]) registerFieldType(manifest);
251
456
 
252
457
  // src/logic.ts
253
458
  function own(record, key) {
@@ -397,7 +602,31 @@ function recallRefs(text) {
397
602
  }
398
603
 
399
604
  // src/parse.ts
400
- var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
605
+ function shown(v) {
606
+ let text;
607
+ try {
608
+ text = JSON.stringify(v);
609
+ } catch {
610
+ text = void 0;
611
+ }
612
+ text ??= typeof v === "bigint" ? `${v}n` : typeof v;
613
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
614
+ }
615
+ var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
616
+ var isInt = (v) => typeof v === "number" && Number.isInteger(v);
617
+ var LITERAL_TYPE = {
618
+ "short-text": "string",
619
+ "long-text": "string",
620
+ email: "string",
621
+ phone: "string",
622
+ rating: "number",
623
+ nps: "number",
624
+ "single-choice": "string",
625
+ "multi-choice": "string",
626
+ "yes-no": "boolean",
627
+ list: "string"
628
+ };
629
+ var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
401
630
  var OPERATORS = /* @__PURE__ */ new Set([
402
631
  "eq",
403
632
  "neq",
@@ -437,7 +666,48 @@ function parseForm(input) {
437
666
  }
438
667
  return true;
439
668
  };
440
- if (!isObject(input)) {
669
+ const optionalInt = (v, path, floor) => {
670
+ if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
671
+ };
672
+ const ordered = (low, high, path, what) => {
673
+ if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
674
+ };
675
+ const structuredProps = (type, props2, path) => {
676
+ if (type === "list") {
677
+ optionalInt(props2.min, `${path}.min`, 0);
678
+ optionalInt(props2.max, `${path}.max`, 1);
679
+ optionalInt(props2.itemMaxLength, `${path}.itemMaxLength`, 1);
680
+ optionalText(props2.placeholder, `${path}.placeholder`);
681
+ ordered(props2.min, props2.max ?? 10, `${path}.min`, "most items");
682
+ } else if (type === "table") {
683
+ const columns = props2.columns;
684
+ if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
685
+ issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
686
+ } else {
687
+ const seen = /* @__PURE__ */ new Set();
688
+ columns.forEach((c, j) => {
689
+ const at = `${path}.columns[${j}]`;
690
+ if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
691
+ if (name(c.key, `${at}.key`, "A column key")) {
692
+ if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
693
+ seen.add(c.key);
694
+ }
695
+ text(c.label, `${at}.label`);
696
+ optionalBoolean(c.long, `${at}.long`);
697
+ });
698
+ }
699
+ optionalInt(props2.minRows, `${path}.minRows`, 0);
700
+ optionalInt(props2.maxRows, `${path}.maxRows`, 1);
701
+ ordered(props2.minRows, props2.maxRows ?? 3, `${path}.minRows`, "most rows");
702
+ } else if (type === "media-notes") {
703
+ optionalInt(props2.maxNotes, `${path}.maxNotes`, 1);
704
+ optionalInt(props2.textMaxLength, `${path}.textMaxLength`, 1);
705
+ } else if (type === "text-highlights") {
706
+ optionalText(props2.text, `${path}.text`);
707
+ optionalInt(props2.maxHighlights, `${path}.maxHighlights`, 1);
708
+ }
709
+ };
710
+ if (!isObject2(input)) {
441
711
  issue("", "A form definition must be an object.");
442
712
  return fail();
443
713
  }
@@ -446,19 +716,19 @@ function parseForm(input) {
446
716
  text(input.title, "title");
447
717
  optionalText(input.description, "description");
448
718
  if (input.layout !== void 0) {
449
- if (!isObject(input.layout)) issue("layout", "Must be an object.");
719
+ if (!isObject2(input.layout)) issue("layout", "Must be an object.");
450
720
  else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
451
721
  issue("layout.mode", "Must be focus or classic.");
452
722
  }
453
723
  }
454
724
  if (input.settings !== void 0) {
455
- if (!isObject(input.settings)) issue("settings", "Must be an object.");
725
+ if (!isObject2(input.settings)) issue("settings", "Must be an object.");
456
726
  else {
457
727
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
458
728
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
459
729
  }
460
730
  }
461
- if (input.meta !== void 0 && (!isObject(input.meta) || !isJson(input.meta))) {
731
+ if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
462
732
  issue("meta", "Must be a JSON object.");
463
733
  }
464
734
  if (!Array.isArray(input.blocks)) {
@@ -470,9 +740,10 @@ function parseForm(input) {
470
740
  const keys = /* @__PURE__ */ new Map();
471
741
  const fieldTypes = /* @__PURE__ */ new Map();
472
742
  const order = /* @__PURE__ */ new Map();
743
+ const choices = /* @__PURE__ */ new Map();
473
744
  input.blocks.forEach((block, i) => {
474
745
  const path = `blocks[${i}]`;
475
- if (!isObject(block) || typeof block.id !== "string" || !block.id) {
746
+ if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
476
747
  issue(path, "Every block needs a string id.");
477
748
  return;
478
749
  }
@@ -493,18 +764,30 @@ function parseForm(input) {
493
764
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
494
765
  }
495
766
  if (block.props !== void 0) {
496
- if (!isObject(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
767
+ if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
497
768
  else if (block.props.options !== void 0) {
498
769
  const options = block.props.options;
499
770
  if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
500
771
  else {
501
772
  options.forEach((o, j) => {
502
773
  const at = `${path}.props.options[${j}]`;
503
- if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
774
+ if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
504
775
  else optionalText(o.label, `${at}.label`);
505
776
  });
777
+ choices.set(
778
+ block.id,
779
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
780
+ );
506
781
  }
507
782
  }
783
+ const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
784
+ if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
785
+ issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
786
+ }
787
+ if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
788
+ issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
789
+ }
790
+ if (isObject2(block.props) && typeof block.type === "string") structuredProps(block.type, block.props, `${path}.props`);
508
791
  }
509
792
  if (validKey) {
510
793
  if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
@@ -521,9 +804,10 @@ function parseForm(input) {
521
804
  }
522
805
  });
523
806
  const hiddenNames = /* @__PURE__ */ new Set();
807
+ const hiddenTypes = /* @__PURE__ */ new Map();
524
808
  if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
525
809
  (Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
526
- if (!isObject(h) || typeof h.name !== "string" || !h.name) {
810
+ if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
527
811
  issue(`hidden[${i}]`, "Each hidden input needs a name.");
528
812
  return;
529
813
  }
@@ -536,13 +820,15 @@ function parseForm(input) {
536
820
  issue(`hidden[${i}].type`, "Must be string, number or boolean.");
537
821
  }
538
822
  optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
823
+ optionalBoolean(h.identifying, `hidden[${i}].identifying`);
539
824
  hiddenNames.add(h.name);
825
+ if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
540
826
  });
541
827
  const endingIds = /* @__PURE__ */ new Set();
542
828
  if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
543
829
  (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
544
830
  const path = `endings[${i}]`;
545
- if (!isObject(e)) {
831
+ if (!isObject2(e)) {
546
832
  issue(path, "Each ending must be an object with an id and a title.");
547
833
  return;
548
834
  }
@@ -554,7 +840,7 @@ function parseForm(input) {
554
840
  });
555
841
  const checkOperand = (o, path, reads) => {
556
842
  if (o === void 0) return;
557
- if (!isObject(o)) return issue(path, "An operand must be an object.");
843
+ if (!isObject2(o)) return issue(path, "An operand must be an object.");
558
844
  if ("field" in o) {
559
845
  if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
560
846
  reads.push(o.field);
@@ -567,7 +853,7 @@ function parseForm(input) {
567
853
  }
568
854
  };
569
855
  const checkCondition = (c, path, reads, depth = 0) => {
570
- if (!isObject(c)) return issue(path, "A condition must be an object.");
856
+ if (!isObject2(c)) return issue(path, "A condition must be an object.");
571
857
  if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
572
858
  if ("all" in c || "any" in c) {
573
859
  const group = "all" in c ? "all" : "any";
@@ -576,7 +862,7 @@ function parseForm(input) {
576
862
  return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
577
863
  }
578
864
  if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
579
- if (typeof c.op !== "string" || !isObject(c.left)) {
865
+ if (typeof c.op !== "string" || !isObject2(c.left)) {
580
866
  return issue(path, "A comparison needs an `op` and a `left` operand.");
581
867
  }
582
868
  if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
@@ -585,16 +871,53 @@ function parseForm(input) {
585
871
  if (typeof c.left.field === "string") {
586
872
  const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
587
873
  if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
588
- issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
874
+ return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
589
875
  }
590
876
  }
877
+ checkLiterals(c.op, c.left, c.right, path);
878
+ };
879
+ const checkLiterals = (op, left, right, path) => {
880
+ if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
881
+ const literal = isObject2(right) && "value" in right;
882
+ if (right !== void 0 && !literal) return;
883
+ let what = "the left side";
884
+ let type;
885
+ let options;
886
+ if (typeof left.field === "string" && fieldTypes.has(left.field)) {
887
+ const fieldType = fieldTypes.get(left.field);
888
+ what = `"${keyOf(left.field)}"`;
889
+ type = LITERAL_TYPE[fieldType];
890
+ if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
891
+ } else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
892
+ what = `hidden input "${left.hidden}"`;
893
+ type = hiddenTypes.get(left.hidden);
894
+ }
895
+ const outcome = op === "neq" ? "always true" : "never true";
896
+ let values;
897
+ if (op === "in") {
898
+ if (!literal || !Array.isArray(right.value)) {
899
+ return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
900
+ }
901
+ values = right.value;
902
+ } else if (!literal) return;
903
+ else values = [right.value];
904
+ if (ORDERING.has(op)) {
905
+ if (type !== void 0 && type !== "number") {
906
+ return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
907
+ }
908
+ type = "number";
909
+ }
910
+ for (const v of values) {
911
+ const message = type !== void 0 && typeof v !== type ? `${what} holds a ${type}, but ${shown(v)} is not one; this comparison is ${outcome}.` : options && typeof v === "string" && !options.has(v) ? `${shown(v)} is not an option of ${what}; this comparison is ${outcome}.` : null;
912
+ if (message) warnings.push({ path: `${path}.right`, message });
913
+ }
591
914
  };
592
915
  const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
593
916
  const ruleIds = /* @__PURE__ */ new Set();
594
917
  if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
595
918
  (Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
596
919
  const path = `logic[${i}]`;
597
- if (!isObject(rule) || typeof rule.id !== "string" || !isObject(rule.when)) {
920
+ if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
598
921
  issue(path, "Each rule needs an id and a `when` condition.");
599
922
  return;
600
923
  }
@@ -613,7 +936,7 @@ function parseForm(input) {
613
936
  at = rule.from;
614
937
  inclusive = true;
615
938
  }
616
- if (isObject(rule.to)) {
939
+ if (isObject2(rule.to)) {
617
940
  if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
618
941
  issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
619
942
  }
@@ -645,7 +968,7 @@ function parseForm(input) {
645
968
  }
646
969
  });
647
970
  input.blocks.forEach((block, i) => {
648
- if (!isObject(block)) return;
971
+ if (!isObject2(block)) return;
649
972
  const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
650
973
  const texts = raw.filter((t) => typeof t === "string");
651
974
  for (const t of texts) {
@@ -663,11 +986,23 @@ function parseForm(input) {
663
986
  }
664
987
  }
665
988
  });
989
+ const extra = [["description", input.description]];
990
+ (Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
991
+ if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
992
+ });
993
+ for (const [path, t] of extra) {
994
+ if (typeof t !== "string") continue;
995
+ for (const ref of recallRefs(t)) {
996
+ if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
997
+ warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
998
+ }
999
+ }
1000
+ }
666
1001
  return issues.length ? fail() : { ok: true, form: input, warnings };
667
1002
  }
668
1003
 
669
1004
  // src/define.ts
670
- function field(type, key, options, props) {
1005
+ function field(type, key, options, props2) {
671
1006
  const block = {
672
1007
  kind: "field",
673
1008
  id: options.id ?? key,
@@ -680,16 +1015,18 @@ function field(type, key, options, props) {
680
1015
  if (options.required) block.required = true;
681
1016
  if (options.sensitive) block.sensitive = true;
682
1017
  if (options.identifying) block.identifying = true;
683
- if (props && Object.keys(props).length) block.props = props;
1018
+ if (props2 && Object.keys(props2).length) block.props = props2;
684
1019
  return block;
685
1020
  }
1021
+ var props = (all) => Object.fromEntries(Object.entries(all).filter((e) => e[1] !== void 0));
686
1022
  var choiceProps = (options) => ({
687
1023
  options: options.map((o) => o.label ? { value: o.value, label: o.label } : { value: o.value })
688
1024
  });
689
1025
  var hiddenSpec = (type, o) => ({
690
1026
  type,
691
1027
  source: o.source ?? "url",
692
- ...o.sensitive ? { sensitive: true } : {}
1028
+ ...o.sensitive ? { sensitive: true } : {},
1029
+ ...o.identifying ? { identifying: true } : {}
693
1030
  });
694
1031
  var f = {
695
1032
  shortText: (key, o) => field("short-text", key, o, o.maxLength ? { maxLength: o.maxLength } : void 0),
@@ -701,6 +1038,41 @@ var f = {
701
1038
  singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
702
1039
  multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
703
1040
  yesNo: (key, o) => field("yes-no", key, o),
1041
+ /** Several short answers, one per line: goals, action items, three things that went well. */
1042
+ list: (key, o) => field(
1043
+ "list",
1044
+ key,
1045
+ o,
1046
+ props({ min: o.min, max: o.max, itemMaxLength: o.itemMaxLength, placeholder: o.itemPlaceholder })
1047
+ ),
1048
+ /**
1049
+ * A small table each answer fills row by row, one answer per column: an
1050
+ * action plan (What / Who / By when), pros and cons.
1051
+ */
1052
+ table: (key, o) => field("table", key, o, {
1053
+ columns: o.columns.map((c) => c.long ? { key: c.key, label: c.label, long: true } : { key: c.key, label: c.label }),
1054
+ ...props({ minRows: o.minRows, maxRows: o.maxRows })
1055
+ }),
1056
+ /**
1057
+ * Notes pinned to times in a video or audio (lecture notes, a recorded call
1058
+ * review): `at` is seconds from its start.
1059
+ */
1060
+ mediaNotes: (key, o) => field(
1061
+ "media-notes",
1062
+ key,
1063
+ o,
1064
+ props({ maxNotes: o.maxNotes, textMaxLength: o.textMaxLength })
1065
+ ),
1066
+ /**
1067
+ * Highlight passages of a text and note on each (annotating an article, a
1068
+ * policy or a contract): `text` here, or supplied by the host app.
1069
+ */
1070
+ textHighlights: (key, o) => field(
1071
+ "text-highlights",
1072
+ key,
1073
+ o,
1074
+ props({ text: o.text, maxHighlights: o.maxHighlights })
1075
+ ),
704
1076
  /** Any registered field type, including plugins. */
705
1077
  custom: (type, key, o) => field(type, key, o, o.props),
706
1078
  heading: (id, text) => ({ kind: "content", id, type: "heading", text }),
@@ -784,7 +1156,8 @@ function defineForm(input) {
784
1156
  name,
785
1157
  type: spec.type,
786
1158
  source: spec.source,
787
- ...spec.sensitive ? { sensitive: true } : {}
1159
+ ...spec.sensitive ? { sensitive: true } : {},
1160
+ ...spec.identifying ? { identifying: true } : {}
788
1161
  }));
789
1162
  }
790
1163
  if (input.logic?.length) definition.logic = input.logic.map((rule, i) => rule.build(resolve2, `r${i + 1}`));
@@ -809,6 +1182,7 @@ ${list}`);
809
1182
 
810
1183
  // src/hidden.ts
811
1184
  var MAX_HIDDEN_STRING_LENGTH = 1e3;
1185
+ var DECIMAL = /^-?\d+(\.\d+)?$/;
812
1186
  function coerceHiddenValue(value, type) {
813
1187
  if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
814
1188
  if (type === "string") {
@@ -818,7 +1192,7 @@ function coerceHiddenValue(value, type) {
818
1192
  }
819
1193
  if (type === "number") {
820
1194
  if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
821
- if (typeof value !== "string" || value.trim() === "") return void 0;
1195
+ if (typeof value !== "string" || !DECIMAL.test(value)) return void 0;
822
1196
  const n = Number(value);
823
1197
  return Number.isFinite(n) ? n : void 0;
824
1198
  }
@@ -999,7 +1373,7 @@ function build(definition, options, replaying = false) {
999
1373
  for (const id of state.path) {
1000
1374
  const field2 = fields.get(id);
1001
1375
  const value = own(state.values, id);
1002
- if (field2 && value !== void 0) out[id] = normalizeValue(value, field2);
1376
+ if (field2 && !isEmptyValue(value, field2)) out[id] = normalizeValue(value, field2);
1003
1377
  }
1004
1378
  return out;
1005
1379
  }
@@ -1169,6 +1543,6 @@ function describeCondition(condition, def, fields = fieldMap(def)) {
1169
1543
  return `${left} ${op} ${right}`;
1170
1544
  }
1171
1545
 
1172
- export { MAX_HIDDEN_STRING_LENGTH, REQUIRED_MESSAGE, canonicalJson, choiceOptions, coerceHidden, coerceHiddenValue, computePath, createForm, defineForm, describeCondition, evaluate, explain, f, fingerprint, getFieldType, isEmptyValue, isFormEvent, isJson, isSensitive, listFieldTypes, normalizeValue, parseForm, recall, registerFieldType, replay, ruleVisible, steps, summarizeValue, validateField, when };
1546
+ export { HIGHLIGHT_NOTE_MESSAGE, MAX_HIDDEN_STRING_LENGTH, MAX_HIGHLIGHT_NOTE_LENGTH, MAX_HIGHLIGHT_QUOTE_LENGTH, MAX_TABLE_CELL_LENGTH, MAX_TABLE_COLUMNS, MAX_TIMESTAMP_SECONDS, REQUIRED_MESSAGE, STRUCTURED_FIELD_TYPES, canonicalJson, choiceOptions, coerceHidden, coerceHiddenValue, computePath, createForm, defineForm, describeCondition, evaluate, explain, f, fingerprint, formatTimestamp, getFieldType, isEmptyValue, isFormEvent, isJson, isSensitive, listFieldTypes, listLimits, normalizeValue, parseForm, parseTimestamp, recall, registerFieldType, replay, ruleVisible, steps, summarizeValue, tableColumns, tableLimits, validateField, when };
1173
1547
  //# sourceMappingURL=index.js.map
1174
1548
  //# sourceMappingURL=index.js.map