@parag.more.withsurface.com/forms-sdk 0.1.5 → 0.1.7

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 (107) hide show
  1. package/README.md +12 -6
  2. package/dist/index.cjs +1 -2736
  3. package/dist/index.mjs +1 -2713
  4. package/dist/react/index.cjs +1 -43
  5. package/dist/react/index.mjs +1 -20
  6. package/dist/surface-forms-sdk.min.js +1 -1
  7. package/dist/types/bind/bindForm.d.ts +2 -0
  8. package/dist/types/bind/bookingWidget.d.ts +29 -6
  9. package/dist/types/bind/scheduler/embedUtils.d.ts +51 -0
  10. package/dist/types/bind/scheduler/mountScheduler.d.ts +2 -0
  11. package/dist/types/bind/scheduler/providers/cal.d.ts +3 -0
  12. package/dist/types/bind/scheduler/providers/calendly.d.ts +3 -0
  13. package/dist/types/bind/scheduler/providers/chilical.d.ts +3 -0
  14. package/dist/types/bind/scheduler/providers/clari.d.ts +3 -0
  15. package/dist/types/bind/scheduler/providers/hubspot.d.ts +3 -0
  16. package/dist/types/bind/scheduler/providers/index.d.ts +4 -0
  17. package/dist/types/bind/scheduler/providers/reclaimai.d.ts +3 -0
  18. package/dist/types/bind/scheduler/providers/revenuehero.d.ts +3 -0
  19. package/dist/types/bind/scheduler/providers/savvycal.d.ts +3 -0
  20. package/dist/types/bind/scheduler/providers/surface.d.ts +3 -0
  21. package/dist/types/bind/scheduler/providers/zoom.d.ts +3 -0
  22. package/dist/types/bind/scheduler/styles.d.ts +3 -0
  23. package/dist/types/bind/scheduler/types.d.ts +65 -0
  24. package/dist/types/core/client/emailValidation.d.ts +3 -0
  25. package/dist/types/core/client/responses.d.ts +3 -0
  26. package/dist/types/core/client/runtimeConfig.d.ts +40 -0
  27. package/dist/types/core/client/scheduler.d.ts +4 -0
  28. package/dist/types/core/client/transport.d.ts +4 -0
  29. package/dist/types/core/engine/answerAudit.d.ts +3 -0
  30. package/dist/types/core/engine/answerStates.d.ts +4 -0
  31. package/dist/types/core/engine/formEngine.d.ts +18 -0
  32. package/dist/types/core/engine/redirect.d.ts +1 -0
  33. package/dist/types/core/engine/responsePayload.d.ts +4 -0
  34. package/dist/types/core/engine/schedulerEvents.d.ts +40 -0
  35. package/dist/types/core/engine/urlData.d.ts +2 -0
  36. package/dist/types/core/engine/urlPrefill.d.ts +1 -0
  37. package/dist/types/core/events/dropoffDetection.d.ts +1 -0
  38. package/dist/types/core/events/emitter.d.ts +1 -0
  39. package/dist/types/core/events/formJourneyTracker.d.ts +1 -0
  40. package/dist/types/core/events/pushEvents.d.ts +2 -0
  41. package/dist/types/core/events/startedDetection.d.ts +1 -0
  42. package/dist/types/core/events/viewDetection.d.ts +1 -0
  43. package/dist/types/core/identity/browserFingerprint.d.ts +3 -0
  44. package/dist/types/core/identity/ensureIdentified.d.ts +1 -0
  45. package/dist/types/core/identity/identify.d.ts +1 -0
  46. package/dist/types/core/identity/journey.d.ts +3 -0
  47. package/dist/types/core/identity/leadDataCache.d.ts +1 -0
  48. package/dist/types/enrichment/attachEnrichment.d.ts +1 -0
  49. package/dist/types/index.d.ts +5 -2
  50. package/dist/types/react/useSurfaceForm.d.ts +1 -0
  51. package/dist/types/surfaceForms.d.ts +28 -1
  52. package/dist/types/tracking/attachTracking.d.ts +1 -0
  53. package/dist/types/tracking/conversionRules.d.ts +3 -0
  54. package/dist/types/tracking/vendors.d.ts +3 -0
  55. package/docs/api.md +59 -43
  56. package/docs/reference/binding.md +86 -13
  57. package/docs/reference/clients.md +21 -1
  58. package/docs/reference/engine.md +14 -7
  59. package/docs/reference/events.md +4 -1
  60. package/llms.txt +95 -29
  61. package/package.json +1 -1
  62. package/skills/surface-forms-sdk/SKILL.md +66 -26
  63. package/skills/surface-forms-sdk/debugging.md +21 -19
  64. package/skills/surface-forms-sdk/patterns.md +71 -5
  65. package/dist/types/bind/bindForm.test.d.ts +0 -1
  66. package/dist/types/bind/bookingWidget.test.d.ts +0 -1
  67. package/dist/types/core/client/emailValidation.test.d.ts +0 -1
  68. package/dist/types/core/client/responses.test.d.ts +0 -1
  69. package/dist/types/core/client/runtimeConfig.contract.test.d.ts +0 -1
  70. package/dist/types/core/client/runtimeConfig.test.d.ts +0 -1
  71. package/dist/types/core/client/scheduler.contract.test.d.ts +0 -1
  72. package/dist/types/core/client/scheduler.test.d.ts +0 -1
  73. package/dist/types/core/client/transport.test.d.ts +0 -1
  74. package/dist/types/core/engine/answerAudit.test.d.ts +0 -1
  75. package/dist/types/core/engine/answerStates.contract.test.d.ts +0 -1
  76. package/dist/types/core/engine/formEngine.test.d.ts +0 -1
  77. package/dist/types/core/engine/redirect.test.d.ts +0 -1
  78. package/dist/types/core/engine/responsePayload.contract.test.d.ts +0 -1
  79. package/dist/types/core/engine/responsePayload.test.d.ts +0 -1
  80. package/dist/types/core/engine/urlData.contract.test.d.ts +0 -1
  81. package/dist/types/core/engine/urlData.test.d.ts +0 -1
  82. package/dist/types/core/engine/urlPrefill.test.d.ts +0 -1
  83. package/dist/types/core/events/dropoffDetection.test.d.ts +0 -1
  84. package/dist/types/core/events/emitter.test.d.ts +0 -1
  85. package/dist/types/core/events/formJourneyTracker.contract.test.d.ts +0 -1
  86. package/dist/types/core/events/formJourneyTracker.test.d.ts +0 -1
  87. package/dist/types/core/events/pushEvents.contract.test.d.ts +0 -1
  88. package/dist/types/core/events/pushEvents.test.d.ts +0 -1
  89. package/dist/types/core/events/startedDetection.test.d.ts +0 -1
  90. package/dist/types/core/events/viewDetection.test.d.ts +0 -1
  91. package/dist/types/core/identity/browserFingerprint.test.d.ts +0 -1
  92. package/dist/types/core/identity/ensureIdentified.test.d.ts +0 -1
  93. package/dist/types/core/identity/identify.contract.test.d.ts +0 -1
  94. package/dist/types/core/identity/identify.test.d.ts +0 -1
  95. package/dist/types/core/identity/journey.contract.test.d.ts +0 -1
  96. package/dist/types/core/identity/journey.test.d.ts +0 -1
  97. package/dist/types/core/identity/journey.testStubs.d.ts +0 -3
  98. package/dist/types/core/identity/leadDataCache.test.d.ts +0 -1
  99. package/dist/types/enrichment/attachEnrichment.test.d.ts +0 -1
  100. package/dist/types/react/ssr.test.d.ts +0 -1
  101. package/dist/types/react/useSurfaceForm.test.d.ts +0 -1
  102. package/dist/types/surfaceForms.test.d.ts +0 -1
  103. package/dist/types/tracking/attachTracking.test.d.ts +0 -1
  104. package/dist/types/tracking/conversionRules.contract.test.d.ts +0 -1
  105. package/dist/types/tracking/conversionRules.test.d.ts +0 -1
  106. package/dist/types/tracking/vendors.test.d.ts +0 -1
  107. package/dist/types/validation/attachEmailValidation.test.d.ts +0 -1
