Skip to content

Mobile Audit — Patient Conversation Interface (375px)

Date: 2026-05-10 Scope: ConversationApp + MessageThread/Bubble + RichCard + MSO wizard + chat input + sidebar Auditor: /impeccable audit (Opus 4.7, 1M context) Coverage: Comprehensive across the eight scope items. No truncation.


Audit Health Score

# Dimension Score Key finding
1 Accessibility 3/4 Most touch targets >=44px and ARIA roles correct. aria-live="polite" on thread is good. Two issues: hidden-via-CSS streaming bubble in MessageThread (h-0 + opacity-0) is exposed/aria-hidden inconsistently across paths, and RichCard <label> rows in MatchResultsCard are missing the underlying form control association cue (visible checkbox is decorative — actual input absent).
2 Performance 3/4 Solid: WS streaming has rAF drain, MessageBubble is memoised with surgical comparator, in-place merge avoids whole-array re-renders. Lingering issue: streaming-cursor::after blinks via opacity, but the CurawayAvatar animate-[curaway-pulse_1.5s_ease-in-out_infinite] runs continuously on every assistant turn while streaming and prefers-reduced-motion is not honoured for that one keyframe.
3 Theming 3/4 Brand tokens used consistently (bg-chat-brand, text-chat-coral, hsl(var(--chat-brand))). Only one hardcoded brand hex slipped: RazorpayPaymentStep.tsx:133 ships color: '#008B8B' to the Razorpay SDK config (acceptable since the SDK requires a literal, but a getComputedStyle lookup would fix it).
4 Responsive 2/4 This is the weakest axis. Conversation thread max-width is 920px and the message-bubble inner content uses px-2 sm:px-3 md:px-6 — but the input bar uses max-w-3xl mx-auto (768px) with no centered flag in active state, breaking visual alignment with the thread at 375px. The MSO wizard's day-picker scrolls horizontally without scroll-shadow affordances at 375px (~5 cards = ~340px content fits, but the 7-card row exceeds viewport). The MSOSchedulingCard time-picker uses grid-cols-3 which produces ~108px-wide chips at 375px — fine — but the chip min-h-[44px] is set without min-w-[44px], and short labels (e.g. "9 AM") sit in a chip narrower than 44px.
5 Anti-patterns 2/4 Two BANNED side-stripe violations: DocumentChecklistCard.tsx lines 55, 61, 67, 73, 79, 85, 91 use border-l-2 (2px) for status, and ConfirmationCard.tsx:47 uses border-l-2 border-chat-coral/40 for on-site test list items. Per .impeccable.md and the audit rubric, side-stripes >1px are banned. The top accent stripe (border-t-[var(--accent-stripe-width)] = 3px) on ConfirmationCard is BORDERLINE — it's a top stripe, not a side stripe, but at 3px it reads as the same hero/anti-AI tell. No gradients, no glassmorphism, no sparklines, no hero metric layout, no AI cyan/purple palette.
Total 13/20 Solid but not impeccable — fix the side stripes and the responsive input/thread alignment to climb to 17.

Anti-patterns verdict

Mostly pass. A designer would call this "Claude.ai-influenced" rather than "AI-generated slop". The chat shell is restrained, the brand colours are correctly muted, the typography is consistent with Montserrat. The only two tells:

  1. The 2px coloured left-borders in DocumentChecklistCard and ConfirmationCard read as "status decoration" — exactly the BANNED pattern. Should be 1px (or replaced with a status icon + tonal background, which is already there).
  2. The 3px top accent stripe (--accent-stripe-width: 3px) on ConfirmationCard sub-cards is a coloured rule above a card surface — it's the same family of decoration the rubric warns about. It's not strictly banned (rubric explicitly bans side stripes >1px), but in the context of two cards it accumulates.

No gradient text, no glassmorphism, no sparkline decorations, no purple/cyan palette, no hero metric layout, no modal overuse. ConsentCard correctly uses the inline-in-chat paradigm via MobileModal only for the Terms drill-down.


