# GUIDE — Forms & lead capture

**Status:** law — v1, 2026-08-31
**Applies to:** every form on a Relay surface that captures something from a person —
marketing lead capture (register, waitlist, support), concept forms bound for production,
product-UI form concepts, and **server-rendered decision screens that carry a control**
(the MCP/OAuth authorization screen at `auth.relayctx.com/oauth/authorize` is one — it is
a form in every sense that matters here, and piece 6b is written for it). Inert
demonstration mocks are exempt but must stay visibly inert.
**Parent:** [`GUIDE.interface-principles.md`](GUIDE.interface-principles.md)
**Companions:** [`GUIDE.component-contracts.md`](GUIDE.component-contracts.md) (the
component table — its Form field and Forms kit rows point here) ·
[`../concepts/forms-kit-v1.html`](../concepts/forms-kit-v1.html) (the living specimens) ·
`scripts/check_forms.py` (the gate) · `marketing/web/stealth/relay-access.js` (canonical
live wiring) · [`GUIDE.states-and-language.md`](GUIDE.states-and-language.md) (error copy)

This document exists because the estate had exactly one real form implementation and
seventeen inert mocks. The one real one — the stealth register, `relay-access.js` — does
several things exactly right and was never written down as a standard, so every new form
started from zero or from a mock. This is the write-down: what every lead-capture form
carries, where the live proof lives, and what fails the checker.

The shape — written contract + live gallery + enforcement gate — is adapted from the
Execution Space estate's operating model, per the handoff blueprint. The model transfers;
every value, class name, and behaviour in this document is Relay's own.

---

## The enforcement triangle

One standard, held from three sides. A form is compliant when all three agree:

| Leg | Artifact | Role |
|---|---|---|
| **Law** | this document | What every lead-capture form carries, and why |
| **Specimens** | [`../concepts/forms-kit-v1.html`](../concepts/forms-kit-v1.html) | Every piece live — type into it, trip its states, copy its markup. Decisions get made on working pieces, never on screenshots |
| **Gate** | `scripts/check_forms.py` | Fails CI (and the pre-push run) when a lead form is missing a required piece. Scope and detection rules live in the checker's own docstring — the checker is the authority on what it scans |

The Execution Space estate enforces its kit in a production build script. This estate has
**no build step** — `main` is production and Cloudflare Pages serves the repo verbatim — so
the gate is a checker in the standing checker battery (`CLAUDE.md`), following the
house checker contract: committed baseline for pre-existing debt, `--update-baseline` to
lock a win, a `forms-ok:` inline escape hatch with a reason, `_archive/` and `-v33`/`-v34`
generations exempt as frozen records.

**The gate sees markup, not behaviour.** `check_forms.py` statically enforces the pieces a
parser can see — an accessible label on every control (piece 1), `autocomplete` on identity
fields (piece 4), and `aria-labelledby`/`aria-describedby` references that resolve (part of
piece 3); its docstring is the authority. The behavioural pieces — the error lifecycle, the
Turnstile gating, the consent intercept, the confirmation focus move — are **review-time
obligations**, held by the pre-publish checklist in `MAINTENANCE.md` and by review against
this document, not by any checker.

