Files
Ready2Blob/.planning/phases/07-validation-ux-polish/07-RESEARCH.md
T
2026-03-31 09:13:53 +02:00

26 KiB
Raw Blame History

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>

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. </user_constraints>

<phase_requirements>

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: 324 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
</phase_requirements>

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.tsindex.tsFieldRenderer.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 && <div>...} 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

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.

// 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.

// 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 &&.

// 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:
<label htmlFor={field.key} className="text-sm font-medium text-gray-700 flex items-center gap-1">
  {field.label}
  {field.required && <span className="ml-1 text-red-500">*</span>}
  {field.tooltipText && (
    <button
      type="button"
      onClick={() => setShowTooltip(v => !v)}
      aria-label={`More info about ${field.label}`}
      className="ml-1 text-blue-500 hover:text-blue-700 text-xs"
    >
      
    </button>
  )}
</label>

// Tooltip panel (below input, above helpText/error):
{field.tooltipText && showTooltip && (
  <p className="text-xs text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-1.5">
    {field.tooltipText}
  </p>
)}

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.

// 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.

// SftpAuthToggle.tsx addition:
const [showAuthTip, setShowAuthTip] = useState(false);

// Before the segmented control div:
<div className="flex items-center gap-1">
  <span className="text-sm font-medium text-gray-700">Authentication Method</span>
  <button
    type="button"
    onClick={() => setShowAuthTip(v => !v)}
    aria-label="More info about authentication methods"
    className="text-blue-500 hover:text-blue-700 text-xs"
  >
    
  </button>
</div>
{showAuthTip && (
  <p className="text-xs text-blue-700 bg-blue-50 border border-blue-200 rounded px-2 py-1.5">
    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.
  </p>
)}

Anti-Patterns to Avoid

  • Calling .optional() then .regex() in Zod v4: z.string().optional() returns ZodOptional<ZodString> 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 <button> inside the <label> element, getByLabelText behavior may differ depending on the DOM structure. The ⓘ should be OUTSIDE the <label> element to avoid accessibility issues.

How to avoid: Keep the ⓘ button as a sibling element after the label (wrapped in a flex container), not inside the <label> tag. Test with screen.getByRole('button', { name: /more info about/i }) or getByLabelText on the input separately.

Pitfall 5: S3 region regex edge cases

What goes wrong: The decided regex /^[a-z][a-z0-9-]+[a-z0-9]$/ requires at least 3 characters (start + middle + end). This is correct for all current AWS regions. However, future AWS regions could theoretically be 2-character — though this has never happened.

Why it happens: The + quantifier on the middle group requires at least one character between start and end.

How to avoid: The decided regex is acceptable for the current AWS region list (verified by cross-checking against known regions like us-east-1, eu-west-1, ap-southeast-2). Document the regex choice in registry helpText or a comment.


Code Examples

Verified patterns from codebase + Zod v4 runtime tests:

Zod v4 regex chaining (verified 2026-03-31)

// Required field with regex — order: min(1) then regex
z.string().min(1, 'Storage Account Name is required')
  .regex(/^[a-z0-9]{3,24}$/, 'Must be 324 lowercase alphanumeric characters')

// Runtime verified:
// .safeParse('abc123') → { success: true }
// .safeParse('ABC') → { success: false, error.issues[0].message: 'Must be 324...' }
// .safeParse('') → { success: false, error.issues[0].message: 'Storage Account Name is required' }

buildZodSchema() extended loop body

function buildZodSchema(backendType: BackendType): z.ZodObject<Record<string, z.ZodTypeAny>> {
  const fields = BACKEND_REGISTRY[backendType].fields;
  const shape: Record<string, z.ZodTypeAny> = {};
  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;
  }
  return z.object(shape);
}

FieldRenderer useState tooltip pattern

import { useState } from 'react';

export function FieldRenderer({ field, register, error }: FieldRendererProps) {
  const [showTooltip, setShowTooltip] = useState(false);
  // ...
  // Label row (flex, items-center):
  // <label> text + required star + ⓘ button (if field.tooltipText)
  // <input>
  // tooltip panel (if field.tooltipText && showTooltip)
  // helpText (if !error && !showTooltip, or always — Claude's discretion)
  // error (if error)
}

Registry entry with both validate and tooltipText

// azureblob account field — validate only
{
  key: 'account',
  label: 'Storage Account Name',
  inputType: 'text',
  required: true,
  placeholder: 'mystorageaccount',
  helpText: 'The storage account name (not the full URL)',
  validate: {
    regex: /^[a-z0-9]{3,24}$/,
    message: 'Must be 324 lowercase alphanumeric characters (no hyphens or uppercase)',
  },
}

