RelayCTX Creative guidelinesGUIDE.forms.md
RelayCTX Creative · Document

GUIDE — Forms & lead capture

3,021 words · 14 min View raw Source History

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:

LegArtifactRole
Lawthis documentWhat every lead-capture form carries, and why
Specimens../concepts/forms-kit-v1.htmlEvery piece live — type into it, trip its states, copy its markup. Decisions get made on working pieces, never on screenshots
Gatescripts/check_forms.pyFails 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:

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:

StateMeaningSubmit
Token heldChallenge passedEnabled
PendingScript arrived, token not yet mintedBlocked — with the button dimmed, and (kit addition) an inline nudge naming the reason, so the block never reads as a dead button
Never loadedScript blocked, offline, or unreachablePasses 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:

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).

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

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 — requiredPiece 6b — optional
Gates the primary actionYes — aria-disabled, intercepted, flags, takes focusNever. Declining costs nothing and the primary button never changes state
UnansweredBlocks; the form answers the attempt by pointing at the reasonIs a complete answer. Unchecked is declined; nothing is asked twice
CopyStates what is being agreed toStates what is collected and what is not — the negative half is load-bearing
Revocable laterUsually 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:

Two obligations specific to the optional ask:

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:

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 — 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: 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


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


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.