Executive summary

  • Total issues: P0=1, P1=4, P2=6, P3=3 (14 total)
  • Top must-fix items:
  • [P0] Welcome state input bar has no bottom-keyboard handling — at 375px on iOS, the on-screen keyboard pushes the suggestion chips off-screen and the centered layout's flex items-center justify-center collapses the chip area beneath the keyboard.
  • [P1] Banned side-stripe pattern: border-l-2 in DocumentChecklistCard (7 occurrences) + ConfirmationCard:47.
  • [P1] MSOSchedulingCard at 375px — the wizard renders inside max-w-card but the inner min-w-[68px] day-picker × 7 days = 476px content width inside a horizontally-scrollable row that has no scroll-edge fade or "swipe for more" affordance. Patient cannot tell more days exist.
  • [P1] Sidebar/MobileBottomNav z-index + safe-area: bottom-nav is z-30 and uses pb-[env(safe-area-inset-bottom)], but the InputBar's container only adds pb-2 on mobile (no safe-area on the input itself). On a notch-less iPhone SE this is fine; on a Pro Max with the home indicator, the input rests at 56px above the bottom edge but the gesture indicator overlaps the input's coral send button on landscape rotation.
  • [P1] Welcome-state error banner close-button has min-w-[44px] min-h-[44px] (good), but the banner itself wraps inside max-w-2xl text-center and at 375px the close button sits flush against the trailing edge with no inner padding — the touch target overlaps the page edge.
  • Recommended next commands (in priority order): /adapt for responsive fixes, /polish for the side-stripe and accent-stripe replacements, /clarify for one piece of error microcopy.

Detailed findings

[P0] Welcome state — keyboard occlusion at 375px

  • Location: apps/patient-app/src/pages/ConversationApp.tsx:815-844
  • Category: Responsive
  • Impact: When a new patient lands on the welcome state at 375px and taps the input, the iOS keyboard (~290px tall) covers the suggestion chips and pushes the Curaway logo + greeting off-screen. The outer flex items-center justify-center means the layout collapses upward; the min-h-[100svh] parent does not account for 100vh - keyboard. Patient cannot see the chips while typing.
  • Evidence:
    // ConversationApp.tsx:816-817
    <div className="flex-1 flex items-center justify-center px-4">
      <div className="w-full max-w-2xl text-center">
    
  • Recommendation: Anchor the InputBar+chips block to the bottom on mobile with md:items-center justify-end md:justify-center, or use 100dvh with interactive-widget=resizes-content viewport meta. Verify with iOS Safari's visualViewport API.
  • Suggested command: /adapt

[P1] Banned side-stripe pattern — DocumentChecklistCard rows

  • Location: apps/patient-app/src/components/chat/cards/DocumentChecklistCard.tsx:55,61,67,73,79,85,91
  • Category: Anti-pattern
  • Impact: Rubric explicitly bans side-stripe borders >1px ("BANNED"). The border-l-2 (2px) coloured left rule on every checklist row is the textbook anti-AI tell. Status is already conveyed via iconBg/iconColor and the row's tonal bg-status-info-bg/30 — the 2px stripe is redundant decoration.
  • Evidence:
    not_uploaded: {
      border: 'border-l-2 border-l-chat-border-strong border-dashed', // <-- BANNED
      ...
    }
    
  • Recommendation: Drop to border-l border-l-chat-border-strong (1px) OR remove the side rule entirely and rely on the existing iconBg circle + tonal row background.
  • Suggested command: /polish

[P1] Banned side-stripe pattern — ConfirmationCard on-site tests list

  • Location: apps/patient-app/src/components/chat/cards/ConfirmationCard.tsx:47
  • Category: Anti-pattern
  • Impact: Same as above — border-l-2 border-chat-coral/40 pl-3 is a 2px coloured side stripe.
  • Evidence:
    <li key={i} className="border-l-2 border-chat-coral/40 pl-3">
    
  • Recommendation: Replace with border-l border-chat-coral/40 pl-3 (1px) or use a leading bullet/icon.
  • Suggested command: /polish

