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 Companions: GUIDE.component-contracts.md (the component table — its Form field and Forms kit rows point here) · ../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 (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 | 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:264states 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 onchange, since they don't fireinputeverywhere. - The error message says what to do, in the register of
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"— notdisabled. Adisabledbutton 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:
- Start from the specimens. Copy the field anatomy from
../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. - 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.
- Hints where guidance is needed (piece 3): visible line, reserved height,
aria-describedby,aria-live="polite"if the hint changes state. - Validation lifecycle (piece 2): validate on submit → flag + focus first invalid → clear per-field on input/change. The reference implementation is
initRegisterForm()inrelay-access.js; the kit page carries the same lifecycle as a copyable specimen. - Turnstile (piece 5): one slot element, rendered through the shared explicit-render loader (
withTurnstile/renderTurnstile— queue,onerrordegrade, reset handle). Never a second ad-hoc loader on the same page. - Consent (piece 6) wired to the submit button —
aria-disabled, intercept, flag, focus. - Confirmation (piece 7) targeted at the form it replaces.
- Run the checkers —
check_forms.pywith 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: 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 (
changefor selects), focus to first invalid - Hints always-visible,
aria-describedby, reserved height autocompleteon 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-disabledsubmit, 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.pyexits 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 isrelay-access.js, which is stealth-scoped. The pick: extract a sharedrly-consent/rly-confirmpair (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
.labeland 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.