# Phase 7: Validation & UX Polish - Research **Researched:** 2026-03-31 **Domain:** React / react-hook-form / Zod v4 / Tailwind CSS inline validation and tooltip patterns **Confidence:** HIGH ## User Constraints (from CONTEXT.md) ### Locked Decisions **Validation scope:** - Azure account name (`account` field, azureblob): 3-24 lowercase alphanumeric characters — validate with regex `/^[a-z0-9]{3,24}$/` - S3 region (`region` field, s3): valid AWS region format (lowercase letters, digits, hyphens) — validate with regex `/^[a-z][a-z0-9-]+[a-z0-9]$/` - GCS project number (`project_number` field, gcs): digits only — validate with regex `/^\d+$/` - S3-compatible endpoint: non-empty only (existing required behavior) — no URL format validation - S3-compatible region: skip — optional field, no universal format rule - No other backends/fields need format validation **Validation rule location:** - Add `validate?: { regex: RegExp; message: string }` to `FieldDef` in `src/schemas/registry.ts` - `buildZodSchema()` in `src/schemas/index.ts` reads `field.validate` and adds `.regex(...)` to the Zod string - Registry entries for the 3 validated fields get their `validate` rule inline **Tooltip trigger:** - Info icon (ⓘ) placed next to the field label — click/tap toggles explanation - Inline reveal: explanation expands directly below the field input, pushing content down - Toggle state is local to the field component (no global state needed) - Tooltip stays visible until ⓘ is clicked again (not dismissed on blur) **Tooltip data location:** - Add optional `tooltipText?: string` to `FieldDef` interface - `helpText` remains unchanged (brief hint, always visible below the field) - `tooltipText` is the longer plain-language explanation shown on ⓘ click - `FieldRenderer` and `PasswordField` render the ⓘ icon + inline expand when `tooltipText` is present **Fields that get tooltips:** - `azureblob sas_url`: explain SAS URL vs access key — SAS URL bundles endpoint + time-limited token, scoped to containers; access key is the full account credential - `azureblob key` (access key): explain it's the full storage account key — different from SAS, full access - `sftp` auth method: explain password vs key-based auth — SftpAuthToggle UI needs an ⓘ on the tab labels or above the toggle - `onedrive token`: explain what the JSON token is and how to obtain it via `rclone authorize "onedrive"` ### Claude's Discretion - Exact Tailwind styling of the ⓘ icon and inline tooltip panel - Whether SftpAuthToggle gets its ⓘ on the tab label or as a standalone line above the tabs - ⓘ icon source (Heroicons, inline SVG, or text character) - Exact wording of tooltip content (stay close to existing helpText tone) ### Deferred Ideas (OUT OF SCOPE) None — discussion stayed within phase scope. ## Phase Requirements | ID | Description | Research Support | |----|-------------|-----------------| | VALID-01 | User sees inline validation error when a field value violates backend-specific format rules (Azure account name: 3–24 lowercase alphanumeric; S3 region: valid format; etc.) | Zod v4 `.regex()` chaining verified; `buildZodSchema()` extension pattern confirmed; error display already exists in FieldRenderer | | UX-01 | User can view a contextual tooltip on sensitive or complex fields (SAS token vs. access key, region codes, SFTP auth method, OneDrive token) | React `useState` toggle pattern sufficient; `FieldDef` extension with `tooltipText?` is the correct approach; PasswordField and FieldRenderer both need ⓘ treatment | --- ## Summary Phase 7 is a narrowly-scoped surgical enhancement to an already-working wizard. Two independent concerns: (1) format validation wired through the schema registry into Zod, and (2) per-field inline tooltip panels wired through the registry into the field renderer components. Both concerns touch the same three-file chain (`registry.ts` → `index.ts` → `FieldRenderer.tsx`/`PasswordField.tsx`), plus `SftpAuthToggle.tsx` for the auth-method tooltip. The project uses **Zod v4.3.6** (not v3). The `.regex()` method is available on `z.string()` and chains normally — verified by direct runtime test. However, `.optional()` in Zod v4 wraps the inner type, making `.regex()` unavailable after calling `.optional()`. The correct order when a field is optional but has a regex constraint is `z.string().regex(...).optional()`, not `z.string().optional().regex(...)`. All three validated fields (azureblob `account`, s3 `region`, gcs `project_number`) are `required: true`, so the simpler pattern applies: `z.string().min(1, ...).regex(regex, message)`. The tooltip feature requires zero new dependencies. React `useState` per field is the right tool. The CSS toggle pattern already established in `AzureAuthToggle` and `SftpAuthToggle` (div.block / div.hidden) is the exact pattern to reuse for tooltip panel visibility. All existing tests pass (147/147); new tests must be added for VALID-01 regex rejection and UX-01 tooltip toggle behavior. **Primary recommendation:** Implement as two sequential plans — Plan 01 adds validation (registry + schema + tests), Plan 02 adds tooltips (FieldDef extension + FieldRenderer + PasswordField + SftpAuthToggle + tests). --- ## Standard Stack ### Core (already installed — no new dependencies) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | zod | 4.3.6 | Schema validation | Already in use — `buildZodSchema()` already chains `.min(1)`, just extend with `.regex()` | | react-hook-form | 7.72.0 | Form state + validation trigger | Already wired — `mode: 'onSubmit', reValidateMode: 'onChange'` means errors appear after first submit then live | | @hookform/resolvers | 5.2.2 | zodResolver bridges Zod into RHF | Already wired in RemoteConfigStep | | react | 18.3.1 | `useState` for tooltip toggle | useState already used in PasswordField for show/hide | | tailwindcss | 4.2.2 | Styling | Inline tooltip panel follows existing helpText style (`text-xs`) | **No new npm packages needed for this phase.** ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | Local `useState` toggle | Global tooltip store | Local is correct here — each field is independent, no cross-field coordination | | CSS div.hidden / div.block pattern | `{showTooltip &&
...}` conditional render | Both work; CSS-hidden is the established project convention for toggles (AzureAuthToggle, SftpAuthToggle) — use CSS-hidden for consistency | | Text character ⓘ | Heroicons InformationCircleIcon | No new dependency needed; text or inline SVG both work; Claude's discretion | --- ## Architecture Patterns ### Recommended File Modification Order ``` src/schemas/registry.ts # Step 1: extend FieldDef interface, add validate + tooltipText fields src/schemas/index.ts # Step 2: extend buildZodSchema() to read field.validate src/components/ui/FieldRenderer.tsx # Step 3: add ⓘ icon + tooltip panel for text/select renderers src/components/ui/PasswordField.tsx # Step 3: add ⓘ icon + tooltip panel for password renderer src/components/wizard/SftpAuthToggle.tsx # Step 4: add ⓘ above the tab toggle src/components/wizard/RemoteConfigStep.test.tsx # Tests: VALID-01 + UX-01 coverage ``` ### Pattern 1: FieldDef Interface Extension **What:** Add two optional properties to the existing `FieldDef` interface. **When to use:** Always — this is the single source of truth for both validate and tooltipText. ```typescript // src/schemas/registry.ts — add to FieldDef interface export interface FieldDef { key: string; label: string; inputType: 'text' | 'password' | 'select' | 'toggle'; required: boolean; placeholder?: string; helpText?: string; options?: { value: string; label: string }[]; validate?: { regex: RegExp; message: string }; // NEW: format validation rule tooltipText?: string; // NEW: longer plain-language explanation } ``` ### Pattern 2: buildZodSchema() Extension **What:** After the existing required/optional branch, chain `.regex()` if `field.validate` is present. **Critical detail:** For required fields, chain `.regex()` AFTER `.min(1)`. For optional fields, chain `.regex()` BEFORE `.optional()` — though all three validated fields are required, so the optional case does not apply in this phase. ```typescript // src/schemas/index.ts — updated loop body for (const field of fields) { let schema: z.ZodTypeAny = field.required ? z.string().min(1, `${field.label} is required`) : z.string(); if (field.validate) { schema = (schema as z.ZodString).regex(field.validate.regex, field.validate.message); } if (!field.required) { schema = (schema as z.ZodString).optional(); } shape[field.key] = schema; } ``` **Zod v4 verification:** `z.string().min(1, ...).regex(/pattern/, 'message')` — confirmed working via runtime test. Regex fires after min(1), so empty string shows "required" error not "invalid format". ### Pattern 3: Tooltip Toggle in FieldRenderer **What:** Add local `useState` for tooltip visibility; conditionally render the ⓘ button and panel. **When to use:** When `field.tooltipText` is present — guard with `field.tooltipText &&`. ```typescript // src/components/ui/FieldRenderer.tsx — text case (same pattern for select) import { useState } from 'react'; // Inside the component function: const [showTooltip, setShowTooltip] = useState(false); // Label with optional ⓘ icon: // Tooltip panel (below input, above helpText/error): {field.tooltipText && showTooltip && (