The stealth register itself predates this standard and misses several behavioural pieces
(named honestly below). That debt is recorded **here, in this document** — it does not and
cannot appear in `scripts/forms-baseline.txt`, because the register's markup passes the
static gate clean. The register stays as-is because its behaviour **mirrors production**
and the standing rule is that the mirror does not drift from the live site
(`marketing/CHANGELOG.md`, 2026-08-27: *"Turnstile: production was already right; the
concept mirror was not"*). The fix path for the stealth register runs through production
first — relay-platform's copy of `relay-access.js` changes, then the mirror follows in the
same round. New forms have no such excuse: they ship the full set.

---

## The seven pieces

Every form that captures a lead carries all seven. None is optional, none substitutes for
another.

### 1. An accessible label on every control

A visible `<label for>` on every input, select, and textarea. `aria-label` is acceptable
only on a single-field component whose one purpose is visible from context — the waitlist
email input (`aria-label="Email for updates"`) is the precedent and roughly the limit.
**A placeholder is never the label.** Placeholders may show a format example
(`you@company.com`); they vanish on first keystroke, which is exactly when someone
mid-correction needs the label most.

In-repo proof: every control on the stealth and v36-sharpened registers already does this —
the v3.6.2 register's `<label for="em">Email</label>`, the stealth register's classed variant
`<label class="label" for="email">` — re-verified against the 2026-08-31 rebuild. This piece
is convention made law.

### 2. A per-field error line, toggled on submit, cleared on input

Each field owns an error slot **in text, next to the field** — never one aggregate note
under the button, and never colour alone. The lifecycle, canonized from
`relay-access.js`:

- **On submit:** validate every field, flag each invalid one (error line + invalid border),
  and move focus to the **first** invalid field. Submit is where errors appear — not on
  first focus, not while someone is still typing a value that isn't finished yet.
- **On input:** the moment the visitor edits a flagged field, its error clears —
  `relay-access.js:264` states the reason: *"Clear a field's invalid state as soon as the
  visitor corrects it — otherwise an error flagged on submit lingers while they fix it."*
  Selects clear on `change`, since they don't fire `input` everywhere.
- The error message says what to do, in the register of
  [`GUIDE.states-and-language.md`](GUIDE.states-and-language.md) — and never discards what
  someone typed.

**Honest gap, named:** the live register today toggles a border class (`.state-error`),
clears it on input, and moves focus — the mechanics are right — but carries per-field error
*text* on only one field (the invite-code hint). The kit closes that gap: every field gets
the text line, wired with `aria-describedby` so the flagged state is announced, not merely
painted.

### 3. Helper text: an always-visible hint + `aria-describedby`

Where a field needs guidance the label can't carry, the guidance is an **always-visible**
hint line under the field, connected with `aria-describedby` — never a placeholder, never a
tooltip. The invite-code field is the canonical instance: a reserved-height `.field-hint`
with `aria-describedby="code-hint"` and `aria-live="polite"`, so the hint's live states
(checking / accepted / not recognised) announce themselves as they change. Reserve the
hint's height (`min-height`) so state changes never shift the layout under the pointer.

### 4. `autocomplete` on name and email

`autocomplete="given-name"`, `"family-name"`, `"email"` — already the convention on both
registers. Browsers fill most of a lead form; a form that blocks that is a form that loses
the lead on a phone keyboard. Fields that must not be autofilled (the invite code) say so
explicitly (`autocomplete="off"`).

### 5. The bot check, with pending-vs-unloaded gating

The bot check is Cloudflare Turnstile, explicit render, and the gating rule is the piece of
`relay-access.js` this document canonizes most deliberately (lines 241–262). The widget has
**three states, and only one of them blocks the visitor:**

| State | Meaning | Submit |
|---|---|---|
| **Token held** | Challenge passed | Enabled |
| **Pending** | Script arrived, token not yet minted | Blocked — with the button dimmed, and (kit addition) an inline nudge naming the reason, so the block never reads as a dead button |
| **Never loaded** | Script blocked, offline, or unreachable | **Passes through** — after a grace window the submit unblocks and the server verifies. `relay-access.js:254`: *"If turnstile never arrives, unblock after grace period"* |

The distinction is the whole point: *pending* is the visitor's state and they can resolve
it; *never loaded* is the network's state and they cannot. A visitor must **never be
trapped client-side** by a third-party script that was blocked before it could render. The
server is the real gate; the widget is friction for bots, not a wall for people.

Two more behaviours ride along, both already in the canonical wiring:

- **Tokens are single-use.** A failed submit resets the widget before any retry —
  `relay-access.js:141`: *"a failed submit must mint a fresh one before retry, or the retry
  fails on the consumed token."*
- **Expiry and error callbacks null the token**, which re-blocks submit — a stale token
  never rides a late submit.

**The always-pass TEST sitekey in this repo is deliberate — do not "fix" it.** Verbatim
from `relay-access.js:19`: *"Turnstile sitekeys are bound to a hostname, and the production
key … is registered for relayctx.com only. Dropping it in here would make the widget FAIL
to render on creative.relayctx.com rather than work."* Production is already correct. The
estate copy runs Cloudflare's test key so the flow stays reviewable end-to-end on this
host; swapping it is a regression, not a hardening (`marketing/CHANGELOG.md`, 2026-08-27).

### 6. A consent control that locks submit — and never plays dead

Where a form needs consent (data processing, contact permission), the consent control
**gates submit**, and it does so legibly:

- Until checked, the submit button is **dimmed and `aria-disabled="true"`** — not
  `disabled`. A `disabled` button is unfocusable and silent; it plays dead, and a visitor
  who missed the checkbox gets no answer at all.
- A click on the gated button — and an Enter-key submit — is **intercepted**: the consent
  row flags red (error line + invalid state, same grammar as piece 2) and **takes focus**,
  so the form answers the attempt by pointing at the reason.
- The whole padded row is the control: sentence, policy link, and checkbox are one target,
  comfortably over the 44px floor.
- **Consent is never pre-checked.** A pre-checked box records nothing about the person; it
  records that we shipped a checkbox.

**The counter-example is live in this repo, and it stays**: the stealth register's `opt_in`
checkbox (`register.html`) ships `checked` and never gates submit. That control is a
**marketing opt-in on the production mirror** — it is not a consent control, a pre-checked
box can never serve as one, and its behaviour is locked to production's (the mirror rule
above). The static gate cannot see checkbox state or submit gating, so this paragraph is
where the exception is recorded and the pre-publish checklist is where it is held; whether
production's own opt-in moves to unchecked is an open pick below, and the mirror follows
whatever production does. (The v36-sharpened register — a concept page, not the mirror —
ships the same updates opt-in **unchecked**, which is this piece's default; re-verified
against the 2026-08-31 rebuild.)

