@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.
- package/README.md +63 -0
- package/dist/index.cjs +2737 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.mjs +2714 -0
- package/dist/index.mjs.map +7 -0
- package/dist/react/index.cjs +44 -0
- package/dist/react/index.cjs.map +7 -0
- package/dist/react/index.mjs +21 -0
- package/dist/react/index.mjs.map +7 -0
- package/dist/surface-forms-sdk.min.js +2 -0
- package/dist/surface-forms-sdk.min.js.map +7 -0
- package/dist/types/bind/bindForm.d.ts +43 -0
- package/dist/types/bind/bindForm.d.ts.map +1 -0
- package/dist/types/bind/bindForm.test.d.ts +2 -0
- package/dist/types/bind/bindForm.test.d.ts.map +1 -0
- package/dist/types/bind/bookingWidget.d.ts +35 -0
- package/dist/types/bind/bookingWidget.d.ts.map +1 -0
- package/dist/types/bind/bookingWidget.test.d.ts +2 -0
- package/dist/types/bind/bookingWidget.test.d.ts.map +1 -0
- package/dist/types/core/client/emailValidation.d.ts +23 -0
- package/dist/types/core/client/emailValidation.d.ts.map +1 -0
- package/dist/types/core/client/emailValidation.test.d.ts +2 -0
- package/dist/types/core/client/emailValidation.test.d.ts.map +1 -0
- package/dist/types/core/client/responses.d.ts +35 -0
- package/dist/types/core/client/responses.d.ts.map +1 -0
- package/dist/types/core/client/responses.test.d.ts +2 -0
- package/dist/types/core/client/responses.test.d.ts.map +1 -0
- package/dist/types/core/client/runtimeConfig.contract.test.d.ts +2 -0
- package/dist/types/core/client/runtimeConfig.contract.test.d.ts.map +1 -0
- package/dist/types/core/client/runtimeConfig.d.ts +72 -0
- package/dist/types/core/client/runtimeConfig.d.ts.map +1 -0
- package/dist/types/core/client/runtimeConfig.test.d.ts +2 -0
- package/dist/types/core/client/runtimeConfig.test.d.ts.map +1 -0
- package/dist/types/core/client/scheduler.contract.test.d.ts +2 -0
- package/dist/types/core/client/scheduler.contract.test.d.ts.map +1 -0
- package/dist/types/core/client/scheduler.d.ts +121 -0
- package/dist/types/core/client/scheduler.d.ts.map +1 -0
- package/dist/types/core/client/scheduler.test.d.ts +2 -0
- package/dist/types/core/client/scheduler.test.d.ts.map +1 -0
- package/dist/types/core/client/transport.d.ts +56 -0
- package/dist/types/core/client/transport.d.ts.map +1 -0
- package/dist/types/core/client/transport.test.d.ts +2 -0
- package/dist/types/core/client/transport.test.d.ts.map +1 -0
- package/dist/types/core/engine/answerAudit.d.ts +21 -0
- package/dist/types/core/engine/answerAudit.d.ts.map +1 -0
- package/dist/types/core/engine/answerAudit.test.d.ts +2 -0
- package/dist/types/core/engine/answerAudit.test.d.ts.map +1 -0
- package/dist/types/core/engine/answerStates.contract.test.d.ts +2 -0
- package/dist/types/core/engine/answerStates.contract.test.d.ts.map +1 -0
- package/dist/types/core/engine/answerStates.d.ts +92 -0
- package/dist/types/core/engine/answerStates.d.ts.map +1 -0
- package/dist/types/core/engine/formEngine.d.ts +145 -0
- package/dist/types/core/engine/formEngine.d.ts.map +1 -0
- package/dist/types/core/engine/formEngine.test.d.ts +2 -0
- package/dist/types/core/engine/formEngine.test.d.ts.map +1 -0
- package/dist/types/core/engine/redirect.d.ts +36 -0
- package/dist/types/core/engine/redirect.d.ts.map +1 -0
- package/dist/types/core/engine/redirect.test.d.ts +2 -0
- package/dist/types/core/engine/redirect.test.d.ts.map +1 -0
- package/dist/types/core/engine/responsePayload.contract.test.d.ts +2 -0
- package/dist/types/core/engine/responsePayload.contract.test.d.ts.map +1 -0
- package/dist/types/core/engine/responsePayload.d.ts +74 -0
- package/dist/types/core/engine/responsePayload.d.ts.map +1 -0
- package/dist/types/core/engine/responsePayload.test.d.ts +2 -0
- package/dist/types/core/engine/responsePayload.test.d.ts.map +1 -0
- package/dist/types/core/engine/urlData.contract.test.d.ts +2 -0
- package/dist/types/core/engine/urlData.contract.test.d.ts.map +1 -0
- package/dist/types/core/engine/urlData.d.ts +30 -0
- package/dist/types/core/engine/urlData.d.ts.map +1 -0
- package/dist/types/core/engine/urlData.test.d.ts +2 -0
- package/dist/types/core/engine/urlData.test.d.ts.map +1 -0
- package/dist/types/core/engine/urlPrefill.d.ts +21 -0
- package/dist/types/core/engine/urlPrefill.d.ts.map +1 -0
- package/dist/types/core/engine/urlPrefill.test.d.ts +2 -0
- package/dist/types/core/engine/urlPrefill.test.d.ts.map +1 -0
- package/dist/types/core/events/dropoffDetection.d.ts +21 -0
- package/dist/types/core/events/dropoffDetection.d.ts.map +1 -0
- package/dist/types/core/events/dropoffDetection.test.d.ts +2 -0
- package/dist/types/core/events/dropoffDetection.test.d.ts.map +1 -0
- package/dist/types/core/events/emitter.d.ts +23 -0
- package/dist/types/core/events/emitter.d.ts.map +1 -0
- package/dist/types/core/events/emitter.test.d.ts +2 -0
- package/dist/types/core/events/emitter.test.d.ts.map +1 -0
- package/dist/types/core/events/formJourneyTracker.contract.test.d.ts +2 -0
- package/dist/types/core/events/formJourneyTracker.contract.test.d.ts.map +1 -0
- package/dist/types/core/events/formJourneyTracker.d.ts +69 -0
- package/dist/types/core/events/formJourneyTracker.d.ts.map +1 -0
- package/dist/types/core/events/formJourneyTracker.test.d.ts +2 -0
- package/dist/types/core/events/formJourneyTracker.test.d.ts.map +1 -0
- package/dist/types/core/events/pushEvents.contract.test.d.ts +2 -0
- package/dist/types/core/events/pushEvents.contract.test.d.ts.map +1 -0
- package/dist/types/core/events/pushEvents.d.ts +62 -0
- package/dist/types/core/events/pushEvents.d.ts.map +1 -0
- package/dist/types/core/events/pushEvents.test.d.ts +2 -0
- package/dist/types/core/events/pushEvents.test.d.ts.map +1 -0
- package/dist/types/core/events/startedDetection.d.ts +11 -0
- package/dist/types/core/events/startedDetection.d.ts.map +1 -0
- package/dist/types/core/events/startedDetection.test.d.ts +2 -0
- package/dist/types/core/events/startedDetection.test.d.ts.map +1 -0
- package/dist/types/core/events/viewDetection.d.ts +15 -0
- package/dist/types/core/events/viewDetection.d.ts.map +1 -0
- package/dist/types/core/events/viewDetection.test.d.ts +2 -0
- package/dist/types/core/events/viewDetection.test.d.ts.map +1 -0
- package/dist/types/core/identity/browserFingerprint.d.ts +40 -0
- package/dist/types/core/identity/browserFingerprint.d.ts.map +1 -0
- package/dist/types/core/identity/browserFingerprint.test.d.ts +2 -0
- package/dist/types/core/identity/browserFingerprint.test.d.ts.map +1 -0
- package/dist/types/core/identity/ensureIdentified.d.ts +27 -0
- package/dist/types/core/identity/ensureIdentified.d.ts.map +1 -0
- package/dist/types/core/identity/ensureIdentified.test.d.ts +2 -0
- package/dist/types/core/identity/ensureIdentified.test.d.ts.map +1 -0
- package/dist/types/core/identity/identify.contract.test.d.ts +2 -0
- package/dist/types/core/identity/identify.contract.test.d.ts.map +1 -0
- package/dist/types/core/identity/identify.d.ts +48 -0
- package/dist/types/core/identity/identify.d.ts.map +1 -0
- package/dist/types/core/identity/identify.test.d.ts +2 -0
- package/dist/types/core/identity/identify.test.d.ts.map +1 -0
- package/dist/types/core/identity/journey.contract.test.d.ts +2 -0
- package/dist/types/core/identity/journey.contract.test.d.ts.map +1 -0
- package/dist/types/core/identity/journey.d.ts +28 -0
- package/dist/types/core/identity/journey.d.ts.map +1 -0
- package/dist/types/core/identity/journey.test.d.ts +2 -0
- package/dist/types/core/identity/journey.test.d.ts.map +1 -0
- package/dist/types/core/identity/journey.testStubs.d.ts +5 -0
- package/dist/types/core/identity/journey.testStubs.d.ts.map +1 -0
- package/dist/types/core/identity/leadDataCache.d.ts +27 -0
- package/dist/types/core/identity/leadDataCache.d.ts.map +1 -0
- package/dist/types/core/identity/leadDataCache.test.d.ts +2 -0
- package/dist/types/core/identity/leadDataCache.test.d.ts.map +1 -0
- package/dist/types/enrichment/attachEnrichment.d.ts +35 -0
- package/dist/types/enrichment/attachEnrichment.d.ts.map +1 -0
- package/dist/types/enrichment/attachEnrichment.test.d.ts +2 -0
- package/dist/types/enrichment/attachEnrichment.test.d.ts.map +1 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +2 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/react/ssr.test.d.ts +2 -0
- package/dist/types/react/ssr.test.d.ts.map +1 -0
- package/dist/types/react/useSurfaceForm.d.ts +25 -0
- package/dist/types/react/useSurfaceForm.d.ts.map +1 -0
- package/dist/types/react/useSurfaceForm.test.d.ts +2 -0
- package/dist/types/react/useSurfaceForm.test.d.ts.map +1 -0
- package/dist/types/surfaceForms.d.ts +120 -0
- package/dist/types/surfaceForms.d.ts.map +1 -0
- package/dist/types/surfaceForms.test.d.ts +2 -0
- package/dist/types/surfaceForms.test.d.ts.map +1 -0
- package/dist/types/tracking/attachTracking.d.ts +35 -0
- package/dist/types/tracking/attachTracking.d.ts.map +1 -0
- package/dist/types/tracking/attachTracking.test.d.ts +2 -0
- package/dist/types/tracking/attachTracking.test.d.ts.map +1 -0
- package/dist/types/tracking/conversionRules.contract.test.d.ts +2 -0
- package/dist/types/tracking/conversionRules.contract.test.d.ts.map +1 -0
- package/dist/types/tracking/conversionRules.d.ts +87 -0
- package/dist/types/tracking/conversionRules.d.ts.map +1 -0
- package/dist/types/tracking/conversionRules.test.d.ts +2 -0
- package/dist/types/tracking/conversionRules.test.d.ts.map +1 -0
- package/dist/types/tracking/vendors.d.ts +46 -0
- package/dist/types/tracking/vendors.d.ts.map +1 -0
- package/dist/types/tracking/vendors.test.d.ts +2 -0
- package/dist/types/tracking/vendors.test.d.ts.map +1 -0
- package/dist/types/validation/attachEmailValidation.d.ts +8 -0
- package/dist/types/validation/attachEmailValidation.d.ts.map +1 -0
- package/dist/types/validation/attachEmailValidation.test.d.ts +2 -0
- package/dist/types/validation/attachEmailValidation.test.d.ts.map +1 -0
- package/docs/api.md +310 -0
- package/docs/reference/binding.md +116 -0
- package/docs/reference/clients.md +212 -0
- package/docs/reference/engine.md +193 -0
- package/docs/reference/events.md +93 -0
- package/docs/reference/identity.md +116 -0
- package/llms.txt +317 -0
- package/package.json +25 -0
- package/skills/surface-forms-sdk/SKILL.md +153 -0
- package/skills/surface-forms-sdk/debugging.md +64 -0
- package/skills/surface-forms-sdk/editing.md +67 -0
- 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.
|