RelayCTX Forms kit · F01–F08 · v1

Every form state, live, in one place.

The lead-capture kit as working specimens — type into them, trip their states, reset them, and copy the markup, never a screenshot. Each piece below is one obligation from the written law, demonstrated at full behaviour.

Nothing on this page sends anywhere. Every form is an inert specimen and says so in its own frame; the bot check is a simulated stub, marked as such.

v1 · 2026-08-31 law: GUIDE.forms.md gate: scripts/check_forms.py in the kit open pick

The contract — seven pieces, none optional

  1. An accessible label on every control. Visible <label for>; aria-label only on a single-field component; a placeholder is never the label. → F01
  2. Per-field error lines. Shown on submit, cleared on input (change for selects), focus to the first invalid field — never one note under the button. → F02
  3. Helper text that stays. An always-visible hint with aria-describedby and reserved height — guidance never lives in the placeholder. → F03
  4. autocomplete on name and email. Browsers fill most of a lead form; a form that blocks that loses the lead. → F04
  5. The bot check gates honestly. Pending blocks with a stated reason; never-loaded passes through after a grace window — a visitor is never trapped client-side. → F05
  6. Consent locks submit — and never plays dead. Dimmed and aria-disabled, an intercepted click flags the row and takes focus. Never pre-checked. → F06
  7. Success replaces the form. A confirmation with role="status" and a focus move — the task is over and the surface says so. → F07

F08 assembles all seven into the full lead form — the shape a new form starts from. Specimen numbers are permanent: F-numbers never renumber, a retired specimen keeps its number, new pieces append.

Three picks stay open for EC, none decided here: the label register (live in F01), kit packaging (shared components vs copyable specimens), and the production register’s opt-in default — details in GUIDE.forms.md, Open picks.

F01

Label law

in the kit

Three ways a field announces itself — two allowed, one forbidden. Type into the third and watch the only thing naming the field disappear; that demonstration runs in pure CSS, no script involved.

Visible label — the defaultinert · nothing sends
The placeholder shows a format example. The label survives it.
aria-label — the narrow exceptioninert · nothing sends
One field, one purpose visible from context — the waitlist row is the precedent, and roughly the limit.
Placeholder as labelanti-pattern · never ship
…and now nothing on screen says what this field is. It vanishes on the first keystroke — exactly when someone mid-correction needs the label most — and assistive tech may never announce it.

The label register — which style becomes the kit’s

open pick

Two label styles are live in the estate today. One of them becomes the kit’s register; the pick is EC’s, and both stay demonstrated here until it lands (GUIDE.forms.md, Open picks).

Option A — sentence casestealth register
Option B — mono microsupport-form concept
<label for> on every input, select, textarea · single-field component only → aria-label on the control · placeholder = format example, never the label · label register: .fk-label (A) vs .fk-label-alt (B) — open pick
F02

Per-field error lifecycle

in the kit

Submit the form empty: an error line appears at each field — never one aggregate note under the button — and focus lands on the first invalid field. Fix a field and its line clears alone, the moment you type; the select clears the moment you choose. Errors appear on submit, not while someone is still typing.

Lead form — the lifecycleinert · nothing sends
.fk-field > label + control + .fk-err[id] · control: aria-describedby → its err id, aria-invalid while flagged · submit → flag each invalid + focus the first · input clears its own field · select clears on change · the message says what to do and never discards what was typed
F03

Helper text

in the kit

Guidance the label can’t carry lives in an always-visible hint under the field — never the placeholder, never a tooltip. Type something and the hint changes state without the layout shifting (its height is reserved); trip the error and the hint survives beneath it, because hint and error are separate lines and aria-describedby carries both.

Hint + error, coexistinginert · nothing sends
Came with your invite email. Leave it blank to join the review queue.
.fk-hint[id] always visible, min-height reserved · control: aria-describedby="hint err" · aria-live="polite" where the hint changes state · guidance never in the placeholder · autocomplete="off" where autofill would be wrong (this field)
F04

