Skip to content

Wizard Conventions

Status: Active Last reviewed: 2026-08-25

The consolidated contract for every multi-step wizard — create-club, onboarding, and create-session today; any future wizard joins it. Distilled from the create-club rebuild's owner-ratified iterations (2026-08-06 → 08-09). Feature code consumes the primitives and follows the laws below; it never re-invents shell chrome, reveal mechanics, field grammar, or footer behavior per wizard. Laws that live in components.md are pointed to, not restated — that file stays the authority for them.

The shell — WizardShell (@twomore/ui)

Every wizard renders inside WizardShell — never a hand-rolled header/footer.

  • Progress segments: one segment per step; completed segments are tap targets (onStepPress + maxNavigableStep) — a user can jump back to any step they've reached, and later-step data is preserved on a backward jump (edit-in-place). Pass stepPressAccessibilityLabel.
  • Nav law (owner-ratified Option B, decision board wizard-nav.jsx, confirmed 2026-08-15): "위저드 푸터는 단일 CTA만 소유한다. 이동(뒤로)과 이탈(건너뛰기)은 헤더가 소유한다." The header owns both movement and departure; the footer owns exactly one job. Concretely: the header's left slot is ONE button — ChevronLeft once onBack is non-null, X otherwise (step 0, a gate, a terminal step) via onClose — and its right slot is 건너뛰기 (secondaryLabel/onSecondaryPress) on skippable steps. Both are REAL buttons, never a bare icon or plain text: back/close is a bordered+filled 40pt square, skip is the secondary variant ($surfaceSecondary + $text, never ghost) — each meets the ≥44pt hit-target floor. Hardware/system back mirrors the header-left button 1:1 via the shared useWizardBackHandler (@twomore/ui) — pass it the same onBack value given to WizardShell; every wizard wires it instead of a bespoke BackHandler listener. The footer keeps only primaryHint + the single full-width primary CTA; content + footer share a KeyboardAvoidingView (behavior="padding") so the CTA lifts above the keyboard on ordinary text-input steps. Search takeover law (owner-directed 2026-09-01, supersedes the 2026-08-30 footer-hide exception): an open-ended SEARCH field inside a wizard step never lives in the step's scroll — it presents as a full-screen overlay (VenueSearchTakeover: RN Modal, pinned header, panel body, no KeyboardAvoidingView). This is the industry-standard mobile pattern (M3 SearchView is a full-screen Dialog at phone widths; iOS UISearchController presents a distinct results controller over the obscured host; Google Places' Android form-field autocomplete launches a full-screen picker; Daum postcode = layer mode). The step keeps a search-field-shaped trigger row; the keyboard's 검색 key commits free text inside the overlay. Rationale: in-scroll search cannot win — when content below the field is short, scroll-to-reveal clamps (pan+KAV blank band) and manufacturing headroom shows raw emptiness; a viewport-owning layer makes the space below the bar the search surface itself. The old primaryDemoted footer-hide machinery is deleted — the wizard footer never needs to hide because the wizard is never the active surface while searching. Armed edit detours label the back affordance with its destination (Apple HIG labeled-back convention — chevron + short destination title, not a bare chevron): while an edit-from-review detour is armed (returnToReview), pass WizardShell's backLabel with the destination's short i18n noun (e.g. backToReviewShort — '검토'/'Review') so the header-left button renders as a content-hugging pill (ChevronLeft + the label, same border/fill/radius/pressed-state grammar) instead of the plain 40pt square; normal step-to-step back stays icon-only (backLabel unset) since there's no single named destination to call out.
  • primaryHint: every disabled primary names its missing requirement in copy pinned to the CTA — never improvised inside step bodies where it scrolls away. Pairs with the field-level required markers (LabeledField required).
  • Contextual CTA: the primary slot follows the innermost open PANEL scope (저장 while an attached panel/sheet editor is open, 다음 otherwise) — law and bridge pattern in components.md ("Contextual footer CTA"). Field buffers never relabel the CTA (input law below).
  • Sanctioned extension — step-owned primary (currently unused): a step MAY blank/disable the shell's primary CTA (a per-step hideShellPrimary-style boolean the screen folds into primaryLabel/primaryDisabled) and render its own full-width primary action(s) in-content, when the WHOLE STEP IS the decision. This doesn't reopen the nav law above — the footer still owns exactly one job; for that step, that job is delegated wholesale to the step's own content. The former precedent (the session wizard's 경기 규칙 step) retired its use on 2026-08-27 (create-session-step5.tsx header note) — no live consumer remains, so a new adopter re-establishes the wiring rather than copying one.

