@parag.more.withsurface.com/forms-sdk 0.1.6 → 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.
@@ -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.
@@ -84,15 +106,22 @@ references alongside this file — read the one that matches the job:
84
106
  explicit `data-field-name` per control.
85
107
  - **Boot flash** — mark every step container after the first `hidden`; the SDK
86
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.
87
113
  - **Teardown** — the finishing write is awaited (`submit()` resolving means the
88
- server has it), but partial saves are beacons: `await form.flush()` before a
89
- programmatic page close or your test harness records a partial. In a React
90
- effect cleanup call `destroy()` alone — a cleanup cannot await, and beacons
91
- survive an SPA unmount; `flush()` is for closing the whole page/process.
92
- - **Email validation fails open** — `{ isValid: true, failed: true }` on vendor
93
- outage, and a fresh domain may resolve to a definitive verdict later. Check
94
- `failed` to soft-warn; don't write tests expecting stable verdicts for new
95
- 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.
96
125
  - **Enrichment can be silently inert** — check `form.capabilities.enrichment`
97
126
  before promising enriched fields; a declared source field without an enabled
98
127
  provider does nothing (`debug: true` warns).
@@ -107,6 +136,9 @@ references alongside this file — read the one that matches the job:
107
136
  `onComplete`. Traps: Clari emits no booking signal (embed-only);
108
137
  Calendly/Zoom/Reclaim mounts create the response early (their embed URLs
109
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.
110
142
  - **Business logic is invisible to Surface tooling.** Your qualification/
111
143
  branching thresholds live in page code no validator can see — test them in a
112
144
  real browser walk (both branches, plus a boundary case).
@@ -114,14 +146,13 @@ references alongside this file — read the one that matches the job:
114
146
  ## Starter template
115
147
 
116
148
  ```html
117
- <div id="form" data-surface-nav="js" hidden>
149
+ <div id="form" hidden>
118
150
  <section data-step-id="STEP_1_ID">
119
151
  <label
120
152
  >Work email <input type="email" data-question-id="Q_EMAIL_ID" />
121
153
  <!-- EmailForm infers type+field -->
122
154
  </label>
123
- <p class="error" hidden></p>
124
- <button type="button" id="to-step-2">Continue</button>
155
+ <button type="button" class="surface-next-button">Continue</button>
125
156
  </section>
126
157
 
127
158
  <section data-step-id="STEP_2_ID" hidden>
@@ -144,23 +175,19 @@ const form = await SurfaceForms.init({
144
175
  formId: "FORM_ID",
145
176
  apiBaseUrl: "API_BASE_URL", // from get_sdk_binding_map — do not omit
146
177
  container: document.querySelector("#form"),
147
- emailValidation: true, // opt-in: writes native-parity verdict meta
178
+ emailValidation: true, // default policy: annotate the response, never block
148
179
  });
149
180
  document.querySelector("#form").hidden = false;
150
181
 
151
- // JS-driven forward nav: gate on the email verdict, then branch yourself.
152
- document.querySelector("#to-step-2").addEventListener("click", async () => {
153
- const email = document.querySelector("[data-question-id=Q_EMAIL_ID]").value;
154
- const { isValid, reason } = await form.validateEmail(email);
155
- if (!isValid) return showError(`That address looks undeliverable (${reason}).`);
156
- form.next(); // or form.goToStep("…") / form.disqualify() per your rules
157
- });
158
-
159
182
  form.on("saved", ({ responseId, resumeToken }) =>
160
183
  localStorage.setItem("surface-resume", JSON.stringify({ responseId, resumeToken }))
161
184
  );
162
185
  ```
163
186
 
164
- Back navigation can stay markerless JS too (`form.back()`), or use the
165
- declarative markers (`surface-next-button`, `data-surface-goto-step`,
166
- `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):
@@ -142,7 +180,10 @@ Never iframe the hosted booking page, and never bind the question in HTML:
142
180
  Note the booking itself needs no wiring — persistence is automatic (`persist: false` opts out);
143
181
  `onEvent` here only drives navigation. `styled: false` skips the injected scoped stylesheet
144
182
  (Surface provider). Clari is embed-only (no booking signal → drive navigation yourself).
145
- In preview mode the Surface path books synthetically and nothing is written.
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.
146
187
 
147
188
  Custom booking UI (only when asked): mount the unstyled widget directly and write the answer
148
189
  yourself —
@@ -1,4 +0,0 @@
1
-
2
- export declare const stubJourneyDom: ({ href }?: {
3
- href?: string;
4
- }) => Map<string, string>;