Design Consistency Audit — Ticket #3273 (Forgot Password Flow)
Gate 6 | 2026-09-08
================================================================

Skill resolution:
  - nextjs-tailwind-design-system-audit: Option 3 (read_file) —
    /opt/drcarrot-deploy/cross-repo-orchestration/skills/nextjs-tailwind-design-system-audit/SKILL.md
  - nextjs-tailwind-accessibility-audit: Option 3 (read_file) —
    /opt/drcarrot-deploy/cross-repo-orchestration/skills/nextjs-tailwind-accessibility-audit/SKILL.md

Files audited:
  - app/(auth)/forgot-password/page.tsx
  - app/(auth)/forgot-password/verify/page.tsx
  - app/(auth)/forgot-password/reset/page.tsx
  - components/auth/ForgotPasswordForm.tsx
  - components/auth/VerifyOtpForm.tsx
  - components/auth/ResetPasswordForm.tsx
  - components/auth/SignInForm.tsx

Design spec: docs/specs/ticket-3273-design.md
Tailwind config: tailwind.config.ts (read and cross-referenced)

================================================================
PART 1 — DESIGN SYSTEM AUDIT
================================================================

Methodology: Read tailwind.config.ts token definitions, then inspected every
Tailwind class in each feature file against the design spec's Token Mapping
table and the Layout / Spacing Values table. Checked for:
  - Arbitrary hex/rgb values that duplicate an existing token
  - Inconsistent typography/spacing for the same UI role
  - Components that should reuse the shared component library but don't

