mckaywrigley/chatbot-ui
Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.
30 files reviewed·May 13, 2026
Also worth noting
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
Accessibility
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="••••••••"
/>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}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>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>
)}UX
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>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
Typography
Color
Spacing
Components
Motion
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