← All stories

COMPONENT (Tier 3) · ui-public-form-flow

ui-public-form-flow

Component · Tier 3 (compound) Recurrence: 6 stories Composes: ui-modal, ui-form, ui-text-input, ui-checkbox, ui-toast, ui-status-pill Run-validated: deployed booking-pages diverges from this contract

The full lifecycle of a public-side form: identity fields + optional consent + submit + success/already-completed view + browser-back-safe + idempotent retry. Extracted because the harness's first deployed-surface run (2026-04-29) confirmed the booking-pages app diverges from this contract — codifying it now lets the Ratchet narrow that divergence over time.

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 checked
  • onSubmit: (values, idempotencyKey) => Promise<SubmitResult> — parent owns transport; harness asserts the same idempotencyKey is reused across retries
  • successView: ReactNode — what to render after success (confirmation, code, next steps)
  • alreadyCompletedView?: ReactNode — for browser-back-after-success; shows the prior submission as read-only
  • idempotencyKeyPrefix: 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 offer
  • capacityFullView?: 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 5
  • renderMode?: "modal" | "page" — defaults to "page"; modal mode wraps in ui-modal

Composition

  • ui-modal — when renderMode=modal; inherits focus-trap + esc-closes
  • ui-form — submission lifecycle; busy state during in-flight submit
  • ui-text-input — per identity field
  • ui-checkbox — when consentRequired is set
  • ui-toast — for transient errors (network, validation)
  • ui-status-pill — for capacity remaining ("3 spots left") and success state

Interaction surface

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

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

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

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

  5. 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-testWherePurpose
ui-public-form-flowOuter wrapperdata-state attr (form / submitting / success / already-completed / anti-probing / capacity-full)
ui-public-form-flow-formForm regionVisible in form/submitting states
ui-public-form-flow-capacity-pillCapacity remainingVisible only when capacityState provided AND current > 0
ui-public-form-flow-successSuccess viewVisible after successful submit; role=status
ui-public-form-flow-already-completedAlready-completed viewVisible on browser-back-after-success
ui-public-form-flow-anti-probing-responseUniversal rejection viewVisible on any 4xx; byte-identical across cases
ui-public-form-flow-capacity-full-conversionCapacity-full conversion offerVisible when 422 CAPACITY_FULL with conversion option
ui-public-form-flow-rate-limitedRate-limit messageVisible after 429
ui-public-form-flow-network-error-toastTransient network errorVisible 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

BranchModeSpecifics
EF-014 invitation-revealpageemail-only field; anti-probing on email-not-on-list
EF-016 public-registrationpageThe simplest flow — most-tested
EF-017 public-purchasepage (with Stripe iframe)Inherits the flow; Stripe Elements is an additional region
EF-019 invite-purchasepage (token-gated)Inherits + invitation-token preconditions
EF-025 waitlist-publicpageTwo flows: signup + status check
EF-031 registration-button-modalmodalThe 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.