Positive baseline (consistency confirmed):
  - All four form components reuse BrandPanel, SubmitButton, Button, and
    PasswordField from the shared component library where the design calls for
    existing components.
  - All semantic colors (accent, text, text-subtle, text-faint, border-field,
    status-success, status-danger, surface) use their Tailwind token classes
    consistently across all files.
  - Typography scale is consistent: auth headings use text-[26px]/leading-8/
    font-bold (same as SignInForm from #3252), reset heading uses text-[20px]/
    font-semibold (per spec), body uses text-sm, footnotes use text-xs/
    leading-[18px].
  - Spacing matches the design spec's arbitrary-value table exactly: w-[360px]
    (steps 1-2), max-w-[480px] (step 3), h-[52px] (OTP boxes), gap-[10px]
    (OTP row), mb-[14px] (back nav), mt-[10px] (forgot link).
  - The two-panel auth layout (fixed inset-0 bg-surface flex overflow-hidden
    z-[100] + BrandPanel + flex-grow centered column) is structurally identical
    across all four forms.

Findings:

[DS-1] P3 — SVG hardcoded stroke values (pre-existing pattern, not new)
  File: components/auth/ResetPasswordForm.tsx, lines 138, 164
  Problem: SVG check icons use inline stroke={rulesMet[i] ? "rgb(34,158,75)" :
    "rgb(163,163,163)"} and stroke="rgb(163,163,163)" instead of Tailwind
    stroke-status-success / stroke-text-faint classes.
  Values correct: rgb(34,158,75) = #229e4b = status.success; rgb(163,163,163) =
    #a3a3a3 = text.faint. Token values match exactly.
  Context: Copied from components/forms/ResetPasswordForm.tsx (lines 130, 137)
    as instructed by the design spec. Pre-existing pattern, not a regression.
  Recommended fix: Refactor both files to use className="stroke-status-success"
    / className="stroke-text-faint" and remove inline stroke attributes.
  Risk: None — values identical, only mechanism changes.

[DS-2] P3 — Inactive strength bar hardcoded color (pre-existing, documented)
  File: components/auth/ResetPasswordForm.tsx, line 112
  Problem: bg-[rgb(230,230,230)] for empty strength bar segments has no named
    token.
  Context: Design spec explicitly documents this as "Existing pattern — copy
    verbatim." Same usage in components/forms/ResetPasswordForm.tsx. No token
    needed per spec.

[DS-3] P3 — BrandPanel SVG fill attributes (pre-existing, not this ticket)
  File: components/auth/BrandPanel.tsx, lines 33-39
  Problem: Decorative SVG uses inline fill="rgb(226,205,176)" (accent.selected),
    fill="rgb(247,241,233)" (accent.subtle), fill="rgb(69,40,40)"
    (accent.DEFAULT) as raw values.
  Context: BrandPanel predates this ticket (#3252). Decorative, aria-hidden.
    SVG fill attributes in this pattern are standard.

No P1 or P2 design-system findings. All design-spec token mappings verified
correct. No off-token colors introduced by this ticket.

================================================================
PART 2 — ACCESSIBILITY AUDIT
================================================================

Methodology: Grepped for outline-none, onClick on non-interactive elements,
aria-describedby, aria-live, role attributes. Read each form component checking:
focus indicators, keyboard navigation, label association, semantic HTML, error
message association, state announcements, disabled-state correctness.

Positive baseline (correctly handled):
  - All interactive elements are proper semantic HTML: <button>, <input>, <a>
    (via Next.js <Link>). Zero clickable <div>/<span> instances.
  - All text inputs have associated <label htmlFor> — username, mobile, password
    fields all properly linked.
  - OTP digit boxes each have aria-label={`Digit ${i+1} of ${OTP_LENGTH}`}.
  - OTP auto-focus-advance (onInput) and back-on-Backspace (onKeyDown) provide
    proper keyboard navigation. Paste handler covers bulk entry.
  - PasswordField's show/hide toggle has aria-label="Show password"/"Hide
    password" and uses <button type="button">.
  - Decorative SVGs and icons have aria-hidden="true" throughout.
  - <Button> component includes focus-visible:ring-2 focus-visible:ring-accent
    focus-visible:ring-offset-2 — visible, themed focus ring.
  - Disabled states use actual disabled attribute (SubmitButton, Resend button),
    not just visual opacity.
  - OTP digit focus state replaces outline-none with
    focus:shadow-[inset_0_0_0_2px_theme(colors.accent.DEFAULT)] — visible,
    clearly themed focus indicator.

Findings:

[A11Y-1] P2 — Error messages not programmatically linked to inputs
  Files: ForgotPasswordForm.tsx (line 68), VerifyOtpForm.tsx (lines 148-150),
    ResetPasswordForm.tsx (via PasswordField), SignInForm.tsx (line 56)
  Category: forms
  Problem: Error messages rendered with className="field-error" are visually
    adjacent to their input fields but not linked via aria-describedby. Screen
    readers navigating by form controls will not announce the error when the
    user focuses the invalid field.
  Evidence: grep for aria-describedby across all components/ returns zero
    matches — this is a codebase-wide pre-existing pattern, not a regression
    introduced by #3273.
  WCAG: 3.3.1 (Error Identification) — partial failure. Errors are visible and
    in DOM order but not programmatically associated.
  Why it matters: Screen-reader users who Tab through form fields won't hear the
    error until they navigate past the input into the error paragraph. On forms
    with multiple fields (ResetPasswordForm), this can cause confusion about
    which field has the error.
  Recommended fix: Add id="<field>-error" to each .field-error <p>, and
    aria-describedby="<field>-error" (conditional on error being truthy) to each
    <input>. PasswordField could handle this internally for all consumers.
  Risk: Low — additive attributes only, no visual change.

[A11Y-2] P2 — Dynamic status messages not announced to screen readers
  File: VerifyOtpForm.tsx, lines 154-157 (codeSentMessage), line 150
    (resendError); ForgotPasswordForm.tsx, lines 75-76 (success message)
  Category: aria / states
  Problem: The "A new code has been sent" confirmation, resend-error text, and
    OTP-request success messages appear visually but have no aria-live="polite"
    or role="status". Screen readers won't announce them.
  Evidence: The repo has a Toast component (components/ui/Toast.tsx) with
    role="status" aria-live="polite", but these inline messages don't use it.
  WCAG: 4.1.3 (Status Messages) — failure for dynamically inserted status text
    not in a live region.
  Why it matters: A screen-reader user who clicks "Resend Code" won't know
    whether it succeeded or failed until they manually navigate to the message.
  Recommended fix: Wrap the status/confirmation region in a container with
    aria-live="polite" that persists in the DOM (empty when no message), so
    insertions are announced. Alternatively, fire the existing Toast for these
    messages.
  Risk: Low — additive, no visual change.

[A11Y-3] P3 — Mobile input focus indicator at container level
  File: ForgotPasswordForm.tsx, line 50 (focus-within:border-accent), line 64
    (outline-none, focus:outline-none)
  Category: focus
  Problem: The mobile number <input> has outline-none and focus:outline-none.
    The parent <div> uses focus-within:border-accent as the visual focus
    indicator. This means the focus ring appears on the container (the entire
    +91 prefix + input box), not on the input itself.
  Why it matters: Container-level focus indicators are acceptable per WCAG 2.4.7
    (Focus Visible) — the indicator IS visible, it's just at a different
    boundary than expected. Some keyboard users may find it less intuitive, but
    it does not block functionality.
  Recommended fix: No change required. This is an intentional design decision
    for the composite input pattern (prefix + field share one border). If
    desired, could add focus-within:ring-2 focus-within:ring-accent to the
    container for a more prominent indicator.
  Risk: N/A — cosmetic preference.

[A11Y-4] P3 — Strength meter not announced to screen readers
  File: ResetPasswordForm.tsx, lines 103-123
  Category: aria
  Problem: The password strength label ("Weak"/"Fair"/"Good"/"Strong") and the
    strength bar visuals update live as the user types, but there is no
    aria-live region wrapping the strength output. Screen readers won't
    announce the change.
  Why it matters: Low impact — the adjacent policy checklist (which shows each
    rule as met/unmet) provides the same information in a more granular way.
    The strength label is a convenience summary, not the sole source of
    feedback.
  Recommended fix: Add aria-live="polite" to the strength meter wrapper <div>
    (line 105).
  Risk: None — additive.

[A11Y-5] P3 — Success message in ForgotPasswordForm not in live region
  File: ForgotPasswordForm.tsx, lines 75-76
  Category: aria
  Problem: The success message from requestPasswordResetOtp (line 76,
    text-status-success) is not in an aria-live region. Same issue as A11Y-2
    but for a different form.
  Why it matters: Low impact — after a successful OTP request, the page
    redirects to /forgot-password/verify via the server action's redirect(),
    so the success message is transient and the user quickly moves to a new
    page.
  Recommended fix: Same as A11Y-2 — wrap in aria-live="polite" container.

================================================================
AUTO-FIX ASSESSMENT
================================================================

Per gate instructions, auto-fix is limited to "clearly safe" changes. This
repo checkout is read-only, so fixes are noted but not applied.

Fixable (safe, would auto-fix if write access):
  - None applied (read-only mode).

Would be safe to auto-fix:
  - DS-1: Replace inline stroke={} with className on SVGs in
    ResetPasswordForm.tsx — exact value-to-token mapping is verified.
  - A11Y-2/A11Y-5: Add aria-live="polite" wrapper around status messages —
    additive, no visual change.
  - A11Y-4: Add aria-live="polite" to strength meter wrapper — additive.

Requires design judgment (not auto-fixable):
  - A11Y-1: aria-describedby wiring requires touching PasswordField's API
    (adding an error-id prop or auto-generating IDs) — cross-component change
    that affects all PasswordField consumers codebase-wide. Safe but scoped
    beyond a single-file auto-fix.
  - DS-2/DS-3: Pre-existing patterns documented as intentional; no fix needed.

================================================================
SUMMARY
================================================================

P1 findings: 0
P2 findings: 2 (A11Y-1 aria-describedby, A11Y-2 aria-live on status messages)
P3 findings: 5 (DS-1, DS-2, DS-3, A11Y-3, A11Y-4, A11Y-5)

Gate status: PASS — zero unresolved P1 findings.

Both P2 findings are pre-existing codebase patterns (aria-describedby not used
anywhere; inline status messages instead of live regions) that this ticket
follows correctly but does not fix. No new accessibility regressions introduced.
All design token mappings verified correct against tailwind.config.ts.
