Plate № 28 · UX · forms / resilience
A pattern from the gf.cx specimen book
Antifragile contact form · one email escape hatch for every failure
"Graceful error detection and messaging can help smooth over minor issues."
A contact form looks like one thing and is really a chain: a dialog, a bot-challenge widget, an API function, an email provider. Every link can fail silently — and when one does, the visitor sees a green "sent!" while nothing arrives. The lead is lost with no error anywhere. This pattern makes the chain degrade instead of break: whatever fails, the message still reaches you.
Two failures that looked like mail bugs
Both cost real messages on gf.cx before the fix, and both read as "DNS / DKIM / the email provider is broken" when the cause was entirely front-end:
- The honeypot autofill trap. The spam honeypot was a hidden field named
company. Browser and password-manager identity autofill fillcompany/organization/name/emaileven when the field is off-screen — so real humans tripped the trap and the function silently dropped them (it returns{ok:true}with a blank reference and sends nothing). The tell: a green success box with an empty ref. Fix — never name a honeypot after an autofill field; use a neutral name likewebsite_url. - The challenge-in-a-modal empty token. The Cloudflare Turnstile widget lived inside a
<dialog>and used implicit auto-render. Implicit render is unreliable for initially-hidden containers → the token was empty at submit → the server rejected every send withmissing-input-response(400), no email. Not the secret, not the provider — both chased as red herrings first.
Explicit render + token gating
The rule for any challenge widget inside a modal: render it explicitly when the dialog opens, capture the token in a callback, and never POST an empty token.
var tsId = null, tsToken = '';
tsId = turnstile.render('#cf-turnstile-slot', {
sitekey: TS_SITEKEY, size: 'normal',
callback: function (t) { tsToken = t; },
'expired-callback': function () { tsToken = ''; },
'error-callback': function () { tsToken = ''; markTsUnavailable(); }
});
// on submit — gate, don't guess:
if (!tsToken) { fail('One sec — finishing the human-check, then tap Send.'); return; }
Prove the secret independently before blaming it: call the challenge siteverify endpoint with the widget's real secret and a dummy token — invalid-input-response means the secret is valid for the widget; invalid-input-secret means a mismatch. Centering has two traps of its own: the widget renders as a fixed ~300px box, so use align-self:center in a flex column; and a modal <dialog> centers via the UA's position:fixed; margin:auto; inset:0, so never set position:relative on it — that drops it to the top-left.
The shape · one escape hatch for every failure
The core principle — borrowed from the ops baseline, remove the coupling, not the component — is that the contact capability must not depend on any single link being up. Every terminal failure funnels to a prefilled, content-preserving mailto:: the visitor's own mail client, message intact, bypassing the widget AND the provider AND your function at once.
| Failure | Detection | Degrades to |
|---|---|---|
No JS / no <dialog> | feature check | the icon was always a plain mailto: |
| Challenge CDN down | <script onerror> + 5s render timeout | widget → calm note; Send → mail client |
| Function / provider down | non-ok response | clickable mailto, message preserved |
| Network dead / hung | AbortController 15s | same |
| Repeated verify fails | counter ≥ 2 | treat challenge as broken → mail |
Safety: build the fallback link with static link text and an encodeURIComponent href — never route user input through innerHTML.
Everyday errors, caught gently
The escape hatch is for catastrophe. Most of what a form actually meets is a typo'd email or an empty field — so a second tier catches those at the field, before any doomed round-trip:
- Per-field inline messages (
aria-describedby) with friendly copy — mark the offending field, focus the first, keep the status line quiet. - Client-side email-format check → "that email looks off — check for a typo", with no network hit.
- Clear-on-fix — an
inputlistener wipes the error the instant the user edits the field. - Offline pre-check —
navigator.onLine === falseskips the doomed fetch and offers the email escape straight away. - Server
errors{}mapped per-field — surface each on its own field, not one generic line.
The rule across the two tiers: detect early, message specifically, recover quietly — and reserve the alarming "email us directly" copy for genuine outages.
When it breaks
- Honeypot named after an autofill field. The bug that started it all —
company/organization/name/emailget autofilled and flag humans. Neutral names only. - Implicit challenge render in a modal. Empty token, silent 400. Explicit render + token gate, always.
- A widget secret that isn't this widget's. One secret per widget; re-seed from the authoritative value; the platform binds a new secret only on the next deploy.
- No observability. The empty-token cause was invisible until the function logged the verify
error-codesand a live tail watched a real submit. Log the outcome — verify result and provider status — so the next failure names itself.
Reference
- Origin
- Two "sent but nothing arrived" bugs on
gf.cx's contact form (2026-07-28) — honeypot autofill, then a Turnstile-in-dialog empty token — cracked by a livewrangler pages deployment tailon the full deployment UUID. Full write-up on kb.gf.cx. - Kin
- The ops baseline's remove the coupling, not the component — antifragility by decoupling from any single dependency — turned on a reader-facing form instead of an unattended job.
- Rule
- Two tiers: catch the everyday at the field, funnel the catastrophic to one email escape hatch. A form that can't send should still let the message through.