Meerkat design system
This document is the contract for Meerkat's user experience. Read it before changing anything in src/. It applies equally to human contributors and to AI coding agents (see AGENTS.md).
The core promise: Meerkat tells you what happened while you were away, what is wrong now, and what to do about it — in that order, in plain language, on any screen.
Everything below exists to protect that promise. If a change makes Meerkat look different but keeps the promise, it is probably fine. If it weakens the promise, it is not, no matter how good it looks.
- 1. Who we design for
- 2. Principles
- 3. Core UX invariants
- 4. Tokens
- 5. Status language
- 6. Layout and navigation
- 7. Components
- 8. Voice and tone
- 9. Accessibility
- 10. Motion
- 11. Performance and privacy budget
- 12. How to change this document
- 13. Review checklist
1. Who we design for
| Persona | Context | What they need from Meerkat |
|---|---|---|
| Priya, the weekend homelabber (primary) | Runs Nextcloud, Jellyfin and Home Assistant on a mini-PC or Raspberry Pi. Checks Meerkat from her phone, often away from home. Comfortable with Docker Compose, not a sysadmin. | A one-glance answer. Plain words, not alert IDs. A button that fixes the problem. Confidence that nothing happened while she was out. |
| Marco, the tinkerer (primary) | Rebuilds his stack often. Breaks things on purpose. | Meerkat must not fight him (no restarting things he stopped). Fast access via keyboard. |
| Sam, the sysadmin (secondary) | Manages a small office server and a VPS. | Density on demand, keyboard shortcuts, /metrics, exact timestamps on hover. |
Design for Priya first. Marco and Sam get speed through shortcuts and the command palette, never at the cost of Priya's clarity.
2. Principles
1. Answer before data
Every page starts with a sentence that answers the page's question. Numbers and charts come after.
| Do | Don't |
|---|---|
| "2 problems need attention" | A grid of 12 equal-weight tiles |
| "7 of 8 sites up" | "Monitors" as the page heading |
| "Internet is unreachable" | "internet_up: false" |
2. Color means status
The interface is neutral sand. Status colors (green, amber, red) appear only to communicate state, and are always paired with an icon shape and a word. The indigo accent is reserved for interactive elements (primary buttons, focus rings, selected navigation).
| Do | Don't |
|---|---|
| Neutral meters that turn amber/red only past a threshold | Decorative gradients, colored card backgrounds for fun |
✓ Up pill (icon + word + color) |
A green dot with no text |
| Up bars quiet, down bars full-strength red | Every bar shouting bright green |
3. Every problem has a next step
Anything shown as a problem offers at least one action: Restart/Start, Open site, View monitor, View network, Silence. If there is genuinely nothing to do in Meerkat, link to the place that explains it.
4. Honest about time
Every value has an age. The top bar always shows how fresh the data is ("Live · 4s ago"). When Meerkat cannot reach its backend, the UI says so in a banner and keeps showing the last known state with its age — it never shows stale data as if it were live, and never blanks the screen.
5. Calm by default, fast for experts
Generous spacing, one primary action per view, progressive disclosure (advanced options folded). Experts get the command palette (⌘K / Ctrl+K / /), g + letter navigation, and ? for help.
3. Core UX invariants (do not change without a design RFC)
These are the non-negotiable parts of Meerkat's experience. A pull request that changes any of them must link an approved design RFC (a GitHub issue labelled design-rfc that the maintainer has approved). CODEOWNERS enforces review on the files that implement them.
- Status sentence first. Every page renders a
PageHeaderwhose<h1>is an answer sentence, with aStatusGlyphwhen the page has an overall state. - Color never stands alone. Status is always color + icon shape + text. Use
StatusPill,StatusIcon,StatusGlyph. - Problems carry actions. Items in "Needs attention" always have at least one action button (
ProblemActioninsrc/lib/insights.ts). - "Since your last visit" stays on Overview. It is Meerkat's signature feature.
- Data age is always visible, and the offline/stale banner always appears when data is not live.
- Destructive or disruptive actions confirm first (restart, remove, clear history, clear cache) via the shared confirm dialog. Removal offers Undo where possible.
- Navigation order and labels are fixed: Overview · Incidents · Monitors · Containers · Network · Host · Settings (
src/lib/nav.ts). Mobile bottom tabs: Overview, Incidents, Monitors, Containers, More. - Tokens are the only color source. No color literals outside
src/styles/tokens.css(enforced bynpm run check:tokens). - No external requests from the dashboard. No web fonts, CDNs, analytics or trackers. Meerkat must work fully offline on a LAN.
- Accessibility floor: WCAG 2.2 AA, enforced by axe in CI, with no serious or critical violations.
- The logo (
src/components/ui/logo.tsx,src/app/icon.svg) is the standing meerkat lookout. Do not replace or recolor it.
4. Tokens
All tokens live in src/styles/tokens.css. Light, dark and system themes are defined there and nowhere else. Run npm run check:contrast after any change; every text pair must meet AA (4.5:1) and every status/graphic color 3:1.
Color roles
| Role | Tokens | Use for |
|---|---|---|
| Canvas | --bg |
Page background |
| Surfaces | --surface, --surface-2, --surface-3 |
Cards, inputs; sunken areas; tracks and skeletons |
| Lines | --border, --border-strong |
Dividers; input and button outlines |
| Text | --text, --text-2, --text-3 |
Primary; secondary; tertiary/meta (all AA on every surface) |
| Accent | --accent, --accent-hover, --accent-soft, --accent-text, --on-accent, --focus |
Primary buttons, selected nav, links, focus rings. Never status. |
| Status | --ok*, --warn*, --bad*, --unknown* |
State only. *-soft for pill backgrounds, *-text for text on soft backgrounds |
| Brand | --brand-fur, --brand-fur-dark, --brand-sky, --brand-ink |
The logo only |
Savanna palette reference (light): sand canvas #f6f1e9, surface #fffdf9, ink #211b14, dusk indigo accent #4f46e5, ok #15803d, warn #b45309, bad #b91c1c. Dark: night canvas #12100d, surface #1a1712, text #f4eee4, accent #818cf8.
Typography
System font stack (--font-sans), tabular numbers everywhere. Scale: 12 / 14 / 16 / 20 / 24 / 32 px (--text-xs … --text-2xl). Page headlines 32px bold (24px on mobile); card titles 16px semibold; body 14px.
Space, shape, elevation
- 4px grid:
--space-1(4) to--space-12(48). Page padding 32px desktop, 16px mobile. Gaps between cards 24px. - Radius:
--radius-sm6 (chips, inputs inner),--radius-md10 (buttons, inputs, rows),--radius-lg16 (cards, dialogs),--radius-full(pills). - Elevation:
--shadow-1for cards,--shadow-2for overlays only.
5. Status language
| State | Tone | Icon | Words | Where |
|---|---|---|---|---|
| Healthy | ok |
circle-check | Up · Running · Online · All systems normal | Pills, glyphs, uptime bars (muted) |
| Degraded / threshold | warn |
triangle-alert | High · Almost full · Running hot | Meters past warn, warning alerts |
| Failing | bad |
circle-x | Down · Exited · Unreachable · Problem | Pills, bars, problem items |
| Unknown / intentional | unknown |
circle-help (or info) | Unknown · Stopped on purpose · Checking… | No data yet, user-stopped containers |
Thresholds: meters turn warn at 75% and bad at 90% by default (disk 80/90, CPU temperature 70/80 °C).
6. Layout and navigation
- Desktop (> 860px): 248px sidebar (logo, navigation with problem counts, docs/GitHub links, version) + sticky top bar (search/command trigger, connection indicator, silence menu) + content max 1200px.
- Tablet (≤ 1100px): two-column layouts collapse to one; monitor details open in a sheet.
- Mobile (≤ 860px): compact top bar, bottom tab bar (Overview, Incidents, Monitors, Containers, More), sheets slide up from the bottom, touch targets ≥ 44px, safe-area insets respected.
- Page anatomy:
PageHeader(eyebrow, answer headline, meta, page actions) → primary content → secondary content. - Overview layout: left column "Needs attention" then "Since your last visit"; right column "Vital signs" then "Pinned monitors". The setup checklist appears first only when nothing is wrong.
7. Components
Use the existing components; do not hand-roll equivalents. If you need something new, add it to src/components/ui/ with a short usage note here.
| Component | File | Rules |
|---|---|---|
PageHeader |
ui/layout.tsx |
Exactly one per page. Headline is a sentence, not a noun. |
Card |
ui/layout.tsx |
Title is a noun phrase. tone only for cards that contain problems. |
StatusPill / StatusIcon / StatusGlyph / Dot |
ui/status.tsx |
The only way to show state. |
UptimeBars |
ui/data.tsx |
One bar per check; down bars full height and red. Always has an aria-label summary. |
Meter |
ui/data.tsx |
Neutral until a threshold. Shows the value as text. |
Sparkline / LatencyChart |
ui/data.tsx |
Neutral lines; red marks for down checks; hover/touch tooltip with exact values. |
Stat |
ui/data.tsx |
Label + big value + sub line; link it when there is a detail page. |
Dialog (modal / sheet) |
ui/dialog.tsx |
Native <dialog>. Sheets for forms and details; modals for confirmations. |
| Confirm, token prompt, toasts, palette | shell/overlays.tsx, shell/command-palette.tsx |
Use useMeerkat().confirm() and toast(); never window.confirm or alert. |
EmptyState |
ui/layout.tsx |
Says what is missing and offers the next action. |
Segmented, SearchInput |
ui/layout.tsx |
Filters show counts. |
Data flows through useMeerkat() (src/lib/store.tsx). Turning raw payloads into answers happens in src/lib/insights.ts. Copy lives in src/lib/strings.ts.
8. Voice and tone
- Plain, calm, specific. "Blog is down" not "site.blog.down ACTIVE". "Meerkat isn't responding" not "ERR_CONNECTION_REFUSED".
- Say what happened, then what to do. "Every probe host failed. Check the router or your ISP."
- Sentence case for headings and buttons. No exclamation marks. No blame ("you broke").
- Verbs on buttons: Restart, Start, Add monitor, Silence for 1 hour, Resume alerts, Open site. Not "OK", "Submit", "Execute".
- Word list: down (not DN/offline), up, running, stopped on purpose, needs attention, since your last visit, silence/resume (not mute/unmute).
- All strings go in
src/lib/strings.tsso Meerkat can be translated.
9. Accessibility
- WCAG 2.2 AA minimum; axe runs on every page in CI on desktop and mobile.
- Every interactive element is reachable and operable by keyboard with a visible focus ring (
--focus). - Status is never conveyed by color alone (invariant 2).
- Charts and bars have text alternatives (
aria-labelsummaries); meters userole="meter"with values. - Live updates: toasts use
aria-live, errors userole="alert". Don't announce every poll. - Respect
prefers-reduced-motionandprefers-color-scheme. - Minimum touch target 44×44px on mobile.
10. Motion
Motion explains change; it never decorates. Durations --dur-fast (120ms) and --dur-base (200ms) with --ease. Allowed: dialogs rising in, toasts appearing, meter widths easing, the live dot pulsing. Everything is disabled under reduced motion.
11. Performance and privacy budget
- First-load JavaScript per route ≤ 250 KB gzipped, of which the React/Next.js framework is about 150 KB. Enforced by
npm run check:bundlein CI. The only runtime dependencies are React, Next.js andlucide-react. - The dashboard makes requests only to its own
/api/meerkat/*proxy. No third-party requests, fonts or telemetry. - Polling pauses while the tab is hidden and backs off exponentially while the API is unreachable.
- Preferences live in
localStorageand every access is wrapped in try/catch.
12. How to change this document
- Open an issue labelled
design-rfcdescribing the problem, the proposed change, before/after screenshots (light, dark, mobile) and which invariant it touches. - The maintainer (@xrg360) approves or declines.
- Update this file and the implementation in the same PR, linking the RFC.
Small additions that don't touch an invariant (a new component following existing rules, a new string) don't need an RFC, but still need review.
13. Review checklist
Copy into PRs that touch the UI:
- Each changed page still leads with an answer sentence (
PageHeader). - No new color literals;
npm run check:tokensandnpm run check:contrastpass. - Status shown with
StatusPill/StatusIcon(color + icon + word). - Every new problem state offers a next step.
- Destructive actions use
confirm(). - New strings added to
src/lib/strings.ts. - Works at 390px wide and with the keyboard only.
-
npm run build:demo && npx playwright testpasses (axe included). - Screenshots attached: light, dark, mobile.