Set Up Lead Capture
A landing page exists to convert; this form is the conversion. The skill wires it with
one destination seam, invisible-first spam defenses, and consent that would survive an
audit.
1. Gate — is this a public page?
Run the gate from ../_shared/page-types.md. Authenticated app forms (settings,
checkout, CRUD) are the other catalogue's job: skills/frontend/set-up-forms
(schema-first, mutation-wired). This skill owns the public, anonymous form.
2. Audit current state
curl -s "$URL" -o /tmp/page.html
grep -oiE "<form[^>]*" /tmp/page.html # how many forms, where do they post?
grep -ciE "<label" /tmp/page.html # labels present?
grep -ciE "autocomplete=" /tmp/page.html
grep -ciE "type=\"checkbox\"[^>]*consent|consent[^>]*checkbox" /tmp/page.html
Findings to look for: forms posting directly to a vendor URL scattered per page (no
seam), missing labels, no spam defense, no consent checkbox, success state that dumps
PII into the URL. (As in ../set-up-seo/SKILL.md: the greps are coarse presence checks on double-quoted
attributes — the consent pattern will also match prose mentioning consent and checkbox;
read the page, don't trust the counts.)
3. The form contract
<form method="post" action="/api/leads">
<label for="lead-email">Work email</label>
<input id="lead-email" name="email" type="email" autocomplete="email"
required aria-describedby="lead-email-err">
<p id="lead-email-err" class="field-error" hidden></p>
<!-- honeypot: bots autofill it; humans never see it. No aria-hidden — it would
strand a focusable input in a hidden subtree; the label warns anyone who lands here -->
<div class="hp">
<label for="lead-company2">Leave this field empty</label>
<input id="lead-company2" name="company2" type="text" tabindex="-1" autocomplete="off">
</div>
<!-- time-trap: must be stamped per page LOAD (SSR per request, or inline JS) —
a build-time value on a static page never trips the check -->
<input type="hidden" name="form_ts" value="{{render_timestamp}}">
<label class="consent">
<input type="checkbox" name="consent" required>
I'd like to receive the newsletter — see the <a href="/privacy">privacy policy</a>.
</label>
<button type="submit">Get the guide</button>
<p role="status" class="form-feedback"></p>
</form>
.hp { position: absolute; left: -9999px; } /* off-screen: the laziest bots skip display:none; the field existing is the real trap */
- Minimal fields — each extra field costs conversions and widens PII. Often email alone.
- Real
<label>s; autocomplete for WCAG 1.3.5 input purpose, type="email" for the
right mobile keyboard.
- Error state: inline, per field, announced (
aria-describedby — the field-level
channel). Success state: the role="status" element — the form-level channel —
with visible confirmation that says what happens next ("Check your inbox to
confirm"). One message per channel; don't announce the same thing twice.
4. The destination seam
All forms post to one endpoint/handler; the vendor (CRM, list provider, webhook)
lives behind it, named in one place. Shapes and trade-offs: ./capture-patterns.md.
The handler's contract — whatever implements it:
- Reject silently (normal success response, record dropped) if
company2 is non-empty,
or if form_ts is present and now − form_ts < 3 s. A missing form_ts (JS off on
a static page) degrades gracefully — the honeypot still guards.
- Validate the email server-side; honest inline error for real mistakes.
- Rate-limit by IP.
- Store:
{ email, consent: true, consent_text_version, submitted_at, source_page }.
- Trigger the double-opt-in confirmation; the address is used only after the click.
5. Spam defenses — escalate, invisible first
The honeypot is free, invisible, and static-safe — it goes in always. The time-trap
needs a per-load form_ts (SSR per request, or a one-line inline script setting
Date.now() on load); on a fully static page a build-time timestamp never trips the
check, so treat the time-trap as an upgrade where rendering allows, not a given.
Escalate to Cloudflare Turnstile (managed/invisible mode) only when measured spam
pressure demands; an interactive challenge is the last resort, because every challenge
costs real conversions. Rationale and rejection etiquette: ./capture-patterns.md.
6. Consent + double opt-in
- Checkbox unticked by default; specific text; policy linked. Pre-ticked or bundled
consent isn't consent.
- Record consent (timestamp + text version) with the lead.
- Double opt-in: store unconfirmed → confirmation email → only confirmed addresses enter
the list. Established proof-of-consent practice in the EU. (Engineering guidance, not
legal advice — a regulated project encodes its counsel's rules via
../audit-copy-compliance/SKILL.md.)
7. Verify
- Valid submit → success state, record stored with consent fields, confirmation mail
flow triggers.
- Fill the hidden
company2 field → normal success response, no record.
- Submit within 3 s of load → no record (on per-load
form_ts setups).
- Bad email → inline error names the field.
- View the page with JS disabled: the form is present and labeled (it's in the HTML).
References
- ./capture-patterns.md — destination shapes, spam escalation, silent rejection, consent recording, PII minimization, when to deviate.
- ../_shared/page-types.md — the gate.
- ../../frontend/set-up-forms/SKILL.md — authenticated in-app forms (schema-first, mutation-wired); use that skill there.
1---2name: set-up-lead-capture3description: Use when adding or hardening a lead, signup, contact, newsletter, or waitlist form on a public page — the form contract (labels, autocomplete, error and success states), a single destination seam (form service / serverless function / own API), layered spam defenses (honeypot, time-trap, escalation to Turnstile), consent at the point of capture, and double opt-in. Framework-agnostic.4---56# Set Up Lead Capture78A landing page exists to convert; this form is the conversion. The skill wires it with9one destination seam, invisible-first spam defenses, and consent that would survive an10audit.1112## 1. Gate — is this a public page?1314Run the gate from `../_shared/page-types.md`. Authenticated app forms (settings,15checkout, CRUD) are the other catalogue's job: `skills/frontend/set-up-forms`16(schema-first, mutation-wired). This skill owns the *public, anonymous* form.1718## 2. Audit current state1920```bash21curl -s "$URL" -o /tmp/page.html22grep -oiE "<form[^>]*" /tmp/page.html # how many forms, where do they post?23grep -ciE "<label" /tmp/page.html # labels present?24grep -ciE "autocomplete=" /tmp/page.html25grep -ciE "type=\"checkbox\"[^>]*consent|consent[^>]*checkbox" /tmp/page.html26```2728Findings to look for: forms posting directly to a vendor URL scattered per page (no29seam), missing labels, no spam defense, no consent checkbox, success state that dumps30PII into the URL. (As in `../set-up-seo/SKILL.md`: the greps are coarse presence checks on double-quoted31attributes — the consent pattern will also match prose mentioning consent and checkbox;32read the page, don't trust the counts.)3334## 3. The form contract3536```html37<form method="post" action="/api/leads">38 <label for="lead-email">Work email</label>39 <input id="lead-email" name="email" type="email" autocomplete="email"40 required aria-describedby="lead-email-err">41 <p id="lead-email-err" class="field-error" hidden></p>4243 <!-- honeypot: bots autofill it; humans never see it. No aria-hidden — it would44 strand a focusable input in a hidden subtree; the label warns anyone who lands here -->45 <div class="hp">46 <label for="lead-company2">Leave this field empty</label>47 <input id="lead-company2" name="company2" type="text" tabindex="-1" autocomplete="off">48 </div>49 <!-- time-trap: must be stamped per page LOAD (SSR per request, or inline JS) —50 a build-time value on a static page never trips the check -->51 <input type="hidden" name="form_ts" value="{{render_timestamp}}">5253 <label class="consent">54 <input type="checkbox" name="consent" required>55 I'd like to receive the newsletter — see the <a href="/privacy">privacy policy</a>.56 </label>5758 <button type="submit">Get the guide</button>59 <p role="status" class="form-feedback"></p>60</form>61```6263```css64.hp { position: absolute; left: -9999px; } /* off-screen: the laziest bots skip display:none; the field existing is the real trap */65```6667- Minimal fields — each extra field costs conversions and widens PII. Often email alone.68- Real `<label>`s; `autocomplete` for WCAG 1.3.5 input purpose, `type="email"` for the69 right mobile keyboard.70- Error state: inline, per field, announced (`aria-describedby` — the field-level71 channel). Success state: the `role="status"` element — the form-level channel —72 with visible confirmation **that says what happens next** ("Check your inbox to73 confirm"). One message per channel; don't announce the same thing twice.7475## 4. The destination seam7677All forms post to **one** endpoint/handler; the vendor (CRM, list provider, webhook)78lives behind it, named in one place. Shapes and trade-offs: `./capture-patterns.md`.79The handler's contract — whatever implements it:80811. Reject silently (normal success response, record dropped) if `company2` is non-empty,82 or if `form_ts` is present and `now − form_ts < 3 s`. A missing `form_ts` (JS off on83 a static page) degrades gracefully — the honeypot still guards.842. Validate the email server-side; honest inline error for real mistakes.853. Rate-limit by IP.864. Store: `{ email, consent: true, consent_text_version, submitted_at, source_page }`.875. Trigger the double-opt-in confirmation; the address is *used* only after the click.8889## 5. Spam defenses — escalate, invisible first9091The honeypot is free, invisible, and static-safe — it goes in always. The time-trap92needs a **per-load** `form_ts` (SSR per request, or a one-line inline script setting93`Date.now()` on load); on a fully static page a build-time timestamp never trips the94check, so treat the time-trap as an upgrade where rendering allows, not a given.95Escalate to Cloudflare Turnstile (managed/invisible mode) only when measured spam96pressure demands; an interactive challenge is the last resort, because every challenge97costs real conversions. Rationale and rejection etiquette: `./capture-patterns.md`.9899## 6. Consent + double opt-in100101- Checkbox **unticked** by default; specific text; policy linked. Pre-ticked or bundled102 consent isn't consent.103- Record consent (timestamp + text version) with the lead.104- Double opt-in: store unconfirmed → confirmation email → only confirmed addresses enter105 the list. Established proof-of-consent practice in the EU. (Engineering guidance, not106 legal advice — a regulated project encodes its counsel's rules via107 `../audit-copy-compliance/SKILL.md`.)108109## 7. Verify110111- Valid submit → success state, record stored with consent fields, confirmation mail112 flow triggers.113- Fill the hidden `company2` field → normal success response, **no** record.114- Submit within 3 s of load → no record (on per-load `form_ts` setups).115- Bad email → inline error names the field.116- View the page with JS disabled: the form is present and labeled (it's in the HTML).117118## References119- ./capture-patterns.md — destination shapes, spam escalation, silent rejection, consent recording, PII minimization, when to deviate.120- ../_shared/page-types.md — the gate.121- ../../frontend/set-up-forms/SKILL.md — authenticated in-app forms (schema-first, mutation-wired); use that skill there.