Design reference for all UI work in apps/ui. Every visual decision should trace back to a rule here. When in doubt: subtract, not add.
Clevis is a professional developer security tool, not a consumer app. The visual language reflects that:
- Data first. Every pixel should serve information, not decoration.
- Dark and dense. Developers work in dark environments. Information density is a feature.
- Confidence through restraint. A spare UI signals control. Clutter signals chaos.
- Doppler / Linear lineage. The primary design references are Doppler (dark security tooling) and Linear (data-dense dark SaaS). Take cues from both; avoid the "generic shadcn dark" look.
All tokens are defined in apps/ui/app/globals.css under :root.
| Role | Token | Hex | Usage |
|---|---|---|---|
| Page background | --background |
#18181b |
Body, main content area |
| Sidebar | --sidebar |
#0f0f11 |
Left navigation panel |
| Card surface | --card |
#27272a |
All panels, drawers, modals |
| Input / secondary | --input / --secondary |
#27272a / #3f3f46 |
Form fields, chips |
| Muted fills | --muted |
#3f3f46 |
Icon backgrounds, hover fills, table row hover |
The body also carries a subtle radial purple glow at the top (radial-gradient from rgba(168,85,247,0.07) to transparent). Keep it barely perceptible — it should register subconsciously, not visually.
| Role | Token | Hex | Usage |
|---|---|---|---|
| Primary text | --foreground |
#f4f4f5 |
Headings, body copy |
| Muted text | --muted-foreground |
#a1a1aa |
Labels, captions, empty states |
| Sidebar text | --sidebar-foreground |
#f4f4f5 |
Nav labels |
| Inactive nav | — | sidebar-foreground/45 |
De-emphasised nav items |
| Role | Token | Hex | Usage |
|---|---|---|---|
| Primary / brand | --primary |
#a855f7 |
Active nav accent, primary buttons, focus rings, the dot logo |
| Success / pass | --accent |
#22c55e |
Passing checks, success states |
| Warning | --chart-3 |
#f59e0b |
Medium-severity issues, dry-run badge |
| Error / fail | --destructive |
#f87171 |
Failed checks, destructive actions |
Never use raw hex values in component code. Always use the CSS variable via Tailwind (text-primary, bg-destructive, border-green-500/25, etc.).
Use --border (#3f3f46) for all structural borders. For status-tinted card borders (check cards, result panels) use opacity modifiers: border-green-500/25, border-red-500/25. Never a solid opaque status border — they dominate.
Fonts are loaded in apps/ui/app/layout.tsx (Geist Sans + Geist Mono).
| Role | Class | Notes |
|---|---|---|
| Page title | text-base font-semibold |
In PageHeader, not h1 weight — we're a tool not a blog |
| Section label | .section-title |
0.8125rem, weight 500 — card header labels |
| Body / paragraph | text-sm |
Default prose size throughout |
| Caption / label | text-xs |
Form labels, table headers, stat card labels |
| Monospace | font-mono text-xs |
Cache keys, tokens, JSON output, all code content |
| Stat value | text-2xl font-bold tabular-nums tracking-tight |
Numbers in stat cards |
| Score | text-xl font-bold tabular-nums |
SVG gauge label |
Section group labels in the sidebar use text-[0.6875rem] uppercase tracking-widest — the widest tracking in the system. Use sparingly; only for group dividers.
The app shell is a fixed-width sidebar (<Sidebar>) + full-height <SidebarInset>. Content lives inside <main className="flex-1 p-6"> (set in layout.tsx).
All pages follow the same scaffold:
<>
<PageHeader title="..." description="..." /> // always first
{/* content */}
</>Use CSS grid for page-level layout. Standard column splits:
| Layout | Class |
|---|---|
| Config + content | grid gap-4 lg:grid-cols-3 (config = 1 col, content = lg:col-span-2) |
| Stat cards | grid grid-cols-2 lg:grid-cols-4 gap-3 |
| Check card grid | grid gap-3 sm:grid-cols-2 |
Prefer gap-3 (12px) within components and gap-4 (16px) between top-level panels. Card internal padding: px-4 py-4 (content) or px-4 py-3 (headers / footers).
- Two section groups: Analytics (Overview, Activity, Repositories, Health & Security) and Management (Collaborators, Automation).
- Group labels:
text-[0.6875rem] uppercase tracking-widest text-sidebar-foreground/30. - Active item:
bg-sidebar-accent text-sidebar-primary+ a 2px left bar viabefore:pseudo-element (before:w-0.5 before:bg-sidebar-primary). - Inactive item:
text-sidebar-foreground/45, hovertext-sidebar-foreground bg-sidebar-accent/50. - Logo mark: small rounded square with
bg-sidebar-primary/20fill and asize-2purple dot inside. Not an image. - Footer:
GitBranchicon + org name attext-sidebar-foreground/30. One line, no status dot.
Always rendered as the first child of every page. Props:
<PageHeader title="…" description="…" actions={<Button>…</Button>} />- Title:
text-base font-semibold. - Description:
text-sm text-muted-foreground mt-0.5. - Bottom border:
border-b border-border pb-4 mb-5— separates the header from page content. actionsslot: right-aligned; use for page-level CTAs (e.g. "Run scan", "Refresh"). Optional.
Used on the overview page. Four cards in a responsive grid.
<StatCard label="Repositories" value="—" icon={FolderGit2} />- Card:
bg-card border border-border rounded-lg px-4 py-4. - Label:
text-xs text-muted-foreground mb-1.5. - Value:
text-2xl font-bold tabular-nums tracking-tight. - Icon:
size-4 text-muted-foregroundin ap-2 bg-muted/60 rounded-mdpill, top-right.
The raw card pattern used throughout:
<div className="bg-card border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border">
<span className="section-title">Label</span>
</div>
{/* body */}
</div>The header strip always has a border-b. The .section-title utility is the only text style for card headers.
Replaces all "Coming soon" / no-data states.
<EmptyState
icon={Radio}
title="Activity feed coming soon"
description="Connect a GitHub organization to stream real-time events…"
cta={<Button disabled>Connect</Button>} // optional
/>- Icon:
size-6inp-3 bg-muted/60 rounded-lg, centred above text. - Title:
text-sm font-medium text-foreground. - Description:
text-sm text-muted-foreground max-w-xs leading-relaxed. - Vertical padding:
py-16. Always centred.
Used in the security results grid.
- Pass:
border-green-500/25, hoverborder-green-500/40. - Fail:
border-red-500/25, hoverborder-red-500/40. - Severity badge: pill with coloured text + low-opacity background tint (
bg-red-400/10, etc.). - Remediation:
text-xs text-muted-foreground leading-relaxed.
No third-party table library. Use a plain <table> with these conventions:
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left text-muted-foreground font-medium px-4 py-2">Col</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr className="hover:bg-muted/40 transition-colors">
<td className="px-4 py-2.5 …">…</td>
</tr>
</tbody>
</table>text-xsthroughout.- Headers:
text-muted-foreground font-medium. - Row hover:
bg-muted/40. - Monospace columns (keys, hashes):
font-mono text-foreground/80 truncate. - Numeric / right-aligned columns:
text-right tabular-nums.
Inline badge for counts and status labels in card headers and result panels.
<span className="stat-chip">14 total</span>
<span className="stat-chip text-red-400 border-red-500/30">3 failed</span>Base: inline-flex items-center gap-1 text-xs border border-border rounded px-1.5 py-0.5 text-muted-foreground.
SVG arc gauge in security/page.tsx. Pattern:
<svg width="96" height="96" className="-rotate-90">
<circle /* track */ stroke="#3f3f46" strokeWidth="7" />
<circle /* fill */ stroke={color} strokeDasharray={`${progress} ${circumference}`} strokeLinecap="round" />
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold tabular-nums" style={{ color }}>{score}</span>
</div>The wrapper div is relative; the label div is absolute inset-0. Color thresholds: ≥80 → green, ≥50 → amber, <50 → red.
For API result panels:
<pre className="font-mono text-xs text-muted-foreground leading-relaxed overflow-auto bg-muted/30 rounded-md p-3 border border-border/50">
{JSON.stringify(data, null, 2)}
</pre>- Labels:
text-xs font-medium text-foreground block mb-1.5. - Inputs: use
<Input>fromcomponents/ui/input. Monospace inputs (tokens, keys) getclassName="font-mono". - Button row: primary action at top (
<Button>), secondary actions below in agrid grid-cols-2 gap-2. - Error display:
flex items-start gap-2 text-xs text-destructivewith aAlertTriangle size-3.5icon.
Icons come from Lucide React only. No other icon sets.
Sizing conventions:
| Context | Class |
|---|---|
| Nav icons | size-4 |
| Card header icons | size-3.5 |
| Empty state icons | size-6 (inside the icon box) |
| Inline text icons | size-3.5 |
| Status icons (check/x) | size-4 |
Always pass shrink-0 on icons next to truncatable text.
Keep it minimal and functional:
transition-colorson interactive elements (nav items, table rows, quick-action links).transition-opacityfor elements that reveal on hover (e.g. the arrow in quick actions).- SVG arc:
transition: stroke-dasharray 0.6s ease— the only animated layout element. - No entrance animations, no skeletons, no loading spinners beyond
<Loader2 animate-spin>inside buttons.
--radius is 0.25rem — very tight. The system is intentionally angular:
| Scale | Value |
|---|---|
rounded |
0.25rem — default for all components |
rounded-md |
0.375rem — icon boxes, muted fill pills |
rounded-lg |
0.5rem — cards, panels, inputs |
rounded-full |
Reserved for the score gauge track only |
Avoid rounded-xl and above.
Do:
- Build data density. Err on the side of more information per screen.
- Use
text-muted-foregroundfor anything secondary. Foreground is for primary content only. - Reach for
.stat-chipandEmptyStatebefore inventing new patterns. - Keep borders at
border-border(#3f3f46). Status tints use opacity (/25,/30), never solid.
Don't:
- Add gradients to components (the body glow is the only gradient).
- Use colour for decoration — only for status (pass/fail/warn/error).
- Add shadow utilities (
shadow-*). The dark-on-dark palette doesn't need depth shadows. - Stack multiple font weights on the same line. Pick one.
- Use
rounded-xlor larger radius — it undermines the sharp/professional feel. - Introduce a new icon library or icon set.