[P1] MSO day-picker — overflow without affordance at 375px

  • Location: apps/patient-app/src/components/chat/cards/MSOSchedulingCard.tsx:233-264
  • Category: Responsive
  • Impact: 7 day-cards × 68px min-width + gaps = ~510px of horizontal scroll content inside a card whose available width at 375px (chat thread inset + card inset) is roughly 320px. Without a scroll shadow, fade-edge, or "Today / +6 days" bookend label, the patient does not know more days exist beyond what's visible.
  • Evidence:
    <div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 snap-x" ...>
      {dayGroups.map(([key, daySlots]) => {
        ...
        className="min-w-[68px] snap-start rounded-lg border px-3 py-2 ..."
    
  • Recommendation: Add mask-image: linear-gradient(to right, black 90%, transparent) on the right edge OR a faint chevron-right indicator when scrollLeft + clientWidth < scrollWidth. Also wire aria-hidden="false" on a "scroll for more days" SR-only span.
  • Suggested command: /adapt

[P1] InputBar safe-area — bottom inset only on desktop branch

  • Location: apps/patient-app/src/pages/ConversationApp.tsx:878
  • Category: Responsive
  • Impact: The active-chat InputBar wrapper applies safe-area inset only on the desktop branch (md:pb-[max(8px,env(safe-area-inset-bottom,0px))]) — mobile gets pb-2. The MobileBottomNav correctly applies pb-[env(safe-area-inset-bottom)], so on phones with a home indicator the nav clears the gesture area, but the visual rhythm between input bar and nav is uneven (8px on top of nav's safe-area = 8 + 34 = 42px gap on iPhone 15 Pro vs 8px gap on iPhone SE2).
  • Evidence:
    <div className="px-2 sm:px-3 md:px-6 py-2 md:py-3 flex-shrink-0
                    pb-2 md:pb-[max(8px,env(safe-area-inset-bottom,0px))]
                    border-t border-chat-border">
    
  • Recommendation: Use pb-[max(8px,env(safe-area-inset-bottom,0px))] unconditionally; the bottom-nav already handles its own safe-area.
  • Suggested command: /adapt

[P1] Welcome-state file-error close button overlaps page edge

  • Location: apps/patient-app/src/pages/ConversationApp.tsx:823-828
  • Category: Accessibility
  • Impact: The error banner is mb-2 px-3 py-2 ... flex items-center gap-2 and the close button has min-w-[44px] min-h-[44px]. At 375px, the parent max-w-2xl collapses to viewport width; the close button's 44px touch area extends to the right edge with zero margin. Adjacent edge gestures (back-swipe) compete with the tap.
  • Evidence: see ConversationApp.tsx:823-828
  • Recommendation: Add pr-2 on the banner OR move the close button before the error message and reserve mr-2.
  • Suggested command: /polish

[P2] MessageThread streaming bubble — aria-hidden race

  • Location: apps/patient-app/src/components/chat/MessageThread.tsx:182-227
  • Category: Accessibility
  • Impact: Two adjacent transition-opacity motion-fast divs are toggled together with opacity-0 pointer-events-none h-0 overflow-hidden and conditionally render their child. The aria-hidden flag is set from the same boolean that gates rendering, so SR users see the typing indicator appear/disappear correctly — but during the cross-fade, BOTH containers can briefly be opacity-0 (one transitioning out, the other not yet rendered). aria-live announcements may double-fire on slow devices.
  • Evidence: see lines 182-227
  • Recommendation: Wrap the indicator + batch-panel in a single container with a discriminated union, or move the motion-fade token out and rely on the inner element's aria-busy.
  • Suggested command: /animate

[P2] MatchResultsCard — checkbox is visual-only div, not real input

  • Location: apps/patient-app/src/components/chat/cards/MatchResultsCard.tsx:71-80
  • Category: Accessibility
  • Impact: The "checkbox" is a <div> styled like a checkbox inside a <label> that has no associated <input>. Screen readers announce the row as a label without a control; keyboard users cannot tab to the checkbox itself. The onClick on the parent <label> carries the toggle, but Enter/Space on a focused row does nothing.
  • Evidence:
    <label ... onClick={() => toggleProvider(pid)}>
      <div className={cn('w-5 h-5 rounded border-2 ...', ...)}>
        {isSelected && <Check className="w-3 h-3 text-white" />}
      </div>
    
  • Recommendation: Add a real <input type="checkbox" className="sr-only peer" checked={isSelected} onChange={...} /> and use peer-focus-visible:ring-2 on the visual div.
  • Suggested command: /polish

[P2] CurawayAvatar pulse animation ignores prefers-reduced-motion

  • Location: apps/patient-app/src/components/chat/MessageBubble.tsx:124
  • Category: Accessibility / Performance
  • Impact: The animate-[curaway-pulse_1.5s_ease-in-out_infinite] keyframe runs continuously throughout the streaming session. The global @media (prefers-reduced-motion: reduce) block in index.css covers the named animation classes (drawer-panel, progress-fill, etc.) but does NOT include this arbitrary-value Tailwind class.
  • Evidence:
    msg.isStreaming && 'animate-[curaway-pulse_1.5s_ease-in-out_infinite]',
    
  • Recommendation: Either add motion-safe: prefix (Tailwind ships this), or extend the @media (prefers-reduced-motion: reduce) block in index.css to set animation: none !important on a [data-streaming="true"] selector and toggle that attr instead.
  • Suggested command: /animate

[P2] InputBar — textarea grows up to 160px without internal max-h respecting keyboard

  • Location: apps/patient-app/src/components/chat/InputBar.tsx:52-65
  • Category: Responsive
  • Impact: The textarea grows to Math.min(scrollHeight, 160) px. At 375px with the iOS keyboard up (~290px viewport), a 160px-tall textarea + ~52px action row + nav = the entire visible area. The patient cannot see what they wrote above the cursor while typing a long message.
  • Evidence:
    e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px';
    
  • Recommendation: Use max(80px, min(scrollHeight, 30vh)) on mobile, or query window.visualViewport.height and clamp dynamically.
  • Suggested command: /adapt

[P2] InputBar — disabled={sending} blocks typing during slow LLM calls

  • Location: apps/patient-app/src/components/chat/InputBar.tsx:64
  • Category: Responsive / UX
  • Impact: Setting disabled={sending} on the textarea while the assistant is replying means a patient cannot start typing their next question while an answer is streaming. Claude.ai keeps the input enabled and queues the next turn; this UX is less responsive.
  • Evidence: see line 64
  • Recommendation: Disable only the send button while in-flight; let the textarea remain editable so the patient can compose.
  • Suggested command: /clarify (or design discussion via /critique)

[P2] MSO time-picker — short-label chips can dip below 44px tap width

  • Location: apps/patient-app/src/components/chat/cards/MSOSchedulingCard.tsx:268-292
  • Category: Accessibility
  • Impact: grid-cols-3 sm:grid-cols-4 gap-2 with min-h-[44px] + px-2 py-2 produces ~108px-wide chips at 375px (good), but the chip lacks min-w-[44px] — there are theoretical layouts where a single-row 4-column grid could squeeze below WCAG 2.5.5 target size.
  • Evidence: line 282 — only min-h-[44px], no min-w-[44px].
  • Recommendation: Add min-w-[44px].
  • Suggested command: /polish

[P3] Welcome suggestion chips — hardcoded English flagged in console

  • Location: apps/patient-app/src/pages/ConversationApp.tsx:41-53
  • Category: Theming / i18n
  • Impact: TODO already self-flagged. Not blocking for the audit but a known polish item.
  • Recommendation: When react-i18next lands, pipe through t().
  • Suggested command: /clarify

[P3] FileAttachmentCard — decoration-dotted underline on filename

  • Location: apps/patient-app/src/components/chat/FileAttachmentCard.tsx:71
  • Category: Theming
  • Impact: Dotted underline on the clickable filename is a small typographic tell — uncommon outside of dictionary apps. Not anti-pattern enough to flag higher.
  • Recommendation: Switch to hover:underline underline-offset-2 (solid on hover, no underline at rest).
  • Suggested command: /polish

[P3] DocumentChecklistCard glyph for unchecked status

  • Location: apps/patient-app/src/components/chat/cards/DocumentChecklistCard.tsx:122
  • Category: Theming
  • Impact: A unicode circle is used where a Lucide Circle icon would match the rest of the card's iconography. Already noted in code comment that emojis were removed for cross-platform consistency; this last unicode glyph slipped through.
  • Recommendation: Replace with <Circle className="w-3 h-3" /> from lucide-react.
  • Suggested command: /polish

Patterns / systemic issues

  • Side-stripe overuse for status: DocumentChecklistCard + ConfirmationCard both reach for border-l-2 to differentiate row state. The status is already legible via icon + tonal background; the stripes are decorative.
  • Mobile-first responsive gaps: Several places (pb-2 vs md:pb-[env(...)], max-w-3xl input vs max-w-[920px] thread, day-picker without scroll affordance) suggest the layout was designed desktop-first then walked down to 375px without an end-to-end mobile pass.
  • Animation tokens partially honoured: motion-fast/motion-slow tokens exist and are used, but arbitrary-value animate-[...] calls bypass the prefers-reduced-motion block in index.css.
  • Touch-target hygiene is generally great: w-11 h-11 (44px) is consistently used on icon buttons in InputBar, MobileBottomNav, MobileModal close, MessageThread NewMessagePill — only the matchresults checkbox-as-div misses it.

Positive findings

  • CurawayAvatar + brand tokens: Consistent use of chat-brand, chat-coral, deep-ocean semantic tokens. Almost no hardcoded hex.
  • MessageBubble memoisation: The custom comparator is surgical and correct — load-bearing fields explicitly listed, callbacks assumed stable. This is the right way to memo a chat bubble.
  • In-place server-message merge: The 559-575 merge logic preserving the placeholder ID is excellent — prevents the whole-thread re-render that historically caused the "screen flicker" SD reported.
  • MSO wizard step model: The 5-step wizard with explicit WizardStep union, back-buttons on every step, persistent cancel link, slot-conflict 409 → bounce-to-time, payment provider routing — this is well-shaped UX. Good error handling, good fallbacks (BE charge block missing → "you'll be invoiced").
  • Doctor identity strip: Repeating the doctor photo + name on the confirmation card is exactly the right reassurance pattern for medical UX.
  • MobileModal: Radix-backed, properly portals, correct safe-area top + bottom handling, full-screen on mobile (inset-0) collapsing to centered dialog on md.
  • aria-live="polite" on the message thread + correct semantic roles on day-picker (role="tablist"/tab) and time-picker (role="radiogroup"/radio) — top-tier accessibility setup.
  • WCAG-conscious touch targets: 44px is the consistent baseline. NewMessagePill, InputBar buttons, MobileBottomNav, MSO day/time chips all clear it.

  1. [P0/P1] /adapt — Mobile responsive pass: keyboard occlusion on welcome state, day-picker scroll affordance, InputBar safe-area unification, textarea max-height vs visualViewport, MSO chip min-w-[44px]. This is the single biggest score lift (Responsive 2 → 4).
  2. [P1] /polish — Replace border-l-2 (DocumentChecklistCard ×7, ConfirmationCard ×1), tighten the welcome error banner padding, fix the matchresults checkbox to a real input, swap the for a Lucide Circle. This kills the anti-pattern flags (Anti-patterns 2 → 4).
  3. [P2] /animate — Add motion-safe: to the avatar pulse, single-container the typing/batch fade, audit any other arbitrary-value animate-[...] against prefers-reduced-motion.
  4. [P2] /clarify — Decide and apply InputBar enabled-while-streaming behaviour; tighten error banner microcopy if needed.
  5. [P3] /critique — Optional: take the cleaned-up wizard through a UX critique to validate the doctor → time → review → pay → confirm flow at 375px against persona "anxious 60-year-old in UAE".