### 6b. The optional ask — identical control, opposite behaviour

Piece 6 governs consent a form **needs**. There is a second control that looks exactly
like it and must behave in the opposite direction: an **optional ask** riding alongside a
required action. The live case is the MCP authorization screen — a data-capture box beside
an Authorize button — and the pattern generalizes to any "while you're here, may we also…"

The two are one component with a modifier (relay-app `.consent-row` /
`.consent-row--required`, `packages/ui/form.css`) precisely *because* they look alike:
separated into two lookalikes they drift, and the drift is always in the same direction —
an optional ask quietly acquiring the gating behaviour of a required one.

What flips:

| | Piece 6 — required | Piece 6b — optional |
|---|---|---|
| Gates the primary action | **Yes** — `aria-disabled`, intercepted, flags, takes focus | **Never.** Declining costs nothing and the primary button never changes state |
| Unanswered | Blocks; the form answers the attempt by pointing at the reason | Is a complete answer. Unchecked *is* declined; nothing is asked twice |
| Copy | States what is being agreed to | States what is collected **and what is not** — the negative half is load-bearing |
| Revocable later | Usually moot (a one-time agreement) | **Required** — a standing preference needs a standing off-switch on a settings surface, named in the same change |

What does not flip — carried verbatim from piece 6:

- **Never pre-checked**, in either variant. A pre-checked box records nothing about the
  person; it records that we shipped a checkbox.
- The whole padded row is the control — sentence, fine print and box are one target, over
  the 44px floor.
- The flag state is never colour alone.

Two obligations specific to the optional ask:

- **Say what is *not* collected.** "A summary of the type of task, not the full prompt" is
  informative; "help us improve" is not. A row whose detail line cannot name the boundary
  is not an informed ask and does not ship.
- **The wording is versioned.** If the sentence changes, the old answer does not cover the
  new sentence. Whatever stores the answer stores which wording it answered.

**Never bundle an optional ask into the primary action's label or its consequences.** An
Authorize button that also means "and yes, collect that" is the dark pattern this piece
exists to name. Rationale and the platform-side model: relay-platform
`docs/DESIGN-NOTE.mcp-authorization-consent.md`.

### 7. A confirmation screen that replaces the form

Success replaces the form — the visitor's task is over and the surface says so, rather than
leaving a filled form under a toast. Canonized from the register's confirmation
(`relay-access.js:333`): a panel with **`role="status"`**, a quiet eyebrow, a display-face
headline addressed to the visitor by first name when one was given, and one paragraph of
what happens next.

Two requirements the kit adds beyond the live wiring:

- **Focus moves to the confirmation** (`tabindex="-1"` + `.focus()`). `role="status"`
  announces the arrival; the focus move puts keyboard and screen-reader users *at* it.
  The live register does not do this yet — debt recorded here (a focus move is invisible
  to the static gate), closed in the kit; the mirror waits on production.
- **Reduced motion gets the complete resting frame instantly** — the confirmation is the
  state, the fade is the flourish. The live wiring already honours this.

