From e6883089b4177f0c29f8a924c59d2bb3d022f2d7 Mon Sep 17 00:00:00 2001 From: Kawa Date: Thu, 26 Mar 2026 14:35:08 +0100 Subject: [PATCH] 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 --- .planning/phases/03-wizard-ui/03-RESEARCH.md | 488 +++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 .planning/phases/03-wizard-ui/03-RESEARCH.md diff --git a/.planning/phases/03-wizard-ui/03-RESEARCH.md b/.planning/phases/03-wizard-ui/03-RESEARCH.md new file mode 100644 index 0000000..09bd8ad --- /dev/null +++ b/.planning/phases/03-wizard-ui/03-RESEARCH.md @@ -0,0 +1,488 @@ +# 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 (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. + + + +## 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 | + + +--- + +## 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. + +```tsx +// 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 ( +
+ + +
+ ); +} +``` + +### 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+. + +```tsx +// 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) => { + dispatch({ type: 'SET_REMOTE_PARAMS', payload: values }); + dispatch({ type: 'SET_STEP', payload: 2 }); + }; + + return ( +
+ {fields.map(field => ( + + ))} + + + ); +} +``` + +### 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. + +```tsx +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. + +```tsx +// 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. + +```tsx +// 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` 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. +```tsx + +``` +**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 `/// ` 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 + +```tsx +// 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 + +```tsx +// 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 + +```tsx +// 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 + +```tsx +// 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 + +```tsx +// 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 + +```tsx +// 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)