From 31d618309d98b6cfdfb07621d852d1a810f98c28 Mon Sep 17 00:00:00 2001 From: Kawa Date: Tue, 31 Mar 2026 09:13:53 +0200 Subject: [PATCH] docs(07): research phase validation and ux polish Co-Authored-By: Claude Sonnet 4.6 --- .../07-validation-ux-polish/07-RESEARCH.md | 493 ++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 .planning/phases/07-validation-ux-polish/07-RESEARCH.md diff --git a/.planning/phases/07-validation-ux-polish/07-RESEARCH.md b/.planning/phases/07-validation-ux-polish/07-RESEARCH.md new file mode 100644 index 0000000..7f23716 --- /dev/null +++ b/.planning/phases/07-validation-ux-polish/07-RESEARCH.md @@ -0,0 +1,493 @@ +# 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 `