Investigate react-hook-form + Zod v4 + Tailwind v4 patterns for the multi-step wizard, document registry-driven form rendering, touch-then-live validation, Azure auth toggle, and breadcrumb navigation approach. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
25 KiB
Phase 3: Wizard UI - Research
Researched: 2026-03-26 Domain: React multi-step wizard UI with react-hook-form, Zod v4, Tailwind v4 Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Backend Selection Layout
- Clickable card grid — 3 cards for Azure Blob, Amazon S3, S3-Compatible
- Clicking a card auto-advances to the config step (no explicit Next button on step 1)
- Remote name field (WIZD-04) appears at the TOP of step 1, before the backend cards
- Name field validated: alphanumeric, dashes, underscores only — inline error shown after first Next attempt
Azure Credential Toggle
- Segmented control / radio toggle to switch between "SAS URL" and "Access Key" auth methods
- Default auth method: SAS URL (pre-selected on first load)
- Switching auth method PRESERVES both fields in state (inactive field hidden but value kept) — generator must strip the inactive field at config generation time
- All password-type fields (access key, SAS URL, S3 secret) have a show/hide eye toggle
Step Progress Indicator
- Labeled breadcrumb: 1. Backend > 2. Remote Config > 3. Deployment
- Completed steps show checkmark + muted text (e.g., "checkmark Backend") and are clickable to jump back
- Current step shown as bold/active
- Clicking a completed step navigates directly to it (not just Back button)
Forward Navigation Validation
- Required fields: Next button blocked if required fields are empty/invalid
- Error display: errors shown ONLY after first Next click, then update live as user fixes them (no errors while initially filling)
- Inline error messages below each invalid field
- Backend change (via breadcrumb click-back): clears
remote.paramssince fields differ per backend; deployment options preserved - Remote name validation shows inline error below the field after first Next attempt
Claude's Discretion
- Exact Tailwind styling, card visual design, color palette
- Loading/transition animations between steps
- S3 and S3-Compatible config form layout (they share most fields)
- Deployment options step layout (fields are already defined in store: includeInstall, configPath, scriptTargets)
Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| WIZD-01 | User can select a storage backend from a popularity-sorted list (Azure Blob and S3 shown first) | Card grid component with BACKEND_REGISTRY ordering; SET_BACKEND_TYPE dispatch on click |
| WIZD-02 | User navigates a multi-step wizard: backend selection → backend config → deployment options → review/download | Step router in App.tsx driven by state.currentStep; SET_STEP dispatch; breadcrumb nav |
| WIZD-03 | User can go back to previous steps without losing entered data | SET_STEP without resetting params; breadcrumb click only dispatches SET_STEP + SET_REMOTE_PARAMS({}) when backend changed |
| WIZD-04 | User can set a custom remote name (validated: alphanumeric, dash, underscore only) | Remote name field at top of step 1; Zod regex /^[a-zA-Z0-9_-]+$/; SET_REMOTE_NAME dispatch |
| BACK-01 | User can configure an Azure Blob Storage remote (storage account name, authentication method: SAS token or access key) | BACKEND_REGISTRY azureblob fields + segmented auth toggle; both key/sas_url kept in params |
| BACK-02 | User can configure an Amazon S3 remote (access key ID, secret access key, region) | BACKEND_REGISTRY s3 fields rendered via registry-driven form; react-hook-form + BACKEND_SCHEMAS['s3'] |
| BACK-03 | User can configure an S3-compatible remote via endpoint override | BACKEND_REGISTRY s3-compatible fields; shares layout with S3 step; endpoint field present |
| </phase_requirements> |
Summary
Phase 3 builds the entire wizard navigation shell: three visible steps (Backend Selection, Remote Config, Deployment Options) plus the in-memory step router. The entire state layer (store, reducer, context, schemas) is already in place from Phases 1 and 2. Phase 3's job is to wire UI components to that state — no new store design is needed.
The stack is already locked: React 18, react-hook-form v7, Zod v4, @hookform/resolvers v5, Tailwind v4 via the Vite plugin. All field definitions live in BACKEND_REGISTRY; all Zod schemas are pre-built in BACKEND_SCHEMAS. The form rendering layer must consume these rather than duplicate them.
The most nuanced requirement is the "touch-then-live" validation pattern (errors only shown after first Next attempt, then update on every keystroke), the Azure auth toggle that preserves both field values in state while showing only one, and the backend-change breadcrumb flow that resets remote.params but preserves deployment.
Primary recommendation: Build step router in App.tsx first, then each step as an isolated component, wiring react-hook-form + Zod per step. Use BACKEND_REGISTRY to drive field rendering rather than hardcoding JSX per backend.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| react-hook-form | ^7.72.0 | Form state, validation trigger, field registration | Already installed; useForm + resolver = controlled validation |
| @hookform/resolvers | ^5.2.2 | Bridge between react-hook-form and Zod v4 | v5 is required for Zod v4 compat — already installed |
| zod | ^4.3.6 | Schema-based field validation | Already installed; BACKEND_SCHEMAS pre-built |
| tailwindcss | ^4.2.2 | Utility-first styling | Already installed via @tailwindcss/vite, no config file |
| react | ^18.3.1 | Component framework | Project foundation |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| @testing-library/react | ^16.3.2 | Component tests | All wizard step tests |
| @testing-library/dom | ^10.4.1 | DOM queries in tests | Used by @testing-library/react |
| vitest | ^4.1.1 | Test runner | All unit + component tests |
| jsdom | ^29.0.1 | DOM simulation in vitest | Required for React component tests |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| react-hook-form | Controlled state | react-hook-form avoids re-renders per keystroke; already in the project |
| Zod (inline) | Manual regex checks | BACKEND_SCHEMAS already exist; don't duplicate |
| Tailwind utilities | CSS modules | Tailwind v4 already configured via Vite plugin; CSS modules add build complexity |
Installation: No new packages required. All dependencies are already in package.json.
Architecture Patterns
Recommended Project Structure
src/
├── components/
│ ├── wizard/
│ │ ├── StepIndicator.tsx # Breadcrumb nav — 1.Backend > 2.Config > 3.Deployment
│ │ ├── BackendSelectionStep.tsx # Step 0: remote name + backend card grid
│ │ ├── RemoteConfigStep.tsx # Step 1: registry-driven form fields
│ │ ├── DeploymentStep.tsx # Step 2: includeInstall, configPath, scriptTargets
│ │ └── AzureAuthToggle.tsx # Azure-specific SAS/Key toggle sub-component
│ └── ui/
│ ├── FieldRenderer.tsx # Renders a single FieldDef from BACKEND_REGISTRY
│ ├── PasswordField.tsx # password inputType with show/hide eye toggle
│ └── BackendCard.tsx # Clickable backend selection card
├── App.tsx # Step router: renders correct step by currentStep
├── store/ # Phase 1 — do not modify
├── schemas/ # Phase 1 — do not modify
└── generators/ # Phase 2 — do not modify
Pattern 1: Step Router in App.tsx
What: currentStep from store drives which component renders. No router library.
When to use: Always — this is the only navigation mechanism.
// App.tsx — step routing
import { useWizard } from './store/context';
import { BackendSelectionStep } from './components/wizard/BackendSelectionStep';
import { RemoteConfigStep } from './components/wizard/RemoteConfigStep';
import { DeploymentStep } from './components/wizard/DeploymentStep';
const STEPS = [BackendSelectionStep, RemoteConfigStep, DeploymentStep];
export default function App() {
const { state } = useWizard();
const StepComponent = STEPS[state.currentStep];
return (
<div>
<StepIndicator />
<StepComponent />
</div>
);
}
Pattern 2: Registry-Driven Form (RemoteConfigStep)
What: Iterate BACKEND_REGISTRY[backendType] to render fields. No hardcoded JSX per backend.
When to use: RemoteConfigStep must use this — adding a new backend to the registry should "just work" in Phase 4+.
// RemoteConfigStep.tsx — registry-driven form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { BACKEND_REGISTRY } from '../../schemas/registry';
import { BACKEND_SCHEMAS } from '../../schemas';
import { useWizard } from '../../store/context';
export function RemoteConfigStep() {
const { state, dispatch } = useWizard();
const backendType = state.remote.backendType!;
const fields = BACKEND_REGISTRY[backendType];
const schema = BACKEND_SCHEMAS[backendType];
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: state.remote.params,
});
const onNext = (values: Record<string, string>) => {
dispatch({ type: 'SET_REMOTE_PARAMS', payload: values });
dispatch({ type: 'SET_STEP', payload: 2 });
};
return (
<form onSubmit={handleSubmit(onNext)}>
{fields.map(field => (
<FieldRenderer key={field.key} field={field} register={register} error={errors[field.key]} />
))}
<button type="submit">Next</button>
</form>
);
}
Pattern 3: Touch-Then-Live Validation ("validate on submit, then live")
What: react-hook-form's mode: 'onSubmit' with reValidateMode: 'onChange' gives exactly this behavior. Errors appear only after first submit attempt, then update live.
When to use: ALL step forms — this is the locked UX decision.
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
mode: 'onSubmit', // no errors on initial fill
reValidateMode: 'onChange', // live errors once touched
defaultValues: state.remote.params,
});
Pattern 4: Azure Auth Toggle — Both Values in State
What: The toggle sets a local authMethod state ('sas' | 'key'). Both fields are always registered in react-hook-form. Only the active field is displayed. Both values are saved to remote.params on Next (the generator — Phase 2 — strips the inactive one).
When to use: AzureAuthToggle component only.
// AzureAuthToggle.tsx
const [authMethod, setAuthMethod] = useState<'sas' | 'key'>('sas');
// Both fields registered regardless of display:
// register('sas_url') — hidden when authMethod === 'key'
// register('key') — hidden when authMethod === 'sas'
// On toggle: just setAuthMethod, do NOT clear the hidden field's value
Pattern 5: Backend-Change via Breadcrumb
What: When user clicks a completed step (Back to Backend step), dispatch SET_STEP + conditionally SET_REMOTE_PARAMS({}) if backendType changes.
When to use: StepIndicator click handler.
// StepIndicator — clicking a completed step
const handleStepClick = (targetStep: number) => {
if (targetStep === 0 && state.currentStep > 0) {
// Going back to backend selection: clear params (backend may change)
dispatch({ type: 'SET_REMOTE_PARAMS', payload: {} });
// Note: do NOT reset deployment — user keeps those choices
}
dispatch({ type: 'SET_STEP', payload: targetStep });
};
Anti-Patterns to Avoid
- Hardcoding per-backend JSX: Never write
if (backendType === 'azureblob') { ... }for field rendering — useBACKEND_REGISTRYiteration instead. - Storing form state outside react-hook-form: Don't use
useStatefor individual field values — let react-hook-form manage them; sync to wizard store only on Next. - Resetting deployment on backend change:
SET_REMOTE_PARAMS({})is correct on backend change; never dispatchRESET(that wipes deployment too). - Calling
SET_BACKEND_TYPEwithout clearing params: The reducer does NOT clear params onSET_BACKEND_TYPE(by design from Phase 1). Phase 3 must dispatchSET_REMOTE_PARAMS({})explicitly when the backend changes. - Using
mode: 'onChange'on useForm: This would show errors immediately on first keystroke — violates the locked UX decision.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Form validation | Custom validate functions | zodResolver(BACKEND_SCHEMAS[type]) |
Schema already exists; hand-roll misses edge cases |
| Zod schema per backend | New z.object() calls |
BACKEND_SCHEMAS from src/schemas/index.ts |
Schemas are programmatically generated from BACKEND_REGISTRY — already done |
| Field definitions | JSX props per field | BACKEND_REGISTRY[backendType] |
Registry is the single source of truth; duplicating creates drift |
| Touch-then-live validation | useState(touched) flags |
mode: 'onSubmit', reValidateMode: 'onChange' |
react-hook-form handles this natively |
| Show/hide password | type attribute toggle |
Local useState<boolean> for visibility |
Simple toggle, but must be per-field not global |
| Step routing | React Router | state.currentStep + conditional render |
No URL routing needed; store already has currentStep |
Key insight: The entire data layer is built. Phase 3 is a UI wiring exercise — every custom "utility" re-invents something the stack already provides.
Common Pitfalls
Pitfall 1: defaultValues Stale on Backend Change
What goes wrong: When user goes back, changes backend, and comes forward, react-hook-form renders the old defaultValues because the component didn't unmount/remount.
Why it happens: react-hook-form caches defaultValues at mount. If the component stays mounted across backend changes, old values persist in the form.
How to avoid: Key the RemoteConfigStep form (or the whole component) on backendType. Changing the key prop forces a full remount.
<RemoteConfigStep key={state.remote.backendType} />
Warning signs: S3 fields showing Azure values after backend switch.
Pitfall 2: Registering Hidden Fields Causes Validation Failures
What goes wrong: Azure's key and sas_url are both in the Zod schema. If both are registered but only one is shown, the hidden one may fail validation (required: false but empty string fails .min(1) if misconfigured).
Why it happens: buildZodSchema marks key and sas_url as required: false, making them z.string().optional(). This is already correct — empty string passes. But if someone adds .min(1) to these fields in future, hidden field validation breaks.
How to avoid: Confirm both Azure auth fields are required: false in BACKEND_REGISTRY (they are). When submitting, pass all field values including the hidden one — the generator (Phase 2) already strips the inactive field.
Warning signs: Form cannot be submitted even with valid visible field filled.
Pitfall 3: Tailwind v4 Has No Config File
What goes wrong: Developer tries to add custom colors or breakpoints in tailwind.config.js — file doesn't exist, changes don't apply.
Why it happens: Tailwind v4 via @tailwindcss/vite uses CSS-first configuration (@theme in CSS) rather than a JS config file.
How to avoid: Add custom tokens in src/index.css using @theme {} block. Do not create tailwind.config.js.
Warning signs: Custom colors defined in a config file silently ignored.
Pitfall 4: SET_BACKEND_TYPE Does Not Reset Params
What goes wrong: User selects Azure, partially fills form, goes back, selects S3. The S3 form has defaultValues from state.remote.params which contains Azure keys — S3 fields appear pre-filled with Azure values.
Why it happens: The reducer's SET_BACKEND_TYPE case intentionally does not reset remote.params (Phase 1 design decision, logged in STATE.md).
How to avoid: The BackendSelectionStep's card click handler must dispatch BOTH SET_BACKEND_TYPE AND SET_REMOTE_PARAMS({}) before advancing.
Warning signs: S3 form pre-filled with stale Azure credentials.
Pitfall 5: Vitest Needs jsdom Environment for Component Tests
What goes wrong: Component tests using @testing-library/react fail with "document is not defined."
Why it happens: Vitest defaults to node environment; jsdom is installed but not configured.
How to avoid: Add /// <reference types="vitest" /> and configure test.environment: 'jsdom' in vite.config.ts, or add // @vitest-environment jsdom comment at the top of each test file.
Warning signs: ReferenceError: document is not defined in test output.
Code Examples
Verified patterns from the existing codebase:
Dispatching Step Navigation
// Source: src/store/types.ts — WizardAction union
dispatch({ type: 'SET_STEP', payload: 1 });
dispatch({ type: 'SET_BACKEND_TYPE', payload: 'azureblob' });
dispatch({ type: 'SET_REMOTE_PARAMS', payload: { account: 'foo', key: '', sas_url: 'https://...' } });
dispatch({ type: 'SET_DEPLOYMENT', payload: { includeInstall: true } });
Accessing All Wizard State
// Source: src/store/context.tsx
const { state, dispatch } = useWizard();
// state.currentStep, state.remote.backendType, state.remote.name,
// state.remote.params, state.deployment.includeInstall, etc.
Resolving BACKEND_SCHEMAS for a Backend
// Source: src/schemas/index.ts
import { BACKEND_SCHEMAS } from '../schemas';
import { zodResolver } from '@hookform/resolvers/zod';
const resolver = zodResolver(BACKEND_SCHEMAS[state.remote.backendType!]);
Reading Field Definitions for a Backend
// Source: src/schemas/registry.ts
import { BACKEND_REGISTRY } from '../schemas/registry';
const fields = BACKEND_REGISTRY['azureblob'];
// fields[0] = { key: 'account', label: 'Storage Account Name', inputType: 'text', required: true }
// fields[1] = { key: 'key', label: 'Access Key', inputType: 'password', required: false }
// fields[2] = { key: 'sas_url', label: 'SAS URL', inputType: 'password', required: false }
Remote Name Validation Pattern
// WIZD-04: alphanumeric, dash, underscore only
const remoteNameSchema = z.object({
name: z.string()
.min(1, 'Remote name is required')
.regex(/^[a-zA-Z0-9_-]+$/, 'Only letters, numbers, dashes, and underscores allowed'),
});
Deployment Step Fields Mapping to Store
// Source: src/store/types.ts — deployment shape
// includeInstall: boolean → toggle (checkbox/switch)
// configPath: 'machine-wide' | 'user-profile' → radio group
// scriptTargets: ('intune' | 'rmm')[] → checkbox group (default: both selected)
dispatch({ type: 'SET_DEPLOYMENT', payload: { includeInstall: true } });
dispatch({ type: 'SET_DEPLOYMENT', payload: { configPath: 'user-profile' } });
dispatch({ type: 'SET_DEPLOYMENT', payload: { scriptTargets: ['intune'] } });
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Tailwind JS config file | CSS-first @theme {} in index.css |
Tailwind v4 | No tailwind.config.js exists or should be created |
@hookform/resolvers v3 for Zod v3 |
@hookform/resolvers v5 for Zod v4 |
Zod v4 release | v5 resolvers REQUIRED — already in package.json |
z.object() with .nonempty() |
z.string().min(1, msg) |
Zod v4 | .nonempty() removed in Zod v4 |
Deprecated/outdated:
z.string().nonempty(): Removed in Zod v4. Usez.string().min(1, 'message')— already used correctly inbuildZodSchema.tailwind.config.js: Not used in Tailwind v4 via Vite plugin. Custom tokens go in@theme {}in CSS.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest ^4.1.1 |
| Config file | None — vitest config not yet added to vite.config.ts |
| Quick run command | npx vitest run --reporter=verbose |
| Full suite command | npx vitest run |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| WIZD-01 | Backend card grid renders Azure, S3, S3-Compatible; Azure first | unit | npx vitest run src/components/wizard/BackendSelectionStep.test.tsx |
Wave 0 |
| WIZD-02 | App renders correct step component for currentStep 0, 1, 2 | unit | npx vitest run src/App.test.tsx |
Wave 0 |
| WIZD-03 | Going back preserves remote.params; deployment untouched | unit | npx vitest run src/components/wizard/StepIndicator.test.tsx |
Wave 0 |
| WIZD-04 | Remote name validates regex; error shown after first submit | unit | npx vitest run src/components/wizard/BackendSelectionStep.test.tsx |
Wave 0 |
| BACK-01 | Azure config form renders account + auth toggle; both values preserved in params | unit | npx vitest run src/components/wizard/RemoteConfigStep.test.tsx |
Wave 0 |
| BACK-02 | S3 config form renders access_key_id, secret_access_key, region | unit | npx vitest run src/components/wizard/RemoteConfigStep.test.tsx |
Wave 0 |
| BACK-03 | S3-compatible config form renders endpoint field | unit | npx vitest run src/components/wizard/RemoteConfigStep.test.tsx |
Wave 0 |
Sampling Rate
- Per task commit:
npx vitest run --reporter=verbose - Per wave merge:
npx vitest run - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
src/App.test.tsx— covers WIZD-02: step routing by currentStepsrc/components/wizard/BackendSelectionStep.test.tsx— covers WIZD-01, WIZD-04src/components/wizard/RemoteConfigStep.test.tsx— covers BACK-01, BACK-02, BACK-03src/components/wizard/StepIndicator.test.tsx— covers WIZD-03vite.config.tsupdate: addtest: { environment: 'jsdom', passWithNoTests: true }— required for all React component tests
Open Questions
-
Vitest jsdom environment configuration
- What we know: jsdom is installed (package.json); vitest has no environment config yet
- What's unclear: Whether to configure via
vite.config.tstest.environmentor per-file@vitest-environment jsdomcomments - Recommendation: Configure globally in
vite.config.ts— all Phase 3 tests need jsdom; per-file comments are fragile
-
S3
providerfield visibility- What we know: BACKEND_REGISTRY
s3has aproviderselect field with a single option (AWS);s3-compatiblehasprovider: Other - What's unclear: Should this field be rendered as a visible dropdown (one option) or hidden/pre-filled automatically?
- Recommendation: Hide the
providerfield at the UI level and auto-set it to the registry default value on form submit — users don't need to see a one-option dropdown
- What we know: BACKEND_REGISTRY
-
AzureAuthToggle default pre-population
- What we know: Default auth method is SAS URL; state starts with
params: {} - What's unclear: On returning to the Azure form after the user navigated away and back WITHOUT changing backend, should the form restore the previously filled SAS URL?
- Recommendation: Yes — use
defaultValues: state.remote.paramsin useForm. Thekey={backendType}remount pattern only triggers on backend change; same-backend navigation preserves the form viadefaultValues.
- What we know: Default auth method is SAS URL; state starts with
Sources
Primary (HIGH confidence)
- Codebase direct read:
src/store/types.ts— WizardState shape, action union, INITIAL_STATE - Codebase direct read:
src/store/reducer.ts— reducer action handling, confirmed SET_BACKEND_TYPE does not reset params - Codebase direct read:
src/store/context.tsx— useWizard hook, WizardProvider - Codebase direct read:
src/schemas/registry.ts— BACKEND_REGISTRY field definitions for all 3 backends - Codebase direct read:
src/schemas/index.ts— BACKEND_SCHEMAS, buildZodSchema pattern - Codebase direct read:
package.json— exact installed versions of all dependencies - Codebase direct read:
vite.config.ts— Tailwind v4 Vite plugin, no separate tailwind config - STATE.md decisions log — architectural decisions from Phases 1 and 2
Secondary (MEDIUM confidence)
- react-hook-form docs pattern:
mode: 'onSubmit', reValidateMode: 'onChange'for touch-then-live validation behavior - Vitest docs pattern:
test.environment: 'jsdom'in vite.config for React component tests
Tertiary (LOW confidence)
- None — all critical claims verified against the actual codebase
Metadata
Confidence breakdown:
- Standard stack: HIGH — all packages confirmed in package.json with exact versions
- Architecture: HIGH — store contracts confirmed from source; patterns match existing Phase 1/2 code
- Pitfalls: HIGH — SET_BACKEND_TYPE non-reset confirmed in reducer.ts; Tailwind v4 config confirmed in vite.config.ts; Zod v4 patterns confirmed in schemas/index.ts
- Validation architecture: MEDIUM — jsdom is installed but vitest environment config gap is real (no test config found)
Research date: 2026-03-26 Valid until: 2026-04-25 (stable stack; no fast-moving dependencies)