Skip to content

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:

  1. Read this file end-to-end.
  2. Open frontend/src/index.css to confirm the active design tokens.
  3. Open the closest existing route under frontend/src/routes/ and mimic its structure (imports order, query setup, layout scaffold).
  4. Use Tailwind utilities generated from @theme tokens (e.g. bg-panel, text-muted, border-border). Never hard-code hex values. The default Tailwind palette is disabled in @theme, so classes like bg-slate-50 or text-gray-700 generate no CSS.
  5. 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-strong is never used for hover affordances any more — that role belongs to border-hover. Custom focus:border-… rules on inputs are an anti-pattern (see § 9 — let the global :focus-visible rule handle it).
  • focus-on-accent is consumed by <Button variant="primary"> and <Button variant="danger"> to override outline-color so the focus indicator stays AA-legible against saturated fills.
  • overlay is 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

  1. Propose it in the PR description with justification.
  2. If accepted, add it to @theme in src/index.css for both light and html.dark. Both modes must define it.
  3. 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-mono for 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-relaxed only 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 form space-y-5 px-6 py-6 compiles to identical CSS and is acceptable in existing routes; new code should prefer the shorter p-6.
  • Multi-section pages: wrap each section in space-y-6 if 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-auto is the standard width.

4.2 Grid & flex

  • KPI strips: grid grid-cols-4 gap-3 (4 cards); grid-cols-2 on 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-3 for 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; use border border-border. <Card> and <Button> no longer ship shadow-sm.
  • Modals and dropdowns: the one exception — shadow-xl on floating surfaces over a bg-overlay backdrop.
  • Focus indicator: the canonical pattern is the global :focus-visible rule (outline: 2px solid var(--color-focus); outline-offset: 2px; declared in index.css). Tailwind's ring-* utility is implemented via box-shadow, which is clipped by overflow:hidden ancestors (tables, modals, tinted panels) and silently fails WCAG 2.4.13 — so outline + outline-offset is preferred over ring-* for focus, and primitives that sit on saturated fills (<Button variant="primary">, <Button variant="danger">) override outline-color to var(--color-focus-on-accent) for AA contrast.
  • outline is reserved for focus indicators — do not use outline as 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 use px-3
  • Buttons: px-3 py-1.5 text-xs font-semibold for toolbar buttons; px-3 py-1.5 text-sm font-semibold for 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() in src/lib/use-theme.ts (re-exported from src/lib/theme.ts)
  • Reads the active cluster from the URL path (/clusters/$cluster/…) via useCluster() in src/lib/use-cluster.ts

5.2 Adding a top-level route

  1. Add the file: src/routes/my-page.tsx, or src/routes/clusters.$cluster.my-page.tsx for a cluster-scoped page.
  2. Add the nav entry in Shell.tsx only 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.
  3. Run bun run routes:generate (or rely on bun run build). The generated routeTree.gen.ts is 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:

const { cluster } = useCluster();

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-mono if 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 — never role="button", never tabIndex. 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 it data-row-primary and pass clickable to <DataTableRow>; a click elsewhere on the row is forwarded to it as a mouse convenience. Column mode does this for you when onRowClick is set (isRowExpanded adds aria-expanded for inline detail panels).
  • Empty result: show a single row spanning all columns with text-center text-muted py-8 and 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 message from useFieldError, which pairs the red tint with a named ⊗ icon — never colour alone), aria-invalid="true" on the control, and aria-describedby pointing at the error text's id. Use useFieldError so 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", never role="alert"), so errors from live validation do not interrupt typing.
  • Destructive actions require a <Modal> / <ConfirmDialog> — never a single click, never window.confirm.
  • Disabled controls must convey why they are disabled via aria-describedby plus either a visible <Notice> or an sr-only <span>. Never rely on title= 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-4 inline with text, h-5 w-5 standalone, 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's text-* 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 under src/lib/queries/ (one module per resource). Components spread a factory and add view-specific options (enabled, refetchInterval, …). Inline queryKey literals elsewhere fail src/__checks__/query-keys.test.ts.
  • Keys are arrays of [<resource>, ...scopes]; cluster-scoped keys include clusterKey(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.
  • refetchInterval is 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: localStorage under the namespace kafkito.* (e.g. kafkito.theme, kafkito.tailBuffer). Never unnamespaced keys. State that belongs to one task in one tab uses sessionStorage under 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). Never 0, 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 a TODO(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.

  1. Loading. Skeleton rows for tables; skeleton cards for KPI strips. No full-page spinners.
  2. Empty (data loaded, zero rows). <EmptyState> with an icon, a clear sentence explaining why it's empty, and a CTA where appropriate.
  3. Error (fetch failed). <ErrorState> with the error message and a retry button. Never silent-fail.
  4. 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 existing limited code paths in clusters.$cluster.groups.index.tsx and clusters.index.tsx for precedent. Kept deviation: when the missing permission only costs one column (the topic list without DESCRIBE_CONFIGS loses 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 in aria-describedby.
  5. Populated. The happy path.

9 · Accessibility

  • Every interactive element has a visible focus indicator from the global :focus-visible rule in index.css (outline: 2px solid var(--color-focus); outline-offset: 2px;). Primitives that sit on saturated backgrounds (primary / danger Button) override outline-color to var(--color-focus-on-accent) for AA contrast. Custom focus:border-… rules on inputs are an anti-pattern — let the global rule handle it; do not reach for focus-visible:ring-2 either (Tailwind's ring-* is a box-shadow and is clipped by overflow:hidden ancestors — 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-label or 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 in danger for 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/api types, 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 __root renders 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: PascalCase files and exports. Every .tsx file under components/, features/ and auth/ is named in PascalCase, tests included (ConfirmDialog.test.tsx). All other modules (lib/, content/, __checks__/, e2e/) use kebab-case (use-cluster.ts). Biome's useFilenamingConvention enforces both. Route files under routes/ are exempt: TanStack Router derives URLs from their names. The generated routeTree.gen.ts and lib/api.gen.ts are 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: true is on; don't turn it off.
  • No any. Use unknown + narrowing, or extend the type in api.ts.
  • No non-null assertions (x!) except immediately after a TanStack Query enabled guard or a useMemo that filters by existence.
  • Prefer type aliases over interface unless 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)

  1. ❌ Hard-coded hex values, rgb(), or hsl() in component code.
  2. ❌ Default Tailwind palette classes (bg-slate-*, text-gray-*, border-zinc-*). They are disabled in @theme and generate no CSS. Arbitrary token values (bg-[var(--color-panel)]) are out too: use the utility (bg-panel). src/__checks__/tokens.test.ts fails on them.
  3. ❌ max-w-6xl on data-dense pages.
  4. ❌ Per-page cluster picker (a form field naming a cluster for its data is fine, § 5.3).
  5. ❌ Adding a dependency without discussion.
  6. ❌ Inline <style> tags in TSX files.
  7. ❌ Fabricating data to make a design look full.
  8. ❌ alert(), confirm(), or prompt() — use <Modal> / <ConfirmDialog> / toast() from sonner instead.
  9. ❌ useEffect for data fetching.
  10. ❌ Full-page loading spinners.
  11. ❌ Emoji in UI chrome.
  12. ❌ Shadow-heavy card styling (we are flat + bordered).
  13. ❌ Animation libraries. CSS transitions and animate-* utilities are enough.
  14. ❌ Modals that don't trap focus or don't close on Escape.
  15. ❌ Copy that yells. No all-caps sentences; eyebrows and table headers are the only exceptions.
  16. ❌ 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.