@parag.more.withsurface.com/forms-sdk 0.1.4

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 (177) hide show
  1. package/README.md +63 -0
  2. package/dist/index.cjs +2737 -0
  3. package/dist/index.cjs.map +7 -0
  4. package/dist/index.mjs +2714 -0
  5. package/dist/index.mjs.map +7 -0
  6. package/dist/react/index.cjs +44 -0
  7. package/dist/react/index.cjs.map +7 -0
  8. package/dist/react/index.mjs +21 -0
  9. package/dist/react/index.mjs.map +7 -0
  10. package/dist/surface-forms-sdk.min.js +2 -0
  11. package/dist/surface-forms-sdk.min.js.map +7 -0
  12. package/dist/types/bind/bindForm.d.ts +43 -0
  13. package/dist/types/bind/bindForm.d.ts.map +1 -0
  14. package/dist/types/bind/bindForm.test.d.ts +2 -0
  15. package/dist/types/bind/bindForm.test.d.ts.map +1 -0
  16. package/dist/types/bind/bookingWidget.d.ts +35 -0
  17. package/dist/types/bind/bookingWidget.d.ts.map +1 -0
  18. package/dist/types/bind/bookingWidget.test.d.ts +2 -0
  19. package/dist/types/bind/bookingWidget.test.d.ts.map +1 -0
  20. package/dist/types/core/client/emailValidation.d.ts +23 -0
  21. package/dist/types/core/client/emailValidation.d.ts.map +1 -0
  22. package/dist/types/core/client/emailValidation.test.d.ts +2 -0
  23. package/dist/types/core/client/emailValidation.test.d.ts.map +1 -0
  24. package/dist/types/core/client/responses.d.ts +35 -0
  25. package/dist/types/core/client/responses.d.ts.map +1 -0
  26. package/dist/types/core/client/responses.test.d.ts +2 -0
  27. package/dist/types/core/client/responses.test.d.ts.map +1 -0
  28. package/dist/types/core/client/runtimeConfig.contract.test.d.ts +2 -0
  29. package/dist/types/core/client/runtimeConfig.contract.test.d.ts.map +1 -0
  30. package/dist/types/core/client/runtimeConfig.d.ts +72 -0
  31. package/dist/types/core/client/runtimeConfig.d.ts.map +1 -0
  32. package/dist/types/core/client/runtimeConfig.test.d.ts +2 -0
  33. package/dist/types/core/client/runtimeConfig.test.d.ts.map +1 -0
  34. package/dist/types/core/client/scheduler.contract.test.d.ts +2 -0
  35. package/dist/types/core/client/scheduler.contract.test.d.ts.map +1 -0
  36. package/dist/types/core/client/scheduler.d.ts +121 -0
  37. package/dist/types/core/client/scheduler.d.ts.map +1 -0
  38. package/dist/types/core/client/scheduler.test.d.ts +2 -0
  39. package/dist/types/core/client/scheduler.test.d.ts.map +1 -0
  40. package/dist/types/core/client/transport.d.ts +56 -0
  41. package/dist/types/core/client/transport.d.ts.map +1 -0
  42. package/dist/types/core/client/transport.test.d.ts +2 -0
  43. package/dist/types/core/client/transport.test.d.ts.map +1 -0
  44. package/dist/types/core/engine/answerAudit.d.ts +21 -0
  45. package/dist/types/core/engine/answerAudit.d.ts.map +1 -0
  46. package/dist/types/core/engine/answerAudit.test.d.ts +2 -0
  47. package/dist/types/core/engine/answerAudit.test.d.ts.map +1 -0
  48. package/dist/types/core/engine/answerStates.contract.test.d.ts +2 -0
  49. package/dist/types/core/engine/answerStates.contract.test.d.ts.map +1 -0
  50. package/dist/types/core/engine/answerStates.d.ts +92 -0
  51. package/dist/types/core/engine/answerStates.d.ts.map +1 -0
  52. package/dist/types/core/engine/formEngine.d.ts +145 -0
  53. package/dist/types/core/engine/formEngine.d.ts.map +1 -0
  54. package/dist/types/core/engine/formEngine.test.d.ts +2 -0
  55. package/dist/types/core/engine/formEngine.test.d.ts.map +1 -0
  56. package/dist/types/core/engine/redirect.d.ts +36 -0
  57. package/dist/types/core/engine/redirect.d.ts.map +1 -0
  58. package/dist/types/core/engine/redirect.test.d.ts +2 -0
  59. package/dist/types/core/engine/redirect.test.d.ts.map +1 -0
  60. package/dist/types/core/engine/responsePayload.contract.test.d.ts +2 -0
  61. package/dist/types/core/engine/responsePayload.contract.test.d.ts.map +1 -0
  62. package/dist/types/core/engine/responsePayload.d.ts +74 -0
  63. package/dist/types/core/engine/responsePayload.d.ts.map +1 -0
  64. package/dist/types/core/engine/responsePayload.test.d.ts +2 -0
  65. package/dist/types/core/engine/responsePayload.test.d.ts.map +1 -0
  66. package/dist/types/core/engine/urlData.contract.test.d.ts +2 -0
  67. package/dist/types/core/engine/urlData.contract.test.d.ts.map +1 -0
  68. package/dist/types/core/engine/urlData.d.ts +30 -0
  69. package/dist/types/core/engine/urlData.d.ts.map +1 -0
  70. package/dist/types/core/engine/urlData.test.d.ts +2 -0
  71. package/dist/types/core/engine/urlData.test.d.ts.map +1 -0
  72. package/dist/types/core/engine/urlPrefill.d.ts +21 -0
  73. package/dist/types/core/engine/urlPrefill.d.ts.map +1 -0
  74. package/dist/types/core/engine/urlPrefill.test.d.ts +2 -0
  75. package/dist/types/core/engine/urlPrefill.test.d.ts.map +1 -0
  76. package/dist/types/core/events/dropoffDetection.d.ts +21 -0
  77. package/dist/types/core/events/dropoffDetection.d.ts.map +1 -0
  78. package/dist/types/core/events/dropoffDetection.test.d.ts +2 -0
  79. package/dist/types/core/events/dropoffDetection.test.d.ts.map +1 -0
  80. package/dist/types/core/events/emitter.d.ts +23 -0
  81. package/dist/types/core/events/emitter.d.ts.map +1 -0
  82. package/dist/types/core/events/emitter.test.d.ts +2 -0
  83. package/dist/types/core/events/emitter.test.d.ts.map +1 -0
  84. package/dist/types/core/events/formJourneyTracker.contract.test.d.ts +2 -0
  85. package/dist/types/core/events/formJourneyTracker.contract.test.d.ts.map +1 -0
  86. package/dist/types/core/events/formJourneyTracker.d.ts +69 -0
  87. package/dist/types/core/events/formJourneyTracker.d.ts.map +1 -0
  88. package/dist/types/core/events/formJourneyTracker.test.d.ts +2 -0
  89. package/dist/types/core/events/formJourneyTracker.test.d.ts.map +1 -0
  90. package/dist/types/core/events/pushEvents.contract.test.d.ts +2 -0
  91. package/dist/types/core/events/pushEvents.contract.test.d.ts.map +1 -0
  92. package/dist/types/core/events/pushEvents.d.ts +62 -0
  93. package/dist/types/core/events/pushEvents.d.ts.map +1 -0
  94. package/dist/types/core/events/pushEvents.test.d.ts +2 -0
  95. package/dist/types/core/events/pushEvents.test.d.ts.map +1 -0
  96. package/dist/types/core/events/startedDetection.d.ts +11 -0
  97. package/dist/types/core/events/startedDetection.d.ts.map +1 -0
  98. package/dist/types/core/events/startedDetection.test.d.ts +2 -0
  99. package/dist/types/core/events/startedDetection.test.d.ts.map +1 -0
  100. package/dist/types/core/events/viewDetection.d.ts +15 -0
  101. package/dist/types/core/events/viewDetection.d.ts.map +1 -0
  102. package/dist/types/core/events/viewDetection.test.d.ts +2 -0
  103. package/dist/types/core/events/viewDetection.test.d.ts.map +1 -0
  104. package/dist/types/core/identity/browserFingerprint.d.ts +40 -0
  105. package/dist/types/core/identity/browserFingerprint.d.ts.map +1 -0
  106. package/dist/types/core/identity/browserFingerprint.test.d.ts +2 -0
  107. package/dist/types/core/identity/browserFingerprint.test.d.ts.map +1 -0
  108. package/dist/types/core/identity/ensureIdentified.d.ts +27 -0
  109. package/dist/types/core/identity/ensureIdentified.d.ts.map +1 -0
  110. package/dist/types/core/identity/ensureIdentified.test.d.ts +2 -0
  111. package/dist/types/core/identity/ensureIdentified.test.d.ts.map +1 -0
  112. package/dist/types/core/identity/identify.contract.test.d.ts +2 -0
  113. package/dist/types/core/identity/identify.contract.test.d.ts.map +1 -0
  114. package/dist/types/core/identity/identify.d.ts +48 -0
  115. package/dist/types/core/identity/identify.d.ts.map +1 -0
  116. package/dist/types/core/identity/identify.test.d.ts +2 -0
  117. package/dist/types/core/identity/identify.test.d.ts.map +1 -0
  118. package/dist/types/core/identity/journey.contract.test.d.ts +2 -0
  119. package/dist/types/core/identity/journey.contract.test.d.ts.map +1 -0
  120. package/dist/types/core/identity/journey.d.ts +28 -0
  121. package/dist/types/core/identity/journey.d.ts.map +1 -0
  122. package/dist/types/core/identity/journey.test.d.ts +2 -0
  123. package/dist/types/core/identity/journey.test.d.ts.map +1 -0
  124. package/dist/types/core/identity/journey.testStubs.d.ts +5 -0
  125. package/dist/types/core/identity/journey.testStubs.d.ts.map +1 -0
  126. package/dist/types/core/identity/leadDataCache.d.ts +27 -0
  127. package/dist/types/core/identity/leadDataCache.d.ts.map +1 -0
  128. package/dist/types/core/identity/leadDataCache.test.d.ts +2 -0
  129. package/dist/types/core/identity/leadDataCache.test.d.ts.map +1 -0
  130. package/dist/types/enrichment/attachEnrichment.d.ts +35 -0
  131. package/dist/types/enrichment/attachEnrichment.d.ts.map +1 -0
  132. package/dist/types/enrichment/attachEnrichment.test.d.ts +2 -0
  133. package/dist/types/enrichment/attachEnrichment.test.d.ts.map +1 -0
  134. package/dist/types/index.d.ts +28 -0
  135. package/dist/types/index.d.ts.map +1 -0
  136. package/dist/types/react/index.d.ts +2 -0
  137. package/dist/types/react/index.d.ts.map +1 -0
  138. package/dist/types/react/ssr.test.d.ts +2 -0
  139. package/dist/types/react/ssr.test.d.ts.map +1 -0
  140. package/dist/types/react/useSurfaceForm.d.ts +25 -0
  141. package/dist/types/react/useSurfaceForm.d.ts.map +1 -0
  142. package/dist/types/react/useSurfaceForm.test.d.ts +2 -0
  143. package/dist/types/react/useSurfaceForm.test.d.ts.map +1 -0
  144. package/dist/types/surfaceForms.d.ts +120 -0
  145. package/dist/types/surfaceForms.d.ts.map +1 -0
  146. package/dist/types/surfaceForms.test.d.ts +2 -0
  147. package/dist/types/surfaceForms.test.d.ts.map +1 -0
  148. package/dist/types/tracking/attachTracking.d.ts +35 -0
  149. package/dist/types/tracking/attachTracking.d.ts.map +1 -0
  150. package/dist/types/tracking/attachTracking.test.d.ts +2 -0
  151. package/dist/types/tracking/attachTracking.test.d.ts.map +1 -0
  152. package/dist/types/tracking/conversionRules.contract.test.d.ts +2 -0
  153. package/dist/types/tracking/conversionRules.contract.test.d.ts.map +1 -0
  154. package/dist/types/tracking/conversionRules.d.ts +87 -0
  155. package/dist/types/tracking/conversionRules.d.ts.map +1 -0
  156. package/dist/types/tracking/conversionRules.test.d.ts +2 -0
  157. package/dist/types/tracking/conversionRules.test.d.ts.map +1 -0
  158. package/dist/types/tracking/vendors.d.ts +46 -0
  159. package/dist/types/tracking/vendors.d.ts.map +1 -0
  160. package/dist/types/tracking/vendors.test.d.ts +2 -0
  161. package/dist/types/tracking/vendors.test.d.ts.map +1 -0
  162. package/dist/types/validation/attachEmailValidation.d.ts +8 -0
  163. package/dist/types/validation/attachEmailValidation.d.ts.map +1 -0
  164. package/dist/types/validation/attachEmailValidation.test.d.ts +2 -0
  165. package/dist/types/validation/attachEmailValidation.test.d.ts.map +1 -0
  166. package/docs/api.md +310 -0
  167. package/docs/reference/binding.md +116 -0
  168. package/docs/reference/clients.md +212 -0
  169. package/docs/reference/engine.md +193 -0
  170. package/docs/reference/events.md +93 -0
  171. package/docs/reference/identity.md +116 -0
  172. package/llms.txt +317 -0
  173. package/package.json +25 -0
  174. package/skills/surface-forms-sdk/SKILL.md +153 -0
  175. package/skills/surface-forms-sdk/debugging.md +64 -0
  176. package/skills/surface-forms-sdk/editing.md +67 -0
  177. package/skills/surface-forms-sdk/patterns.md +222 -0