Edit-from-review detours

A review-row edit icon (onEdit on the review step) jumps the user off review to an earlier step and arms a detour (returnToReview/handleEditStep, e.g. create-club-screen.tsx): while armed, the header back and the footer primary (once the visited step is valid) return straight to review instead of walking 다음 through every intermediate step. Owner-ratified detour rules (2026-08-16), on top of that existing arm/disarm:

  • Save auto-returns: closing any scope (a panel's 저장, or a field buffer's advance-flush) while armed AND the step is valid post-commit continues straight to handleReturnToReview in the SAME tap — never a second 돌아가기 press. Safe because the live-draft model already committed the value on every keystroke; close() only flips the section's reveal marker, which no stepNValid/isNextEnabled gate reads, so checking validity before or after close() gives the same answer.
  • Skip returns, not advances: 건너뛰기 on a skippable step while armed calls handleReturnToReview instead of the normal handleSkip — skipping a detour step means "I'm done editing this, take me back," not "advance to the next step in sequence."
  • Exit resumes at review: the armed flag is mirrored into the wizard draft (returnToReviewArmed on ClubWizardFormData/SessionWizardFormData) alongside every setReturnToReview call (via a setReturnToReviewArmed wrapper — never call the raw setter directly), so an app exit or dismiss mid-detour doesn't strand the resumed session on whichever intermediate step it happened to be on. handleResumeDraft checks it: when set, resume lands directly on the review step and clears the flag — there's no detour left to return FROM once already on review. A persisted-shape addition like this follows the store's own key-bump precedent (each file's persist config comments the bump history).

The flow — sequential reveal