@@ -35,8 +35,30 @@ references alongside this file — read the one that matches the job:
35
35
  docs may say `@parag.more.withsurface.com/forms-sdk`; the installed name wins.
36
36
  - Bundler `target: "esnext"` (top-level await), or wrap `init` in an async fn.
37
37
 
38
+ ## Decisions to confirm with the form owner (before authoring)
39
+
40
+ The record can't express these and the SDK deliberately doesn't decide them —
41
+ ask, don't assume. One question each, with the default stated:
42
+
43
+ - **Invalid email address** — default: annotate only (`emailValidation: true`;
44
+ the visitor continues, the response carries `isEmailValid: false` for the
45
+ owner's workflows). Alternatives: soft-warn inline, block the step with a
46
+ message, or `disqualify()`. Never gate navigation on `validateEmail` unless
47
+ the owner picked block/disqualify.
48
+ - **Required fields** — the binding map's `required` is advisory: the SDK
49
+ never blocks a step, your page does (native forms block). Confirm which
50
+ fields block Continue and what the message says.
51
+ - **Selection limits** — `selectionLimit` on a multi-select is not enforced
52
+ by the SDK; enforce it in markup (disable extra checkboxes) if the owner
53
+ wants parity with the hosted form.
54
+
38
55
  ## The workflow
39
56
 
57
+ Binding to an EXISTING published record (the common integration case): skip
58
+ step 2, read the contract with `get_sdk_binding_map` (`source: "live"` when
59
+ you don't own the draft), then author → validate → deploy. Preview (6) needs
60
+ a draft you own; publish (7) only when you changed the record.
61
+
40
62
  1. **Plan (optional).** Vague requirements → `plan_form` with `sdk: true`.
41
63
  Branching stays prose (`routingIntent`) — your page implements it; never
42
64
  encode structured routing for an SDK form.
@@ -53,7 +75,13 @@ references alongside this file — read the one that matches the job:
53
75
  5. **Validate.** `validate_form_html` until clean. Scheduler/file questions
54
76
  report as notes (no binding needed — code writes their answers). If your
55
77
  navigation/submit is JS-driven, declare `data-surface-nav="js"` on any
56
- element instead of adding fake buttons.
78
+ element instead of adding fake buttons. For a JSX/React page the check is a
79
+ one-shot against transcribed markup — ALSO commit a drift test: extract
80
+ every `data-question-id` / `data-step-id` / choice `value` from the
81
+ component source (regex over the file is fine) and assert them against the
82
+ binding map's ids saved in your contract module. Binding drift is silent
83
+ data loss; only a committed test catches the refactor that renames a
84
+ question wrapper.
57
85
  6. **Preview before publishing.** `init({ preview: true, previewToken })`
58
86
  serves the DRAFT record with zero network writes; the scheduler returns a
59
87
  synthetic booking. Skip `simulate_response` — it evaluates record routing,
@@ -78,22 +106,39 @@ references alongside this file — read the one that matches the job:
78
106
  explicit `data-field-name` per control.
79
107
  - **Boot flash** — mark every step container after the first `hidden`; the SDK
80
108
  reveals the current one after async init.
109
+ - **Continue is instant** — `next()`/`goToStep()` switch the step immediately
110
+ and the partial save lands behind it; never disable the CTA or wait for
111
+ `saved` before showing the next step. Only `submit()`/`disqualify()` await
112
+ their write — disable THAT button until the promise resolves.
81
113
  - **Teardown** — the finishing write is awaited (`submit()` resolving means the
82
- server has it), but partial saves are beacons: `await form.flush()` before a
83
- programmatic page close or your test harness records a partial.
84
- - **Email validation fails open** `{ isValid: true, failed: true }` on vendor
85
- outage, and a fresh domain may resolve to a definitive verdict later. Check
86
- `failed` to soft-warn; don't write tests expecting stable verdicts for new
87
- domains.
114
+ server has it), but partial saves run in the background: `await form.flush()`
115
+ before a programmatic page close or your test harness records a partial. In
116
+ a React effect cleanup call `destroy()` alone a cleanup cannot await, and
117
+ queued saves survive an SPA unmount; `flush()` is for closing the whole
118
+ page/process.
119
+ - **Email validation never blocks by default** — `emailValidation: true`
120
+ annotates the response; an invalid address still continues. Gate with
121
+ `validateEmail` only when the owner chose block/disqualify (Decisions
122
+ above). Verdicts fail open (`{ isValid: true, failed: true }` on vendor
123
+ outage) and a fresh domain may resolve to a definitive verdict later —
124
+ check `failed` to soft-warn; don't write tests expecting stable verdicts.
88
125
  - **Enrichment can be silently inert** — check `form.capabilities.enrichment`
89
126
  before promising enriched fields; a declared source field without an enabled
90
127
  provider does nothing (`debug: true` warns).
91
128
  - **Custom values on the response** — `form.mergeMeta({ qualificationScore })`;
92
129
  reserved SDK keys are dropped. Don't invent external-state types.
93
- - **Scheduler** — mount the SDK's widget (never iframe the hosted page), write
94
- the answer in `onComplete` via `setAnswer({ ...toSchedulerAnswerState(b),
95
- type: "CalendlyScreen" })`. Labels are human-readable with raw values in
96
- `data-value`; style via the `surface-booking-*` classes (tree in llms.txt).
130
+ - **Scheduler** — default to ONE call: `mountScheduler({ form, container })`
131
+ renders the record's scheduler (any provider Surface widget default-styled,
132
+ or the third-party embed), tracks the lifecycle on `form.on("scheduler")`,
133
+ and persists the booking automatically. Never iframe the hosted booking
134
+ page. Build custom UI only when explicitly asked: `mountBookingWidget` +
135
+ `setAnswer({ ...toSchedulerAnswerState(b), type: "CalendlyScreen" })` in
136
+ `onComplete`. Traps: Clari emits no booking signal (embed-only);
137
+ Calendly/Zoom/Reclaim mounts create the response early (their embed URLs
138
+ carry the responseId); `styled: false` opts out of the injected scoped CSS.
139
+ The Surface widget opens on the first month with availability — a
140
+ persistent "No availability" means the event type really has none (host
141
+ schedule / max advance), not a wrong month.
97
142
  - **Business logic is invisible to Surface tooling.** Your qualification/
