# 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 `