The single-field variant (the waitlist's "done" line) follows the same law at its own
scale: the input row is replaced, the done state carries `role="status"`, and the hint line
survives beneath it. The live `mountWaitlist` done state predates the `role="status"`
requirement — debt recorded here, same closure path: production first, then the mirror.

---

## Wiring — how a new form is assembled

A new lead-capture form is **assembled, not designed**. The order:

1. **Start from the specimens.** Copy the field anatomy from
   [`../concepts/forms-kit-v1.html`](../concepts/forms-kit-v1.html) — markup and classes,
   never a screenshot. Field visuals come from the current generation's stylesheet; every
   colour resolves to a `--rly-*` token from `/brand/generated/relay-tokens-v36.css`.
2. **Labels and autocomplete first** (pieces 1, 4) — they're markup, and they're the pieces
   the checker can see before a single line of script exists.
3. **Hints where guidance is needed** (piece 3): visible line, reserved height,
   `aria-describedby`, `aria-live="polite"` if the hint changes state.
4. **Validation lifecycle** (piece 2): validate on submit → flag + focus first invalid →
   clear per-field on input/change. The reference implementation is
   `initRegisterForm()` in `relay-access.js`; the kit page carries the same lifecycle as a
   copyable specimen.
5. **Turnstile** (piece 5): one slot element, rendered through the shared explicit-render
   loader (`withTurnstile`/`renderTurnstile` — queue, `onerror` degrade, reset handle).
   Never a second ad-hoc loader on the same page.
6. **Consent** (piece 6) wired to the submit button — `aria-disabled`, intercept, flag,
   focus.
7. **Confirmation** (piece 7) targeted at the form it replaces.
8. **Run the checkers** — `check_forms.py` with the rest of the battery — and verify by
   rendering at a desktop and a phone width: states exercised, no JS errors, no overflow.

Motion note, owned by [`GUIDE.motion-register.md`](GUIDE.motion-register.md): **fields and
submit controls never animate and never move.** Flourish sits *around* interactive
elements, `aria-hidden`, and the confirmation is a complete resting frame under reduced
motion.

Every form also declares its nature honestly: a live form posts to a real (or contracted)
endpoint and degrades gracefully; a demonstration mock is inert **and says so in its
markup** — the half-alive form that looks live and swallows submissions is the failure mode
this line exists to prevent.

---

## Checklist

- [ ] Visible `<label for>` on every control; placeholder never the label
- [ ] Per-field error line: shown on submit, cleared on input (`change` for selects), focus to first invalid
- [ ] Hints always-visible, `aria-describedby`, reserved height
- [ ] `autocomplete` on name/email; `autocomplete="off"` where autofill would be wrong
- [ ] Turnstile: pending blocks with a stated reason; never-loaded passes through; reset before retry
- [ ] Consent (where required): unchecked by default, `aria-disabled` submit, intercepted click flags the row and takes focus
- [ ] Optional asks (piece 6b): unchecked, never gate the primary action, detail line names what is *not* collected, an off-switch exists on a settings surface
- [ ] Confirmation replaces the form: `role="status"` + focus move + complete reduced-motion frame
- [ ] `scripts/check_forms.py` exits 0; rendered at both widths with states exercised

---

## Open picks — for EC, not for a session to decide

- **Packaging.** The Execution Space model packages consent/confirmation as small web
  components in a shared `_components/` layer. This estate has no shared component layer —
  the closest thing is `relay-access.js`, which is stealth-scoped. The pick: extract a
  shared `rly-consent`/`rly-confirm` pair (a new shared layer for the estate), or keep the
  kit as copyable specimen markup + the documented wiring above. Both satisfy this law;
  the gallery demonstrates the pieces either way.
- **Label register.** Two label styles are live in the estate: the stealth register's
  sentence-case `.label` and the support-form concept's uppercase mono micro-label. One of
  them becomes the kit's label register; specimens for both sit in the gallery as the pick.
- **Production opt-in default.** Whether the production register's pre-checked marketing
  opt-in moves to unchecked is a relayctx.com/platform call with compliance weight — not
  this repo's to make. Until it's made, the stealth mirror keeps production's behaviour and
  the exception stays recorded in piece 6 above — the static gate cannot see it, so this
  document and the pre-publish checklist are what hold it.

---

## Framework note

Everything here except the token names, the invite-code field, and the stealth-mirror rule
is `framework-candidate` — the seven pieces, the pending-vs-unloaded distinction, the
consent-intercept pattern, and the enforcement triangle port to any estate. The Relay
specifics are the specimens, not the law.