98
143
  branching thresholds live in page code no validator can see — test them in a
99
144
  real browser walk (both branches, plus a boundary case).
@@ -101,14 +146,13 @@ type: "CalendlyScreen" })`. Labels are human-readable with raw values in
101
146
  ## Starter template
102
147
 
103
148
  ```html
104
- <div id="form" data-surface-nav="js" hidden>
149
+ <div id="form" hidden>
105
150
  <section data-step-id="STEP_1_ID">
106
151
  <label
107
152
  >Work email <input type="email" data-question-id="Q_EMAIL_ID" />
108
153
  <!-- EmailForm infers type+field -->
109
154
  </label>
110
- <p class="error" hidden></p>
111
- <button type="button" id="to-step-2">Continue</button>
155
+ <button type="button" class="surface-next-button">Continue</button>
112
156
  </section>
113
157
 
114
158
  <section data-step-id="STEP_2_ID" hidden>
@@ -131,23 +175,19 @@ const form = await SurfaceForms.init({
131
175
  formId: "FORM_ID",
132
176
  apiBaseUrl: "API_BASE_URL", // from get_sdk_binding_map — do not omit
133
177
  container: document.querySelector("#form"),
134
- emailValidation: true, // opt-in: writes native-parity verdict meta
178
+ emailValidation: true, // default policy: annotate the response, never block
135
179
  });