The full contract ("sequential wizard reveal") lives in components.md; the wizard-facing summary:

  • One decision at a time via RevealStack; answered sections stack upward and stay editable; the active section is the last one visible.
  • The input law (owner-ratified 2026-08-28, superseding the 2026-08-16 save-then-proceed law): free-text inputs are BLUR-COMMIT + ADVANCE-FLUSH — typing writes only to a local buffer (useEditScope, the kill-mid-edit fix), the buffer commits when the field blurs (dirty-gated), and the footer primary stays 다음 throughout: pressing it with a still-open buffer flushes via close() and then advances through a deferred pendingAdvanceRef check once the commit lands (or stays put with the normal disabled+hint behavior if the step remains incomplete). The one field where the footer is NOT reachable mid-typing is the venue SEARCH bar (the search-field exception above hides it) — there the keyboard's 검색 key doubles as the free-text commit (VenuePickPanel onSearchSubmit → the session wizard's closeVenueEdit), so the returning footer's 다음 goes straight to advancing. Field-level 저장 CTAs are RETIRED — explicit 저장 survives ONLY inside overlay editors (the match-rules sheet, step-3's schedule AttachedPanel), where it means close-and-apply, and those PANEL scopes still context-switch the footer per the contextual-CTA law. Discrete controls (chips, forks, steppers, defaulted DropdownChips, slider release) are unchanged: the tap/release itself both commits AND reveals (per the TEXT-vs-choice distinction below). The scope bridge shape (XEditState { complete, close } via onEditStateChange) is unchanged and still required for every free-text field — the screen needs it for the advance-flush and for disabled-gating on the buffer's validity.
  • The confirm affordance is otherwise the field's own interaction — for a TEXT field, blur (or the footer's advance-flush); the selection tap for choices (re-confirming a default counts). REQUIRED enums use DropdownChip allowClear={false} with a placeholder — never prepopulated. No stack-rendered confirm-button rows.
  • Loop-proof publish contract (owner-ratified, 2026-08-24, after an on-device max-update-depth loop traced to an unmemoized setDraft wrapper churning close's identity every render): the useEffect that calls onEditStateChange(...) may depend ONLY on semantic booleans (the editing flag, the complete flag) and on permanently-stable function identities — never on the whole draft object, and never directly on a raw closeXEdit callback. close is delivered through a ref trampoline (const closeXEditRef = useRef(closeXEdit); closeXEditRef.current = closeXEdit; const stableCloseXEdit = useCallback(() => closeXEditRef.current(), []);), so its published identity is stable regardless of whatever closeXEdit's own deps do. This is defense-in-depth on top of the actual fix — the screen's setDraft wrapper must itself be memoized with stable deps — so a publish effect can never re-fire from identity churn alone, however it's introduced upstream.
  • Optional fields ride along inside the section of the required field they belong to (tagline with name) — they never gate a reveal on their own.
  • Choice fields start empty (no preselected chip/day/time) so an untouched section reads as unanswered; forks that gate branches (정기 일정이 있나요?) are asked once and disappear once the flow itself is the evidence.
  • Reveal is a LATCH (owner 2026-08-09): once a section has revealed it never re-hides — invalidating an earlier answer (deleting the club name) shows that field's own error and disables 다음 via primaryHint; validity gates the FOOTER, never the stack. Enforced inside RevealStack (keyed latch), so steps cannot regress it.
  • Every reveal fires an a11yAnnouncement and the screen scrolls the new section into view (onRevealscrollToEnd, deferred one frame). The reveal animation preset (250ms decelerate) is the only reveal motion.
  • Chained inputs auto-advance where the next input is certain: confirming 시작 opens the 종료 picker (via the modal's onHide, and only when 종료 is still empty).

The data model — live draft

Wizard state is a live draft: every answer commits into the draft the moment it's given (there is no per-step "apply"). Consequences:

  • 다음 means "this step is confirmed, continue" — it never doubles as a save.
  • Inline editors (attached panels) edit the draft directly; closing IS saving, which is why the contextual 저장 CTA only closes the scope.
  • Repeating entries (schedules) are pushed into the draft the moment their first field commits and are filled in place — "the round IS a draft entry"; no round-local state that could drift from the draft.
  • Resume restores the draft and re-derives confirmed sections from validity (a resumed valid name starts confirmed — settled answers must not re-hide).

Prefill law

A prefill shortcut (create-session's 정기 일정/지난 일정 selectors) seeds several draft fields from one tap — a convenience the live-draft model treats no differently from any other answer, EXCEPT for what it's allowed to do silently. Applies silently + success toast only while every field it would seed is still untouched (equal to its INITIAL_DRAFT value, or the prefill would write the same value anyway — a no-op overwrite doesn't count). The moment a prefill would actually replace a value the 총무 already edited on an earlier or later step, it MUST confirm first — useConfirm/confirm.show (@twomore/ui), the same destructive-confirm grammar both wizards already use elsewhere (create-session-step1.tsx's own calendar-duplicate-date confirm; create-club-step1.tsx and create-club-screen.tsx's several confirm.show guards). Cancel applies nothing — no partial merge, no toast; the draft is left exactly as it was. This is the same shape as RemoveIconButton's built-in confirm (components.md, "Item removes"): a silent path for the no-consequence case, a confirm gate the instant the action becomes destructive.

The field grammar — which primitive for what

InputPrimitiveNotes
Short textLabeledField size="lg" (+required) + StyledInputbuffered edit scope (input law above) — useEditScope, blur commits, footer 다음 flushes; the raw-value-per-keystroke shortcut stays retired
2-way forkpaired SelectionCardsrequired forks gate everything below
Multi/single choice chipsSelectionChip (checkbox/radio roles)first tap confirms the reveal; more taps keep editing
Defaulted enumDropdownChip standaloneGCal-style; selection tap confirms even when re-picking the default
Fully-defaulted axisthe axis's own real control (chips/fork/DropdownChip/Stepper), rendered directlypre-confirmed, visible from the moment its section reveals — see the defaulted-axis law below; never a collapsed/expander row
Time rangeTimeBandSliderowner-ratified 2026-08-13, replacing the paired 시작/종료 PickerRow + DateTimePickerModal rows; drag the domain band directly (snap + live label, three grab surfaces — start/end handles + whole-band shift) instead of two list pickers
Add another entryAddCarddashed open-slot tile in the CONTENT area — never a footer-style button
Remove an entryRemoveIconButtontrash vocabulary + built-in confirm — law in components.md ("Item removes")
Edit an entry in placewhole-row tap → AttachedPanelbordered field row (PickerRow border/radius recipe + fold chevron; $primary border while its panel is open); panel = $primary border + $primarySubtle + notch, rendered directly below the row; re-tapping the row folds it
Entry summarycard with one compact line per entrymetadata-only summary rows, no middle dots

Control rendering laws (owner 2026-08-09): a control standing on its OWN LINE fills it — single-line chip groups give every chip flex={1}, DropdownChip takes fullWidth, and a lone Stepper takes fullWidth (bordered field row, ± as 32px circular bordered surfaces — never bare glyphs against the background). Computed results (calculator receipts, suggested values) render on AttachedPanel, nested directly under the LAST control feeding the calculation — the belongs-to-the-control grammar (field grammar table above), not a detached SummaryCard (correction, owner audit 2026-08-24: create-club-step6.tsx's 운영비 receipt ~290-391 explicitly rejects SummaryCard in favor of this attachment, and create-session-step3.tsx's player-calculation receipt ~282-354 mirrors it as precedent). SummaryCard remains the tinted $primarySubtle surface distinct from Card (content) and AttachedPanel (editing scope) for a computed result with no owning control to attach under; suggestions apply via an explicit action, never silent writes. Glyph taxonomy (owner 2026-08-09): EMOJIS are the attention-grabbers — colorful glyphs for facts and summaries the eye should catch (fact chips, context/status rows, review-row leading glyphs, via GlyphSlot emoji=); lucide Icons are functional visual aids on controls and affordances (chevrons, ±, trash, close, camera). Never swap the two directions. NO calculator exemption from the reveal contract: calculator steps reveal their sections sequentially too (defaulted controls confirm on their own interaction — the touched pattern), with the receipt as the final revealed section.

Defaulted axis — real control, no expander (owner-ratified LAW, 2026-08-25, superseding the collapsed-axis streamline below): an axis with a REAL default — not a null-until-answered required field — renders its own true control (chips/fork/DropdownChip/bare Stepper/etc.) DIRECTLY and VISIBLY, pre-confirmed with that default: zero taps to accept, and the section counts as CONFIRMED from the start, never gating the reveal chain on its own. It must NEVER be hidden behind a chevron/pencil expander — owner directive, 2026-08-25: "I hate the edit panels that only open when a chevron is clicked — one more step. Don't do this." Never for required unset-start fields (the law immediately below stays authoritative for those). Precedent: create-session-step3.tsx's 구성/실력/성격 and create-session-step4.tsx's 참석 마감/반복 (all render their real controls directly); create-session-step-players.tsx's 1인당 경기 수 (bare Stepper, default 2, amending its prior required-null-start law for this field specifically) was never collapsed and needed no change.

Retired (2026-08-25): the CollapsedAxisRow primitive. A one-round streamline tried rendering a fully-defaulted axis as a pre-answered label+value+pencil row (CollapsedAxisRow, @twomore/ui) that expanded its real control into an AttachedPanel on tap. The owner rejected this outright — collapsing a zero-tap-to-accept default behind an expander reintroduces the exact one-more-step friction the "zero taps" law exists to eliminate; a default the user never has to touch should never be hidden either. CollapsedAxisRow is deleted from @twomore/ui; any surviving reference to it in a wizard step is drift, not precedent — fix on next touch.

Required marker + unset-start (owner QA, step-5 audit, 2026-08-15): every REQUIRED control's title carries the LabeledField required asterisk ($error, size-matched to the label) — the ONE shared visual; a hand-rolled <Text>{label}</Text><Text color="$error">*</Text> pair is drift, not a second implementation. Its draft field starts genuinely UNSET — null, never a plausible-looking literal default (2, 0.6, …) — so a skipper can't mistake "never touched" for "already answered"; a Stepper/ValueSlider that can't render null displays a floor value below its valid range (value ?? 0) so the untouched control visibly reads the sentinel, and the first real interaction writes the actual answer. The step's isNextEnabled gate (create-club-screen.tsx pattern: a per-step stepNValid(draft) export) checks that nullness DIRECTLY — never a proxy useState "touched" flag that can drift out of sync with the draft — and primaryHint names the still-null field. A control that structurally can't represent "unset" (a hue slider always has SOME color) is exempt: required there means "can't be skipped", not "must render blank". Skippable steps (owner-designated, e.g. 회비/소개) keep their fields optional and unmarked — the step-level skip affordance already communicates that, an asterisk on every field inside it would be noise. A value the same club authored (its preferredFormat, memberComposition) counts as an answer and may seed a required field; a literal default (2, 0.6, 'doubles') never does.

AttachedPanel (@twomore/ui) is the one implementation of the attached-panel grammar (centered notch default, notchRight for asymmetric anchors like a fork's right tile) — never hand-roll the border/notch recipe.

Below-control caption (owner audit, 2026-08-17 — "tip below the input box has no canon"; grounded in research, 2026-08-18 — the owner had challenged this as convention-codified, not evidence-based): every wizard/settings field had been hand-rolling its own sibling <Text role="cardMeta"> under a control, with no shared slot — a survey of create-club/create-session found the same three-line shape (label → control → loose caption <Text>) reimplemented independently in step2, step5, step6, step7, step-guests, and create-session step2–6. Canon: below-control caption = LabeledField's caption prop (packages/ui/src/labeled-field.tsx) — one line, cardMeta, INFO-ONLY (a unit hint, a derived value, a live suggestion). It is never validation — that's primaryHint's job (the why-is-Next-disabled line pinned to the footer) and LabeledField's own error prop's job (inline field error).

External grounding (2026-08-18 research pass): Material Design's supporting/helper text spec — single line, sits below the field, either persistent or visible-only-on-focus, and error text REPLACES supporting text while the field is invalid (reverting once fixed) — M2 helper-text spec, M3 text-field specs. This matches (not corrects) our existing caption/error swap-not-stack behavior and single-line, below-control placement. NN/g's stance on placeholder-as-helper-text — placing guidance text INSIDE the field instead of as a persistent line below it hurts usability/accessibility because it disappears once typing starts — is the underlying reason caption is a separate persistent line and never placeholder copy: Placeholders in Form Fields are Harmful. Apple's HIG is comparatively unopinionated here: it describes label + placeholder-hint as the field-purpose pattern but has no dedicated below-field helper-text spec — HIG text fields — so it neither supports nor contradicts this canon. Toss has no public design-system documentation covering this pattern (checked, none found) — not cited. Net verdict: the existing canon (single line, below control, info-only, swaps with error rather than stacking) holds up against the cross-industry evidence; no correction was forced, only citations added.

Caption vs. AttachedPanel ordering (owner-ratified, 2026-08-18): when a control has an AttachedPanel nested under it, the caption must NOT sit between the control and the panel — a caption there reads as commentary on the control, then the panel appears to float in from nowhere below an already-closed thought. Order is control → AttachedPanel → caption, or omit the caption on that field entirely (fold its info into the panel's own content, or drop it). This means the caption CANNOT be passed via LabeledField's caption prop when that same LabeledField also has a sibling AttachedPanel below it (the prop renders immediately under children, i.e. above any sibling) — render it as a manual trailing <Text role="cardMeta" color="$textSecondary"> line after the AttachedPanel instead. Fixed 2026-08-18: create-club step6's 운영비 field had caption on the LabeledField wrapping the slider/stepper, with the receipt AttachedPanel rendered as a sibling below — canon violation, reordered.

Attached boxes are for dependent CONTROLS — AttachedPanel renders a control that only makes sense once its parent field has a value (e.g. 납부일's day-of-month Stepper, which depends on 월 회비 being nonzero) — never for a text tip; a caption is never worth its own bordered/notched box. Migrated onto the caption prop 2026-08-17: create-club step5 (경기 수, 참석률, 모집 목표), step-guests (참가비, 방문 횟수 제한), step6 (납부일). Other pre-existing sibling-<Text> captions (step2, step7, create-session step2–6) are legacy instances of the same shape — migrate on next touch, not swept in one pass.

Quick start

A wizard MAY offer one toggle that seeds real values for every defaultable axis and shortens the step sequence to skip past the now-answered steps — the toggle itself is the deliberate act (docs/canon/wizards.md's own required-marker law: "a value the same club authored... counts as an answer"; a quick-start seed is exactly that, authored by the toggle, not a literal-default placeholder). Card-critical forks that cannot be defaulted (a logo choice, whether there's a regular meet) stay in the sequence regardless — they're the axes club-surfaces-unification.md item #13 requires a deliberate answer for, and no toggle may silently supply one. The review step always shows every seeded value as its own ordinary editable row (the edit-from-review detour reaches it exactly like any other answer) with a small 기본값 badge naming it as seeded, never a separate un-editable summary or a second confirm step. Turning the toggle back off only clears the flag — already-seeded values stay, since they're legal answers now, not draft artifacts to unwind. Precedent: create-club's 빠르게 만들기 (U-32, 2026-09-17) — [0, 지역, 일정, 확인] — seeds 구성/형식/실력/성격/게스트-허용 and skips 스타일/인원/회비/소개/멤버십·게스트 (create-club/quick-start.ts, applyQuickStartDefaults).

Applying to a wizard

A conforming wizard: WizardShell + per-step RevealStack flows + live draft + the field grammar above. When a step's UX doesn't map cleanly (dense forms, branching that isn't a fork), render the options as wireframes and iterate with the owner first — the contract constrains grammar, it doesn't excuse design-by-default. Current adopters and gaps are tracked in current-project-state.md.

Markdown remains the source of truth. Run yarn docs:check before handoff.