Files
kawaandClaude Sonnet 4.6 e6883089b4 docs(03): research phase wizard UI
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>
2026-03-26 14:35:08 +01:00

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.params since 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

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 — use BACKEND_REGISTRY iteration instead.
  • Storing form state outside react-hook-form: Don't use useState for 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 dispatch RESET (that wipes deployment too).
  • Calling SET_BACKEND_TYPE without clearing params: The reducer does NOT clear params on SET_BACKEND_TYPE (by design from Phase 1). Phase 3 must dispatch SET_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. Use z.string().min(1, 'message') — already used correctly in buildZodSchema.
  • 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 currentStep
  • src/components/wizard/BackendSelectionStep.test.tsx — covers WIZD-01, WIZD-04
  • src/components/wizard/RemoteConfigStep.test.tsx — covers BACK-01, BACK-02, BACK-03
  • src/components/wizard/StepIndicator.test.tsx — covers WIZD-03
  • vite.config.ts update: add test: { environment: 'jsdom', passWithNoTests: true } — required for all React component tests

Open Questions

  1. 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.ts test.environment or per-file @vitest-environment jsdom comments
    • Recommendation: Configure globally in vite.config.ts — all Phase 3 tests need jsdom; per-file comments are fragile
  2. S3 provider field visibility

    • What we know: BACKEND_REGISTRY s3 has a provider select field with a single option (AWS); s3-compatible has provider: Other
    • What's unclear: Should this field be rendered as a visible dropdown (one option) or hidden/pre-filled automatically?
    • Recommendation: Hide the provider field 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
  3. 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.params in useForm. The key={backendType} remount pattern only triggers on backend change; same-backend navigation preserves the form via defaultValues.

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)