136
180
  document.querySelector("#form").hidden = false;
137
181
 
138
- // JS-driven forward nav: gate on the email verdict, then branch yourself.
139
- document.querySelector("#to-step-2").addEventListener("click", async () => {
140
- const email = document.querySelector("[data-question-id=Q_EMAIL_ID]").value;
141
- const { isValid, reason } = await form.validateEmail(email);
142
- if (!isValid) return showError(`That address looks undeliverable (${reason}).`);
143
- form.next(); // or form.goToStep("…") / form.disqualify() per your rules
144
- });
145
-
146
182
  form.on("saved", ({ responseId, resumeToken }) =>
147
183
  localStorage.setItem("surface-resume", JSON.stringify({ responseId, resumeToken }))
148
184
  );
149
185
  ```
150
186
 
151
- Back navigation can stay markerless JS too (`form.back()`), or use the
152
- declarative markers (`surface-next-button`, `data-surface-goto-step`,
153
- `surface-disqualify-button`) when no async gate is needed.
187
+ Declarative markers (`surface-next-button`, `data-surface-goto-step`,
188
+ `surface-disqualify-button`, `surface-submit-button`) cover linear and static
189
+ branching. Owner-requested gates (block on an invalid email, required-field
190
+ checks) and dynamic branching are JS: call `form.next()` /
191
+ `form.goToStep(…)` / `form.disqualify()` from your own handler and put
192
+ `data-surface-nav="js"` on any element so `validate_form_html` knows —
193
+ snippets in `patterns.md` §Invalid-email policy.
@@ -29,25 +29,27 @@ that is the only end-to-end truth.
29
29
 
30
30
  ## Symptom table
31
31
 
32
- | Symptom | Likely cause | Check |
33
- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
34
- | `init` throws "could not fetch runtime config" | Wrong/missing `apiBaseUrl` — the error names the exact URL and host it tried | Use the `apiBaseUrl` from `get_sdk_binding_map`; curl the URL from the error |
35
- | No responses at all | `preview: true` left in the deployed page (blocks ALL writes) | Grep the page for `preview`; `saved` event never fires in preview |
36
- | No responses at all | Form never published — runtime-config serves the "not published yet" placeholder, so step/question ids don't match | Console shows unbound-questionId warnings; `publish_form` |
37
- | Some answers missing | Binding drift: questionId unbound or misspelled | Console warning "record declares questionIds with no bound element"; `validate_form_html` |
38
- | Some answers missing | Partial saves off and visitor abandoned mid-form | `trackPartialResponses` in settings; expected behavior |
39
- | Finished response stored as a partial | SDK < 0.1.3 sent the terminal write fire-and-forget; current SDK awaits it — remaining cause is tearing the page down mid-flight | Upgrade; `await form.submit()` then `await form.flush()` before any programmatic close |
40
- | 400 "Fields are missing or incorrectly formatted" | Hand-built `setAnswer` with the wrong shape (choice questions want structured lists, not label strings) | Compare the payload with the binding map's `answerShape`; prefer `data-*` binding, which builds choice answers itself |
41
- | Choice answers wrong/empty | Radio/checkbox `value` attrs drifted from the record's option keys | `validate_form_html` warns option-key drift; diff against `choiceKeys` |
42
- | Same email accepted one day, rejected the next | Fail-open verdict (`isValid: true, failed: true`) later resolving to a definitive one — expected waterfall behavior, not a bug | Check `failed` on the verdict; don't write tests expecting stable verdicts for fresh domains |
43
- | Thank-you never shows | Record has no `endStepKind: "thank_you"` step, or the HTML has no container for it | Binding map: does a step carry `endStepKind`? Submit reveals it automatically when it exists |
44
- | Disqualified visitors see the thank-you | No `endStepKind: "disqualified"` step; or page routes them manually to the wrong container | Same check; `disqualify()` only reveals a `disqualified` ending |
45
- | Steps don't switch | `data-step-id` doesn't match record stepIds, or goto target invalid | Console warns on invalid `goToStep`; `validate_form_html` blocks unknown step ids |
46
- | Duplicate responses per visitor | `init` called more than once without `destroy()` (SPA remounts) | One engine per page-life; call `form.destroy()` on teardown |
47
- | GTM/GA4/Meta events not firing | Preview mode (tracking is fully blocked in preview), or settings not set | Deployed page without preview; `window.dataLayer` after a step submit; settings via `update_form_settings` |
48
- | Conversions (ads rules) not firing | Rules gate on the trigger + URL conditions; terminal rules only fire on `completed`, never on disqualify; everything queues until the first `responseId` | Check rule trigger/conditions in the dashboard; watch for the `saved` event before expecting queued fires |
49
- | Enrichment fields empty | The environment has no ENABLED enrichment provider a record-declared `enrichmentSourceField` is then silently inert | `form.capabilities.enrichment` (false = inert); `debug: true` warns at boot; enable a provider in the dashboard |
50
- | Lead attribution missing | `identify` blocked (preview) or ad-blocked; re-identify after SPA route change | `form.identify()`; network tab `identify` call |
32
+ | Symptom | Likely cause | Check |
33
+ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
34
+ | `init` throws "could not fetch runtime config" | Wrong/missing `apiBaseUrl` — the error names the exact URL and host it tried | Use the `apiBaseUrl` from `get_sdk_binding_map`; curl the URL from the error |
35
+ | No responses at all | `preview: true` left in the deployed page (blocks ALL writes) | Grep the page for `preview`; `saved` event never fires in preview |
36
+ | No responses at all | Form never published — runtime-config serves the "not published yet" placeholder, so step/question ids don't match | Console shows unbound-questionId warnings; `publish_form` |
37
+ | Some answers missing | Binding drift: questionId unbound or misspelled | Console warning "record declares questionIds with no bound element"; `validate_form_html` |
38
+ | Some answers missing | Partial saves off and visitor abandoned mid-form | `trackPartialResponses` in settings; expected behavior |
39
+ | Finished response stored as a partial | SDK < 0.1.3 sent the terminal write fire-and-forget; current SDK awaits it — remaining cause is tearing the page down mid-flight | Upgrade; `await form.submit()` then `await form.flush()` before any programmatic close |
40
+ | 400 "Fields are missing or incorrectly formatted" | Hand-built `setAnswer` with the wrong shape (choice questions want structured lists, not label strings) | Compare the payload with the binding map's `answerShape`; prefer `data-*` binding, which builds choice answers itself |
41
+ | Choice answers wrong/empty | Radio/checkbox `value` attrs drifted from the record's option keys | `validate_form_html` warns option-key drift; diff against `choiceKeys` |
42
+ | Same email accepted one day, rejected the next | Fail-open verdict (`isValid: true, failed: true`) later resolving to a definitive one — expected waterfall behavior, not a bug | Check `failed` on the verdict; don't write tests expecting stable verdicts for fresh domains |
43
+ | Thank-you never shows | Record has no `endStepKind: "thank_you"` step, or the HTML has no container for it | Binding map: does a step carry `endStepKind`? Submit reveals it automatically when it exists |
44
+ | Disqualified visitors see the thank-you | No `endStepKind: "disqualified"` step; or page routes them manually to the wrong container | Same check; `disqualify()` only reveals a `disqualified` ending |
45
+ | Steps don't switch | `data-step-id` doesn't match record stepIds, or goto target invalid | Console warns on invalid `goToStep`; `validate_form_html` blocks unknown step ids |
46
+ | Continue looks dead, then the step switches later | Earlier SDK builds awaited the partial save before switching; the current SDK switches immediately and saves behind (`debug: true` logs `next requested`) | Upgrade; `next()` resolves on the step change, `saved` fires when the write lands |
47
+ | Scheduler says "No availability this month" | The event type has no bookable slots (host schedule, max advance, no connected host) the widget already opens on the first month the summary reports | `getAvailabilitySummary` `next_available_date`; the host's schedule in the dashboard; `preview: true` mocks slots |
48
+ | Duplicate responses per visitor | `init` called more than once without `destroy()` (SPA remounts) | One engine per page-life; call `form.destroy()` on teardown |
49
+ | GTM/GA4/Meta events not firing | Preview mode (tracking is fully blocked in preview), or settings not set | Deployed page without preview; `window.dataLayer` after a step submit; settings via `update_form_settings` |
50
+ | Conversions (ads rules) not firing | Rules gate on the trigger + URL conditions; terminal rules only fire on `completed`, never on disqualify; everything queues until the first `responseId` | Check rule trigger/conditions in the dashboard; watch for the `saved` event before expecting queued fires |
51
+ | Enrichment fields empty | The environment has no ENABLED enrichment provider — a record-declared `enrichmentSourceField` is then silently inert | `form.capabilities.enrichment` (false = inert); `debug: true` warns at boot; enable a provider in the dashboard |
52
+ | Lead attribution missing | `identify` blocked (preview) or ad-blocked; re-identify after SPA route change | `form.identify()`; network tab `identify` call |
51
53
 
52
54
  ## Reading a response back
53
55
 
@@ -104,6 +104,44 @@ Dynamic branching instead of static buttons: keep a handle from `init` and call
104
104
  code-driven. Full event list for your own listeners: `viewed`, `started`, `stepChanged`,
105
105
  `stepCompleted`, `completed`, `disqualified`, `saved`, `error`.
106
106
 
107
+ Timing: `next()`/`goToStep()` switch the step immediately — the partial save lands behind
108
+ them, so the visitor never waits on the network. `submit()`/`disqualify()` await the finishing
109
+ write before the ending shows; disable that button while the promise is pending:
110
+
111
+ ```js
112
+ submitButton.addEventListener("click", async () => {
113
+ submitButton.disabled = true;
114
+ await form.submit();
115
+ });
116
+ ```
117
+
118
+ ## Invalid-email policy (ask the owner — default is "annotate only")
119
+
120
+ The SDK never decides what an invalid address means for the visitor. Confirm the owner's
121
+ choice, then pick ONE shape:
122
+
123
+ ```js
124
+ // Default — annotate only: verdict meta rides the response; the visitor continues.
125
+ SurfaceForms.init({ ..., emailValidation: true });
126
+ // (declarative surface-next-button markup is enough — no JS gate)
127
+
128
+ // Block — keep the visitor on the step until the address is deliverable.
129
+ continueButton.addEventListener("click", async () => {
130
+ const { isValid, reason, failed } = await form.validateEmail(emailInput.value);
131
+ if (isValid || failed) return form.next(); // fail-open: never lock out on a vendor outage
132
+ errorEl.textContent = `That address looks undeliverable (${reason}).`;
133
+ });
134
+
135
+ // Disqualify — end the form on the "not a fit" ending.
136
+ continueButton.addEventListener("click", async () => {
137
+ const { isValid } = await form.validateEmail(emailInput.value);
138
+ isValid ? form.next() : form.disqualify();
139
+ });
140
+ ```
141
+
142
+ Block/disqualify pages carry `data-surface-nav="js"`. Both paths share one per-address cache
143
+ with automatic mode, so pairing a gate with `emailValidation: true` bills each address once.
144
+
107
145
  ## Multi-field component
108
146
 
109
147
  `data-field-name` resolves per control — set it on each input, not (only) the wrapper. `IdentityInfo` is the standard contact-details component; bind only the fields the record declares (its `fieldNames` in the binding map):
@@ -116,14 +154,41 @@ code-driven. Full event list for your own listeners: `viewed`, `started`, `stepC
116
154
  </div>