// azureblob sas_url field — tooltipText only
{
  key: 'sas_url',
  label: 'SAS URL',
  inputType: 'password',
  required: false,
  placeholder: 'https://mystorageaccount.blob.core.windows.net/?sv=...',
  helpText: 'Full SAS URL including account and container.',
  tooltipText: 'A SAS URL (Shared Access Signature) bundles the storage endpoint with a time-limited, scope-limited token. It grants access only to the containers specified and expires automatically. Use this if you want limited-access credentials. If you have the full account key instead, switch to Access Key.',
}

State of the Art

Old Approach Current Approach When Changed Impact
Hand-written Zod schemas per backend buildZodSchema() from registry Phase 5 (v1.1) Adding a registry field automatically adds its Zod validation
Hardcoded field lists in components Registry-driven fields.map() loop Phase 5-6 New field with validate property gets regex validation for free

Deprecated/outdated:

  • None for this phase — patterns are all already established.

Open Questions

  1. helpText visibility when tooltip is showing

    • What we know: helpText is the brief always-visible hint; tooltipText is the expanded explanation
    • What's unclear: Should helpText hide when the tooltip panel is open to reduce visual clutter, or always show?
    • Recommendation: Keep helpText always visible (it's brief). Show tooltip panel below it. Claude's discretion on layout.
  2. AzureAuthToggle registry lookup vs hardcoded tooltipText

    • What we know: AzureAuthToggle bypasses FieldRenderer, so registry tooltipText won't appear automatically
    • What's unclear: Whether to read from registry inside AzureAuthToggle or hardcode the tooltip text strings
    • Recommendation: Read from registry (BACKEND_REGISTRY.azureblob.fields.find(f => f.key === 'sas_url')?.tooltipText) to keep registry as single source of truth. Hardcoding is acceptable if simpler.

Validation Architecture

Test Framework

Property Value
Framework Vitest 4.1.1 + @testing-library/react 16.3.2
Config file vitest.config.ts (environment: node; test files use @vitest-environment jsdom pragma)
Quick run command npx vitest run src/components/wizard/RemoteConfigStep.test.tsx
Full suite command npx vitest run

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
VALID-01 Azure account name rejects non-lowercase, non-alphanumeric, wrong length unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
VALID-01 Azure account name accepts valid 3-24 char lowercase alphanumeric unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
VALID-01 S3 region rejects invalid format (e.g., 'us_east_1', uppercase) unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
VALID-01 S3 region accepts valid format (e.g., 'us-east-1') unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
VALID-01 GCS project_number rejects non-digits unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
VALID-01 GCS project_number accepts digits unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
UX-01 ⓘ button present on azureblob sas_url field unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
UX-01 Clicking ⓘ on sas_url shows tooltip panel unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
UX-01 Clicking ⓘ again hides tooltip panel unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
UX-01 ⓘ button present on SFTP auth method section unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0
UX-01 ⓘ button present on OneDrive token field unit npx vitest run src/components/wizard/RemoteConfigStep.test.tsx Wave 0

Sampling Rate

  • Per task commit: npx vitest run src/components/wizard/RemoteConfigStep.test.tsx
  • Per wave merge: npx vitest run
  • Phase gate: Full suite green (currently 147 tests) before /gsd:verify-work

Wave 0 Gaps

  • VALID-01 test stubs in src/components/wizard/RemoteConfigStep.test.tsx — covers regex rejection and acceptance for all 3 fields
  • UX-01 test stubs in src/components/wizard/RemoteConfigStep.test.tsx — covers ⓘ button presence and toggle behavior for all 4 tooltip fields

(Note: test FILE exists — new describes/its are added to the existing file)


Sources

Primary (HIGH confidence)

  • Direct Zod v4.3.6 runtime execution — .regex() chaining verified, .optional().regex() order issue verified
  • Codebase read: src/schemas/registry.ts, src/schemas/index.ts, src/components/ui/FieldRenderer.tsx, src/components/ui/PasswordField.tsx, src/components/wizard/AzureAuthToggle.tsx, src/components/wizard/SftpAuthToggle.tsx, src/components/wizard/RemoteConfigStep.tsx, src/components/wizard/RemoteConfigStep.test.tsx
  • Test suite run: npx vitest run — 147/147 pass as baseline

Secondary (MEDIUM confidence)

  • 07-CONTEXT.md — user locked decisions, regexes, field lists
  • REQUIREMENTS.md — VALID-01, UX-01 success criteria

Tertiary (LOW confidence)

  • None — all findings are from direct codebase inspection and runtime verification.

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all libraries already installed and in use; no new dependencies
  • Architecture: HIGH — patterns directly observed in existing code; Zod v4 regex chaining runtime-verified
  • Pitfalls: HIGH — AzureAuthToggle bypass confirmed by reading RemoteConfigStep; Zod optional+regex order confirmed by runtime test

Research date: 2026-03-31 Valid until: 2026-04-30 (stable codebase, no moving targets)