@@ -0,0 +1,116 @@
1
+ # Reference — DOM binding, booking widget, React adapter
2
+
3
+ Exhaustive reference for the `bind/` layer and the React subpath. Map of everything:
4
+ [`../api.md`](../api.md). Concrete HTML shapes:
5
+ [`../../skills/surface-forms-sdk/patterns.md`](../../skills/surface-forms-sdk/patterns.md).
6
+
7
+ ## `bindForm({engine, root, expectedQuestionIds?}): () => void`
8
+
9
+ Makes plain `data-*` HTML drive a `FormEngine` with zero authored JS. `init` calls it when
10
+ `container` is passed; use directly with your own engine. Returns a full teardown. Exact
11
+ behavior:
12
+
13
+ ### Field discovery + resolution
14
+
15
+ - Bound elements are everything with `data-question-id` under `root` (the element itself when it
16
+ IS an `input`/`select`/`textarea`, otherwise every such control inside it).
17
+ - **Nearest-wrapper rule**: a control inside a NESTED `data-question-id` wrapper belongs to the
18
+ inner question only — it never also binds an outer one.
19
+ - **Field name per control** (`fieldNameFor`): wrapper `data-field-name` ?? control
20
+ `data-field-name` ?? inferred — `"selected"` for a `<select>` on a Dropdown, `"email"` for
21
+ `input[type=email]`, else `"input"`.
22
+ - **Question type** (`questionTypeFor`): wrapper `data-question-type` ?? control attr ??
23
+ inferred — `input[type=email]` → `EmailForm`, else `ShortInput`. Every answer carries `type`
24
+ (the wire validates a discriminated union).
25
+ - **Values**: checkboxes contribute `checked` (boolean), radios their `value`, everything else
26
+ `field.value`. Multi-field components accumulate into ONE state per question
27
+ (`{firstName, lastName, …}`).
28
+ - **Pre-filled values are seeded at bind time**: non-empty DOM values (SSR, autofill, values set
29
+ before init resolved) are captured immediately, not only on the next input event.
30
+
31
+ ### Choice groups (`CHOICE_ANSWER_BUILDERS`)
32
+
33
+ For containers whose type is one of the five choice components, the answer is built from the
34
+ whole radio/checkbox group (never per control; `data-field-name` is ignored on the options):
35
+
36
+ | Type | Built answer |
37
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------ |
38
+ | `MultipleOptionsQuestion` | `{choices: [{key: option.value, value: option.checked, index}, …]}` — the FULL option list |
39
+ | `MultipleChoiceQuestion` / `MMChoiceQuestion` | `{choices: [{key, value}, …]}` — full list, no index |
40
+ | `GraphicOptions` / `MultipleChoiceSingleAnswer` | `{value, index}` of the checked option (`{value: "", index: -1}` when none) |
41
+
42
+ Control `value` attributes must equal the record's option keys exactly. A stray text control in
43
+ the group (an "Other" input) is left to the author's own `setAnswer`.
44
+
45
+ ### Navigation markers
46
+
47
+ Selector: `[data-surface-goto-step], .surface-disqualify-button, .surface-submit-button,
48
+ .surface-next-button`. ONE action per element, most specific wins, in priority order: `goto` →
49
+ `disqualify` → `submit` → `next`. `goto` failures (unknown step) are caught and console-warned,
50
+ never thrown into the page. A `<form>` root's native `submit` event is also intercepted
51
+ (`preventDefault`) and finishes the form.
52
+
53
+ ### Step visibility
54
+
55
+ On every `stepChanged` (and at bind), each `[data-step-id]` container gets
56
+ `hidden = (its id !== currentStepId)` — one visible step at a time. Single-step records may omit
57
+ containers. (Author containers after the first as `hidden` in the HTML to avoid the pre-init
58
+ flash.)
59
+
60
+ ### View detection + drift warning
61
+
62
+ - `observeFormView(root, () => engine.markViewed())` is wired automatically.
63
+ - `expectedQuestionIds` (init derives them from the record) minus the ids actually bound →
64
+ a console warning naming the unbound ids; the runtime counterpart of `validate_form_html`.
65
+
66
+ ## `mountBookingWidget(options): () => void`
67
+
68
+ The unstyled Surface-scheduler booking flow (date → time → name/email form → booked) over a
69
+ `SchedulerClient` ([`clients.md`](clients.md)). Never iframe the hosted booking page.
70
+
71
+ ### Options (`BookingWidgetOptions`)
72
+
73
+ | Option | Type | Semantics |
74
+ | ------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
75
+ | `container` | `Element` | The widget appends its own `div.surface-booking` root here; teardown removes it. |
76
+ | `scheduler` | `SchedulerClient` | From `createSchedulerClient` (share the transport; pass `preview` there for synthetic bookings). |
77
+ | `eventTypeId` | `string` | From the record's `CalendlyScreen` content. |
78
+ | `timezone` | `string?` | Defaults to the visitor's (`Intl.DateTimeFormat().resolvedOptions().timeZone`); slot labels format in it. |
79
+ | `duration` | `number?` | Threaded into availability/slots/book queries. |
80
+ | `prefill` | `{name?, email?}?` | Pre-fills the widget's name/email inputs. |
81
+ | `identity` | `{responseId?, leadSessionId?, fingerprint?}?` | Threaded into `book()` for booking↔response linkage. |
82
+ | `formatters` | `{month?, date?, time?}?` | Override the copy: `month("YYYY-MM")`, `date("YYYY-MM-DD")`, `time(slot)` (ISO instants; format in the widget timezone). Defaults are locale-formatted via `Intl`. |
83
+ | `onComplete` | `(booking: Booking) => void` | Fires after a successful booking — persist via `setAnswer({...toSchedulerAnswerState(booking), type: "<record type>"})` and navigate. |
84
+
85
+ ### Behavior + DOM
86
+
87
+ - Every month/date/slot element carries its raw value in `data-value` (`YYYY-MM`, `YYYY-MM-DD`,
88
+ ISO instant) — relabel or assert without parsing text.
89
+ - Async renders are generation-guarded: a stale fetch resolving after navigation never touches
90
+ the DOM; teardown ignores in-flight results.
91
+ - Errors render `p.surface-booking-error` + `button.surface-booking-retry`; a failed booking
92
+ keeps the form up with the error appended; the submit button disables while booking.
93
+ - No CSS is injected; the full class tree (also in `llms.txt`):
94
+
95
+ ```
96
+ div.surface-booking
97
+ ├─ p.surface-booking-month [data-value] (or p.surface-booking-loading / error+retry)
98
+ ├─ div.surface-booking-month-nav > button.surface-booking-month-button ×2
99
+ └─ ul.surface-booking-dates > li.surface-booking-date > button.surface-booking-date-button [data-value]
100
+ (li.surface-booking-no-dates when empty)
101
+ -- date picked -- p.surface-booking-day [data-value] · button.surface-booking-back
102
+ ul.surface-booking-slots > li.surface-booking-slot > button.surface-booking-slot-button [data-value]
103
+ -- slot picked -- form.surface-booking-form > input.surface-booking-name + input.surface-booking-email + button.surface-booking-submit
104
+ -- booked -- p.surface-booking-booked
105
+ ```
106
+
107
+ ## React — `useSurfaceForm(engine): UseSurfaceFormResult` (from `<package>/react`)
108
+
109
+ `useSyncExternalStore` adapter over any `FormEngine` (including the `init` handle). Returns
110
+ `{state, setAnswer, next, back, goToStep, submit, disqualify, savePartial, markViewed, on}` —
111
+ `state` re-renders on engine mutations and is referentially stable between them. React ≥18 is an
112
+ optional peer dependency; the main entry stays React-free. One engine per mounted form;
113
+ `destroy()` on unmount (see the SPA pattern in
114
+ [`patterns.md`](../../skills/surface-forms-sdk/patterns.md)); init-level members not on the hook
115
+ (`flush`, `mergeMeta`, `validateEmail`, `capabilities`, `identify`, `destroy`) are read from the
116
+ handle itself.
@@ -0,0 +1,212 @@
1
+ # Reference — transport, runtime config, responses, scheduler, email validation, enrichment
2
+
3
+ Exhaustive reference for the `core/client` domain plus the watcher modules that ride on it.
4
+ Map of everything: [`../api.md`](../api.md).
5
+
6
+ ## Transport (`core/client/transport`)
7
+
8
+ - **`DEFAULT_API_BASE_URL`** = `"https://forms.withsurface.com"`.
9
+ - **`SDK_VERSION`** rides every request: header `X-Surface-Sdk-Version` on fetch paths, `?sdv=`
10
+ query param on beacons (sendBeacon cannot carry headers).
11
+ - **`createTransport({apiBaseUrl?}): Transport`** — trailing slash stripped; all paths resolve
12
+ absolute against it (SDK pages are customer-hosted, always cross-origin).
13
+
14
+ ```ts
15
+ type Transport = {
16
+ get: <T>(path) => Promise<TransportResult<T>>;
17
+ post: <T>(
18
+ path,
19
+ payload,
20
+ opts?: { keepalive?: boolean; credentials?: boolean }
21
+ ) => Promise<TransportResult<T>>;
22
+ /** true only when sendBeacon queued it; the fire-and-forget fetch fallback reports false. */
23
+ beacon: (path, payload) => boolean;
24
+ };
25
+ type TransportResult<T> = { ok: true; data: T } | { ok: false; error: TransportError };
26
+ type TransportError = { kind: "network"; cause: unknown } | { kind: "http"; status: number; body?: unknown };
27
+ ```
28
+
29
+ Never throws — always a typed result (the never-break-the-host-page guarantee). `credentials:
30
+ true` sends cookies (the `{formId}_pa` repeat-visitor cookie lives on the API origin;
31
+ `SameSite=None; Secure`, so only credentialed requests carry it cross-origin). The beacon
32
+ fallback fetch is always credentialed (matches sendBeacon's behavior); unserializable payloads
33
+ are swallowed.
34
+
35
+ ## Runtime config (`core/client/runtimeConfig`)
36
+
37
+ - **`fetchRuntimeConfig(transport, formId, {preview?, previewToken?})`** →
38
+ `GET /api/v1/public/forms/{formId}/runtime-config` — the same payload the hosted page reads.
39
+ `preview: true` appends `?preview=1` (+`&pt=<previewToken>`): a NEVER-published form serves its
40
+ draft config when the token validates; otherwise the published config (a placeholder step for
41
+ never-published forms). `settings.password` never arrives (server-stripped; gated forms
42
+ validate via `POST /api/v1/check-password`).
43
+
44
+ ```ts
45
+ type RuntimeConfig = {
46
+ id: string;
47
+ name?: string;
48
+ environmentId?: string;
49
+ config: { steps?: Array<{ id; endStepKind?; components? }> } & Record<string, unknown>;
50
+ settings?: ({ trackPartialResponses?: boolean; trackCookie?: boolean } & Record<string, unknown>) | null;
51
+ };
52
+ ```
53
+
54
+ Helpers over a fetched config (all tolerate grid-layout or flat component arrays):
55
+
56
+ - **`stepsOf(config)`** → ordered `{id, endStepKind?}[]` — the engine's `steps` input.
57
+ - **`statefulQuestionsOf(config)`** → `{questionId, componentType?}[]` (answer-recording
58
+ components); **`statefulQuestionIdsOf(config)`** → just the ids.
59
+ - **`enrichmentSourcesOf(config)`** → `{questionId, fieldNames}[]`: IdentityInfo fires on its
60
+ configured `enrichmentSourceField` (default `emailAddress`) and always on `websiteUrl`;
61
+ EmailForm on `email`.
62
+ - **`hasEnabledEnrichmentProvider(settings)`** — v2 enrichment config with ≥1 enabled provider
63
+ exposing fields (the runtime gate for automatic enrichment).
64
+
65
+ ## Responses (`core/client/responses`)
66
+
67
+ - **`submitResponse(transport, payload)`** → `POST /api/v1/responses/` (awaited, credentialed —
68
+ reads/refreshes the `{formId}_pa` cookie). Returns
69
+ `CreateResponseResult = { responseId, leadId: string|null, resumeToken: string|null }`; a 200
70
+ without an id is normalized to an http error.
71
+ - **`updateResponse(transport, payload)`** → beacon to `/api/v1/responses/update`
72
+ (fire-and-forget; the engine uses an awaited keepalive post for the FINISHING update — see
73
+ [`engine.md`](engine.md)).
74
+
75
+ ## Scheduler (`core/client/scheduler`)
76
+
77
+ - **`createSchedulerClient({transport, preview?}): SchedulerClient`** — public Surface-scheduler
78
+ endpoints; shapes mirror the native booking UI byte-for-byte. `preview: true`: queries carry
79
+ `preview=true`, and `book()` resolves a fabricated booking with NO network call.
80
+
81
+ ```ts
82
+ type SchedulerClient = {
83
+ getPublicEventType: <T = unknown>(eventTypeId) => Promise<TransportResult<T>>;
84
+ getAvailabilitySummary: (args: {
85
+ eventTypeId;
86
+ timezone?;
87
+ }) => Promise<TransportResult<{ data?: AvailabilitySummary }>>;
88
+ getMonthAvailability: (args: {
89
+ eventTypeId;
90
+ month /* "YYYY-MM" */;
91
+ timezone;
92
+ duration?;
93
+ hostId?;
94
+ ownerEmail?;
95
+ }) => Promise<TransportResult<{ data?: { dates?: unknown[]; resolvedHost? } }>>;
96
+ getSlots: (args: {
97
+ eventTypeId;
98
+ date /* "YYYY-MM-DD" */;
99
+ timezone;
100
+ duration?;
101
+ hostId?;
102
+ ownerEmail?;
103
+ }) => Promise<TransportResult<{ data?: { slots?: TimeSlot[] } }>>;
104
+ book: (args: {
105
+ eventTypeId;
106
+ startTime;
107
+ endTime /* required; preview fabrication only, never sent */;
108
+ timezone;
109
+ inviteeName;
110
+ inviteeEmail;
111
+ duration?;
112
+ intakeResponses?;
113
+ guestEmails?;
114
+ captchaToken?;
115
+ responseId?;
116
+ leadSessionId?;
117
+ fingerprint?;
118
+ hostRouting?;
119
+ }) => Promise<TransportResult<Booking>>;
120
+ reschedule: (args: {
121
+ bookingId;
122
+ rescheduleToken;
123
+ newStartTime;
124
+ timezone;
125
+ }) => Promise<TransportResult<Booking>>;
126
+ };
127
+ type Booking = {
128
+ id;
129
+ startTime;
130
+ endTime;
131
+ timezone;
132
+ inviteeName;
133
+ inviteeEmail;
134
+ meetingJoinLink: string | null;
135
+ cancelToken;
136
+ rescheduleToken;
137
+ attendees: BookingAttendee[];
138
+ };
139
+ type BookingAttendee = { name; email; isHost: boolean };
140
+ type TimeSlot = { start: string; end: string }; // ISO instants
141
+ type AvailabilitySummary = {
142
+ has_availability;
143
+ available_slots_7d;
144
+ available_slots_14d;
145
+ available_slots_30d;
146
+ available_days_7d;
147
+ next_available_date: string | null;
148
+ total_available_slots;
149
+ rep_meeting_count_today;
150
+ };
151
+ ```
152
+
153
+ `book()`'s `responseId`/`leadSessionId`/`fingerprint` thread into the booking meta block — the
154
+ server links booking↔response with them; pass what you have.
155
+
156
+ - **`toSchedulerAnswerState(booking): Record<string, unknown>`** — the persisted scheduler
157
+ answer (`SchedulerAnswer` shape, [`engine.md`](engine.md)). Emits `type: "DynamicScheduler"` —
158
+ override `type` to the record's component type when persisting
159
+ (`{...toSchedulerAnswerState(b), type: "CalendlyScreen"}`).
160
+
161
+ The DOM widget over this client (`mountBookingWidget`) is in [`binding.md`](binding.md).
162
+
163
+ ## Email validation (`core/client/emailValidation` + `validation/attachEmailValidation`)
164
+
165
+ - **`createEmailValidator({transport, environmentId, preview?}): EmailValidator`** —
166
+ `POST /api/v1/edge/emailValidation` (provider waterfall + the environment's spam blacklist;
167
+ vendor keys stay server-side).
168
+ - `validate(email)` / `validateBatch(emails)`; a batch posts only uncached addresses and shares
169
+ one request.
170
+ - **Per-address Promise cache** for the validator's lifetime — successes AND failures stick, so
171
+ nothing is ever billed twice and concurrent calls share the in-flight request.
172
+ - **Fail-open**: request failure or `validationSuccessfullyRan: false` →
173
+ `{isValid: true, status: "unknown", reason: "validation_failed", failed: true}`. The same
174
+ address can later resolve definitively — design for the transition, check `failed` to
175
+ soft-warn.
176
+ - `preview: true` → synthetic `{isValid: true, status: "preview"}` with no network.
177
+
178
+ ```ts
179
+ type EmailValidationResult = {
180
+ email;
181
+ isValid: boolean;
182
+ status?;
183
+ reason?;
184
+ blacklistEntryIds?: string[];
185
+ failed?: boolean;
186
+ };
187
+ ```
188
+
189
+ - **`attachEmailValidation({engine, validator}): () => void`** — the `emailValidation: true`
190
+ watcher: watches answer keys `email`/`emailAddress`/`workEmailAddress`, debounces 300 ms
191
+ (native's), flushes on `stepCompleted`/`completed`, then merges native's response-meta keys via
192
+ `engine.mergeMeta`: `isEmailValid` (ANY currently-present email valid; fail-open counts valid),
193
+ `emailValidation` (`{status, reason, blacklistEntryIds?}` of the first detailed verdict),
194
+ `emailValidationFailed` (any verdict failed). The aggregation window follows the LIVE answers —
195
+ a replaced address's verdict stops describing the response; verdicts landing after the finish
196
+ amend the stored response. Returns a teardown.
197
+
198
+ ## Enrichment (`enrichment/attachEnrichment`)
199
+
200
+ - **`attachEnrichment({engine, transport, formId, sources}): () => void`** — real-time identity
201
+ enrichment. Watches the `sources` (`{questionId, fieldNames}[]`, from `enrichmentSourcesOf`);
202
+ a value that is a valid email or domain and stable for 1 s fires
203
+ `POST /api/v1/services/enrichment/realtime` ONCE per value (deduped, in-flight guarded);
204
+ `stepCompleted`/`completed` flush the pending debounce. The result is stored via
205
+ `engine.setExternalState(ENRICHMENT_STATE_ID, …)` — riding every save as `enrichedFields` and
206
+ readable at `state().externalStates.selfServeEnrichment.data` for caller autofill. The request
207
+ is never awaited (submission must not block on the vendor); a post-finish result amends the
208
+ stored response. Returns a teardown.
209
+ - **`ENRICHMENT_STATE_ID`** = `"selfServeEnrichment"`; **`EnrichmentSource`** =
210
+ `{questionId, fieldNames}`.
211
+ - `init` wires this automatically only when the record was fetched AND
212
+ `hasEnabledEnrichmentProvider(settings)` — check `form.capabilities.enrichment`.
@@ -0,0 +1,193 @@
1
+ # Reference — engine, answer states, payloads, page-context helpers
2
+
3
+ Exhaustive reference for the `core/engine` domain. Map of everything: [`../api.md`](../api.md).
4
+
5
+ ## `createFormEngine(options): FormEngine`
6
+
7
+ The headless core: answers, traversal, response writes, lifecycle events. `SurfaceForms.init`
8
+ creates one for you; construct directly for offline/custom stacks.
9
+
10
+ ### Options
11
+
12
+ | Option | Type | Semantics |
13
+ | ----------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
+ | `formId` | `string` | Required. |
15
+ | `environmentId` | `string?` | Stamped on `form_viewed`/`form_completed` events. |
16
+ | `steps` | `Array<{id, endStepKind?: "thank_you"\|"disqualified"}>` | Required, ≥1 (throws on empty). The ordered data contract; `endStepKind` drives the ending-step reveal. |
17
+ | `transport` | `Transport` | Required. |
18
+ | `resolveNextStep` | `(currentStepId, answers) => {stepId, reason?} \| "END" \| "INVALID"` | Routing hook consulted by `next()`/`submit()`. `"END"` finishes, `"INVALID"` disqualifies, a step object advances (its `reason` becomes the journey transition reason). Default: linear order, `"END"` after the last step. |
19
+ | `trackPartialResponses` | `boolean` (default `true`) | Gates every mid-form write. The FINISHING write always happens regardless. |
20
+ | `trackCookies` | `boolean` (default `false`) | Rides the payload's cookie-meta gate. |
21
+ | `externalStates` | `ExternalStates?` | Seed pseudo-answers (e.g. a `URL_DATA` entry). |
22
+ | `meta` | `Record<string, unknown>?` | Seed response meta; mutated in place by `mergeMeta` (rides every write by reference). |
23
+ | `identity` | `() => {fingerprint?, leadSessionId?, leadId?}` | A GETTER read at every write/event — a later re-identify attributes subsequent writes to the fresh session. |
24
+ | `pushEvents` | `PushEventClient?` | Receives the lifecycle event stream (see [`events.md`](events.md)). |
25
+ | `formJourney` | `FormJourneyTracker?` | Receives step-journey visits/transitions/terminals. |
26
+ | `preview` | `boolean` (default `false`) | `write()` short-circuits — traversal and events run, responses are never created. |
27
+ | `resume` | `{responseId, answers?}` | Seeds the responseId (every write becomes an update — no duplicate row) and the stored answers (cloned, keyed under the FIRST step — the wire flattens steps; presentation is the caller's navigation). |
28
+
29
+ ### Methods
30
+
31
+ - **`setAnswer(questionId, state)`** — merges (`{...prev, ...state}`) into the current step's
32
+ answer for that question. Fires `started` (and the `form_started` event) exactly once, on the
33
+ first deep divergence from the initial answers. Notifies subscribers.
34
+ - **`next(): Promise<void>`** — resolves the next step. Not finished → emits `stepCompleted`,
35
+ pushes the `form_step_submitted`+`form_step_viewed` batch (`stepCompletionTime` = seconds since
36
+ engine creation), records the journey transition, writes a partial (if enabled) stamped with the
37
+ step being LEFT, advances, emits `stepChanged`. Finished (`"END"`/`"INVALID"`/no next step) →
38
+ behaves like `submit()`/`disqualify()`.
39
+ - **`back(): void`** — pops history, emits `stepChanged`, records a `back_nav` journey transition.
40
+ NEVER writes. No-op when history is empty or the form finished.
41
+ - **`goToStep(stepId): Promise<void>`** — rejects unknown ids without touching state. Same-step
42
+ no-ops. A step already in history → rewinds like `back()` (no write). Otherwise a forward jump
43
+ with full forward semantics; journey reason `conditional_match` (the caller's own condition
44
+ chose the target).
45
+ - **`submit(): Promise<void>`** — finishes: terminal write (`finished: true`), then reveals the
46
+ record's `thank_you` step (display-only step change, no write), records the journey terminal,
47
+ pushes `form_completed`, emits `completed`. When it resolves, the server HAS the response — see
48
+ Delivery below.
49
+ - **`disqualify(): Promise<void>`** — finishes with `invalidResponse: true`; reveals the
50
+ `disqualified` ending; journey terminal carries
51
+ `disqualification: {reason: "conditional_match", atStepId}`; emits `disqualified`.
52
+ - **`savePartial(): Promise<void>`** — explicit mid-form write. The stepId is captured at CALL
53
+ time, so a save queued behind an in-flight advance stays attributed to the step the caller was
54
+ on. Skipped silently if the form finishes before it runs.
55
+ - **`flush(): Promise<void>`** — resolves when every write queued so far (including post-finish
56
+ amends) has settled.
57
+ - **`markViewed(): void`** — once: pushes `form_viewed` + first-step `form_step_viewed`, emits
58
+ `viewed`.
59
+ - **`setExternalState(id, {type, ...})`** — writes/replaces a pseudo-answer that rides every
60
+ subsequent save; post-finish it amends the stored response (below).
61
+ - **`mergeMeta(partial)`** — `Object.assign` onto `meta`; rides the next save or amends
62
+ post-finish. (The `init` handle wraps this with a reserved-key guard.)
63
+ - **`state(): FormEngineState`** — `{currentStepId, answers, externalStates, responseId,
64
+ resumeToken, finished}`. The snapshot is cached until the next mutation, so it is referentially
65
+ stable — the `useSyncExternalStore` contract.
66
+ - **`subscribe(listener): Unsubscribe`** — store subscription; fires on every state change.
67
+ - **`on(event, handler): Unsubscribe`** — see Events.
68
+
69
+ ### Events (`FormEngineEvents`)
70
+
71
+ | Event | Payload | Fires |
72
+ | --------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
73
+ | `viewed` | `{formId}` | Once, from `markViewed`. |
74
+ | `started` | `{formId}` | Once, first answer divergence. |
75
+ | `stepChanged` | `{fromStepId, toStepId}` | Every step move, incl. rewinds and the ending reveal. |
76
+ | `stepCompleted` | `{stepId}` | Leaving a step forward (also flush signal for debounced watchers). |
77
+ | `completed` | `{formId}` | Successful non-disqualified finish. |
78
+ | `disqualified` | `{stepId}` | Disqualified finish. |
79
+ | `saved` | `{responseId, finished, resumeToken}` | After a CONFIRMED write: every create, every partial dispatch, the awaited terminal update. Never fires for a failed terminal write. |
80
+ | `error` | `{scope: "submit", error}` | Failed create (typed `TransportError`) or failed terminal update. |
81
+
82
+ ### Write serialization + delivery
83
+
84
+ - One queue serializes every write-initiating call: a double-clicked `next()` joins the pending
85
+ advance (advances once); `submit()` during an in-flight `next()` queues behind it and then
86
+ finishes (submit intent never dropped); queued ops re-check `finished` when they run, so
87
+ nothing writes after the finishing submit.
88
+ - First forward write CREATES the response (`POST /api/v1/responses/`, awaited, credentialed);
89
+ later writes UPDATE it. Every write resends the FULL answer set + external states.
90
+ - A failed create: `error` event; `response_submission_error` pushEvent only for network-kind
91
+ failures (the server logs HTTP-level ones itself); the responseId stays null (the next write
92
+ retries the create).
93
+ - **The terminal (finishing) update is awaited** `fetch(keepalive)`: `submit()` resolving means
94
+ delivery is confirmed. On failure: `error` event + one best-effort beacon fallback, and `saved`
95
+ does NOT fire. Mid-form updates are beacon-first fire-and-forget (the server's drop-off sweep
96
+ reconciles missed ones).
97
+ - **Post-finish amends**: `setExternalState`/`mergeMeta` after the finish re-issue the terminal
98
+ update by extending the queue (full resends are idempotent; a result landing while the terminal
99
+ create is still in flight amends after it settles; a failed terminal create is never
100
+ resurrected as a new response).
101
+
102
+ ## Typed answer states (`core/engine/answerStates`)
103
+
104
+ Exact wire shapes; contract-tested against the server schemas. `setAnswer` merges partials, so
105
+ patching one key of a multi-field state is legal.
106
+
107
+ ```ts
108
+ type ShortInputAnswer = { type: "ShortInput"; input: string };
109
+ type LongInputAnswer = { type: "LongInput"; input: string };
110
+ type EmailFormAnswer = { type: "EmailForm"; email: string; subscriptionCheck?: boolean };
111
+ type DropdownAnswer = { type: "Dropdown"; selected: string | string[] };
112
+ type DisclaimerAnswer = { type: "Disclaimer"; check?: boolean };
113
+ type FileUploaderAnswer = { type: "FileUploader"; fileUrl?: string };
114
+ // The ENTIRE option list, one entry per record option — never only the picked one:
115
+ type MultipleOptionsAnswer = { type: "MultipleOptionsQuestion";
116
+ choices: Array<{ key: string; value: boolean; index: number }>; selectedOther?: boolean };
117
+ type MMChoiceAnswer = { type: "MMChoiceQuestion"; choices: Array<{ key: string; value: boolean }> };
118
+ type MultipleChoiceAnswer = { type: "MultipleChoiceQuestion"; choices: Array<{ key: string; value: boolean }> };
119
+ type GraphicOptionsAnswer = { type: "GraphicOptions"; value: string; index: number };
120
+ type MultipleChoiceSingleAnswerAnswer = { type: "MultipleChoiceSingleAnswer"; value: string; index: number };
121
+ type IdentityInfoAnswer = { type: "IdentityInfo" } & Partial<Record<IdentityInfoAnswerKey, string>>;
122
+ type SchedulerAnswer = { type: "CalendlyScreen" | "DynamicScheduler"; eventScheduled: boolean;
123
+ uri: string; meetingTime?: string; meetingEndTime?: string; ownerEmails?: string;
124
+ guestEmails?: string; inviteeName?: string; googleMeetLink?: string };
125
+ type KnownAnswerState = /* union of all of the above */;
126
+ ```
127
+
128
+ `IDENTITY_INFO_ANSWER_KEYS` (complete): `firstName`, `lastName`, `fullName`, `emailAddress`,
129
+ `workEmailAddress`, `phoneNumber`, `companyName`, `numberOfEmployees`, `websiteUrl`, `title`,
130
+ `streetAddress`, `aptSuiteEtc`, `city`, `state`, `country`, `zipCode`, `companyRevenue`,
131
+ `profilePictureUrl`.
132
+
133
+ ## Response payload builders (`core/engine/responsePayload`)
134
+
135
+ ```ts
136
+ type SingleFormResponse = { questionId: string; response: Record<string, unknown> };
137
+ type AnswerStates = Record<stepId, Record<questionId, Record<string, unknown>>>;
138
+ type ExternalStates = Record<stateId, { type: string } & Record<string, unknown>>;
139
+ type FormResponsePayload = {
140
+ formId;
141
+ responses: SingleFormResponse[];
142
+ enrichedFields;
143
+ finished;
144
+ invalidResponse;
145
+ stepId;
146
+ fingerprint?;
147
+ leadSessionId?;
148
+ meta;
149
+ responseShapeVersion: "2.0";
150
+ };
151
+ type FormUpdateResponsePayload = Omit<FormResponsePayload, "responseShapeVersion"> & { responseId };
152
+ ```
153
+
154
+ - **`buildResponsesArray(answers, externalStates)`** → `{responses, enrichedFields, bookingId?}` —
155
+ flattens ALL steps' answers plus every external state as a pseudo-answer keyed by its stateId;
156
+ skips `SCHEDULER_AVAILABILITY_DATA` (transient); detects a Surface-native booking (first answer
157
+ with `eventScheduled: true` and a non-Calendly/Cal.com `uri`) into `bookingId`; hoists the
158
+ enrichment state's fields into `enrichedFields` (existing keys win).
159
+ - **`createSubmitResponsePayload(options)`** / **`createUpdateResponsePayload(options)`** — the
160
+ full wire payloads; every write resends everything (server-side UTM preservation depends on the
161
+ `URL_DATA` entry riding every update). Cookie capture inside `meta` is gated by `trackCookies`.
162
+
163
+ ## Page-context helpers
164
+
165
+ ### `core/engine/urlData`
166
+
167
+ - **`buildUrlData({trackCookies?, defaultUrl?}): UrlData`** — the `URL_DATA` attribution entry:
168
+ `{type: "URL_DATA", fullUrl?, params?, referrer?, cookies?}` read directly from
169
+ `location`/`document` (self-hosted pages need no query-param relay). `defaultUrl` wins over
170
+ `location.href`; cookies only when `trackCookies`; malformed URLs are swallowed.
171
+ - **`constructUrlParamsObject(url)`** — query params as a record (repeated params: last wins,
172
+ pinned native quirk).
173
+ - **`parseCookiesObject(cookieString)`** — `document.cookie` → record.
174
+
175
+ ### `core/engine/urlPrefill`
176
+
177
+ - **`parsePrefillParams(search): ParsedUrlQuestionId[]`** / **`parseQuestionId(input)`** — the
178
+ `?<questionId>_<fieldName>=value` prefill syntax. Splits on the FIRST underscore (field names
179
+ may contain more); both sides trimmed; `url`/`referrer`/`cookies` reserved for attribution;
180
+ params without an underscore ignored. `ParsedUrlQuestionId` =
181
+ `{originalId, questionId, fieldName, value}`.
182
+
183
+ ### `core/engine/redirect`
184
+
185
+ - **`buildRedirectUrl(url, {leadId?, sessionId?})`** — appends `leadId`/`sessionId` query params
186
+ (identity handoff so the destination's tag/SDK resolves the same visitor). Blank → `""`;
187
+ unparseable → passed through unchanged. Protocol default pins native's `addProtocolToUrl`
188
+ including its quirk: the check is `startsWith("https")`, so an explicit `http://` URL gets
189
+ double-prefixed.
190
+ - **`performRedirect({url, redirectType?, leadId?, sessionId?, preview?})`** — `"CurrentWindow"`
191
+ (default) sets `window.location`, `"NewTab"` opens a tab. `preview: true` never navigates.
192
+ - **`DEFAULT_REDIRECT_TIMEOUT_MS`** = `400` (native's delay before an ending redirect).
193
+ - **`RedirectType`** = `"NewTab" | "CurrentWindow"`.
@@ -0,0 +1,93 @@
1
+ # Reference — event pipeline, journey telemetry, detectors, emitter
2
+
3
+ Exhaustive reference for the `core/events` domain. Map of everything: [`../api.md`](../api.md).
4
+ (The engine's own lifecycle events — `viewed`/`started`/`stepChanged`/… — are in
5
+ [`engine.md`](engine.md); this file covers the modules beneath them.)
6
+
7
+ ## Push events (`core/events/pushEvents`)
8
+
9
+ - **`createPushEventClient({transport, preview?}): PushEventClient`** — the internal analytics
10
+ pipeline, `POST /api/v1/pushEvent`, beacon-first. Client-side bot filter (UA regex); any event
11
+ missing `type`/`entityType`/`entityId` drops the WHOLE request; `preview: true` blocks every
12
+ send. `push(request)` is fire-and-forget.
13
+ - **`buildStepTransitionEventBatch({sessionId, formId, currentStepId, nextStepId, stepCompletionTime})`**
14
+ — the one batch shape the server accepts: `form_step_submitted` (with
15
+ `stepCompletionTime` property, seconds) + `form_step_viewed` for the next step.
16
+
17
+ ```ts
18
+ type SurfaceEventType =
19
+ | "form_viewed"
20
+ | "form_started"
21
+ | "form_completed"
22
+ | "form_step_viewed"
23
+ | "form_step_submitted"
24
+ | "form_step_dropoff"
25
+ | "form_question_submitted"
26
+ | "response_submission_created"
27
+ | "response_submission_error";
28
+ type SurfaceEventEntityType = "form" | "form_step" | "response";
29
+ type PushEventProperty = { key: string; value: unknown };
30
+ type PushEventPayload = {
31
+ type;
32
+ entityType;
33
+ entityId;
34
+ stepId?: string | null;
35
+ leadSessionId?: string | null;
36
+ properties?: PushEventProperty[];
37
+ leadId?;
38
+ environmentId?;
39
+ };
40
+ type PushEventBatch = { events: [submitted, viewed] }; // exactly the step-transition pair
41
+ type PushEventRequest = PushEventPayload | PushEventBatch;
42
+ type PushEventClient = { push: (request: PushEventRequest) => void };
43
+ ```
44
+
45
+ ## Form-journey telemetry (`core/events/formJourneyTracker`)
46
+
47
+ - **`createFormJourneyTracker({transport, preview?}): FormJourneyTracker`** — step-journey
48
+ visits/transitions/outcomes beaconed to `POST /api/v1/form-journey/track`. Direct native port:
49
+ visit ordinals (revisits get new ordinals; back-nav never overwrites a prior enter time),
50
+ close-previous-visit timing (`exitedAt`/`durationMs`), 50-visit cap with `truncated`,
51
+ freeze-on-terminal-outcome, microtask coalescing (a transition immediately followed by a
52
+ terminal sends ONE beacon), synchronous flush for the `in_progress` terminal (pagehide-safe).
53
+ Sends only once a responseId is set.
54
+
55
+ ```ts
56
+ type FormJourneyOutcome = "in_progress" | "completed" | "partial" | "disqualified" | "dropped_off";
57
+ type FormJourneyTransitionReason = "initial" | "linear" | "conditional_match" | "back_nav" | …;
58
+ type FormJourneyVisit = { ordinal; stepId; enteredAt; exitedAt?; durationMs?; fromStepId?;
59
+ transitionReason; transitionMeta? };
60
+ type FormJourneyTrackPayload = { responseId; formId; outcome?; disqualification?:
61
+ { reason: "conditional_match"|"default_fallback"; matched? } | null;
62
+ disqualifiedAtStepId?; disqualifiedTriggerStepId?; events: FormJourneyVisit[] };
63
+ interface FormJourneyTracker {
64
+ recordEntry(stepId): void;
65
+ recordTransition({fromStepId, toStepId, reason, transitionMeta?}): void;
66
+ recordTerminal({outcome, disqualification?: {reason, atStepId, triggerStepId?, matched?}}): void;
67
+ setResponseId(responseId): void;
68
+ setFormId(formId): void;
69
+ hasRecordedTransition(): boolean;
70
+ }
71
+ ```
72
+
73
+ ## Detectors
74
+
75
+ - **`observeFormView(element, onViewed): () => void`** — `form_viewed` detection:
76
+ IntersectionObserver on the bound root, fires the callback exactly once, then disconnects.
77
+ When IO is unavailable the callback fires immediately (counting a below-fold mount beats
78
+ losing view analytics). Returns a teardown.
79
+ - **`createStartedDetector(initialAnswers): (current) => boolean`** — `form_started` detection:
80
+ snapshots (deep-clones) the initial answers; returns true exactly ONCE, on the first
81
+ deep-inequality (key-order insensitive) vs the snapshot.
82
+ - **`observeDropoff(pushEvents, getState): () => void`** — `form_step_dropoff` on `pagehide`
83
+ (the only teardown event that still delivers beacons), only after the form started, never for
84
+ bfcache suspensions (`event.persisted`). `getState` is read at fire time:
85
+ `() => ({started, formId, stepId, sessionId})`. Returns a teardown.
86
+
87
+ ## Emitter primitive (`core/events/emitter`)
88
+
89
+ - **`createEmitter<TEventMap>(): Emitter<TEventMap>`** — the typed, instance-scoped emitter the
90
+ engine's `on()` is built on. `on(event, handler) => Unsubscribe`; `emit(event, payload)`.
91
+ Handler exceptions are isolated per handler — a throwing host callback never breaks the engine
92
+ or sibling handlers. No module-level singletons: multi-form pages get independent emitters.
93
+ - **`Unsubscribe`** = `() => void`.