mckaywrigley on GitHub

mckaywrigley/chatbot-ui

Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.

30 files reviewed·May 13, 2026

Top fix

Wire labels to inputs with matching id attributes on login.

See the fix

Verdict

Functional scaffolding ships without the accessibility and error-handling layer that makes it production-safe — labels disconnected, failures silent, ARIA absent throughout. The single biggest risk is that auth errors vanish entirely, leaving users with no recovery path.

Files Rams reviewed

app/[locale]/[workspaceid]/chat/page.tsx

app/[locale]/[workspaceid]/layout.tsx

app/[locale]/layout.tsx

app/[locale]/login/page.tsx

app/[locale]/setup/page.tsx

app/[locale]/[workspaceid]/chat/[chatid]/page.tsx

app/[locale]/[workspaceid]/page.tsx

app/[locale]/help/page.tsx

app/[locale]/login/password/page.tsx

app/[locale]/page.tsx

app/[locale]/globals.css

components/chat/assistant-picker.tsx

components/chat/chat-command-input.tsx

components/chat/chat-files-display.tsx

components/chat/chat-help.tsx

components/chat/chat-hooks/use-chat-handler.tsx

components/chat/chat-hooks/use-chat-history.tsx

components/chat/chat-hooks/use-prompt-and-command.tsx

components/chat/chat-hooks/use-scroll.tsx

components/chat/chat-hooks/use-select-file-handler.tsx

components/chat/chat-input.tsx

components/chat/chat-messages.tsx

components/chat/chat-retrieval-settings.tsx

components/chat/chat-secondary-buttons.tsx

components/chat/chat-settings.tsx

components/chat/chat-ui.tsx

components/chat/file-picker.tsx

components/chat/prompt-picker.tsx

components/chat/quick-setting-option.tsx

components/chat/quick-settings.tsx

91/100

Accessibility

1 critical3 serious
Accessibility·app/[locale]/login/page.tsx:163Critical

Email and password inputs have no id — labels are disconnected

Both `<Input>` elements use `name` but no `id` attribute. The `<Label>` elements set `htmlFor="email"` and `htmlFor="password"`, but without matching `id`s on the inputs, the association is broken — clicking a label does not focus the field and screen readers don't announce the label with the input.

Why it matters

Screen readers announce the input without its label, so users hear 'edit text, required' instead of 'Email, edit text, required.' Click targets are also reduced to the input field itself, which makes the form harder to use on mobile.

Fix

Every input needs an `id` that matches its label's `htmlFor`.

<Input
  className="mb-3 rounded-md border bg-inherit px-4 py-2"
  name="email"
  placeholder="you@example.com"
  required
/>

<Label className="text-md" htmlFor="password">
  Password
</Label>
<Input
  className="mb-6 rounded-md border bg-inherit px-4 py-2"
  type="password"
  name="password"
  placeholder="••••••••"
/>
<Input
  className="mb-3 rounded-md border bg-inherit px-4 py-2"
  id="email"
  name="email"
  placeholder="you@example.com"
  required
/>

<Label className="text-md" htmlFor="password">
  Password
</Label>
<Input
  className="mb-6 rounded-md border bg-inherit px-4 py-2"
  id="password"
  type="password"
  name="password"
  placeholder="••••••••"
/>
Accessibility·app/[locale]/setup/page.tsx:155Serious

Disabled Next button on step 1 has no accessible explanation for why it's inactive

Step 1 passes `showNextButton={!!(username && usernameAvailable)}` — when false, the Next button is hidden or disabled. There is no `aria-describedby`, status message, or visible hint explaining that a username is required before proceeding.

Why it matters

Screen reader users land on the Profile step, tab to where the Next button should be, and encounter either nothing or a disabled element with no label explaining the requirement. The validation gate is invisible to assistive tech.

Fix

Add a visible hint or `aria-live` region that announces the condition blocking progression when the step is first rendered.

showNextButton={!!(username && usernameAvailable)}
showNextButton={!!(username && usernameAvailable)}
nextButtonHint={!username ? "Enter a username to continue" : !usernameAvailable ? "Username is taken" : undefined}
Accessibility·app/[locale]/[workspaceid]/chat/page.tsx:36Serious

Corner controls at `left-2 top-2` and `right-2 top-2` are 8px from edges — likely too small to tap

Both `QuickSettings` and `ChatSettings` are wrapped in `div` containers positioned at `left-2 top-2` / `right-2 top-2` (8px offsets). Without knowing the inner button sizes, the 8px inset means the tap targets sit at the very corner of the viewport. If the inner buttons are icon-only (common for settings controls), they're almost certainly under 44×44px.

Why it matters

Small corner targets are consistently the hardest UI elements to hit on mobile, and icon-only controls without visible labels compound the problem for screen reader users.

