keephq/keep
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·July 9, 2026
More findings
Verdict
Interactive controls repeatedly fake button semantics instead of using real ones, locking out keyboard and screen-reader users across filters and widgets. The bigger risk is what happens off the happy path: bad URLs and slow loads collapse to silent blank screens instead of feedback.
Files Rams reviewed
keep-ui/app/(keep)/providers/components/providers-categories/providers-categories.tsx
keep-ui/app/(health)/layout.tsx
keep-ui/app/(keep)/dashboard/GridLayout.tsx
keep-ui/app/(keep)/extraction/[rule_id]/executions/[execution_id]/page.tsx
keep-ui/app/(keep)/extraction/[rule_id]/executions/page.tsx
keep-ui/app/(keep)/incidents/[id]/topology/page.tsx
keep-ui/app/(keep)/layout.tsx
keep-ui/app/(keep)/mapping/[rule_id]/executions/[execution_id]/page.tsx
keep-ui/app/(keep)/mapping/[rule_id]/executions/page.tsx
keep-ui/app/(keep)/providers/layout.tsx
keep-ui/app/(keep)/providers/oauth2/[providerType]/page.tsx
keep-ui/app/(keep)/topology/page.tsx
keep-ui/app/(keep)/workflows/[workflow_id]/workflow-detail-page.tsx
keep-ui/app/(keep)/workflows/preview/[workflowId]/page.tsx
keep-ui/app/(keep)/workflows/preview/page.tsx
keep-ui/app/(signin)/layout.tsx
keep-ui/app/(signin)/mobile/page.tsx
keep-ui/app/(keep)/providers/components/providers-filter-by-label/providers-filter-by-label.tsx
keep-ui/app/(keep)/providers/components/providers-search/providers-search.tsx
keep-ui/app/(health)/health/page.tsx
keep-ui/app/(keep)/[...not-found]/page.tsx
keep-ui/app/(keep)/ai/page.tsx
keep-ui/app/(keep)/alerts/[id]/page.tsx
keep-ui/app/(keep)/alerts/fingerprint/[fp]/page.tsx
keep-ui/app/(keep)/alerts/fingerprint/[fp]/ui/alert-fingerprint-page.tsx
keep-ui/app/(keep)/dashboard/[id]/page.tsx
keep-ui/app/(keep)/deduplication/page.tsx
keep-ui/app/(keep)/extraction/layout.tsx
keep-ui/app/(keep)/extraction/page.tsx
keep-ui/app/(keep)/incidents/[id]/activity/page.tsx
Accessibility
Category filter chips use onClick on a Badge with no button semantics
All 15 category chips ("AI", "Monitoring", "Incident Management", etc.) are Tremor `Badge` components with an `onClick` and `cursor-pointer` class. `Badge` renders a `span`, not a button: there's no `tabIndex`, no keyboard handler, no `role`, and no `aria-pressed` for the toggle state.
Why it matters
Keyboard-only and screen-reader users cannot tab to or activate any category filter, and screen readers never learn a chip is a toggle or whether it's currently selected. This locks a whole input method out of filtering providers.
Fix
Give the interactive chip a button role, keyboard handler, and aria-pressed state, or swap it for a native <button>.
<Badge
color={
providersSelectedCategories.includes(category) ? "orange" : "slate"
}
className={`rounded-full ${
providersSelectedCategories.includes(category)
? "shadow-inner"
: "hover:shadow-inner"
} cursor-pointer`}
key={category}
onClick={() => toggleCategory(category)}
>
{category}
</Badge><Badge
color={
providersSelectedCategories.includes(category) ? "orange" : "slate"
}
className={`rounded-full ${
providersSelectedCategories.includes(category)
? "shadow-inner"
: "hover:shadow-inner"
} cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-orange-500`}
key={category}
role="button"
tabIndex={0}
aria-pressed={providersSelectedCategories.includes(category)}
onClick={() => toggleCategory(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategory(category);
}
}}
>
{category}
</Badge>Build-info footer text is nearly invisible against the page background
The build/version footer renders `text-slate-400` at `text-xs` inside a div with `opacity-80`, sitting directly on the `bg-gray-50` body background.
Why it matters
Blending slate-400 at 80% opacity over gray-50 lands around 2.5:1 contrast, far below the 4.5:1 WCAG AA floor for small text. Anyone checking the build hash or version, including engineers debugging a deploy, strains to read it.
Fix
Raise the footer text to a darker gray token and drop the opacity reduction so the contrast clears 4.5:1.
<div className="pointer-events-none opacity-80 w-full p-2 text-slate-400 text-xs"><div className="pointer-events-none w-full p-2 text-gray-500 text-xs">Widget reordering only works with a mouse drag, no keyboard path
`draggableHandle=".grid-item__widget"` wires drag-to-reposition through `react-grid-layout`, but the file defines no keyboard handler or focusable control that lets a user move or resize a widget without dragging.
Why it matters
Keyboard-only and motor-impaired users can view the dashboard but can never rearrange it, which locks them out of a core feature everyone else has.
Fix
Pair the drag handle with a keyboard-operable alternative (e.g. a move/resize menu or arrow-key repositioning) for the same action.
draggableHandle=".grid-item__widget"draggableHandle=".grid-item__widget"
// TODO: expose a keyboard-accessible move/resize control in GridItemContainer
// as an alternative to pointer-only drag interactionUX
Loading state renders a blank page with no feedback
The component returns `null` while `isLoading` is true or `execution` hasn't loaded yet. Nothing renders: no spinner, no skeleton, no text.
Why it matters
Users landing on this execution detail page from a link see a blank white screen with zero feedback, and can't tell if the page is loading or broken.
Fix
Render a skeleton or loading indicator matching the page layout instead of returning null.
if (isLoading || !execution) {
return null;
}if (isLoading || !execution) {
return (
<div className="p-4">
<Card className="h-40 animate-pulse" />
</div>
);
}Invalid rule_id leaves users on a permanent blank page
`rule` comes from `extractions?.find(...)`. If `params.rule_id` matches nothing, `rule` stays `undefined` forever and the guard on line 39 returns `null` indefinitely, with no message or link back.
Why it matters
A mistyped or stale rule_id in the URL traps the user on an unexplained blank screen with no path forward, instead of telling them the rule doesn't exist and offering a way back to the rule list.
Fix
Render a not-found state with a message and a link back to the extraction rules list when the rule can't be resolved.
if (isLoading || !rule) {
return null;
}if (isLoading) {
return null;
}
if (!rule) {
return (
<div className="p-4">
<Subtitle>Rule not found. <Link href="/extraction">Back to all rules</Link></Subtitle>
</div>
);
}Topology page has no visible heading naming the incident
Both render branches drop straight into `<main><TopologySearchProvider><TopologyMap .../></TopologySearchProvider></main>` with no heading. `generateMetadata` sets the incident name in the browser tab title only, never in the page body.
Why it matters
Users navigating tabs (Timeline, Topology, Activity) inside an incident have no on-screen confirmation of which incident's topology they're viewing, which slows orientation especially with several incident tabs open.
Fix
Show the incident name as a visible heading above the topology map, not just in the document title.
<main className="h-[calc(100vh-28rem)]">
<TopologySearchProvider>
<TopologyMap
selectedApplicationIds={[relevantApplication?.id || ""]}
topologyApplications={applications}
/>
</TopologySearchProvider>
</main><main className="h-[calc(100vh-28rem)]">
<h2 className="sr-only">{getIncidentName(incident)} topology</h2>
<TopologySearchProvider>
<TopologyMap
selectedApplicationIds={[relevantApplication?.id || ""]}
topologyApplications={applications}
/>
</TopologySearchProvider>
</main>Typography
Color
Spacing
Components
Motion
Craft
Working well
- Splitting the render logic on `incident.incident_type === "topology"` versus the fallback branch keeps the data-fetching intent explicit: topology-native incidents get a filtered map scoped to the relevant application, while others get the raw incident.services list. That's the right way to branch server-rendered views on data shape rather than trying to overload one component prop set.
- The two-column grid (`lg:col-span-2` for Logs, a narrower sidebar for Extracted Fields and Alert ID) puts the largest content area on the primary artifact, the logs, while metadata stays secondary. That's the right call: the log output is what someone debugging an execution actually reads first.
- Placing `<ThemeScript />` as the very first child of `<body>`, with a comment explaining it must run before anything else, is the correct fix for theme-flash-on-load: the script needs to set the class/attribute before React hydrates any themed element, not after.
- Centralizing the toggle logic in a single `toggleCategory` function instead of inline per-chip handlers keeps the selection behavior in one place, so a future change (like enforcing single-select) is a one-line edit rather than 15 repeated fixes.
Score your own repo with Rams.
Free on public repos. Rams reviews every pull request for accessibility, color, type, spacing, components, motion, UX, and craft, and posts inline fixes.