@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 — identity, attribution, journey
2
+
3
+ Exhaustive reference for the `core/identity` domain. Map of everything: [`../api.md`](../api.md).
4
+
5
+ ## Fingerprint (`core/identity/browserFingerprint`)
6
+
7
+ - **`getBrowserFingerprint(environmentId): Promise<Fingerprint>`** — the canonical Surface
8
+ fingerprint. The serialization (field insertion order, value shapes, falsy fallbacks) IS the
9
+ wire contract: the lead pipeline keys on `SHA-256(JSON.stringify(fields))`, so never derive
10
+ your own variant — a different serialization splits one visitor into two leads.
11
+ - **`getHash(input): Promise<string>`** — SHA-256 hex via Web Crypto.
12
+
13
+ ```ts
14
+ type Fingerprint = {
15
+ deviceType: "Mobile" | "Desktop";
16
+ screen: { width; height; colorDepth };
17
+ userAgent: string;
18
+ browser: Array<{ brand; version }>;
19
+ os: string;
20
+ language: string;
21
+ plugins?: string[]; // absent (never null/empty) when navigator.plugins is null
22
+ timezone: string;
23
+ environmentId: string;
24
+ id: string; // SHA-256 hex of the serialized fields above
25
+ };
26
+ ```
27
+
28
+ ## Identify (`core/identity/identify`)
29
+
30
+ - **`buildIdentifyPayload({environmentId, fingerprintId, pageUrl, source?, formId?, leadId?, sessionIdFromParams?})`**
31
+ — native's URL normalization (trailing slashes stripped from href and pathname), `source`
32
+ defaults `"sdkForm"`.
33
+ - **`identifyLead(transport, payload, opts?): Promise<IdentifyResult | null>`** —
34
+ `POST /api/v1/lead/identify`, keepalive, `data.data` unwrap, **null on ANY failure** (identity
35
+ is best-effort; writes still happen unattributed).
36
+
37
+ ```ts
38
+ type LeadSource =
39
+ | "surfaceForm"
40
+ | "htmlForm"
41
+ | "websiteDeAnon"
42
+ | "webhook"
43
+ | "website"
44
+ | "imported"
45
+ | "scheduler"
46
+ | "linkedin"
47
+ | "sdkForm";
48
+ type IdentifyPayload = {
49
+ environmentId;
50
+ source: LeadSource;
51
+ sourceURL;
52
+ sourceURLDomain;
53
+ sourceURLPath;
54
+ sourceUrlSearchParams;
55
+ leadId?: string | null;
56
+ formId?;
57
+ fingerprint?;
58
+ sessionIdFromParams?: string | null;
59
+ referrer?: string | null;
60
+ };
61
+ type IdentifyResult = {
62
+ leadId;
63
+ sessionId;
64
+ existing: boolean;
65
+ leadHasExistingBookingAttr: boolean;
66
+ leadHasExistingEmailAttr: boolean;
67
+ };
68
+ ```
69
+
70
+ ## Lead-data cache (`core/identity/leadDataCache`)
71
+
72
+ - **`readLeadData(): CachedLeadData | null`** / **`writeLeadData(data)`** — TTL-stamped
73
+ localStorage cache under **`LEAD_DATA_STORAGE_KEY`** = `"surfaceLeadData"`, TTL
74
+ **`LEAD_DATA_TTL_MS`** = 10 min; expired entries are removed on read. **Byte-compatible with
75
+ the public Surface tag's entry** — on a page running both, whichever identified first wins and
76
+ both agree on the visitor. SSR reads as a miss; storage exceptions are swallowed (cache loss
77
+ only costs an extra identify).
78
+
79
+ ```ts
80
+ type CachedLeadData = {
81
+ leadId: string | null;
82
+ leadSessionId: string | null;
83
+ fingerprint: string;
84
+ landingPageUrl?: string;
85
+ expiry?: number;
86
+ };
87
+ ```
88
+
89
+ ## Boot identity step (`core/identity/ensureIdentified`)
90
+
91
+ - **`ensureIdentified({transport, environmentId, formId?, pageUrl?, source?, preview?})`** — the
92
+ init sequence's identity resolution, run BEFORE any response write (attribute sync silently
93
+ no-ops for responses whose lead was never identified): a fresh cache entry with session +
94
+ fingerprint skips the network; otherwise computes the fingerprint, carries cached
95
+ leadId/session forward so the server preserves identity, calls identify, caches the result.
96
+ `preview: true` blocks the call outright. Concurrent calls may each identify — safe, the
97
+ server find-or-creates by fingerprint.
98
+
99
+ ## Page-view journey (`core/identity/journey`)
100
+
101
+ - **`createJourneyTracker({transport, environmentId}): JourneyTracker`** — the `journey: true`
102
+ module, tag-parity: cookies **`JOURNEY_COOKIE_NAME`** = `"surface_journey_id"` (60 d, eTLD+1)
103
+ and **`RECENT_VISIT_COOKIE_NAME`** = `"surface_recent_visit"` (1 d); duplicate-URL skip;
104
+ delivery split — beacon when the journey id is known, fetch when it isn't so the server-issued
105
+ id can be captured (`POST /api/v1/lead/track`).
106
+
107
+ ```ts
108
+ type JourneyTracker = {
109
+ trackPageView: (url?) => Promise<void>;
110
+ observeRouteChanges: () => () => void; // patches history + popstate; teardown restores originals
111
+ clear: () => void; // deletes both cookies (native's clear-on-first-step hook)
112
+ };
113
+ ```
114
+
115
+ Journey→response stitching (UTM backfill) is server-side; the client only delivers page views
116
+ under a stable id.
package/llms.txt ADDED
@@ -0,0 +1,317 @@
1
+ # Surface Forms SDK
2
+
3
+ > Thin-client SDK for Surface Forms. The form record on Surface is the data model
4
+ > (steps, questions, validation, tracking settings); your HTML is presentation only
5
+ > and you host it yourself — Surface never stores or serves it. This file is the
6
+ > binding contract and boot sequence for agents authoring SDK form pages. The
7
+ > package also ships `skills/surface-forms-sdk/SKILL.md` — the full authoring
8
+ > workflow with a starter template; read it before your first form — and
9
+ > `docs/api.md`, the complete public API reference (every export with
10
+ > semantics). Anything not covered here is covered there; never read the
11
+ > bundle to answer an API question.
12
+
13
+ Import from the package name you actually installed (check your package.json —
14
+ code samples below say `@parag.more.withsurface.com/forms-sdk`, substitute yours). `init` uses
15
+ top-level `await`-friendly ESM; bundlers need `target: "esnext"` (Vite:
16
+ `build.target`) or an async wrapper.
17
+
18
+ ## Boot
19
+
20
+ ```html
21
+ <script src="surface-forms-sdk.min.js"></script>
22
+ <script>
23
+ SurfaceFormsSDK.SurfaceForms.init({
24
+ environmentId: "env_…",
25
+ formId: "form_…",
26
+ apiBaseUrl: "https://…", // REQUIRED unless the environment is on the default host
27
+ container: document.querySelector("#my-form"),
28
+ });
29
+ </script>
30
+ ```
31
+
32
+ ESM: `import { SurfaceForms } from "@parag.more.withsurface.com/forms-sdk"`. `init` is async and returns
33
+ the form handle. When `steps` is omitted (the normal case) it fetches the record
34
+ from `GET {apiBaseUrl}/api/v1/public/forms/{formId}/runtime-config` and derives
35
+ the step order, expected questionIds, and behavioral settings; caller-provided
36
+ values win.
37
+
38
+ **`apiBaseUrl` is the #1 boot failure.** The default is
39
+ `https://forms.withsurface.com`; any environment on another deployment 404s
40
+ until you pass its host. The `get_sdk_binding_map` MCP tool returns the correct
41
+ `apiBaseUrl` and a ready-to-paste `init` snippet that includes it — use that
42
+ snippet verbatim. A failed fetch throws with the exact URL it tried.
43
+
44
+ Options beyond the four above: `preview: true` (no network writes at all — identify,
45
+ responses, events, journey all blocked; pass `previewToken` from the `create_form`
46
+ or `get_sdk_binding_map` reply alongside it and a never-published form serves its
47
+ DRAFT config, so you can preview before the first publish), `debug: true`
48
+ (logs every captured answer + lifecycle event and warns on states the server
49
+ would reject AND on inert configuration like a declared enrichment source with
50
+ no enabled provider; development only), `journey: true` (page-view tracking),
51
+ `urlDataStateId` (attribution pseudo-answer), `emailValidation: true`
52
+ (automatic verdicts, see below), `resume`, `meta`, `source`.
53
+
54
+ Boot-flash tip: mark every `data-step-id` container after the first `hidden`
55
+ in your HTML — `bindForm` sets visibility only after the async `init`
56
+ resolves, so unhidden steps flash stacked during boot.
57
+
58
+ ### Endpoints the SDK calls (all relative to `apiBaseUrl`)
59
+
60
+ `GET /api/v1/public/forms/{formId}/runtime-config` (record; `?preview=1&pt=` for drafts) ·
61
+ `POST /api/v1/lead/identify` · `POST /api/v1/responses/` (create) ·
62
+ `POST /api/v1/responses/update` · `GET /api/v1/responses/getResponse?rid=&rt=` (resume) ·
63
+ `POST /api/v1/edge/emailValidation` · `POST /api/v1/services/enrichment/realtime` ·
64
+ `POST /api/v1/pushEvent` · `POST /api/v1/form-journey/track` · `POST /api/v1/lead/track` (journey) ·
65
+ `POST /api/v1/check-password` · `/api/v1/scheduler/*` (availability, slots, book, reschedule).
66
+
67
+ ## Binding contract (the `data-*` vocabulary)
68
+
69
+ Get the exact ids for a form from the `get_sdk_binding_map` MCP tool — each
70
+ question's `answerShape` there is the exact state object its answer must have.
71
+ Then:
72
+
73
+ - `data-question-id="<questionId>"` — on the input itself or a wrapper; every
74
+ `input`/`select`/`textarea` inside binds. Bind every stateful questionId the
75
+ record declares; unbound ids warn in the console at runtime. EXCEPTION:
76
+ scheduler (`CalendlyScreen`/`DynamicScheduler`) and `FileUploader` questions
77
+ need NO binding — their answers are written in code via `setAnswer`
78
+ (`validate_form_html` knows this and only notes them).
79
+ - `data-field-name="<key>"` — which key of the component's answer object this
80
+ control writes. Resolved PER CONTROL: wrapper attr ?? control attr ?? inferred
81
+ (`"selected"` for a Dropdown's `<select>`, `"email"` for `input[type=email]`,
82
+ else `"input"`). Inference only works when the control type matches the
83
+ component (an email input bound to a ShortInput needs explicit
84
+ `data-field-name="input"`); set it explicitly on each control of a
85
+ multi-field component.
86
+ - `data-question-type="<ComponentType>"` — the record's component type, from the
87
+ binding map. Inferred only for ShortInput (default) and EmailForm (email
88
+ inputs); every other type must be set or the answer carries the wrong type.
89
+ - `data-step-id="<stepId>"` — one container per record step. Multi-step records
90
+ are stepped-only: every step needs a container; the SDK shows exactly one at a
91
+ time (`hidden` on the rest). Single-step records may omit containers.
92
+ - Checkboxes submit `checked` (boolean); radios are handled by the choice-group
93
+ rules below.
94
+
95
+ ### Choice questions bind as a GROUP
96
+
97
+ Five types are group-built — `MultipleOptionsQuestion`, `MultipleChoiceQuestion`,
98
+ `MMChoiceQuestion`, `GraphicOptions`, and `MultipleChoiceSingleAnswer`. Their
99
+ answers are structured (the record's whole option list / a selection object,
100
+ and their shapes differ — only `MultipleOptionsQuestion` carries an option
101
+ `index`; trust each question's `answerShape` from the binding map, never a
102
+ remembered shape), so the SDK builds them from the container's radio/checkbox
103
+ group automatically — never set `data-field-name` on the options:
104
+
105
+ ```html
106
+ <div data-question-id="q_role" data-question-type="MultipleOptionsQuestion">
107
+ <label><input type="radio" name="role" value="Sales Ops" /> Sales Ops</label>
108
+ <label><input type="radio" name="role" value="Leadership" /> Leadership</label>
109
+ </div>
110
+ ```
111
+
112
+ Rules: `data-question-type` is REQUIRED on the container (without it the SDK
113
+ falls back to per-control string capture and the server rejects the answer);
114
+ each control's `value` must equal the record option key exactly; author the
115
+ controls in record order; multi-select uses checkboxes the same way. Dropdowns
116
+ bind a `<select>` whose option values are the record option keys (state key
117
+ `"selected"`).
118
+
119
+ ## Navigation (you own routing)
120
+
121
+ The SDK does not evaluate routing logic — your page decides where visitors go.
122
+ Markers, most specific wins, ONE per element:
123
+
124
+ - `data-surface-goto-step="<stepId>"` — jump to a step. Forward jumps save
125
+ progress; jumping to an already-visited step rewinds without a write.
126
+ - `class="surface-disqualify-button"` — end the form as disqualified.
127
+ - `class="surface-submit-button"` — finish and submit.
128
+ - `class="surface-next-button"` — advance to the next step in record order.
129
+
130
+ A `<form>` container's native submit also finishes. Conditional branching =
131
+ your JS choosing which goto button to show, or calling the API below. Driving
132
+ navigation/submit entirely from JS (async gates like email validation)?
133
+ Put `data-surface-nav="js"` on any element — it tells `validate_form_html`
134
+ the page submits in code, silencing its advance/submit warnings.
135
+
136
+ Finishing reveals the record's matching ending step: after the terminal write,
137
+ `submit()` moves to the step with `endStepKind: "thank_you"` and `disqualify()`
138
+ to the `"disqualified"` one (when the record declares them), so its
139
+ `data-step-id` container shows automatically. Put the submit button on the last
140
+ question step, not on the thank-you step.
141
+
142
+ ## Headless API
143
+
144
+ The handle from `init` (and the engine under `useSurfaceForm`) exposes:
145
+ `setAnswer(questionId, state)`, `next()`, `back()`, `goToStep(stepId)`,
146
+ `submit()`, `disqualify()`, `savePartial()`, `flush()`, `markViewed()`,
147
+ `state()`, `subscribe(cb)`, `on(event, cb)`, `identify()` (re-attribute after
148
+ SPA route change), `validateEmail(email)`, `mergeMeta(partial)`,
149
+ `capabilities`, `runtimeConfig`, `destroy()`.
150
+
151
+ `on` events: `viewed`, `started`, `stepChanged`, `stepCompleted`, `completed`,
152
+ `disqualified`, `saved` (carries `responseId` + `resumeToken`), `error`. Every
153
+ `on`/`subscribe` call returns an unsubscribe function.
154
+
155
+ Delivery guarantees: the FINISHING write is awaited — when `submit()` resolves
156
+ (and `saved` fires with `finished: true`) the server has the finished response;
157
+ a failure emits `error` instead of lying. Mid-form partial saves are
158
+ fire-and-forget beacons reconciled server-side. `await form.flush()` before
159
+ tearing the page down programmatically (SPA unmount, test harness closing the
160
+ browser) so no queued write is cut off.
161
+
162
+ `mergeMeta(partial)` records caller-computed values (a qualification score, an
163
+ experiment bucket) onto the response `meta` — they ride the next save, or amend
164
+ the stored response if the form already finished. SDK-owned keys
165
+ (`surfaceTagStatus`, the email-validation verdicts) are dropped with a warning.
166
+
167
+ `capabilities` reports what actually runs in THIS environment:
168
+ `{ enrichment, emailValidation, tracking }`. Check `capabilities.enrichment`
169
+ before promising enriched lead data — a record can declare an enrichment source
170
+ that the environment backs with no provider (nothing else surfaces that).
171
+
172
+ ## Resume
173
+
174
+ The first save returns a `resumeToken` alongside the `responseId` (both on the
175
+ `saved` event and in `state()`). Persist the pair (e.g. localStorage or a
176
+ `?rid=&rt=` link) and pass it back to continue the same response later:
177
+
178
+ ```js
179
+ SurfaceForms.init({ ..., resume: { responseId, resumeToken } });
180
+ ```
181
+
182
+ Boot fetches the stored answers through the token-gated read-back and seeds
183
+ the engine: writes become updates (no duplicate response), earlier answers are
184
+ preserved in every save, and `state().answers` holds them for your UI to
185
+ prefill. A failed fetch (wrong token, deleted response) warns and starts a
186
+ fresh session. The token proves the caller obtained the responseId
187
+ legitimately — never build resume links from ids alone. Resume is ignored in
188
+ preview.
189
+
190
+ React: `import { useSurfaceForm } from "@parag.more.withsurface.com/forms-sdk/react"` — a
191
+ `useSyncExternalStore` adapter over the same engine; render controlled inputs
192
+ from `state`, call the same actions.
193
+
194
+ Headless `setAnswer` calls should be annotated with the exported answer types
195
+ (`ShortInputAnswer`, `MultipleOptionsAnswer`, `IdentityInfoAnswer`,
196
+ `SchedulerAnswer`, … — union `KnownAnswerState`) so the compiler enforces the
197
+ wire shapes; during development, `debug: true` audits them at runtime too.
198
+
199
+ ## Email validation
200
+
201
+ `form.validateEmail(email)` resolves the server-side deliverability verdict
202
+ (provider waterfall + the environment's spam blacklist; keys stay
203
+ server-side): `{ isValid, status, reason, failed? }`. Verdicts are cached per
204
+ address for the form's lifetime and FAIL OPEN: when no validator can run, the
205
+ result is `{ isValid: true, failed: true, status: "unknown" }` — never lock a
206
+ visitor out on a vendor outage. Two consequences to design for: (1) check
207
+ `failed` if you want to soft-warn instead of hard-block; (2) the same address
208
+ can be accepted today (fail-open) and return a definitive `invalid` tomorrow —
209
+ that's the waterfall resolving, not a bug; don't write tests that assume a
210
+ fixed verdict for a fresh domain. Use it to drive your own navigation:
211
+
212
+ ```js
213
+ const { isValid, reason, failed } = await form.validateEmail(emailInput.value);
214
+ if (isValid) form.next();
215
+ else showError(`Email rejected: ${reason}`);
216
+ ```
217
+
218
+ Pass `emailValidation: true` to `init` for automatic mode: email answers are
219
+ validated as they're captured (debounced; per-address, so nothing is billed
220
+ twice) and the response carries the same `meta` verdict keys native forms
221
+ write (`isEmailValid`, `emailValidation`, `emailValidationFailed`) for
222
+ dashboards and workflows. Preview resolves synthetic valid verdicts with no
223
+ network. Offline callers wire it themselves: `createEmailValidator({
224
+ transport, environmentId })` and optionally `attachEmailValidation({ engine,
225
+ validator })`.
226
+
227
+ ## Real-time enrichment
228
+
229
+ When the form's environment has an enabled enrichment provider, `init` wires
230
+ it automatically for records fetched from runtime-config: a valid email (or
231
+ website domain) answered on an IdentityInfo `enrichmentSourceField` /
232
+ `websiteUrl` / EmailForm field triggers the form-scoped enrichment endpoint
233
+ (provider key stays server-side), and the result rides every save as
234
+ `enrichedFields` — becoming enriched lead attributes. Deduped per value;
235
+ never fires in preview. Nothing to configure page-side. **No enabled provider
236
+ = silently inert**: check `form.capabilities.enrichment` (and `debug: true`
237
+ warns) rather than assuming a declared source field means enrichment runs.
238
+ Customers who want to autofill their own inputs from the result can read
239
+ `state().externalStates.selfServeEnrichment.data` via `subscribe`. Offline
240
+ callers (caller-provided `steps`) wire it themselves: `attachEnrichment({
241
+ engine, transport, formId, sources })` with sources from
242
+ `enrichmentSourcesOf(config)`.
243
+
244
+ ## Scheduler booking (Surface schedulers)
245
+
246
+ A record step with a Surface scheduler (`CalendlyScreen` with
247
+ `calendarType: "Surface"`) renders through the SDK's own unstyled widget — do
248
+ NOT iframe the hosted booking page, and do NOT bind the question in HTML (the
249
+ `onComplete` below writes the answer):
250
+
251
+ ```js
252
+ import { createSchedulerClient, createTransport, mountBookingWidget, toSchedulerAnswerState } from "@parag.more.withsurface.com/forms-sdk";
253
+
254
+ const scheduler = createSchedulerClient({ transport: createTransport({ apiBaseUrl }), preview });
255
+ mountBookingWidget({
256
+ container: document.querySelector("#booking"),
257
+ scheduler,
258
+ eventTypeId: "<from the record's CalendlyScreen content>",
259
+ identity: { responseId: form.state().responseId ?? undefined },
260
+ prefill: { name, email }, // pre-fills the widget's name/email inputs
261
+ onComplete: (booking) => {
262
+ form.setAnswer("<schedulerQuestionId>", { ...toSchedulerAnswerState(booking), type: "CalendlyScreen" });
263
+ form.submit(); // or goToStep/next, per your flow
264
+ },
265
+ });
266
+ ```
267
+
268
+ The widget walks date → time → name/email form → booked. Labels are
269
+ human-readable by default (`Intl` in the visitor's locale; every month/date/slot
270
+ element also carries its raw value in `data-value` — `YYYY-MM`, `YYYY-MM-DD`,
271
+ ISO instant); pass `formatters: { month?, date?, time? }` to override the copy.
272
+ It injects no CSS — style it from your page. The rendered tree:
273
+
274
+ ```
275
+ div.surface-booking
276
+ ├─ p.surface-booking-month (or p.surface-booking-loading / p.surface-booking-error + button.surface-booking-retry)
277
+ ├─ div.surface-booking-month-nav > button.surface-booking-month-button ×2 (Previous/Next month)
278
+ └─ ul.surface-booking-dates > li.surface-booking-date > button.surface-booking-date-button
279
+ (li.surface-booking-no-dates when empty)
280
+ -- after a date is picked --
281
+ p.surface-booking-day · button.surface-booking-back
282
+ ul.surface-booking-slots > li.surface-booking-slot > button.surface-booking-slot-button
283
+ -- after a slot is picked --
284
+ form.surface-booking-form > input.surface-booking-name + input.surface-booking-email + button.surface-booking-submit
285
+ (p.surface-booking-error on failure)
286
+ -- booked --
287
+ p.surface-booking-booked
288
+ ```
289
+
290
+ `toSchedulerAnswerState` emits `type: "DynamicScheduler"`; override `type` to
291
+ the record's component type as shown. With `preview: true` the scheduler client
292
+ returns a synthetic booking, so the whole flow is testable without a calendar.
293
+
294
+ ## Authoring loop (MCP tools)
295
+
296
+ 1. `plan_form` with `sdk: true` when requirements are vague — routing stays
297
+ prose (your page owns it) and styling/layout questions are skipped.
298
+ 2. `create_form` with `sdk: true` — creates the record (no styling/routing params).
299
+ 3. `get_sdk_binding_map` — ids, field names, `answerShape` per question, the
300
+ `environmentId` AND `apiBaseUrl` for `init` (its snippet is ready to paste).
301
+ 4. Write the HTML in the user's project using this contract.
302
+ 5. `validate_form_html` — fix every blocker; take warnings seriously (they are
303
+ silent data loss at runtime). JS-driven navigation declares itself with
304
+ `data-surface-nav="js"`.
305
+ 6. `validate_form` — the record's own logic checks. (`simulate_response` does
306
+ not apply to SDK forms: it evaluates record routing, which SDK records
307
+ don't have.)
308
+ 7. Preview locally with `preview: true` + the `previewToken` from step 2 or 3
309
+ (works before publishing), then `publish_form`; deploy the page.
310
+ 8. Submit one test response, `await form.flush()` before closing the tab, then
311
+ read it back with `get_response`/`list_responses` and check every answer
312
+ landed.
313
+
314
+ Rules: labels live on the record — they default to each question's text and the
315
+ responses table reads them, so give every question real question text. Styling
316
+ and routing MCP tools refuse on SDK forms by design; presentation is entirely
317
+ yours.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@parag.more.withsurface.com/forms-sdk",
3
+ "version": "0.1.4",
4
+ "description": "Thin-client Surface Forms SDK: headless core over the public runtime endpoints (field-test build).",
5
+ "license": "MIT",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/types/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/types/index.d.ts",
13
+ "import": "./dist/index.mjs",
14
+ "require": "./dist/index.cjs"
15
+ },
16
+ "./react": {
17
+ "types": "./dist/types/react/index.d.ts",
18
+ "import": "./dist/react/index.mjs",
19
+ "require": "./dist/react/index.cjs"
20
+ }
21
+ },
22
+ "files": ["dist", "docs", "llms.txt", "README.md", "skills"],
23
+ "peerDependencies": { "react": ">=18" },
24
+ "peerDependenciesMeta": { "react": { "optional": true } }
25
+ }
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: surface-forms-sdk
3
+ description: Build a self-hosted Surface form with the Forms SDK — create the form record over the Surface MCP tools, author HTML against the data-* binding contract, validate, preview, publish, and verify the stored response. Use whenever the task is "build/embed a form with the Surface Forms SDK".
4
+ ---
5
+
6
+ # Building a Surface SDK form
7
+
8
+ You are building a **self-hosted** form: the form record on Surface is the data
9
+ model (steps, questions, validation, tracking); the page — HTML, CSS, routing
10
+ logic — is yours and never leaves the user's project. The SDK binds the two.
11
+
12
+ Read `llms.txt` in this package first (binding contract + full API). This skill
13
+ is the workflow and the traps that cost previous runs the most time. Deeper
14
+ references alongside this file — read the one that matches the job:
15
+
16
+ - [`patterns.md`](patterns.md) — concrete HTML shapes: boot, choice groups,
17
+ multi-step with branching, multi-field components, the scheduler widget, the
18
+ React/SPA adapter (destroy-on-unmount), and tracking setup + verification.
19
+ - [`editing.md`](editing.md) — changing an EXISTING SDK form: the record↔HTML
20
+ sync loop, `ifRevision` guards, and how to sequence the record publish vs the
21
+ HTML deploy so nothing lands answers in a divergence window.
22
+ - [`debugging.md`](debugging.md) — symptom → cause → check runbook (missing
23
+ responses/answers, 400s, verdict flakiness, silent tracking/enrichment).
24
+ - [`../../docs/api.md`](../../docs/api.md) — the complete public API reference:
25
+ every export (transport, engine, clients, identity, events, scheduler,
26
+ React) with semantics. Consult it for anything beyond the happy path —
27
+ never read the bundle.
28
+
29
+ ## Prerequisites
30
+
31
+ - The Surface MCP tools (`create_form`, `get_sdk_binding_map`,
32
+ `validate_form_html`, `publish_form`, `get_response`, …) connected for the
33
+ target environment.
34
+ - This npm package installed. Import from the name in the user's package.json —
35
+ docs may say `@parag.more.withsurface.com/forms-sdk`; the installed name wins.
36
+ - Bundler `target: "esnext"` (top-level await), or wrap `init` in an async fn.
37
+
38
+ ## The workflow
39
+
40
+ 1. **Plan (optional).** Vague requirements → `plan_form` with `sdk: true`.
41
+ Branching stays prose (`routingIntent`) — your page implements it; never
42
+ encode structured routing for an SDK form.
43
+ 2. **Create the record.** `create_form` with `sdk: true`. Give every question
44
+ real question text — the responses table reads those labels. Save the
45
+ `formId` and `previewToken` from the reply.
46
+ 3. **Get the binding map.** `get_sdk_binding_map` returns, per question:
47
+ `questionId`, `componentType`, `fieldNames`, and `answerShape` — the EXACT
48
+ object each answer must be. It also returns **`apiBaseUrl` and a
49
+ ready-to-paste `init` snippet that includes it. Use that snippet.** Booting
50
+ without the right `apiBaseUrl` is the #1 failure: the runtime-config fetch
51
+ 404s against the default host.
52
+ 4. **Author the page** against the template below.
53
+ 5. **Validate.** `validate_form_html` until clean. Scheduler/file questions
54
+ report as notes (no binding needed — code writes their answers). If your
55
+ navigation/submit is JS-driven, declare `data-surface-nav="js"` on any
56
+ element instead of adding fake buttons.
57
+ 6. **Preview before publishing.** `init({ preview: true, previewToken })`
58
+ serves the DRAFT record with zero network writes; the scheduler returns a
59
+ synthetic booking. Skip `simulate_response` — it evaluates record routing,
60
+ which SDK forms don't have.
61
+ 7. **Publish.** `publish_form`.
62
+ 8. **Verify end-to-end.** Submit one real test response; `await form.flush()`
63
+ before closing the page; read it back with `get_response` and confirm every
64
+ answer + `meta.surfaceTagStatus: "sdk"`.
65
+
66
+ ## Traps (each cost a previous field run real time)
67
+
68
+ - **`apiBaseUrl`** — always from `get_sdk_binding_map`; never assume the default.
69
+ - **Answer shapes** — copy `answerShape` per question; never guess. The five
70
+ choice types (`MultipleOptionsQuestion`, `MultipleChoiceQuestion`,
71
+ `MMChoiceQuestion`, `GraphicOptions`, `MultipleChoiceSingleAnswer`) have
72
+ DIFFERENT shapes; the SDK builds them from the radio/checkbox group — never
73
+ put `data-field-name` on choice options, always set `data-question-type` on
74
+ the group container, and make each control's `value` exactly the record's
75
+ option key.
76
+ - **IdentityInfo keys** are a closed set: `emailAddress`/`workEmailAddress`
77
+ (not `email`), `phoneNumber` (not `phone`). Multi-field components need an
78
+ explicit `data-field-name` per control.
79
+ - **Boot flash** — mark every step container after the first `hidden`; the SDK
80
+ reveals the current one after async init.
81
+ - **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.
88
+ - **Enrichment can be silently inert** — check `form.capabilities.enrichment`
89
+ before promising enriched fields; a declared source field without an enabled
90
+ provider does nothing (`debug: true` warns).
91
+ - **Custom values on the response** — `form.mergeMeta({ qualificationScore })`;
92
+ 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).
97
+ - **Business logic is invisible to Surface tooling.** Your qualification/
98
+ branching thresholds live in page code no validator can see — test them in a
99
+ real browser walk (both branches, plus a boundary case).
100
+
101
+ ## Starter template
102
+
103
+ ```html
104
+ <div id="form" data-surface-nav="js" hidden>
105
+ <section data-step-id="STEP_1_ID">
106
+ <label
107
+ >Work email <input type="email" data-question-id="Q_EMAIL_ID" />
108
+ <!-- EmailForm infers type+field -->
109
+ </label>
110
+ <p class="error" hidden></p>
111
+ <button type="button" id="to-step-2">Continue</button>
112
+ </section>
113
+
114
+ <section data-step-id="STEP_2_ID" hidden>
115
+ <div data-question-id="Q_ROLE_ID" data-question-type="MultipleOptionsQuestion">
116
+ <label><input type="radio" name="role" value="OPTION_KEY_1" /> OPTION_KEY_1</label>
117
+ <label><input type="radio" name="role" value="OPTION_KEY_2" /> OPTION_KEY_2</label>
118
+ </div>
119
+ <button type="button" class="surface-submit-button">Submit</button>
120
+ </section>
121
+
122
+ <section data-step-id="THANK_YOU_STEP_ID" hidden><h2>Thanks!</h2></section>
123
+ </div>
124
+ ```
125
+
126
+ ```js
127
+ import { SurfaceForms } from "PACKAGE_NAME";
128
+
129
+ const form = await SurfaceForms.init({
130
+ environmentId: "ENV_ID",
131
+ formId: "FORM_ID",
132
+ apiBaseUrl: "API_BASE_URL", // from get_sdk_binding_map — do not omit
133
+ container: document.querySelector("#form"),
134
+ emailValidation: true, // opt-in: writes native-parity verdict meta
135
+ });
136
+ document.querySelector("#form").hidden = false;
137
+
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
+ form.on("saved", ({ responseId, resumeToken }) =>
147
+ localStorage.setItem("surface-resume", JSON.stringify({ responseId, resumeToken }))
148
+ );
149
+ ```
150
+
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.