Kafkito Frontend Design Guidelines¶
Status: canonical · Owner: design · Applies to: frontend/
This document is the source of truth for the kafkito frontend UI. All new pages, components, and features must follow it.
It is written to be read by both humans and coding agents (Claude Code, GitHub Copilot, Copilot CLI, Cursor, etc.). Agents: treat every rule below as a hard constraint unless a rule explicitly says "prefer" or "may".
0 · TL;DR for agents¶
Before writing any frontend code, do these five things:
- Read this file end-to-end.
- Open
frontend/src/index.cssto confirm the active design tokens. - Open the closest existing route under
frontend/src/routes/and mimic its structure (imports order, query setup, layout scaffold). - Use Tailwind utilities generated from
@themetokens (e.g.bg-panel,text-muted,border-border). Never hard-code hex values. The default Tailwind palette is disabled in@theme, so classes likebg-slate-50ortext-gray-700generate no CSS. - Never fabricate backend data. If a field does not exist on the API
type, render
"—"and add a// TODO(backend): …comment.
frontend/AGENTS.md carries a distilled version of these rules for coding
agents, with the commands and file layout. This document stays canonical;
when the two disagree, follow this one and fix frontend/AGENTS.md.
1 · Stack (immutable)¶
| Concern | Choice | Notes |
|---|---|---|
| Framework | React 19 + TypeScript 7 | function components, no class components |
| Build | Vite 8 | |
| Routing | TanStack Router (file-based) | src/routes/*.tsx — routeTree.gen.ts is generated (gitignored); never hand-edit it |
| Data | TanStack Query v5 | every server read goes through useQuery; every write through useMutation |
| Styles | Tailwind v4 via @tailwindcss/vite |
tokens in src/index.css under @theme |
| Icons | lucide-react |
no other icon libraries |
| Class merging | clsx + tailwind-merge |
re-export as cn() in src/lib/utils.ts |
| Dark mode | class-based (html.dark) |
never media-query-only |
Do not add shadcn, Headless UI, Mantine, Chakra, Material, CSS-in-JS, styled-components, emotion, a state library (Redux/Zustand/Jotai), or a charting library. If you think you need one, stop and open a discussion first.
Radix UI headless primitives (@radix-ui/react-* only, no shadcn) are allowed for behavioural complexity that is hard to get right by hand: focus trap, ARIA, collision-aware positioning. Each addition must be wrapped under frontend/src/components/ui/ with a canonical name and documented in § 6.1. We paint 100% of the visual layer ourselves; Radix provides no styling, only behaviour.
2 · Design tokens¶
Tokens live in frontend/src/index.css under @theme. They are the only
way to introduce color, font-family, or semantic intent.
2.1 Color tokens¶
See src/index.css for the canonical values. Category summary:
| Category | Tokens | When to use |
|---|---|---|
| Surface | bg, panel, subtle, hover |
page bg, card bg, striped row bg, hover bg |
| Border | border, border-hover, border-strong |
border is the default hairline; border-hover is the hover affordance (e.g. inputs, secondary buttons); border-strong is reserved for the focused/selected state |
| Text | text, muted, subtle-text |
primary / secondary / tertiary |
| Accent | accent, accent-hover, accent-subtle, accent-foreground |
primary CTA, active nav, focused input; accent-foreground is the canonical foreground colour on saturated bg-accent / bg-danger fills |
| Focus | focus, focus-on-accent |
global focus-indicator colour (focus) and the override colour used by primitives that sit on saturated backgrounds (focus-on-accent) |
| Semantic | success, warning, danger |
status dots, validation, destructive actions |
| Overlay | overlay |
modal / dropdown scrim — defined for both modes |
| Tint | tint-{green,amber,red}-{bg,fg} |
table cell / badge highlights |
Notes:
- The transitional aliases (
surface-*,text-muted,text-subtle,text-on-accent,*-subtle,info) are gone. Use the canonical names in this table:bg,panel,subtle,hover,muted,subtle-text,accent-foreground,tint-{green,amber,red}-bg,accent-subtle,accent. border-strongis never used for hover affordances any more — that role belongs toborder-hover. Customfocus:border-…rules on inputs are an anti-pattern (see § 9 — let the global:focus-visiblerule handle it).focus-on-accentis consumed by<Button variant="primary">and<Button variant="danger">to overrideoutline-colorso the focus indicator stays AA-legible against saturated fills.overlayis markedly darker in light mode than the original Direction-A spec. The 2026-04 sweep moved the light value into the modern modal-dimmer range to fix a dark-mode invisible-scrim regression; the heavier scrim is intentional in both modes.
2.2 Using tokens¶
Do:
<div className="rounded-xl border border-border bg-panel p-4">
<p className="text-sm text-muted">Last synced 2m ago</p>
</div>
Don't:
// ❌ default Tailwind palette (disabled in @theme, generates no CSS)
<div className="rounded-xl border border-slate-200 bg-white p-4">
// ❌ inline style with hex
<div style={{ background: "#ffffff", borderColor: "#e5e7eb" }}>
// ❌ inline style with var() when a utility exists
<div style={{ background: "var(--color-panel)" }}>
// ❌ arbitrary var() class when a utility exists
<div className="bg-[var(--color-panel)] text-[var(--color-muted)]">
The only case where style={{ color: "var(--color-xxx)" }} is acceptable
is when the value is data-driven (sparkline stroke color per series,
gauge bar width, etc.) and no utility would work.
2.3 Dark mode¶
Dark mode is a single class on <html>. Do not gate components on
useTheme() state for styling — CSS custom properties under html.dark
swap everything automatically.
If you absolutely need a different element (different icon, different illustration), use the dark variant:
<img src="/logo-light.svg" className="block dark:hidden" />
<img src="/logo-dark.svg" className="hidden dark:block" />
2.4 Adding a new token¶
- Propose it in the PR description with justification.
- If accepted, add it to
@themeinsrc/index.cssfor both light andhtml.dark. Both modes must define it. - Document it in this file under § 2.1.
Do not ship one-off hex values. Every color must be a token.
3 · Typography¶
| Role | Class | Use |
|---|---|---|
| Page title (h1) | text-2xl font-semibold tracking-tight |
once per page |
| Section title (h2) | text-sm font-semibold |
card headers |
| Eyebrow | text-[11px] font-semibold uppercase tracking-wider text-muted |
above h1 for breadcrumb/context |
| Body | text-sm |
default |
| Body muted | text-sm text-muted |
helper text |
| Table cell (text) | text-sm |
|
| Table cell (mono) | font-mono text-[13px] |
IDs, names, offsets |
| Table header | text-[11px] uppercase tracking-wider text-muted |
|
| KPI value | text-2xl font-semibold tabular-nums |
|
| Small mono label | font-mono text-[11px] text-muted |
hostnames, IDs in captions |
Rules:
- Always pair numeric data with
tabular-nums. - Always use
font-monofor Kafka identifiers (topics, groups, subjects, principals, broker IDs, offsets, partitions, hostnames). - Never use font sizes not in the table above. If you think you need one, use one of the existing sizes.
- Line-height follows Tailwind defaults; override with
leading-relaxedonly for<pre>blocks of code/schema.
4 · Spacing & layout¶
4.1 Page scaffold¶
Every route renders as children of <Shell> (in src/features/shell/Shell.tsx).
The page body itself follows this pattern:
export const Route = createFileRoute("/my-page")({
component: MyPage,
});
function MyPage() {
return (
<div className="space-y-5 p-6">
<PageHeader
eyebrow={`${cluster} › My things`}
title="My things"
subtitle="Count + brief context"
actions={<Button>+ New</Button>}
/>
<Toolbar />
<DataCard />
</div>
);
}
- Page root:
space-y-5 p-6(20 px vertical rhythm, 24 px gutter). The longer formspace-y-5 px-6 py-6compiles to identical CSS and is acceptable in existing routes; new code should prefer the shorterp-6. - Multi-section pages: wrap each section in
space-y-6if you need more breathing room - Never cap width (
max-w-6xl,container, etc.) on data-dense pages. Tables, toolbars, and KPI grids go edge-to-edge within the shell. - Content-heavy pages (settings, empty states, forms) may cap:
max-w-3xl mx-autois the standard width.
4.2 Grid & flex¶
- KPI strips:
grid grid-cols-4 gap-3(4 cards);grid-cols-2on narrow viewports — add a simple breakpoint (md:grid-cols-4) - Toolbars:
flex items-center gap-2 - Master/detail:
grid grid-cols-[420px_1fr] gap-4 - Form fields:
grid grid-cols-2 gap-3for inline fields, stacked otherwise
4.3 Radii¶
| Element | Class |
|---|---|
| Card / table / panel | rounded-xl (12 px) |
| Button / input / dropdown | rounded-md (6 px) |
| Pill / tag / tint cell | rounded-sm (4 px) |
| Status dot / avatar | rounded-full |
rounded-sm is 4 px because Tailwind v4 sets --radius-sm: 0.25rem; this table said 2 px until 2026-10-10. Per § 13 the code wins here: <Tag>, <Skeleton> and <StateBadge> rely on 4 px and the design-system artifact was extracted from the code (§ 14).
rounded-md is the canonical button radius — <Button>, <IconButton>, <Input>, and <Notice> all consume it. Do not introduce rounded-lg or rounded-2xl on these primitives.
4.4 Borders & surfaces¶
- Flat design: no
shadow-*on cards or buttons; useborder border-border.<Card>and<Button>no longer shipshadow-sm. - Modals and dropdowns: the one exception —
shadow-xlon floating surfaces over abg-overlaybackdrop. - Focus indicator: the canonical pattern is the global
:focus-visiblerule (outline: 2px solid var(--color-focus); outline-offset: 2px;declared inindex.css). Tailwind'sring-*utility is implemented viabox-shadow, which is clipped byoverflow:hiddenancestors (tables, modals, tinted panels) and silently fails WCAG 2.4.13 — sooutline + outline-offsetis preferred overring-*for focus, and primitives that sit on saturated fills (<Button variant="primary">,<Button variant="danger">) overrideoutline-colortovar(--color-focus-on-accent)for AA contrast. outlineis reserved for focus indicators — do not useoutlineas decorative chrome on idle elements.
4.5 Density¶
Kafkito is a data console. Err toward density, not whitespace.
- Table row padding:
px-4 py-2.5(content),px-4 py-2(header) - Card padding:
p-4(standard),p-5(overview/hero) - Form control height:
h-9(36 px), inputs usepx-3 - Buttons:
px-3 py-1.5 text-xs font-semiboldfor toolbar buttons;px-3 py-1.5 text-sm font-semiboldfor primary CTAs
5 · Shell / navigation¶
Every route is wrapped by <Shell> in src/features/shell/Shell.tsx. Do not
bypass it.
5.1 What Shell provides¶
- Header with logo and version, cluster pill, ⌘K search, "What's new", theme toggle and user menu (timezone and other preferences)
- Underlined tab bar for primary nav
<Outlet />for the active route, inside<main id="main-content">(focusable, the fallback focus target when a focused control goes away)- The compare bar (
features/messages/compare/CompareTray.tsx) below the outlet while a message is selected for compare, with an always-mounted polite live region for selection changes - Manages theme (light/dark) via
useTheme()insrc/lib/use-theme.ts(re-exported fromsrc/lib/theme.ts) - Reads the active cluster from the URL path (
/clusters/$cluster/…) viauseCluster()insrc/lib/use-cluster.ts
5.2 Adding a top-level route¶
- Add the file:
src/routes/my-page.tsx, orsrc/routes/clusters.$cluster.my-page.tsxfor a cluster-scoped page. - Add the nav entry in
Shell.tsxonly if the route should be globally visible. Secondary routes (Settings, Brokers) stay out of the primary tab bar and are reached via the cluster detail page or an overflow menu. - Run
bun run routes:generate(or rely onbun run build). The generatedrouteTree.gen.tsis gitignored; do not commit it.
5.3 Cluster selection¶
The active cluster is a path segment: cluster-scoped routes live under
/clusters/$cluster/… (files src/routes/clusters.$cluster.*.tsx, with
clusters.$cluster.tsx as the layout). Read it in the component with the
shared hook, which also resolves private (browser-stored) clusters:
useSearch params are for view state such as filters, not for the cluster.
Never introduce a per-page cluster picker. The pill in the header is the only way to change the active cluster.
A form field that names a cluster for its data is not such a picker: the destination of the Replay dialog and the topic copy, and both messages of the compare page, pick a cluster per field without changing the active one.
6 · Components¶
Generic, domain-free primitives live in frontend/src/components/ui/ and
are imported from there (@/components/ui/Button). Components that belong
to one domain live in frontend/src/features/<domain>/; the app chrome
(<Shell>, command palette, cluster pill, user menu, "What's new") lives
in frontend/src/features/shell/. Shared primitives must not be
duplicated across routes. See § 10.1 for the full layout.
6.1 Core primitives (already exist — use them)¶
Every entry lives in src/components/ui/<Name>.tsx, except <Shell>
(src/features/shell/Shell.tsx) and <StateBadge>
(src/features/groups/StateBadge.tsx).
| Component | Purpose |
|---|---|
<Shell> |
Header + nav + outlet |
<PageHeader> |
eyebrow? + title + subtitle? + actions? |
<KpiCard> |
label + value + unit? + delta? |
<Tag> |
Small mono tag, variant="neutral" \| "info" \| "warn" \| "danger" \| "success" |
<Badge> |
Rounded status pill with optional leadingIcon |
<StatusDot> |
2×2 status mark, shape-coded per intent (filled disc = healthy, bold × = unreachable, ring = warning, small ring = unknown) with an aria-label |
<StatusIcon> / <StatusBox> |
Labelled lucide outcome icon (✓ circle / triangle / ⊗ / info) and the compact result box built on it; use for submit results and load errors |
<StateBadge> |
Consumer-group state pill |
<DataTable> |
Styled <table> with built-in <thead> / row styles, sort headers, and skeleton/empty body states. sort + onSortChange make the sort controlled (e.g. kept in the URL); clickableRows forwards row clicks to a data-row-primary control the cell renders itself, such as a <Link> |
<Toolbar> |
Filter / action row: search? (left), filters? (centre), actions? (right, ml-auto). Replaces hand-rolled flex flex-wrap items-center gap-2 blocks |
<FilterSelect> |
Toolbar filter dropdown ("Label: Value ▾") on a native <select>; strong border while a non-default value is active; disabledReason? disables it and wires the reason via aria-describedby |
<EmptyState> |
icon? + title + description? + action? |
<ErrorState> |
title + detail? + onRetry? (renders the retry <Button>) |
<Modal> |
open + onClose + title + children + actions? + size? (sm \| md \| lg) + ariaDescribedBy? + onSubmit?; centered panel with backdrop, focus-trap, body-scroll lock, Escape-to-close, focus-restore. With onSubmit, body and footer become one <form>: Enter in a field submits, and a type="submit" button in actions is its submit button |
<Notice> |
intent="info" \| "success" \| "warning" \| "danger" + title? + children + icon? + actions?; tinted callout for degraded-capability banners and inline explanations. Always pairs colour with an icon |
<SegmentedControl> |
label + value + onChange + options (value, content, title?) + className? / itemClassName?; single-choice switch on visually hidden native radios, so arrow keys move the choice and the focus ring sits on the visible label; the selected option has a border-border-strong border, not only a tint |
<Button> |
variant="primary" \| "secondary" \| "danger" \| "ghost" + size="sm" \| "md" + leadingIcon? / trailingIcon? + loading? |
<Input> |
h-9 text input + invalid? (sets aria-invalid="true" and switches border to border-danger) + leadingIcon? / trailingIcon?. Does not set outline-none; the global :focus-visible rule is the focus indicator |
useFieldError |
useFieldError(error, describedBy?) (in FieldError.tsx) returns controlProps (aria-invalid + aria-describedby → the error's id) to spread on the control and the message — the visible field error, ⊗ icon named "Error" + text — to render below it |
<IconButton> |
Icon-only button, variant="ghost" \| "secondary" \| "danger" + size="sm" \| "md"; always pass an aria-label |
<SearchInput> |
Filter box with optional count (visible / total) and / shortcut |
<ConfirmDialog> |
Confirmation modal for destructive actions; confirmPhrase? makes the user type a phrase first |
<Card> / <Section> |
Bordered panel (hero?, flush?) and titled page section with actions? |
<Skeleton> |
Loading placeholder block |
<Timestamp> |
Formats a date in the user's timezone setting; use it instead of raw Date formatting |
<MonoId> |
Monospace identifier, optionally shortened and copyable |
<Highlight> |
Marks match ranges inside a string |
<Tooltip> |
Radix tooltip wrapper (content, side?, align?); <TooltipProvider> is mounted once at the root |
<Toaster> |
sonner toast container, mounted once at the root |
If it's not in the list and doesn't exist yet, write it first as a component, then use it. No inlined duplications of table chrome, modal scaffolding, button styles, etc.
6.2 Buttons¶
| Variant | Visual | When |
|---|---|---|
primary |
bg-accent text-accent-foreground hover:bg-accent-hover (with focus-visible:[outline-color:var(--color-focus-on-accent)]) |
the one main action per screen |
secondary |
border border-border bg-panel text-text hover:bg-hover hover:border-border-hover |
everything else |
danger |
bg-danger text-accent-foreground hover:bg-danger/90 (with focus-visible:[outline-color:var(--color-focus-on-accent)]) |
destructive only |
ghost |
text-muted hover:text-text hover:bg-hover |
inline, icon-only, overflow menus |
The variant union is exactly primary | secondary | danger | ghost. The destructive alias was removed — do not reintroduce it.
One primary button per page. Multiple primaries signal unclear hierarchy.
6.3 Tables¶
All tables use <DataTable>. Rules:
- Numeric columns:
text-right tabular-nums,font-monoif they are IDs/offsets - Name columns:
font-mono text-[13px] - Row hover:
hover:bg-hover - Clickable rows keep table semantics: the
<tr>stays a row — neverrole="button", nevertabIndex. The row's action is a real control in the primary (name) cell: a<Link>for navigation, a<button>for in-page actions (Enter, and Space for buttons). Mark itdata-row-primaryand passclickableto<DataTableRow>; a click elsewhere on the row is forwarded to it as a mouse convenience. Column mode does this for you whenonRowClickis set (isRowExpandedaddsaria-expandedfor inline detail panels). - Empty result: show a single row spanning all columns with
text-center text-muted py-8and a "No results" message - Loading: replace
<tbody>with 5 skeleton rows - Error: render
<ErrorState>instead of the table
6.4 Forms¶
- Every input has a label above it (
text-xs font-semibold uppercase tracking-wider text-muted). - Validation errors: visible text below the field (the
messagefromuseFieldError, which pairs the red tint with a named ⊗ icon — never colour alone),aria-invalid="true"on the control, andaria-describedbypointing at the error text's id. UseuseFieldErrorso the three stay in sync, and render the message outside any wrapping<label>so it does not become part of the field's name. The message is a polite live region (aria-live="polite", neverrole="alert"), so errors from live validation do not interrupt typing. - Destructive actions require a
<Modal>/<ConfirmDialog>— never a single click, neverwindow.confirm. - Disabled controls must convey why they are disabled via
aria-describedbyplus either a visible<Notice>or ansr-only<span>. Never rely ontitle=alone for load-bearing reason copy — the disabled-with-tooltip pattern is a Confluent anti-pattern (see § 12). - After mutation, invalidate the matching TanStack Query keys. Never refetch manually.
6.5 Status conventions¶
| State | Color |
|---|---|
| Reachable / stable / allow | success (or tint-green) |
| Warning / rebalancing / lag 1k–10k | warning (or tint-amber) |
| Unreachable / dead / lag ≥ 10k / deny / destructive | danger (or tint-red) |
| Unknown / empty / internal | muted |
Thresholds (lag 1 000 / 10 000) live in src/lib/format.ts — use them,
never hard-code.
The colour in this table is never the whole signal; see § 9 "Never colour alone".
6.6 Iconography¶
- Always import from
lucide-react. - Icon size:
h-4 w-4inline with text,h-5 w-5standalone,h-3.5 w-3.5(14 px) for status glyphs (<StatusIcon>), the shell nav tabs and glyphs inside controls (<SearchInput>,<FilterSelect>), never larger unless for empty-state illustrations. - Strokes inherit
currentColor; set color via the parent'stext-*utility. - Never mix emoji into UI chrome. Unicode glyphs (
›,·,⌕) are fine for separators and inline hints.
7 · Data, state, and side effects¶
7.1 Fetching¶
const q = useQuery({
...topicQueries.list(cluster!),
enabled: !!cluster,
refetchInterval: 10_000, // only if the data is "live"
});
- Keys and query functions live in
queryOptions()factories undersrc/lib/queries/(one module per resource). Components spread a factory and add view-specific options (enabled,refetchInterval, …). InlinequeryKeyliterals elsewhere failsrc/__checks__/query-keys.test.ts. - Keys are arrays of
[<resource>, ...scopes]; cluster-scoped keys includeclusterKey(cluster)(src/lib/queries/cluster-key.ts): the name for a shared cluster, the id for a private one, so the two never share cache entries when their names collide. - One key per resource: views that read the same data use the same factory
and differ only in view options such as
staleTime. - Never fetch in
useEffect. - Never store fetched data in component state.
refetchIntervalis opt-in — only for data the user watches in real time (cluster health, group lag, message tail). Static data does not poll.
7.2 Mutations¶
const qc = useQueryClient();
const m = useMutation({
mutationFn: () => createTopic(cluster, req),
onSuccess: () => qc.invalidateQueries({ queryKey: topicQueries.list(cluster).queryKey }),
onError: (e: Error) => setErr(e.message),
});
- Always invalidate affected keys on success.
- Always surface errors in-page — never
alert(). - Optimistic updates are forbidden for destructive ops (delete topic, reset offsets). User must see the real outcome.
7.3 Client state¶
- URL is the source of truth for view state that should survive reload: active cluster, filters, selected row, open tab. Use search params.
- Ephemeral state (modal open, unsubmitted form values):
useState. - Cross-route state that doesn't belong in the URL:
localStorageunder the namespacekafkito.*(e.g.kafkito.theme,kafkito.tailBuffer). Never unnamespaced keys. State that belongs to one task in one tab usessessionStorageunder the same namespace (kafkito.compareSelection,kafkito.once.*), with an in-memory fallback when storage throws.
7.4 Missing backend data¶
If the design shows a column the backend doesn't expose:
<td className="px-4 py-2.5 text-right font-mono tabular-nums">
{/* TODO(backend): expose per-topic msg rate on /api/v1/clusters/:cluster/topics */}
—
</td>
- Render
"—"(em dash). Never0, never"n/a", never blank. - Always leave a comment in the form
// TODO(backend): <endpoint> <field>(or<endpoint>and a short note on the missing field). Every em-dash that stands in for missing data needs a marker — em-dashes without aTODO(backend):next to them are bugs. - Surface every such TODO in the PR body.
8 · States every page must handle¶
Every data-driven view ships all five states. A page is not done unless each has been implemented and visually checked.
- Loading. Skeleton rows for tables; skeleton cards for KPI strips. No full-page spinners.
- Empty (data loaded, zero rows).
<EmptyState>with an icon, a clear sentence explaining why it's empty, and a CTA where appropriate. - Error (fetch failed).
<ErrorState>with the error message and a retry button. Never silent-fail. - Degraded (partial capability, e.g. missing ACLs in Kafka). Amber
<Notice>at the top of the view explaining which permission is missing and how to fix it. This is a kafkito-specific pattern — see the existinglimitedcode paths inclusters.$cluster.groups.index.tsxandclusters.index.tsxfor precedent. Kept deviation: when the missing permission only costs one column (the topic list withoutDESCRIBE_CONFIGSloses retention), show a single warning toast per cluster and session (claimOncePerSession) instead of a permanent banner; the column shows "—" and its filter is disabled with the reason inaria-describedby. - Populated. The happy path.
9 · Accessibility¶
- Every interactive element has a visible focus indicator from the global
:focus-visiblerule inindex.css(outline: 2px solid var(--color-focus); outline-offset: 2px;). Primitives that sit on saturated backgrounds (primary / danger Button) overrideoutline-colortovar(--color-focus-on-accent)for AA contrast. Customfocus:border-…rules on inputs are an anti-pattern — let the global rule handle it; do not reach forfocus-visible:ring-2either (Tailwind'sring-*is abox-shadowand is clipped byoverflow:hiddenancestors — see § 4.4). - Every icon-only button has
aria-label. - Every modal traps focus and closes on
Escape. - Never colour alone (WCAG 1.4.1). Red-green colour blindness is
common. Every status, outcome or selection carries
a second cue that survives greyscale: visible text, a distinct icon
shape, or both. Two states must never differ only by red vs green (or
amber vs red). Screen readers need the same meaning, so the icon has
an
aria-labelor the text is in the accessible name. - ✅
<StateBadge state="Dead" />— the word is the signal, the tint is decoration. - ✅
<StatusDot reachable={false} />— a bold × instead of the healthy filled disc, named "unhealthy". - ✅
<StatusBox intent="danger">{error}</StatusBox>— labelled ⊗ icon; success gets a ✓ circle. - ✅
<LagBadge>—▲elevated,▲▲critical, plus an sr-only level. - ✅ Selected rows get a check mark or bold text, not just a tint.
- ❌
<span className="h-2 w-2 rounded-full bg-danger" />next to a name, with a green twin for "OK". - ❌
<div className="bg-tint-green-bg text-success">{message}</div>vs. the same box indangerfor failures. - ❌
+/−prefixes that mean "good"/"bad" rather than a real increase or decrease.
frontend/e2e/status-indicators.spec.ts clicks through the status
pages and fails if an indicator loses its name, text or icon.
- Disabled controls must explain why they are disabled via
aria-describedby + a visible <Notice> or an sr-only <span>. Do
not rely on title= alone for load-bearing reason copy.
- Tables that can be sorted or filtered announce that through the column
header's aria-sort attribute.
- Contrast: the design-token pairs (text on surfaces, status tints, focus
ring, strong border) are asserted against WCAG AA in both modes by
frontend/src/__checks__/contrast.test.ts; add a pair there when you
introduce a new foreground/background combination. Rendered pages are
scanned with axe in light and dark theme by frontend/e2e/a11y.spec.ts,
which fails on any moderate, serious or critical violation
(minor findings are reported only).
- Headings do not skip levels: a page has one <h1> (<PageHeader>),
sections and cards below it start at <h2>.
10 · Code hygiene¶
10.1 File layout¶
src/
├── routes/ ← one file per route; file-based routing
│ ├── __root.tsx
│ ├── index.tsx
│ ├── clusters.$cluster.tsx ← layout for every cluster-scoped page
│ ├── clusters.$cluster.topics.index.tsx
│ └── clusters.$cluster.topics.$topic.messages.tsx
├── components/
│ └── ui/ ← generic, domain-free primitives (Button, Modal, DataTable, …)
├── features/ ← components that belong to one domain
│ ├── groups/ ← consumer groups: LagBadge, StateBadge, reset/create modals
│ ├── messages/ ← message browser: MessageRow, RangePicker, ValueBody, replay, bulk copy; compare/ (compare page, select-for-compare action, compare bar)
│ └── shell/ ← app chrome rendered by __root: Shell, CommandPalette, ClusterPill, UserMenu, What's new
├── lib/
│ ├── api.ts ← endpoint functions; don't touch without a backend reason
│ ├── api-client.ts ← openapi-fetch client + request headers middleware
│ ├── api.gen.ts ← generated from api/openapi.yaml (make api-generate)
│ ├── queries/ ← queryOptions() factories, one module per resource
│ ├── format.ts ← formatters + thresholds
│ ├── use-theme.ts ← useTheme hook
│ └── utils.ts ← cn() and tiny helpers
├── auth/ ← AuthProvider, auth hooks, transport (apiFetch)
├── content/ ← static content such as the changelog
├── __checks__/ ← source-level checks run with the unit tests
└── index.css ← tokens only; no component CSS
Where a component goes:
components/ui/when it knows nothing about Kafka or kafkito data (no@/lib/apitypes, no queries). It may be used by one route only.features/<domain>/when it is tied to one domain (topics, messages, groups, schemas, security, clusters, …). Create the folder when the first component for that domain is extracted; do not create empty folders.features/shell/for the chrome that__rootrenders once and that spans every domain.- Otherwise keep it in the route file. Extract into
features/when a route grows large, a component is reused, or it needs its own test.
Tests sit next to the component they cover (ConfirmDialog.tsx +
ConfirmDialog.test.tsx).
Never create styles/, hooks/ (put hooks in lib/), types/
(types live next to the code that owns them or in lib/api.ts; the
existing src/types/ only holds ambient .d.ts declarations for tooling),
assets/ (put images in public/).
10.2 Naming¶
- Components:
PascalCasefiles and exports. Every.tsxfile undercomponents/,features/andauth/is named in PascalCase, tests included (ConfirmDialog.test.tsx). All other modules (lib/,content/,__checks__/,e2e/) use kebab-case (use-cluster.ts). Biome'suseFilenamingConventionenforces both. Route files underroutes/are exempt: TanStack Router derives URLs from their names. The generatedrouteTree.gen.tsandlib/api.gen.tsare outside the Biome file set. - Hooks:
useCamelCase. - Query keys: always arrays, lowercase strings.
- UI copy is English, written inline in the component (no i18n layer or translation keys).
- CSS custom properties:
--color-<role>or--font-<role>; nothing else.
10.3 TypeScript¶
strict: trueis on; don't turn it off.- No
any. Useunknown+ narrowing, or extend the type inapi.ts. - No non-null assertions (
x!) except immediately after a TanStack Queryenabledguard or auseMemothat filters by existence. - Prefer
typealiases overinterfaceunless you need declaration merging.
10.4 Lint & build¶
Every commit must pass:
cd frontend
bun run lint # biome check (lint + format) + tsr generate + tsc -b --noEmit
bun run knip # unused files, exports and dependencies
bun run build # tsr generate + tsc + vite build
bun run test # vitest, incl. static checks in src/__checks__
src/__checks__/ holds source-level checks that run with the unit tests:
undeclared var(--color-*) tokens, WCAG AA contrast of the token pairs,
route files without a parent layout, query keys defined outside
src/lib/queries/, and raw Date formatters that bypass <Timestamp> (opt
out per line with // allow-raw-date: <reason>).
If any of these fails, the commit is not done.
The default Tailwind palette is disabled in @theme (--color-*: initial),
so default palette classes cannot slip in.
11 · Checklist for a new page¶
Copy this into the PR description and tick each box.
- [ ] Route file under src/routes/
- [ ] Search param schema validates every URL-driven piece of state
- [ ] <Shell> wraps the page (automatic via __root)
- [ ] <PageHeader> at the top with eyebrow + title + subtitle + actions
- [ ] Uses tokens (bg-panel / text-muted / border-border / etc.) — no Slate/Gray classes
- [ ] All five states implemented: loading · empty · error · degraded · populated
- [ ] All mutations invalidate matching TanStack Query keys on success
- [ ] Every icon-only button has aria-label
- [ ] Focus rings visible on keyboard-only
- [ ] No color-only signaling (pair with text or icon)
- [ ] Works in light mode
- [ ] Works in dark mode
- [ ] bun run lint passes
- [ ] bun run build passes
- [ ] bun run test passes
- [ ] routeTree.gen.ts regenerated (it is gitignored, not committed)
- [ ] Every TODO(backend): comment is also listed in the PR body
make check runs the full local gate. make e2e is opt-in
(too slow for the per-commit gate) — run it before merging any PR
that touches a destructive flow (Reset-Offsets, Delete-Records, ACL
mutations, SCRAM rotate). Setup + scope: frontend/e2e/README.md.
12 · Anti-patterns (don't do these)¶
- ❌ Hard-coded hex values, rgb(), or hsl() in component code.
- ❌ Default Tailwind palette classes (
bg-slate-*,text-gray-*,border-zinc-*). They are disabled in@themeand generate no CSS. Arbitrary token values (bg-[var(--color-panel)]) are out too: use the utility (bg-panel).src/__checks__/tokens.test.tsfails on them. - ❌
max-w-6xlon data-dense pages. - ❌ Per-page cluster picker (a form field naming a cluster for its data is fine, § 5.3).
- ❌ Adding a dependency without discussion.
- ❌ Inline
<style>tags in TSX files. - ❌ Fabricating data to make a design look full.
- ❌
alert(),confirm(), orprompt()— use<Modal>/<ConfirmDialog>/toast()fromsonnerinstead. - ❌
useEffectfor data fetching. - ❌ Full-page loading spinners.
- ❌ Emoji in UI chrome.
- ❌ Shadow-heavy card styling (we are flat + bordered).
- ❌ Animation libraries. CSS transitions and
animate-*utilities are enough. - ❌ Modals that don't trap focus or don't close on Escape.
- ❌ Copy that yells. No all-caps sentences; eyebrows and table headers are the only exceptions.
- ❌ Status by colour alone — a bare coloured dot, a tinted box without an icon, or two states that differ only by red vs green (§ 9).
13 · When this file and the code disagree¶
When this file and the code disagree, follow the resolution recorded in the most recent design review or PR changelog (see § 14). Where no review or changelog covers the discrepancy, this file wins — but flag the gap in a follow-up PR so the next review can record the decision.
Don't add new violations "to match the existing code". If an existing route deviates and the deviation is not recorded as a kept-deviation in a review, treat it as drift and bring it in line.
14 · Changelog¶
| Date | Change | PR |
|---|---|---|
| initial | Guidelines established alongside Direction A redesign | — |
| 2026-04-26 | Direction-A delivery: WCAG-AA token sweep (focus-on-accent, accent-foreground, border-hover, dark overlay); new primitives (Toolbar, Modal, Input, Notice); kebab/PascalCase consolidation; Button variant rename destructive→danger; PageHeader eyebrow; Incidents-(24h) → Unreachable now semantic fix; outline-based focus indicator | (this PR — link added by author) |
| 2026-05-02 | Hardening: German strings stripped (English-only), confirmPhrase parity on Reset-Offsets and Delete-Records, ErrorState retry migrated to canonical <Button>, focus-indicator escapes removed (TimezoneToggle/SearchInput/CommandPalette), Radix headless-primitive carve-out in § 1 |
(this PR — link added by author) |
| 2026-09-26 | Accessibility: Biome a11y rules on; axe scans in light and dark replace the contrast scanner; token-pair contrast test; "never colour alone" rule with StatusIcon / StatusBox and shape-coded StatusDot | (this PR — link added by author) |
| 2026-09-26 | Frontend layout: primitives move to components/ui/, domain components to features/<domain>/ (groups, messages, shell); PascalCase component files enforced by Biome useFilenamingConvention; MessageRow and RangePicker extracted from the messages route |
(this PR — link added by author) |
| 2026-09-26 | Docs sync: cluster lives in the URL path, full components/ui inventory, route and lib/ paths updated, routeTree.gen.ts is not committed |
(this PR — link added by author) |
| 2026-09-28 | Accessibility follow-ups: clickable table rows keep role row with a real link/button in the primary cell; useFieldError wires aria-invalid + aria-describedby; no skipped heading levels; axe gate raised to moderate |
(this PR — link added by author) |
| 2026-10-01 | Topic list polish: new <FilterSelect> primitive; <DataTable> gains controlled sort and clickableRows; <Modal> gains onSubmit. Kept deviation from § 8.4: a permission gap that only costs one column is a one-time toast, not a banner |
(this PR — link added by author) |
| 2026-10-09 | <DataTable> sortable column headers stay uppercase like the rest of the header row: the sort <button> restates uppercase, which browsers reset on buttons (#159) |
(this PR — link added by author) |
| 2026-10-10 | Token utilities everywhere: every arbitrary [var(--color-*)] class replaced by its utility (bg-panel, text-muted), guarded by tokens.test.ts; transitional aliases removed from index.css; hover borders on controls move from border-strong to border-hover (§ 2.1) |
(this PR — link added by author) |
| 2026-10-10 | Topic configs tab on the primitives: <Card>, <Button> for Edit / Close / Apply, the edit dialog is a <Modal> (focus trap, Escape, bg-overlay scrim) with labelled <Input>s; a read-only Edit names its reason in visible text wired via aria-describedby instead of title= |
(this PR — link added by author) |
| 2026-10-10 | Docs sync with the code: rounded-sm is 4 px (Tailwind v4), decided per § 13 in favour of the code (§ 4.3); h-3.5 w-3.5 icon size for status glyphs, nav tabs and glyphs inside controls (§ 6.6); the colocated-test example names an existing pair, ConfirmDialog.tsx + ConfirmDialog.test.tsx (§ 10.1) |
(this PR — link added by author) |
| 2026-10-10 | Docs only: § 0 points coding agents to frontend/AGENTS.md, a distilled copy of these rules; this file stays canonical |
(this PR — link added by author) |
| 2026-10-12 | Message compare page (/compare): <SegmentedControl> moves from the user menu to components/ui/ and marks the selected option with a border-border-strong border (§ 6.1); diff lines carry "−" / "+" markers and a spoken "Removed" / "Added", the changed stretch of a line is underlined, so a change never shows by colour alone; contrast pairs for text on the diff tints. § 5.3 and § 12 #4 now allow a form field that names a cluster for its data (Replay, topic copy, compare), the active cluster still changes only through the header pill. Kept deviations: the diff is a hand-built <table> in a rounded-md code block like the message detail view, not a <DataTable>; the new compare actions of a message row match the existing hand-styled message row buttons until that strip moves to <Button> |
(this PR — link added by author) |
| 2026-10-12 | Message compare flow: "Select for compare" and "Compare with selected" replace the copy-and-paste start; the message-level actions (Replay to…, compare) move to the head of the expanded message, "Copy value" and "Download" stay with the value; a sticky compare bar (border-t bg-panel, no shadow, not a new primitive) shows the selection on every page but /compare; the compare page gets a Close button that returns to the page it was opened from. Accessibility: the select / clear button keeps its place, and focus, across states; the bar's live region announces changes; Clear hands focus to the message's own button or #main-content; while the bar is shown, scroll-padding-bottom and the toaster offset keep focused controls and toasts clear of it, and it has no z-index so in-page popovers stay on top |
(this PR — link added by author) |
| 2026-10-19 | Expanded message: headers start collapsed behind a headers · n toggle (aria-expanded, same ▸/▾ glyphs as the message row); the per-header "Search by value" / "Search by header" buttons are removed ("Search by key" and click-to-filter on JSON values stay); a new "Audit snapshot" action downloads a PNG drawn on a <canvas> from the light theme's tokens (getComputedStyle) in either theme, production clusters marked with the word [PROD], not colour alone. Kept deviation: "Audit snapshot" matches the existing hand-styled message row buttons until that strip moves to <Button> |
(this PR — link added by author) |
Add a row on every change. Small tweaks to tokens or primitives are fine; major shifts (new visual language, new nav model) require a design review.