Component contract
fields: FieldDef[]— identity inputs (name, email, optional custom registration questions per EF-039/040)consentRequired?: ConsentDef— when set, renders a never-pre-checked consent block per EF-006 GDPR contract; submit blocked until checkedonSubmit: (values, idempotencyKey) => Promise<SubmitResult>— parent owns transport; harness asserts the same idempotencyKey is reused across retriessuccessView: ReactNode— what to render after success (confirmation, code, next steps)alreadyCompletedView?: ReactNode— for browser-back-after-success; shows the prior submission as read-onlyidempotencyKeyPrefix: string— used to derive per-session keys (e.g., "register-", "transfer-", "waitlist-")capacityState?: { current: number; max: number }— when provided, displays remaining and surfaces capacity-full mid-fill conversion offercapacityFullView?: ReactNode— what to render when capacity hits zero before submit (typically a waitlist offer)antiProbingResponseView: ReactNode— universal "we couldn't process this" view used when ANY of: not-found, archived, draft, deleted, capacity-full-without-conversion-option, invalid-token, expired-token rejects. Same UI/timing across all rejection cases.rateLimit?: { perMinute: number }— bot-fill protection; defaults to 5renderMode?: "modal" | "page"— defaults to "page"; modal mode wraps in ui-modal
Composition
ui-modal— when renderMode=modal; inherits focus-trap + esc-closesui-form— submission lifecycle; busy state during in-flight submitui-text-input— per identity fieldui-checkbox— when consentRequired is setui-toast— for transient errors (network, validation)ui-status-pill— for capacity remaining ("3 spots left") and success state
Interaction surface
-
Initial render: identity fields + (consent) + submit.
Submit button disabled until required fields valid AND (when applicable) consent checked. Stable per-form idempotency key generated on mount, persisted to sessionStorage, reused across retries.
-
Capacity remaining surfaces inline.
When capacityState is provided, "3 spots left" pill renders prominently. As capacity ticks down via parent-driven re-render, the pill updates. At 0, transitions to capacityFullView.
-
Submit fires onSubmit with idempotencyKey.
Header `Idempotency-Key:
- ` sent. Form enters busy state. Network failure → retry with SAME key (no double-register). Validation rejection → return to filled-form state with errors per ui-form contract. -
Success → render successView.
SuccessView fully replaces the form region. Browser-back navigates to a route that re-resolves to alreadyCompletedView (not the form). Form state clears from sessionStorage on success.
-
Anti-probing rejection → universal antiProbingResponseView.
Any 4xx (404 not-found, 410 gone, 422 capacity-full-without-conversion, 401 token-revoked) renders the SAME view. Same response timing target (within 50ms across cases). Same UI surface. No leak about which rejection case applied.
Failure modes
Browser back after success → alreadyCompletedView
Trigger: user submits successfully, hits browser back.
The success route is a dedicated URL with the registration code; back-navigation lands there. Form does NOT re-render in submittable state. SubmittedKey lookup against sessionStorage; if found, render alreadyCompletedView. Harness: complete flow, browser-back, alreadyCompletedView visible, NO POST request fires.
Two-tab idempotency
Trigger: user opens form in two tabs, completes both.
Both tabs use the same idempotencyKey (derived from sessionStorage's session ID, not random per tab when the same email logs in). Server dedupes by Idempotency-Key. Both tabs end up showing the same successView with same registration code. Harness: 2 concurrent submits with same identity, exactly 1 server-side row, both UIs show same code.
Network drop during submit → retry with same key
Trigger: network drops between submit and response.
Form retries automatically with same idempotency key (up to 3 retries with exponential backoff). On final failure, transient-error ui-toast surfaces "Connection lost — please try again" with a Retry button that ALSO uses same key. Harness: stub network drop after request leaves browser, retry succeeds → exactly 1 server-side row.
Anti-probing — same response across rejection cases
Trigger: invoke with various rejection reasons (event-not-found, event-archived, token-revoked, capacity-full-without-conversion).
All render antiProbingResponseView with byte-identical content (no leak of which case). Status codes vary at the API layer (404 vs 410 vs 422) but the UI surface and response timing are indistinguishable to the user. Harness: dispatch each rejection scenario, screenshot each result, screenshots are pixel-identical (modulo the always-changing parts like timestamps).
og-tags present for share previews
Trigger: page loaded.
Document head includes og:title, og:description, og:image, og:url. Harness: inspect <head>, all four og:* meta tags present and non-empty.
Bot-fill rate-limit
Trigger: 6 submits from same IP within 60s.
6th submit returns 429 (or captcha). UI surfaces "Too many attempts — try again in N seconds" — generic enough not to reveal whether it's per-IP or per-email rate-limit. Harness: dispatch 6 submits in 60s, 6th surfaces rate-limit message.
Capacity full mid-fill conversion
Trigger: user mid-form, capacity hits 0 server-side; submit returns 422 CAPACITY_FULL.
Inline conversion offer: "This event just filled, but you can join the waitlist." Form values preserved. Click "Join waitlist" → submit re-fires with `intent=waitlist` flag. Harness: stub capacity reaching 0 between mount + submit, conversion offer visible, second submit succeeds.
Consent never pre-checked
Trigger: consentRequired set, form renders.
Inherits EF-006 GDPR contract. Consent checkbox starts unchecked. Submit disabled until checked. Harness: render with consentRequired, checkbox.checked=false, submit disabled. Check it, submit enabled.
Validation errors focus first error field
Trigger: submit with empty required field.
Inherits ui-form's focus-first-error contract. Inline error per field. First error field receives focus. Harness: submit empty form, document.activeElement is the first ui-text-input.
Modal mode inherits focus-trap + esc-closes
Trigger: renderMode=modal, user navigates with Tab + Esc.
Inherits ui-modal contracts. Tab cycles within modal. Esc closes. Focus returns to trigger. Harness: render in modal mode, Tab cycle stays inside, Esc closes.
Accessibility
- Inherits ui-form, ui-text-input, ui-checkbox a11y contracts.
- Modal mode inherits ui-modal a11y.
- SuccessView has role="status" + aria-live="polite" so screen readers announce completion.
- antiProbingResponseView has role="alert" — screen reader announces the rejection clearly without revealing details.
- Capacity remaining pill inherits ui-status-pill a11y.
- axe-clean ≥ serious across all states (form, busy, success, already-completed, anti-probing-response, capacity-full-conversion, rate-limited).
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-public-form-flow | Outer wrapper | data-state attr (form / submitting / success / already-completed / anti-probing / capacity-full) |
ui-public-form-flow-form | Form region | Visible in form/submitting states |
ui-public-form-flow-capacity-pill | Capacity remaining | Visible only when capacityState provided AND current > 0 |
ui-public-form-flow-success | Success view | Visible after successful submit; role=status |
ui-public-form-flow-already-completed | Already-completed view | Visible on browser-back-after-success |
ui-public-form-flow-anti-probing-response | Universal rejection view | Visible on any 4xx; byte-identical across cases |
ui-public-form-flow-capacity-full-conversion | Capacity-full conversion offer | Visible when 422 CAPACITY_FULL with conversion option |
ui-public-form-flow-rate-limited | Rate-limit message | Visible after 429 |
ui-public-form-flow-network-error-toast | Transient network error | Visible during retry/final-failure |
Agent test plan
Probe list
- form-renders-in-page-mode: renderMode=page, ui-public-form-flow data-state=form
- form-renders-in-modal-mode: renderMode=modal, wrapped in ui-modal
- submit-disabled-until-valid: empty required field, submit disabled
- submit-disabled-until-consent: consentRequired, checkbox unchecked, submit disabled
- consent-never-prechecked: render with consentRequired, checkbox.checked=false
- idempotency-key-sent: submit, Idempotency-Key header matches prefix
- network-drop-retry-same-key: stub drop + retry, second request same key
- two-tab-idempotency: 2 concurrent submits, exactly 1 server row
- success-view-replaces-form: success → ui-public-form-flow-success visible, form hidden
- browser-back-shows-already-completed: success + back, ui-public-form-flow-already-completed visible
- browser-back-no-resubmit: back navigation, NO POST request
- anti-probing-pixel-identical: each rejection case → screenshot identical
- anti-probing-timing-similar: each rejection case → response time within 50ms
- og-tags-present: head has og:title, og:description, og:image, og:url all non-empty
- bot-fill-rate-limit-429: 6 submits in 60s, 6th surfaces rate-limit
- capacity-full-conversion-offer: stub 422 CAPACITY_FULL, conversion view visible
- capacity-full-conversion-flow: click join-waitlist, second submit with intent=waitlist
- validation-focuses-first-error: empty submit, activeElement is first input
- aria-live-on-success: success view has role=status + aria-live=polite
- aria-alert-on-anti-probing: anti-probing view has role=alert
- axe-clean-across-states: every state passes axe ≥ serious
Current consumers
| Branch | Mode | Specifics |
|---|---|---|
| EF-014 invitation-reveal | page | email-only field; anti-probing on email-not-on-list |
| EF-016 public-registration | page | The simplest flow — most-tested |
| EF-017 public-purchase | page (with Stripe iframe) | Inherits the flow; Stripe Elements is an additional region |
| EF-019 invite-purchase | page (token-gated) | Inherits + invitation-token preconditions |
| EF-025 waitlist-public | page | Two flows: signup + status check |
| EF-031 registration-button-modal | modal | The Canvas CTA flow; renderMode=modal |
Tightening payoff: each branch can drop ~150 lines of inlined contract (form lifecycle, idempotency, anti-probing, browser-back, og-tags, rate-limit) and reference this component instead. Future tightening of the public-form contract ratchets all 6 consumers automatically.