117
155
  ```
118
156
 
119
- ## Scheduler step (Surface scheduler)
157
+ ## Scheduler step
120
158
 
121
- A `CalendlyScreen` with `calendarType: "Surface"` renders through the SDK's own unstyled widget — never iframe the hosted booking page, and never bind the question in HTML (the `onComplete` writes the answer):
159
+ Default: one call. `mountScheduler` renders the record's scheduler question for ANY supported
160
+ provider (Surface's widget default-styled, or the record's third-party embed — Calendly,
161
+ Cal.com, SavvyCal, Chili Piper, HubSpot, RevenueHero, Zoom, Reclaim.ai, Clari), emits the
162
+ lifecycle on `form.on("scheduler")`, and persists the booking into the response by itself.
163
+ Never iframe the hosted booking page, and never bind the question in HTML:
122
164
 
123
165
  ```html
124
166
  <section data-step-id="step_book" hidden>
125
167
  <div id="booking"></div>
126
168
  </section>
169
+ <script>
170
+ const teardown = SurfaceFormsSDK.mountScheduler({
171
+ form,
172
+ container: document.querySelector("#booking"),
173
+ onEvent(e) {
174
+ if (e.type === "booking_confirmed") form.submit(); // or next()/goToStep, per your flow
175
+ },
176
+ });
177
+ </script>
178
+ ```
179
+
180
+ Note the booking itself needs no wiring — persistence is automatic (`persist: false` opts out);
181
+ `onEvent` here only drives navigation. `styled: false` skips the injected scoped stylesheet
182
+ (Surface provider). Clari is embed-only (no booking signal → drive navigation yourself).
183
+ In preview mode the Surface path books synthetically and nothing is written. The Surface widget
184
+ opens on the current month and, when that month has no slots, jumps to the first bookable date
185
+ the availability summary reports — "No availability this month" after that means the event type
186
+ has none.
187
+
188
+ Custom booking UI (only when asked): mount the unstyled widget directly and write the answer
189
+ yourself —
190
+
191
+ ```html
127
192
  <script>
