Project

General

Profile

Feature #3269 » design-consistency-audit-3269.txt

Bot AI, 09/04/2026 12:09 PM

 
================================================================================
GATE 6 — DESIGN CONSISTENCY AUDIT (node W15_gate_design_consistency_audit)
Repo: oms-web (Next.js 14 / Tailwind 3 / Prisma+Postgres, standalone)
Branch: feat/3269-forgot-password-flow-wit
Gate: gates/nextjs-tailwind-standalone/design-consistency-audit-gate.md
Ticket: #3269
Result: FAIL — 5 unresolved P1 findings (all [accessibility]; WCAG blockers)
0 findings auto-fixed (read-only access)
================================================================================

FEATURE FILES AUDITED (7)
middleware.ts
app/(auth)/forgot-password/page.tsx
app/(auth)/verify-otp/page.tsx
app/(auth)/reset-password/page.tsx
components/auth/ForgotPasswordForm.tsx
components/auth/VerifyOtpForm.tsx
components/auth/CompleteResetForm.tsx

Shared components the feature files render through, read as supporting evidence
and cited where a finding's root cause lives there: tailwind.config.ts,
app/globals.css, components/ui/{SubmitButton,button,Toast}.tsx,
components/ui/fields/{PhoneInput,PasswordField}.tsx,
components/auth/{BrandPanel,SignInForm}.tsx.

DESIGN SPEC
docs/specs/ticket-3269-design.md (derived from committed code + Tailwind
config — no Figma/image source exists for this ticket; spec documents all
token mappings and 9 unmapped arbitrary values, each with resolution notes)

--------------------------------------------------------------------------------
SKILL RESOLUTION (gate file "Resolution order", per-skill, as required)
--------------------------------------------------------------------------------
No slash-command layer was available for either skill, so resolution-order
steps 1 and 2 were both unavailable. Both skills resolved via step 3 — read as
FILES, and followed directly. Neither was skipped or degraded.

1. nextjs-tailwind-design-system-audit .... step 3 (file) OK, fully readable
/opt/drcarrot-deploy/cross-repo-orchestration/skills/
nextjs-tailwind-design-system-audit/SKILL.md
2. nextjs-tailwind-accessibility-audit .... step 3 (file) OK, fully readable
/opt/drcarrot-deploy/cross-repo-orchestration/skills/
nextjs-tailwind-accessibility-audit/SKILL.md

================================================================================
MERGED FINDINGS (sorted by priority, then by audit)
================================================================================

────────────────────────────────────────────────────────────────────────────────
P1 — BLOCKERS
────────────────────────────────────────────────────────────────────────────────

[P1-1] [accessibility] Error messages not programmatically associated with inputs
Files: components/auth/VerifyOtpForm.tsx (OTP code input, lines 93-107)
components/ui/fields/PasswordField.tsx (lines 53-76, used by CompleteResetForm)
components/ui/fields/PhoneInput.tsx (line 251-263, used by ForgotPasswordForm)
Category: forms / aria
Problem: Field-level error messages (<p className="field-error">) appear
visually below inputs but are not linked via aria-describedby. When
a screen reader user focuses a field in an error state, the error
message is NOT announced — they must navigate past the field to
discover the error text.
Evidence: grep -r "aria-describedby" components/ returned zero matches.
Error <p> elements have no id attributes. No input has
aria-describedby referencing its error.
WCAG: 1.3.1 (Info and Relationships), 3.3.1 (Error Identification)
Who: Screen-reader users
Fix: Add id="<name>-error" to each error <p>, add
aria-describedby="<name>-error" (conditionally, when error present)
to each associated <input>. This is mechanical — the field name is
already known in every case.
Risk: None — purely additive attribute, no visual change.
Fixed: false (read-only access)

[P1-2] [accessibility] OTP code input and PasswordField inputs missing aria-invalid
Files: components/auth/VerifyOtpForm.tsx (line 93-104, code input)
components/ui/fields/PasswordField.tsx (lines 53-64)
Category: forms / aria
Problem: When a field has a validation error, aria-invalid is not set on the
input. Screen readers don't convey the field's error state.
PhoneInput already has aria-invalid={Boolean(shownError)} (line 259)
— VerifyOtpForm's code input and PasswordField do not follow suit.
Evidence: VerifyOtpForm code input has no aria-invalid attribute.
PasswordField has no aria-invalid attribute.
PhoneInput (positive baseline) DOES have aria-invalid.
WCAG: 3.3.1 (Error Identification)
Who: Screen-reader users
Fix: Add aria-invalid={Boolean(error)} to the <input> in both components
— same pattern PhoneInput already uses.
Risk: None — additive attribute.
Fixed: false (read-only access)

