directus/directus
Reviewed against Rams quality heuristics — accessibility, hierarchy, color, typography, spacing, components, motion, and UX.
30 files reviewed·May 13, 2026
Also worth noting
Verdict
Token discipline is real but accessibility is an afterthought — icon-only buttons, unlabeled controls, and sub-11px text appear across core chat UI components. The submit button's complete opacity to assistive tech is the single sharpest risk in an otherwise structurally tidy codebase.
Files Rams reviewed
app/src/ai/components/ai-context-card.vue
app/src/ai/components/ai-context-menu.vue
app/src/ai/components/ai-context-menu/context-menu-item.vue
app/src/ai/components/ai-conversation.vue
app/src/ai/components/ai-input-submit.vue
app/src/ai/components/ai-input.vue
app/src/ai/components/ai-magic-button.vue
app/src/ai/components/ai-message-list.vue
app/src/ai/components/ai-message.vue
app/src/ai/components/ai-model-selector.vue
app/src/ai/components/ai-pending-context.vue
app/src/ai/components/ai-prompt-variables-modal.vue
app/src/ai/components/ai-settings-menu.vue
app/src/ai/components/ai-sidebar-detail.vue
app/src/ai/components/ai-textarea.vue
app/src/ai/components/logos/claude.vue
app/src/ai/components/logos/openai.vue
app/src/ai/components/parts/ai-ask-user-summary.vue
app/src/ai/components/parts/ai-message-file-group.vue
app/src/ai/components/parts/ai-message-file.vue
app/src/ai/components/parts/ai-message-reasoning.vue
app/src/ai/components/parts/ai-message-text.vue
app/src/ai/components/parts/ai-message-tool.vue
app/src/ai/components/parts/ai-tool-call-card.vue
app/src/components/transition/bounce.vue
app/src/components/transition/dialog.vue
app/src/components/v-avatar.vue
app/src/components/v-badge.vue
app/src/components/v-banner.vue
app/src/components/v-breadcrumb.vue
Accessibility
Icon-only button has no accessible name for any state
The `<VButton>` renders with only a `<VIcon>` child and no `aria-label`. All three states — submit (`arrow_upward`), stop (`stop_circle`), and retry (`replay`) — are icon-only with no text alternative.
Why it matters
Screen readers announce the button as an unlabelled button or just 'button', making all three action states invisible to assistive tech. This fails WCAG 4.1.2.
Fix
Every icon-only button must have a dynamic aria-label that names the current action.
<VButton
:disabled="isDisabled"
class="submit-button"
x-small
:danger="props.status === 'error'"
icon
@click="handleClick"
>
<VIcon :name="icon" />
</VButton><VButton
:disabled="isDisabled"
:aria-label="ariaLabel"
class="submit-button"
x-small
:danger="props.status === 'error'"
icon
@click="handleClick"
>
<VIcon :name="icon" />
</VButton>Back button in list header has no accessible name
The `VButton` with `icon` and `x-small` props in `.list-header` renders only `<VIcon name="arrow_back" small />` with no `aria-label` or tooltip: ``` <VButton x-small icon secondary @click="closeList"> <VIcon name="arrow_back" small /> </VButton> ``` Screen readers announce this as an unlabeled button.
Why it matters
Users on assistive tech get no indication this button navigates back — keyboard and screen-reader users lose orientation inside the menu.
Fix
Every icon-only button must have an aria-label that names its action.
<VButton x-small icon secondary @click="closeList">
<VIcon name="arrow_back" small />
</VButton><VButton x-small icon secondary :aria-label="t('back')" @click="closeList">
<VIcon name="arrow_back" small />
</VButton>Scroll-to-bottom button has no accessible name for screen readers
The `scroll-to-bottom-btn` `VButton` uses `icon` and `rounded` props with only a `VIcon` child — no visible text, no `aria-label`. Screen readers will announce this as an unlabeled button.
Why it matters
Users navigating by keyboard or assistive tech hear 'button' with no context. They can't tell this control scrolls to the latest message, which disrupts navigation in a chat UI where following the conversation is the primary task.
Fix
Icon-only buttons must carry an aria-label that describes the action, not the icon.
<VButton icon rounded secondary x-small class="scroll-to-bottom-btn" @click="scrollToBottom('smooth')"><VButton icon rounded secondary x-small class="scroll-to-bottom-btn" :aria-label="$t('ai.scroll_to_bottom')" @click="scrollToBottom('smooth')">Badge text at 9px on a tinted background likely fails WCAG AA contrast
`.item-badge` renders uppercase text at 0.5625rem (9px) with `color: var(--theme--primary)` on `background-color: var(--theme--primary-background)`. At 9px, WCAG AA requires 4.5:1. Primary-on-primary-background pairs in most design systems sit around 3:1–3.5:1 — sufficient at normal size, insufficient below 12px.
Why it matters
The badge is the only differentiating signal for labeled items (e.g. a "New" or "Beta" indicator). If it fails contrast at its actual rendered size, it communicates nothing to low-vision users.
Fix
Raise the font size to at least 0.75rem or switch to a higher-contrast token pair (e.g. `var(--theme--primary)` on white/neutral background) so the contrast target is met at the rendered size.
padding: 0.125rem 0.3125rem;
font-size: 0.5625rem;
font-weight: 500;
text-transform: uppercase;
background-color: var(--theme--primary-background);
color: var(--theme--primary);padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
background-color: var(--theme--primary-background);
color: var(--theme--primary);Click-to-focus div on input wrapper breaks keyboard and screen reader affordance
The `.input-wrapper` div carries `@click="textareaComponent?.focus()"` and `cursor: text`. A div with a click handler is not keyboard-reachable and is not announced as interactive by screen readers. Users navigating by keyboard or switch access can't activate this affordance.
Why it matters
Keyboard users lose the click-to-focus shortcut entirely. Screen readers announce nothing, so the wrapper is silently inert — onboarding friction compounds for assistive tech users.
Fix
Move the focus-trigger behavior to a visually transparent label element wrapping the content, or keep the div but add role='none' and move focus management to the AiTextarea's own label.
<div class="input-wrapper" @click="textareaComponent?.focus()"><label class="input-wrapper" @click.self="textareaComponent?.focus()" aria-label="AI prompt input">Removable card container has no focus style despite being interactive
The outer `.ai-context-card.removable` div handles `@mouseenter` and `@mouseleave` and carries `cursor: pointer` via the removable hover block — but there is no `&:focus-visible` rule on the card itself. The close button inside has correct focus treatment, but the card wrapper does not.
Why it matters
Keyboard users tabbing through a list of chips will land on the close button but have no visible indicator that the card itself is a navigable, interactive surface. This breaks the expected Tab → action flow.
Fix
Add a `&:focus-visible` rule to `.ai-context-card.removable` matching the same outline style used on `.close-button`.
&:hover {
border-color: var(--theme--border-color-accent);
background-color: var(--theme--background-normal);
}&:hover {
border-color: var(--theme--border-color-accent);
background-color: var(--theme--background-normal);
}
&:focus-visible {
outline: 2px solid var(--theme--primary);
outline-offset: 1px;
}Hierarchy
Typography
Color
Spacing
Components
Motion
UX
Working well
- ✓The `#scroll-anchor` + `overflow-anchor: auto` technique is the right way to handle auto-scroll in a chat — it lets the browser do the work natively without JavaScript scroll-pinning hacks that fight momentum scrolling and cause jank. Setting `overflow-anchor: none` on all siblings is also correct; it prevents any intermediate element from claiming the anchor position.
- ✓Passing `canSubmit`, `isProcessing`, and `canStop` as separate computed props to `AiInputSubmit` rather than passing the raw store is clean component design. The submit button's logic is fully encapsulated, which means state changes in the store don't leak visual responsibility into the parent — a pattern that scales well as submission states grow.
- ✓The `:focus-within` border treatment on `.input-wrapper` — switching to `var(--theme--primary)` — is the right call. It makes the entire composite input (textarea + controls) feel like a single focusable unit, which is the correct affordance for a compound input shell. Most teams either skip this or apply it only to the raw textarea.
- ✓The `emptyState` computed property consolidates three distinct states (no providers + admin, no providers + user, no messages) into a single object that the template reads once. This is clean composition — it avoids three separate `v-if` blocks in the template and keeps the visual logic in one reviewable place.
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