Fix

Increase corner offsets to at least `left-3 top-3` and ensure inner buttons have a minimum `size-11` hit area with `aria-label` on any icon-only controls.

<div className="absolute left-2 top-2">
  <QuickSettings />
</div>

<div className="absolute right-2 top-2">
  <ChatSettings />
</div>
<div className="absolute left-3 top-3">
  <QuickSettings />
</div>

<div className="absolute right-3 top-3">
  <ChatSettings />
</div>
Accessibility·app/[locale]/login/page.tsx:200Serious

Error message has no ARIA role — screen readers miss auth failures

The `searchParams.message` block renders a `<p>` with no `role="alert"` or `aria-live` region. When a sign-in fails and the page redirects with `?message=Invalid login credentials`, the error appears visually but is never announced to assistive tech.

Why it matters

Users relying on screen readers complete the form, submit, and hear nothing — the error is invisible to them, leaving them unable to correct the problem.

Fix

Status messages that appear after user action must use `role="alert"` so they are announced immediately.

{searchParams?.message && (
  <p className="bg-foreground/10 text-foreground mt-4 p-4 text-center">
    {searchParams.message}
  </p>
)}
{searchParams?.message && (
  <p role="alert" className="bg-foreground/10 text-foreground mt-4 p-4 text-center">
    {searchParams.message}
  </p>
)}
96/100

UX

2 serious
UX·app/[locale]/[workspaceid]/layout.tsx:168Serious

No error state rendered — fetch failures produce a blank or frozen screen

The render block is: - `if (loading) return <Loading />` - otherwise `return <Dashboard>{children}</Dashboard>` There is no `if (fetchError)` branch. A failed workspace load renders `<Dashboard>` with all context state empty — no message, no retry button, no explanation.

Why it matters

Empty context state inside `<Dashboard>` cascades into broken child components that expect workspace data. Users see a broken UI with no recovery path.

Fix

Add an error render branch that shows the failure reason and a retry action before falling through to the Dashboard render.

if (loading) {
  return <Loading />
}

return <Dashboard>{children}</Dashboard>
if (loading) {
  return <Loading />
}

if (fetchError) {
  return (
    <div className="flex h-screen flex-col items-center justify-center gap-4">
      <p className="text-sm text-red-500">{fetchError}</p>
      <button
        type="button"
        className="text-sm underline"
        onClick={() => fetchWorkspaceData(workspaceId)}
      >
        Try again
      </button>
    </div>
  )
}

return <Dashboard>{children}</Dashboard>
UX·app/[locale]/layout.tsx:71Serious

No fallback when Supabase env vars are missing at runtime

Both `process.env.NEXT_PUBLIC_SUPABASE_URL!` and `process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!` use non-null assertions. If either is undefined at runtime, `createServerClient` throws an unhandled error that crashes the entire layout — every page in every locale returns a 500 with no recovery path.

Why it matters

A layout-level crash means every route is broken with no useful feedback. Adding a guard makes the failure visible and debuggable in logs before it reaches users.

Fix

Guard required env vars at the module boundary and throw a descriptive error so misconfigured deployments fail fast with a clear message.

const supabase = createServerClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
  throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY")
}
const supabase = createServerClient<Database>(
  supabaseUrl,
  supabaseAnonKey,

Hierarchy

✓ No issues found

Typography

✓ No issues found

Color

✓ No issues found

Spacing

✓ No issues found

Components

✓ No issues found

Motion

✓ No issues found

Working well

  • The binary split between the empty state and `<ChatUI />` is the right architectural call. Rather than conditionally hiding/showing elements inside one layout, the page renders two completely different trees based on `chatMessages.length`. This means the empty state carries zero DOM overhead during an active chat — no hidden absolute-positioned divs to manage.
  • The step rendering via `renderStep(currentStep)` with a switch statement is the right call here. Each step is completely isolated — ProfileStep, APIStep, and FinishStep each own their own concerns, and the orchestration page holds only the shared state they need to commit together at the end. This is why the component stays readable at 200+ lines of state.
  • The workspace-change effect correctly resets all transient chat state — `setUserInput('')`, `setChatMessages([])`, `setSelectedChat(null)`, `setChatFiles([])`, `setChatImages([])` — before fetching fresh data. This is the right pattern: clear before load, not after, so stale state from the previous workspace never bleeds into the new one.
  • Registering hotkeys at the page level (`useHotkey('o', handleNewChat)` and `useHotkey('l', handleFocusChatInput)`) keeps the keyboard shortcuts co-located with the actions they trigger, rather than scattered across child components. This is the correct place for page-scoped shortcuts — easy to audit and easy to remove.

Score your own repo with Rams.

Free on public repos. Reviews run on every pull request and post inline fix suggestions.

Review my public repoFree