Autocomplete + input hygiene

in the kit

The attribute set that lets a browser fill the form in one gesture — each control’s live attributes are printed under it. On a phone, the email field raises the email keyboard. Paste an email with stray spaces and leave the field: the spaces go, nothing else is touched.

Name + email, fully attributedinert · nothing sends
autocomplete=given-name
autocomplete=family-name
type=email autocomplete=email inputmode=email spellcheck=false autocapitalize=none autocorrect=off
autocomplete=given-name / family-name / email · email: type=email + inputmode=email + spellcheck=false + autocapitalize=none + autocorrect=off · trim on blur (data-fk-trim) — whitespace only, never rewrite what was typed · autofill off only where it would be wrong
F05

Bot check — pending vs unloaded

in the kit simulated

The widget below is a simulated stub — no challenge script loads on this page. That’s deliberate: a real widget can’t be driven into its never-loaded state on demand, and the distinction is the whole lesson. Drive it: pending holds submit and a click on it names the reason; never loaded runs a grace window and then lets the form through, because the server verifies and a visitor is never trapped client-side. Pass the check and the token is single-use — submitting consumes it.

Gating — three states, one blocksinert · nothing sends
simulated no challenge runs on this page
Never loaded. The script didn’t arrive — grace window running… Pending. Script arrived, no token yet — submit is held. Token held. Challenge passed — single use.
three states · token held → submit enabled · pending → submit dimmed + an inline nudge naming the reason · never loaded → grace window, then submit unblocks and the server verifies · tokens are single-use: a failed submit resets the widget before retry · expiry/error callbacks null the token and re-hold submit
F06

Consent lock

in the kit

Fill the email, then click Request access before checking the box: the button is dimmed and aria-disabled, but it never plays dead — the click is intercepted, the consent row flags red, and focus moves to the checkbox, so the form answers the attempt by pointing at the reason. Check it and the button wakes instantly. The whole padded row is the control, and it is never pre-checked.

The lock, and the answerinert · nothing sends
submit: aria-disabled + dimmed, never the disabled attribute (disabled is unfocusable and silent) · click AND Enter intercepted → row flags red + focus moves to the checkbox · checking clears the flag and unlocks · the padded row is one target, ≥44px · never pre-checked
F07

Confirmation screen

in the kit

Success replaces the form — the task is over and the surface says so, rather than leaving a filled form under a toast. The panel carries role="status" so it announces itself, and focus moves onto it so keyboard and screen-reader users arrive with it. Give a first name and the headline addresses you. Reset re-arms the specimen and hands focus back to the first field.

Arrival — and the way backinert · nothing sends
panel: role="status" + tabindex="-1" + .focus() on arrival · replaces the form (form hidden), never a toast over it · entrance is opacity/transform on the panel only — reduced motion gets the complete frame instantly · reset returns the form, clears state, focuses the first field
F08

The assembled form

in the kit

Every piece above in one form, wired by the same script hooks as the specimens — assembled, not designed. Submit it empty to watch all of it fire at once: per-field lines, the consent answer, the held check. Then fill it, complete the check, consent, and go — the confirmation takes the form’s place. This is the shape a new lead form starts from.

Full lead capture — all seven piecesinert · nothing sends
The reply lands here — nothing else is sent to it.
Came with your invite email. Leave it blank to join the review queue.
simulated no challenge runs on this page
Never loaded. The script didn’t arrive — grace window running… Pending. Script arrived, no token yet — submit is held. Token held. Challenge passed — single use.
assembly order (GUIDE.forms.md §Wiring): labels + autocomplete → hints (aria-describedby) → validation lifecycle → bot check → consent lock → confirmation → run the checkers and render at both widths · every colour resolves to a --rly-* token · a live form posts to a real endpoint; a specimen is inert and says so
Law: GUIDE.forms.md Motion: GUIDE.motion-register.md Components: GUIDE.component-contracts.md Canonical live wiring: stealth register House chrome: app components