{field.tooltipText}

)} ``` ### Pattern 4: PasswordField Tooltip **What:** PasswordField currently does not receive `field: FieldDef` — it receives individual props. Two options for adding tooltipText support: **Option A (minimal change):** Add `tooltipText?: string` prop to PasswordFieldProps, same `useState` pattern inside. **Option B (structural change):** Pass the full `FieldDef` to PasswordField so it receives all future FieldDef additions automatically. **Recommendation:** Option A (minimal). The existing interface is stable and PasswordField is already used by both FieldRenderer and the auth toggle components — changing its interface to accept full FieldDef would require updating all call sites. Adding a single optional prop is lower risk. ```typescript // Updated PasswordFieldProps: interface PasswordFieldProps { id: string; label: string; error?: FieldError; registration: UseFormRegisterReturn; placeholder?: string; helpText?: string; tooltipText?: string; // NEW } ``` FieldRenderer passes `tooltipText={field.tooltipText}` when calling PasswordField. AzureAuthToggle call sites do not need tooltipText (azureblob key and sas_url get their tooltip via FieldRenderer in the registry loop — wait, see Pitfall 1 below). ### Pattern 5: SftpAuthToggle Auth-Method Tooltip **What:** The auth-method tooltip is not field-level — it explains the Password vs Private Key choice. A standalone `useState` for one tooltip, placed above the segmented control. ```typescript // SftpAuthToggle.tsx addition: const [showAuthTip, setShowAuthTip] = useState(false); // Before the segmented control div:
Authentication Method
{showAuthTip && (

Password auth sends your password directly to the SFTP server. Key-based auth uses a private key file (more secure — the server only stores your public key). Paste the private key PEM content if using key-based auth.

)} ``` ### Anti-Patterns to Avoid - **Calling `.optional()` then `.regex()` in Zod v4:** `z.string().optional()` returns `ZodOptional` which has no `.regex()` method. Order must be `.regex().optional()` for optional+validated fields (not applicable here since all validated fields are required, but important to know). - **Adding tooltip state to a parent component:** Each field manages its own tooltip state with `useState`. No lifting state up, no global tooltip context. - **Rendering the ⓘ button when `tooltipText` is undefined:** Guard with `field.tooltipText &&` to avoid rendering an empty button for most fields. - **Passing `tooltipText` to PasswordField from AzureAuthToggle for azureblob sas_url and key fields:** These fields are rendered inside AzureAuthToggle which calls PasswordField directly — not via FieldRenderer's registry loop. The `tooltipText` for azureblob.key and azureblob.sas_url must be passed explicitly through AzureAuthToggle (see Pitfall 1). --- ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Regex validation error display | Custom error state in component | Zod `.regex()` + zodResolver | RHF already captures Zod errors and makes them available as `errors[field.key]` — FieldRenderer already renders them | | Tooltip positioning/overflow | CSS `position: absolute` tooltip popover | Inline expand (push content down) | Project decision: inline reveal, pushes content down — simpler, no overflow issues, no z-index conflicts | | Validation trigger timing | Custom onChange handlers | RHF `reValidateMode: 'onChange'` | Already configured in RemoteConfigStep — regex errors appear live after first submit attempt | --- ## Common Pitfalls ### Pitfall 1: AzureAuthToggle bypasses FieldRenderer for sas_url and key **What goes wrong:** `azureblob.sas_url` and `azureblob.key` are not rendered through the registry loop in RemoteConfigStep — they are rendered directly by `AzureAuthToggle` which calls `PasswordField` with hardcoded props. Adding `tooltipText` to the registry entries for these fields is not enough to make the tooltip appear automatically. **Why it happens:** RemoteConfigStep uses a three-branch ternary. The `azureblob` branch renders the account field via FieldRenderer but delegates key+sas_url to `AzureAuthToggle`. AzureAuthToggle has hardcoded `helpText` strings and doesn't read from the registry. **How to avoid:** Two options — (A) look up the registry entry inside AzureAuthToggle and pass `field.tooltipText` to PasswordField, or (B) hardcode the `tooltipText` prop in AzureAuthToggle's PasswordField calls. Option A is cleaner. If using Option A: `BACKEND_REGISTRY.azureblob.fields.find(f => f.key === 'sas_url')?.tooltipText`. **Warning signs:** Registry has `tooltipText` on sas_url and key, but no tooltip appears in the Azure form. Test will catch this if asserting tooltip button presence. ### Pitfall 2: PasswordField used by both FieldRenderer and auth toggles — prop update needed in all call sites **What goes wrong:** PasswordField is called in three places: FieldRenderer, AzureAuthToggle, and SftpAuthToggle. Adding `tooltipText?` to PasswordFieldProps is safe (optional prop), but only FieldRenderer and AzureAuthToggle need it populated. SftpAuthToggle's PasswordField calls (for `pass` and `key_pem`) do not need tooltipText — the auth method tooltip is above the toggle, not on each individual field. **Why it happens:** PasswordField is a shared component used in multiple contexts. **How to avoid:** Simply don't pass `tooltipText` in SftpAuthToggle's PasswordField calls — the prop is optional. ### Pitfall 3: Zod v4 `.regex()` returns a new ZodString — type must be cast for chaining **What goes wrong:** In `buildZodSchema()`, the `schema` variable is typed as `z.ZodTypeAny`. After calling `.min(1, ...)` on a `z.string()`, the result type is still `ZodString`, but TypeScript may not know this through a `ZodTypeAny` variable. **Why it happens:** `ZodTypeAny` is a broad union type. The `.regex()` method is only on `ZodString`. **How to avoid:** Cast to `z.ZodString` before calling `.regex()`: `(schema as z.ZodString).regex(...)`. Alternatively, refactor the loop to keep a `ZodString` variable explicitly. Confirmed working via runtime test. ### Pitfall 4: Test assertions for tooltip — input type='password' accessibility **What goes wrong:** When testing tooltip button presence for password fields (sas_url, key, onedrive token), the label must match what `getByLabelText` expects. The ⓘ button is inline with the label text, not the label itself. **Why it happens:** If the ⓘ is a `