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