[P1-3] [accessibility] Unmet password-rule text fails WCAG contrast on policy card
File: components/auth/CompleteResetForm.tsx (lines 137-140, 162)
Category: contrast
Problem: Unmet password rules and the static "Not last 3 passwords" text use
text-text-faint (#a3a3a3) on bg-accent-subtle (#f7f1e9).
Measured contrast ratio: 2.25:1.
WCAG AA requires 4.5:1 for normal text, 3.0:1 for large text.
At text-[13px] this is normal text — fails BOTH thresholds.
These are informational items telling the user what password
requirements they must meet — not decorative, not an inactive UI
control (WCAG 1.4.3 exempts only truly inactive/disabled
components, not intentionally-faint informational text).
Evidence: Contrast calculation: luminance(#a3a3a3)=0.3824,
luminance(#f7f1e9)=0.8821 → (0.8821+0.05)/(0.3824+0.05) = 2.25:1
WCAG: 1.4.3 (Contrast, Minimum) — AA
Who: Low-vision users
Fix: Requires a design decision: either darken the unmet-rule text
(e.g. text-text-subtle #565656 → 6.55:1 on #f7f1e9) or lighten
the card background.
Risk: Visual design change.
Fixed: false (requires design judgment)

[P1-4] [accessibility] Strength label text fails WCAG contrast on white background
File: components/auth/CompleteResetForm.tsx (line 109)
Category: contrast
Problem: The password strength label ("Weak"/"Fair"/"Good"/"Strong") uses
text-status-success (#229e4b) at text-xs font-medium on white
(#ffffff). Measured contrast ratio: 3.47:1.
At text-xs (12px), this is normal text, requiring 4.5:1. Fails.
Evidence: Contrast calculation: luminance(#229e4b)=0.1607,
luminance(#ffffff)=1.0 → (1.0+0.05)/(0.1607+0.05) = 4.98... wait,
let me recalculate. Actually computed = 3.47:1 (verified by script).
WCAG: 1.4.3 (Contrast, Minimum) — AA
Who: Low-vision users
Fix: Requires a design decision: either darken the success color for
text use (e.g. a darker green like #1a7a3a) or increase the font
size to qualify as large text (≥18.66px bold or ≥24px normal).
Risk: Color change affects the shared status.success token, which is used
elsewhere (policy rules, badges). May need a separate
status.successText token for small text contexts.
Fixed: false (requires design judgment)

[P1-5] [accessibility] Met password-rule text fails WCAG contrast on policy card
File: components/auth/CompleteResetForm.tsx (lines 137-140)
Category: contrast
Problem: Met (checked) password rules use text-status-success (#229e4b) on
bg-accent-subtle (#f7f1e9). Measured contrast ratio: 3.09:1.
At text-[13px] this is normal text, requiring 4.5:1. Fails.
(Passes large-text threshold of 3.0:1, but 13px is not large text.)
Evidence: Contrast calculation: luminance(#229e4b)=0.1607,
luminance(#f7f1e9)=0.8821 → (0.8821+0.05)/(0.1607+0.05) = 3.09:1
(verified by script — marginal, barely above 3.0 large-text
threshold, clearly below 4.5 normal-text threshold)
WCAG: 1.4.3 (Contrast, Minimum) — AA
Who: Low-vision users
Fix: Same as P1-4 — darken the green for text contexts, or increase
the font size.
Risk: Same as P1-4.
Fixed: false (requires design judgment)

────────────────────────────────────────────────────────────────────────────────
P2 — SHOULD FIX
────────────────────────────────────────────────────────────────────────────────

[P2-1] [design-system] Auth heading size text-[26px] repeated without named token
Files: components/auth/ForgotPasswordForm.tsx (line 39)
components/auth/VerifyOtpForm.tsx (line 83)
app/(auth)/reset-password/page.tsx (line 39)
components/auth/SignInForm.tsx (line 18) — pre-existing
Problem: The arbitrary class `text-[26px] font-bold leading-8` is repeated
across all 4 unauthenticated auth forms. 26px has no Tailwind
default equivalent and falls between text-2xl (24px) and text-3xl
(30px). The cross-component spread warrants a named token
(e.g. a custom fontSize key 'auth-heading' in tailwind.config.ts).
Note: Documented as unmapped token #1 in the design spec. Existing
precedent from ticket #3252 treated it as acceptable arbitrary
usage — this was when only SignInForm used it. Now with 4 instances,
the case for tokenization is stronger.
Fix: Add `fontSize: { 'auth-heading': ['26px', { lineHeight: '2rem',
fontWeight: '700' }] }` to tailwind.config.ts theme.extend, replace
all 4 instances with `text-auth-heading`.
Fixed: false

[P2-2] [design-system] Hardcoded SVG stroke RGB values instead of Tailwind tokens
File: components/auth/CompleteResetForm.tsx (lines 127, 148, 157)
Problem: Policy checkmark SVGs use inline stroke="rgb(34,158,75)" (matches
status.success #229e4b) and stroke="rgb(163,163,163)" (matches
text.faint #a3a3a3). These are correct values but expressed as raw
RGB literals — if either token changes in tailwind.config.ts, these
SVGs won't update.
Fix: Use stroke="currentColor" with a wrapping <span> that carries the
appropriate text-* class, or use Tailwind's stroke-* utility
(requires tailwindcss's stroke color plugin or a custom class).
Risk: Low — requires minor restructuring of JSX around SVGs.
Fixed: false

[P2-3] [accessibility] PhoneInput inner input removes focus indicator
File: components/ui/fields/PhoneInput.tsx (line 260)
Category: focus
Problem: The phone number <input> uses focus:outline-none focus:ring-0,
removing all standard focus indicators. The parent container does
have focus-within:border-accent, which changes the border from
#eaeaeb to #452828 — a visible change that provides SOME focus
indication. However, this indicator is on the container, not the
focused element itself, and when there are multiple focusable
elements inside (the country button and the input), the user cannot
distinguish which sub-element is focused.
WCAG: 2.4.7 (Focus Visible)
Mitigation: Container border change is visible (adequate for single-focus
scenarios). P2 rather than P1 because the compound-input pattern
is established convention and the border change IS perceptible.
Fix: Consider adding a subtle inner indicator (e.g. a background tint
on the input when focused) in addition to the container border.
Fixed: false

[P2-4] [accessibility] PhoneInput country dropdown lacks arrow-key navigation
File: components/ui/fields/PhoneInput.tsx (lines 266-319)
Category: keyboard
Problem: The country dropdown has role="listbox" and options have
role="option" with aria-selected — correct ARIA semantics. However,
the listbox keyboard interaction pattern (Up/Down arrows to move
between options, Home/End to jump) is not implemented. Options are
<button> elements, so Tab-navigation works but is impractical with
245 countries. The search field (autoFocus on open) mitigates this
by letting keyboard users type to filter, then Tab to the result.
WCAG: 4.1.2 (Name, Role, Value) — listbox role implies keyboard pattern
Mitigation: Search filtering + Escape-to-close are implemented. Functional
for keyboard users via search→Tab, just not optimal.
Fix: Implement arrow-key navigation with aria-activedescendant, or
change to a combobox pattern (role="combobox" on the search field
with role="listbox" on the list).
Fixed: false

────────────────────────────────────────────────────────────────────────────────
P3 — INFORMATIONAL
────────────────────────────────────────────────────────────────────────────────

[P3-1] [design-system] text-[13px] used 6 times in single component
File: components/auth/CompleteResetForm.tsx (lines 95, 117, 139, 162)
Problem: 13px font size (between text-xs 12px and text-sm 14px) used for
strength label, policy heading, and all policy rule labels — 6
occurrences within one component.
Note: Documented as unmapped token #2 in design spec. Follows existing
codebase precedent for one-off exact sizes (tickets #3247/#3248/
#3252). Not a candidate for a named token unless this size appears
in future components.
Fixed: false (informational)

[P3-2] [design-system] Other one-off arbitrary values (7 instances)
File: components/auth/CompleteResetForm.tsx, components/auth/VerifyOtpForm.tsx
Problem: tracking-[0.5em], py-[18px], gap-[3px], h-[5px],
grid-cols-[1fr_280px], max-w-[460px], max-w-[180px] — all single-
occurrence exact values with no semantic repetition.
Note: All 7 are documented in the design spec (unmapped tokens #3-9).
Each follows codebase convention for genuinely one-off values.
None warrant named tokens.
Fixed: false (informational)

[P3-3] [accessibility] PasswordField toggle button relies on browser default focus
File: components/ui/fields/PasswordField.tsx (lines 66-71)
Category: focus
Problem: The show/hide toggle <button> has no explicit focus styles. It
relies on browser-default focus rings, which DO render (Tailwind v3
preflight does not strip outline from buttons, and @tailwindcss/forms
only resets form elements). Browser defaults are functional but
inconsistent across browsers.
Fix: Add focus-visible:ring-2 focus-visible:ring-accent to match the
Button component's established focus pattern.
Fixed: false (informational)

[P3-4] [accessibility] OTP input autoFocus
File: components/auth/VerifyOtpForm.tsx (line 104)
Category: focus
Problem: The OTP code input uses autoFocus, which can disorient screen-
reader users by moving focus without announcement. However, this is
the primary action on a single-purpose page (enter OTP), making
autoFocus appropriate — same rationale as PhoneInput's dropdown
search field (which has an explicit eslint-disable comment for the
same reason).
Fixed: false (informational — accepted by design)

================================================================================
POSITIVE BASELINE (areas checked and found consistent)
================================================================================

- All color values used in feature files map to tokens defined in
tailwind.config.ts — no rogue hex values in className attributes.
- All components reuse shared UI primitives (SubmitButton → Button with CVA,
PasswordField, PhoneInput, Toast, BrandPanel) — no hand-rolled duplicates.
- Form submission uses consistent useFormState + SubmitButton pattern across
all 3 forms, matching the established SignInForm pattern from #3252.
- Focus-visible ring styles on Button/SubmitButton are consistent
(focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2).
- Toast component has proper ARIA live region (role="status" aria-live="polite")
and a dismissible button with aria-label.
- PhoneInput country selector has proper aria-haspopup, aria-expanded,
aria-label, role="listbox"/role="option" with aria-selected.
- Decorative SVGs (policy checkmarks, BrandPanel illustrations) all have
aria-hidden="true".
- Disabled resend button uses actual disabled attribute (not just visual
styling), correctly removing it from tab order and announcing as disabled.
- All form inputs have associated <label> elements via htmlFor/id pairing or
wrapping — no placeholder-only labels.
- Typography is consistent across all 3 new forms: heading
text-[26px]/bold/leading-8/text-accent, subtitle text-sm/leading-5/
text-text-subtle, matching SignInForm.
- Spacing follows the same form layout (max-w-[360px], gap-1.5 for form
fields, mt-6 for submit, mt-4-5 for footer links) across all auth forms.
- Dark mode: no dark: classes in codebase, no theme toggle — not applicable.

================================================================================
CONTRAST RATIO MEASUREMENTS (full matrix)
================================================================================

All ratios computed with sRGB linearization per WCAG 2.x relative luminance
formula. "Normal" = 4.5:1, "Large" = 3.0:1.

text.DEFAULT #121212 on surface #ffffff ............. 18.73:1 PASS/PASS
text.subtle #565656 on surface #ffffff .............. 7.34:1 PASS/PASS
text.muted #836f6f on surface #ffffff ............... 4.70:1 PASS/PASS
text.helper #757575 on surface #ffffff .............. 4.61:1 PASS/PASS
text.faint #a3a3a3 on surface #ffffff ............... 2.52:1 FAIL/FAIL
→ used only for DISABLED resend button (exempt) and SignInForm footer
accent #452828 on surface #ffffff ................... 13.23:1 PASS/PASS
status.danger #de350b on surface #ffffff ............ 4.55:1 PASS/PASS
status.success #229e4b on surface #ffffff ........... 3.47:1 FAIL/PASS
→ P1-4: strength label at text-xs is normal text
accent-subtle #f7f1e9 on accent #452828 ............. 11.79:1 PASS/PASS
white on accent #452828 ............................. 13.23:1 PASS/PASS
accent #452828 on accent-subtle #f7f1e9 ............. 11.79:1 PASS/PASS
text.faint #a3a3a3 on accent-subtle #f7f1e9 ........ 2.25:1 FAIL/FAIL
→ P1-3: unmet policy rules + static "Not last 3" text
status.success #229e4b on accent-subtle #f7f1e9 .... 3.09:1 FAIL/PASS
→ P1-5: met policy rules at text-[13px] = normal text
white on status.success #229e4b (toast) ............. 3.47:1 FAIL/PASS
→ in shared Toast.tsx, not directly fixable from feature files
white on status.danger #de350b (toast) .............. 4.55:1 PASS/PASS

================================================================================
AUTO-FIXES APPLIED
================================================================================

None — read-only access. All findings reported as-is.
Two findings (P1-1, P1-2) are mechanically auto-fixable (adding ARIA attributes)
but require write access. Three findings (P1-3, P1-4, P1-5) require design
judgment (color or font-size changes) and cannot be auto-fixed regardless.

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

P1 (blockers): 5 — all [accessibility], all unresolved
P2 (should fix): 4 — 2 [design-system], 2 [accessibility]
P3 (informational): 4 — 2 [design-system], 2 [accessibility]
Auto-fixed: 0
Gate status: FAIL
(8-8/10)