128
193
  const { createSchedulerClient, createTransport, mountBookingWidget, toSchedulerAnswerState } =
129
194
  SurfaceFormsSDK;
@@ -144,8 +209,7 @@ A `CalendlyScreen` with `calendarType: "Surface"` renders through the SDK's own
144
209
  The widget emits semantic markup under stable `surface-booking-*` classes (full DOM tree in
145
210
  `llms.txt`) and injects no CSS — style from the page. Labels are locale-formatted by default
146
211
  with the raw value on each element's `data-value`; pass `formatters: { month?, date?, time? }`
147
- to override the copy. In preview mode the client returns a synthetic booking, so the flow is
148
- testable offline.
212
+ to override the copy.
149
213
 
150
214
  ## React adapter (SPA)
151
215
 
@@ -209,7 +273,9 @@ function QualifySteps({ form }: { form: SurfaceForm }) {
209
273
  }
210
274
  ```
211
275
 
212
- `state` is `useSyncExternalStore`-backed: referentially stable between engine mutations, safe to render from directly. `submit()`/`disqualify()` move `state.currentStepId` to the record's matching ending step, so the same step-switch renders the confirmation. On SPA route changes call `form.identify()` to re-attribute the visitor; on programmatic teardown after a submit flow, `await form.flush()` first.
276
+ `state` is `useSyncExternalStore`-backed: referentially stable between engine mutations, safe to render from directly. `state.answers` is keyed by stepId then questionId — the example reads the current step (`state.answers[state.currentStepId]`), but an EARLIER step's answer (booking prefill, a summary screen) is read by that step's id: `state.answers["step_role"]?.["q_role"]`. `submit()`/`disqualify()` move `state.currentStepId` to the record's matching ending step, so the same step-switch renders the confirmation. On SPA route changes call `form.identify()` to re-attribute the visitor.
277
+
278
+ React teardown rule: `destroy()` alone in the effect cleanup is correct — a cleanup cannot await, partial saves are beacons that survive unmount, and the finishing write was already awaited by `submit()` itself before the cleanup can run. Reserve `await form.flush()` for programmatic teardown where the process itself may die with a write still queued (a test harness closing the browser), not for ordinary SPA unmounts.
213
279
 
214
280
  ## Tracking setup
215
281
 
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1,3 +0,0 @@
1
- export declare const stubJourneyDom: ({ href }?: {
2
- href?: string;
3
- }) => Map<string, string>;
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};