Style Guide
HireFinn UI design system — tokens, components, patterns.
HireFinn UI Style Guide & Design System
Living reference for dashboard UI. Read before touching any dashboard component. Last revised: 2026-05-18.
Table of Contents
- Theme System
- Palette Tokens
- Typography
- Spacing & Layout
- Cards
- Tables
- Buttons
- Tabs & Filter Chips
- Search Input
- Empty States
- Loading States
- Sidebar Navigation
- Tooltips & Popovers
- Dialogs & Modals
- Toasts
- Icons
- Form Validation
- Hover States
- Accessibility
- Metadata Display
- Breadcrumbs
- Scrollbars & Gradient Fades
- Status Colors (Semantic)
- Charts
- Audio Player
- Accepted Exceptions
- Anti-Pattern Cheatsheet
1. Theme System
7-variant multi-theme system. useDashboardTheme() is the single source of truth.
import { useDashboardTheme } from "@/contexts/dashboard-theme-context";
const { theme, palette, activeTheme, setTheme } = useDashboardTheme();
| Variable | Type | Notes |
|---|---|---|
theme | "light" | "dark" | Base mode. Use only for non-color logic (chart opacity, etc.) |
activeTheme | DashboardThemeVariant | Exact variant: light, dark, aroma, aurora, midnight, worldofai, nebula |
palette | DashboardPalette | Full resolved color palette — use for ALL surface/text/border colors |
| Variant | Base | Character |
|---|---|---|
light | light | Clean white/grey, brand green accent |
dark | dark | Deep black, brand green accent |
aroma | light | Lavender-blue, purple accent |
aurora | light | Blue-tinted, purple accent |
midnight | dark | Navy space, indigo/purple accent |
worldofai | dark | Deep purple/cyan, violet accent |
nebula | dark | Midnight blue, cyan accent |
Rule: the theme variable must NOT drive surface/text/border color decisions. Use palette.* tokens. theme only acceptable for non-color logic, pre-hydration SSR defaults, or PDF export contexts.
2. Palette Tokens
Destructure once per component, then use everywhere.
const { palette } = useDashboardTheme();
Token reference
Surfaces & backgrounds
| Token | Purpose |
|---|---|
palette.background | Page-level background |
palette.backgroundAccent | Decorative bg gradient (rare) |
palette.headerBackground | Top nav / header strip |
palette.surface | Card / modal / body content background |
palette.surfaceElevated | Floating panel (currently = surface) |
palette.surfaceSoft | Table header row, input bg, hover bg, subtle fill |
Text
| Token | Purpose |
|---|---|
palette.text | Primary content text |
palette.textSoft | Slightly muted headings |
palette.textMuted | Labels, metadata, hints, captions |
Borders & rings
| Token | Purpose |
|---|---|
palette.border | All standard borders (cards, tables, inputs, dividers) |
palette.borderStrong | Accent/emphasis borders |
palette.ring | Focus ring color |
Brand accent
| Token | Purpose |
|---|---|
palette.accent | Primary action color |
palette.accentHover | Hover on accent-colored buttons |
palette.accentSoft | Ghost badge bg / tinted highlight |
palette.accentText | Text on accent bg (typically white) |
Sidebar (dedicated tokens — never use generic surface tokens for sidebar)
| Token | Purpose |
|---|---|
palette.sidebarBackground | Sidebar panel background |
palette.sidebarHover | Sidebar nav item hover bg |
palette.sidebarActive | Sidebar active nav item bg |
Tooltips & popovers
| Token | Purpose |
|---|---|
palette.tooltipBackground | Tooltip/popover background |
palette.tooltipBorder | Tooltip/popover border |
Other
| Token | Purpose |
|---|---|
palette.shadow | Card/layer shadow (resolves to "none" in light/dark) |
Usage
// ✅ CORRECT
style={{ color: palette.text }}
style={{ borderColor: palette.border }}
style={{ backgroundColor: palette.surfaceSoft }}
// ❌ WRONG — theme ternary
style={{ color: theme === "dark" ? "#fff" : "#000" }}
// ❌ WRONG — Tailwind grey/white classes for themed surfaces
className="bg-white text-gray-500 border-gray-200"
3. Typography
Scale
| Level | Class | Color token | Use |
|---|---|---|---|
| Page title (h1) | text-xl font-semibold md:text-2xl md:font-bold lg:text-3xl | palette.text | Top of every dashboard page |
| Page subtitle | text-sm mt-1.5 | palette.textMuted | Below page title |
| Section heading (h2) | text-xl font-bold whitespace-nowrap | palette.text | With flex-1 h-px right rule |
| Card title | text-base font-semibold | palette.text | <CardHeader> labels |
| Sub-label | text-sm font-semibold | palette.textMuted | Group labels |
| Body | text-sm | palette.text | General content |
| Caption / label | text-xs | palette.textMuted | Metadata, pill info |
| Mono / code | font-mono text-xs | palette.text | Call IDs, phone numbers |
Allowed sizes: text-xs (12) · text-sm (14) · text-base (16) · text-lg (18) · text-xl (20) · text-2xl (24) · text-3xl (30).
Allowed weights: font-normal · font-medium · font-semibold · font-bold.
Page header pattern
<div className="mb-6 mt-4">
<h1 className="text-xl font-semibold md:text-2xl md:font-bold lg:text-3xl" style={{ color: palette.text }}>
Page Title
</h1>
<p className="text-sm mt-1.5" style={{ color: palette.textMuted }}>
Short description.
</p>
</div>
Section heading + rule pattern
<div className="flex items-center gap-3 mt-6 mb-4">
<h2 className="text-xl font-bold whitespace-nowrap" style={{ color: palette.text }}>
Section Name
</h2>
<div className="flex-1 h-px" style={{ backgroundColor: palette.border }} />
</div>
Anti-patterns
// ❌ text-md doesn't exist in Tailwind 3+
<CardTitle className="text-md font-bold">
// ❌ arbitrary px breaks rhythm
<p className="text-[11px]"> <p className="text-[14px]">
// ❌ numeric weight when named exists
<p className="font-[600]"> // use font-semibold
// ❌ static H1 doesn't scale across breakpoints
<h1 className="text-2xl font-bold">
Documented exceptions
| Context | Allowed | Reason |
|---|---|---|
PDF/invoice content (id="invoice-content") | text-[8..11px] | Renders to PDF, not dashboard chrome |
Analytics evidence chips (LeadTab, AIAnalysisSection, data-extractor-sidebar) | text-[10px] uppercase tracking-wide font-semibold | Density convention |
| Chart axis labels | text-[8px] | Data viz convention |
| Marketing nav dropdowns | text-[11..13.5px] | Out of dashboard scope |
shadcn primitives (ui/calendar.tsx etc) | library defaults | Vendor-managed |
4. Spacing & Layout
Page-layout types
1. Full-bleed data/list pages — dashboard, tables, analytics, settings list.
<div className="mx-4 sm:mx-0 space-y-4">
{/* content */}
</div>
2. Centered-bounded content pages — checkout, invoice, success splash.
<div className="container mx-auto max-w-4xl px-4 py-8 space-y-4">
{/* bounded content */}
</div>
Canonical values
| Pattern | Canonical | Rationale |
|---|---|---|
| Outer wrapper (full-bleed) | mx-4 sm:mx-0 | 16px mobile margin, edge-to-edge from sm+ |
| Outer wrapper (centered) | container mx-auto max-w-{2xl,4xl,5xl} px-4 py-8 | Pick max-width by content density |
| Page header gap | mt-4 mb-6 | 16px top, 24px bottom |
| Section spacing | space-y-4 | 16px between sections. space-y-6 only when justified |
| Card inner padding | p-4 | p-6 reserved for dialog content |
| 4-up KPI grid | grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 | |
| 3-up content card grid | grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 | |
| Inset table inside card | rounded-md border overflow-hidden | Border collapses with parent |
| Max-width scrollable table | max-w-[calc(100vw-3.5rem)] sm:max-w-[calc(100vw-16.5rem)] | Prevents overflow with sidebar expanded |
Border radius
| Surface | Class |
|---|---|
| Inputs, small cards, table containers | rounded-md |
| Large cards, content sections | rounded-xl |
| Modals, dialogs | rounded-2xl |
| Badges, pills, avatars, status dots | rounded-full |
Anti-patterns
// ❌ inconsistent / non-canonical
<div className="mx-2 sm:mx-0 mb-10">
<div className="container mx-auto p-6"> // dashboard pages don't use Tailwind container
<Card className="p-6"> // p-6 reserved for dialogs
<div className="grid gap-3 sm:gap-4 md:gap-8"> // pick ONE gap
// ✅ canonical
<div className="mx-4 sm:mx-0 space-y-4">
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
5. Cards
<Card
className="shadow-none overflow-hidden"
style={{
borderColor: palette.border,
backgroundColor: palette.surface,
boxShadow: "none",
}}
>
Rules
- Always
shadow-noneon in-flow cards — zero card shadows in dashboard borderColor: palette.borderalwaysbackgroundColor: palette.surfacealways- For cards inside a header box (no inner border):
borderColor: noBorder ? "transparent" : palette.border, backgroundColor: noBorder ? "transparent" : palette.surface,
Never use on in-flow cards: shadow-sm, shadow-md, shadow-lg, hover:shadow-lg, bg-white, bg-card, bg-gray-50.
Allowed shadow exception — floating overlays only: Dropdown menus, popovers, toasts, dirty-form save bars, and other elements positioned fixed / absolute outside the normal document flow may use shadow-lg to lift them visually above the page. Rationale: borders alone don't provide enough z-axis separation on translucent overlays. In-flow cards keep shadow-none — borders + palette tokens carry the structure.
Components using overlay shadow: ProfileMenu dropdown, project-switcher dropdown, DirtyFormBar, all ConfirmDestructive modals (via Dialog primitive).
6. Tables
Full table pattern
<Table>
<TableHeader>
<TableRow style={{ backgroundColor: palette.surfaceSoft, borderColor: palette.border }}>
<TableHead style={{ color: palette.textMuted }}>Column</TableHead>
</TableRow>
</TableHeader>
<TableBody style={{ backgroundColor: palette.surface }}>
<TableRow
style={{
borderBottom: `1px solid ${palette.border}`,
color: palette.text,
backgroundColor: hoveredRowId === row.id ? palette.surfaceSoft : palette.surface,
}}
onMouseEnter={() => setHoveredRowId(row.id)}
onMouseLeave={() => setHoveredRowId(null)}
>
<TableCell style={{ color: palette.text }}>...</TableCell>
<TableCell style={{ color: palette.textMuted }}>...</TableCell>
</TableRow>
</TableBody>
</Table>
Table container
<div className="w-full overflow-x-auto rounded-md border" style={{ borderColor: palette.border }}>
Lightweight row hover (no state)
Read-only lists that don't need to mutate cell colors:
<TableRow
className="transition-colors last:border-b-0 hover:bg-muted/50"
style={{ backgroundColor: palette.surface, borderBottom: `1px solid ${palette.border}` }}
>
Used in: dynamic-deployments/[id], finn-analytics-ib/[finnId], finn-analytics/[finnId], deployment-analytics/[deployment_id].
Pagination footer
<div
className="flex items-center justify-between gap-2 p-3 border-t"
style={{ backgroundColor: palette.surfaceSoft, borderColor: palette.border }}
>
<div className="text-sm" style={{ color: palette.textMuted }}>
Showing {start} to {end} of {total} entries
</div>
<Pagination ... />
</div>
7. Buttons
Primary
<Button style={{ backgroundColor: palette.accent, color: palette.accentText }}>
Deploy
</Button>
Outline / secondary
<Button variant="outline"
style={{ borderColor: palette.border, color: palette.text, backgroundColor: palette.surface }}>
Cancel
</Button>
Accent ghost (table-row action)
<Button variant="outline" size="sm"
style={{ borderColor: palette.accent, backgroundColor: palette.accentSoft, color: palette.accent }}>
Detail
</Button>
Destructive
<Button variant="outline" size="sm"
style={{ backgroundColor: "#ef4444", color: "#ffffff" }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "#dc2626"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "#ef4444"; }}>
Delete
</Button>
Destructive is the only button color that doesn't use palette. Semantic red #ef4444 (hover #dc2626).
Accent hover (inline)
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = palette.accentHover; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = palette.accent; }}
Button label rules
| Rule | Example |
|---|---|
| Sentence case for all button labels | "Stop deployment" not "Stop Deployment" |
| Verb-only when the noun is obvious from context | Inside a deployment header, "Stop" beats "Stop deployment" |
| Verb + noun for ambiguous or destructive actions | "Delete deployment", "Remove number", "Sign out" |
| Loading state = verb + ellipsis | "Stopping…", "Saving…", "Pausing…" (use the … character, not three dots) |
| No "Click to …" | "Refresh" not "Click to refresh" |
| Match the destructive level | Soft action: outline + palette. Hard action: filled palette.destructive/palette.destructiveText. |
Loading affordance in buttons
Use <Spinner> (see §11), never inline animate-pulse on the icon:
{isLoading ? (
<>
<Spinner size="xs" className="mr-2" color="#fff" track="rgba(255,255,255,0.3)" />
Saving…
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save
</>
)}
The color="#fff" + track="rgba(255,255,255,0.3)" props are required when the button background is palette.accent or palette.destructive so the spinner is visible against the dark fill.
Icon-only buttons
Every icon-only button must carry an aria-label:
<Button size="icon" aria-label="Refresh" onClick={onRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
8. Tabs & Filter Chips
<div className="flex rounded-md border p-1"
style={{ backgroundColor: palette.surfaceSoft, borderColor: palette.border }}>
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActive(tab)}
className="rounded-md px-4 py-1.5 text-sm font-medium transition-all"
style={{
backgroundColor: active === tab ? palette.surface : "transparent",
border: `1px solid ${active === tab ? palette.border : "transparent"}`,
color: active === tab ? palette.text : palette.textMuted,
}}
>
{tab}
</button>
))}
</div>
9. Search Input
<div className="relative w-full sm:w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4"
style={{ color: palette.textMuted }} />
<Input
placeholder="Search..."
className="pl-9 text-sm"
style={{ backgroundColor: palette.surface, borderColor: palette.border, color: palette.text }}
/>
</div>
10. Empty States
Two variants — pick based on intent.
A. Page-level empty (large, solid border)
Use for whole-page empty states ("no data exists yet at the top-level scope"). Solid border, generous padding, big icon.
<div className="flex flex-col items-center justify-center py-20 rounded-xl border"
style={{ backgroundColor: palette.surface, borderColor: palette.border }}>
<IconComponent className="h-12 w-12 mb-4" style={{ color: palette.textMuted }} />
<p className="text-base font-semibold" style={{ color: palette.text }}>Nothing here yet</p>
<p className="mt-1 text-sm" style={{ color: palette.textMuted }}>Description of what to do.</p>
<Button className="mt-4" style={{ backgroundColor: palette.accent, color: palette.accentText }}>
Get started
</Button>
</div>
B. Inline empty (dashed border, EmptyState primitive)
Use for section-level "you can create one here" affordances inside settings/forms. Dashed border = "creatable slot." Use the <EmptyState> primitive in components/forms/settings/empty-state.tsx:
<EmptyState
icon={<Phone className="size-4" />}
heading="No phone numbers yet"
description="Rent or connect a number to start making and receiving calls."
action={
<Button size="sm" style={{ backgroundColor: palette.accent, color: palette.accentText }}>
Get a number
</Button>
}
/>
Props: icon, heading, description, action, compact (smaller padding).
Visual difference: dashed border-2 border-dashed rounded-md + circular icon container + py-12 px-6. Conveys "this slot is editable" vs A's "this view has nothing to show."
Decision rule: Is this an entire page with no data? → A. Is this a card-sized section inside a page where the user can take action? → B.
11. Loading States
Global <Spinner> component
Always use <Spinner> — never write inline animate-spin divs.
import { Spinner } from "@/components/ui/spinner";
<Spinner /> // md, palette.accent
<Spinner size="sm" />
<Spinner size="xl" />
<Spinner size="sm" color="#ffffff" track="rgba(255,255,255,0.3)" className="mr-2" /> // white-on-button
Sizes: xs (h-3), sm (h-4), md (h-6), lg (h-10), xl (h-16). Props: size, color (border-top, defaults to palette.accent), track (border, defaults to palette.border), className, style.
Full-screen loaders
import FullScreenLoader from "@/components/ui/full-screen-loader";
<FullScreenLoader /> // covers right of sidebar
<FullScreenLoader isRootPage={true} /> // covers full viewport
Homepage / pre-auth: use HealthCheckLoader (components/shared/) — wired to Redux health slice, do not instantiate manually.
Skeleton
Use shadcn <Skeleton> — warm-tone overrides applied globally per theme:
| Theme | Color |
|---|---|
| light | #e7e0d7 |
| dark | #3a3835 |
| aurora | #e0d4c4 |
| aroma | #ddd3c8 |
| midnight / worldofai / nebula | #2d2925 |
<Skeleton className="h-32 w-full rounded-lg" />
// Manual pulse skeleton — use palette
<div className="h-3 w-24 rounded animate-pulse" style={{ backgroundColor: palette.surfaceSoft }} />
Anti-patterns
// ❌ inline spinner div
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: palette.accent }} />
// ✅ correct
<Spinner />
<Spinner size="sm" color="#ffffff" track="rgba(255,255,255,0.3)" className="mr-2" />
12. Sidebar Navigation
Sidebar uses dedicated tokens — never generic surface tokens.
// Container
style={{ background: palette.sidebarBackground }}
// Nav item — inactive
style={{ backgroundColor: "transparent", color: palette.textMuted }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = palette.sidebarHover;
e.currentTarget.style.color = palette.text;
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
e.currentTarget.style.color = palette.textMuted;
}}
// Nav item — active
style={{ backgroundColor: palette.sidebarActive, color: palette.text, fontWeight: 600 }}
// Icon
style={{ color: isActive ? palette.accent : palette.textMuted }}
13. Tooltips & Popovers
<TooltipContent style={{
backgroundColor: palette.tooltipBackground,
borderColor: palette.tooltipBorder,
color: palette.text,
}}>
Label
</TooltipContent>
DropdownMenuContent:
<DropdownMenuContent style={{ backgroundColor: palette.surface, borderColor: palette.border }}>
<DropdownMenuItem className="cursor-pointer text-sm transition-colors"
style={{ color: palette.text }}>
Action
</DropdownMenuItem>
</DropdownMenuContent>
Never add focus:bg-gray-100 dark:focus:bg-[#2a2a2a] to DropdownMenuItems.
14. Dialogs & Modals
Template
<DialogContent className="rounded-2xl p-5 max-w-[500px]"
style={{ backgroundColor: palette.surface, borderColor: palette.border, color: palette.text }}>
<DialogHeader className="text-left sm:text-left">
<DialogTitle className="text-lg font-semibold sm:text-xl" style={{ color: palette.text }}>
Dialog title
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm" style={{ color: palette.textMuted }}>
One-line description.
</DialogDescription>
</DialogHeader>
{/* body */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}
style={{ borderColor: palette.border, color: palette.text }}>Cancel</Button>
<Button style={{ backgroundColor: palette.accent, color: palette.accentText }}>Confirm</Button>
</div>
</DialogContent>
Rules
DialogContentalwaysrounded-2xl— neverrounded-xl/rounded-lg/default- Padding:
p-5default;p-6only for data-heavy forms DialogHeaderalwaystext-left sm:text-left— never centered- Title:
text-lg font-semibold sm:text-xl - Description:
text-xs sm:text-smwithpalette.textMuted - Sentence case everywhere
- Buttons: outline secondary left, primary accent right
- All color via palette tokens
Width scale
| Use | Class | ~Width |
|---|---|---|
| Confirm / warning | max-w-[400px] md:max-w-[500px] | 400–500px |
| Default form | max-w-[500px] md:max-w-lg | ~512px |
| Multi-field form | max-w-2xl | 672px |
| Data viewer / preview | max-w-4xl | 896px |
Consent line (contact-upload dialogs only)
<p className="text-xs" style={{ color: palette.textMuted }}>
By uploading contacts you confirm you have consent to contact them.{' '}
<a href="https://hirefinn.ai/compliance" target="_blank" rel="noopener noreferrer"
className="underline" style={{ color: palette.accent }}>
Read our consent policy ↗
</a>
</p>
Mobile sheet
<SheetContent style={{ backgroundColor: palette.background, borderColor: palette.border }}>
Shadcn renders the backdrop automatically. Custom modal-portal overlays elsewhere use rgba(0,0,0,0.5).
15. Toasts
Canonical lib: sonner — single source of truth.
import { toast } from "sonner";
toast.success("Saved");
toast.success("Saved", { description: "Changes applied." });
toast.error("Save failed", { description: err?.message ?? "Please try again." });
toast.info("Sync in progress");
toast.warning("Low credits");
const id = toast.loading("Importing...");
toast.success("Imported 124 contacts", { id });
toast.error("Network down", { duration: 6000 });
Do NOT use @/components/ui/use-toast — radix wrapper with different API. All usage migrated.
// ❌ radix-style API
toast({ title: "Saved", description: "...", variant: "destructive" });
// ❌ generic toast() for errors loses styling
toast("Failed to save");
// ✅ correct
toast.error("Failed to save", { description: err.message });
Error convention: toast.error(err?.message || "Action failed"). Never let raw [object Object] reach the user.
16. Icons
Canonical lib: lucide-react — every dashboard icon. Do NOT import from @heroicons/react.
import { CheckCircle, XCircle, Phone, Activity, RefreshCw } from "lucide-react";
Sizes
| Context | Class | Pixel |
|---|---|---|
| Inline-text (button label, table cell) | h-4 w-4 / size-4 | 16 |
| Small inline (badge, dense row) | h-3 w-3 / size-3 | 12 |
| Standalone (toolbar) | h-5 w-5 / size-5 | 20 |
| Page-header big | h-8 w-8 / size-8 | 32 |
| Empty-state hero | h-12 w-12 | 48 |
size-N and h-N w-N are equivalent. Prefer size-N for new code.
Color
Default to currentColor. Explicit:
<Icon className="h-4 w-4" style={{ color: palette.textMuted }} />
<Icon className="h-4 w-4" style={{ color: palette.accent }} /> // active/primary
For status icons: semantic colors per §23.
17. Form Validation
Two patterns coexist.
Pattern A: react-hook-form + zod + shadcn <Form> primitives (preferred for 3+ field forms)
const schema = z.object({ name: z.string().min(2) });
const form = useForm({ resolver: zodResolver(schema) });
<Form {...form}>
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</Form>
Used in: checkout/, team-settings-form, compliance-application-dialog, ivr/basic-info-form, newsletter-form.
Pattern B: manual useState + inline error (small forms / quick auth)
const [error, setError] = useState<string>("");
// ...
{error && <div className="text-sm text-red-500">{error}</div>}
Used in: auth/signin-form, auth/signup-form, forms/loginpay.
Error render (both patterns)
- Color:
text-red-500 - Class:
text-smminimum - Position: directly below the input
Field labels
Always pair <Input> / <Textarea> / <Select> with <Label htmlFor> or <FormLabel>. Placeholder alone fails accessibility.
18. Hover States
Every clickable element MUST have a visible hover affordance. Goal: user always knows what's clickable.
| Element | Hover treatment |
|---|---|
shadcn <Button> (any variant) | Built-in via variant |
Raw <button> (filter pill, dropdown item) | hover:bg-muted/50 |
| Table row lightweight | transition-colors last:border-b-0 hover:bg-muted/50 |
| Table row stateful | setHoveredRowId + backgroundColor: palette.surfaceSoft |
| Sidebar nav item | onMouseEnter → palette.sidebarHover |
<a> / <Link> text link | hover:underline |
| Icon-only button | hover:opacity-80 + always aria-label |
| Card-as-button | hover:bg-muted/50 OR palette.surfaceSoft |
Anti-patterns
// ❌ no hover affordance
<button onClick={...}>Filter: Active</button>
<div onClick={...}>Click me</div>
// ❌ cursor-pointer alone (no color change)
<button className="cursor-pointer" onClick={...}>Filter</button>
// ❌ hover:bg-gray-100 (not theme-aware)
<button className="hover:bg-gray-100" onClick={...}>Item</button>
// ❌ hardcoded brand hex
<button className="hover:bg-[#496F45]" onClick={...}>Save</button>
// ✅ theme-aware
<button className="hover:bg-muted/50" onClick={...}>Filter</button>
cursor-pointer is implied on <button> and <a> — don't add. Only on non-semantic elements with onClick, and always paired with visible hover treatment.
19. Accessibility
Icon-only buttons
Must include aria-label or <span className="sr-only">…</span>. Screen readers otherwise read just "button".
// ✅ sr-only label
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open actions menu</span>
</Button>
// ✅ aria-label
<Button variant="ghost" size="icon" aria-label="Back to step 1" onClick={...}>
<ArrowLeft className="h-5 w-5" />
</Button>
// ❌ no label
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
Form inputs
Pair every <Input> / <Textarea> / <Select> with <Label htmlFor> matching the input's id, OR with aria-label. Placeholder is NOT a label.
Focus rings
Default shadcn uses focus-visible:ring-2 focus-visible:ring-ring. Do not strip. If overriding with inline styles, preserve focus-visible behavior.
20. Metadata Display
Dot-separated plain text. No bordered pill badges for page-level metadata.
// ❌ pills compete with content
<Badge variant="outline">Use case: Sales</Badge>
<Badge variant="outline">Call type: Outbound</Badge>
// ✅ dot-separated
<p className="text-sm" style={{ color: palette.textMuted }}>
Sales · Outbound · +1 415 000 0000 · Audience name
</p>
// With wrap + truncation (deployment analytics)
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs"
style={{ color: palette.textMuted }}>
{items.filter(Boolean).map((item, i, arr) => (
<span key={i} className="flex items-center gap-1.5">
<span className="max-w-[260px] truncate" title={String(item)}>{item}</span>
{i < arr.length - 1 && <span className="opacity-30 select-none">·</span>}
</span>
))}
</div>
21. Breadcrumbs
- Inactive links:
color: palette.textMuted - Active/current:
color: palette.text - Separator chevron:
color: palette.textMuted
22. Scrollbars & Gradient Fades
// Scrollbar
style={{ scrollbarWidth: "thin", scrollbarColor: `${palette.border} ${palette.surfaceSoft}` }}
// "Read more" fade overlay
<div className="absolute bottom-0 inset-x-0 h-8 pointer-events-none"
style={{ background: `linear-gradient(to bottom, transparent, ${palette.surface})` }} />
23. Status Colors (Semantic)
Intentional semantic overrides. These do NOT use palette. Always hardcoded hex.
| Status | Color | Use |
|---|---|---|
| Live / success / positive | palette.accent (brand green) | Active deployments |
| Failed / error | #DC2626 | Errors, failed calls |
| Warning / scheduled / neutral | #D97706 | Pending, scheduled |
| Draft | #ea580c (light) / #fb923c (dark) | Draft Finn status |
| Destructive button | #ef4444 / hover #dc2626 | Delete/cancel |
Badge patterns
// Trained — green
{ color: "#16a34a", backgroundColor: "rgba(22,163,74,0.08)", borderColor: "rgba(22,163,74,0.3)" }
// Draft — amber
{ color: "#ea580c", backgroundColor: "rgba(234,88,12,0.08)", borderColor: "rgba(234,88,12,0.3)" }
PCA diff answer colors (correct/incorrect) are also intentional semantic exceptions.
24. Charts
Chart colors are exempt from palette-only rule — they follow data viz conventions.
// Accent
const chartAccent = palette.accent;
// Grid stroke
stroke={palette.border}
// Custom series colors may be hardcoded — annotate inline
// chart series — intentional, not palette
25. Audio Player
backgroundColor: palette.surfaceSoft // player bg
backgroundColor: palette.accent // play/pause btn + progress fill
backgroundColor: palette.border // seekbar track
color: palette.textMuted // timestamp
color: palette.accentText // icon on button
26. Accepted Exceptions
Do NOT convert these.
| Pattern | Reason |
|---|---|
jsPDF / PDF export hex colors | No theme context in PDF render |
{false && ...} dead code | Inactive, will be removed |
Pre-hydration SSR defaults in DashboardThemeToggle | Context unavailable pre-mount |
theme-toggle.tsx THEMES array swatches | Data values representing themes themselves |
| Semantic status badges (green/amber/red) | Per §23 |
| PCA diff answer colors | Semantic evaluation UI |
| Chart series colors | Per §24 |
rgba(0,0,0,0.5) modal backdrop | Universal overlay |
27. Anti-Pattern Cheatsheet
| Don't | Do |
|---|---|
theme === "dark" ? "#xxx" : "#yyy" | palette.token |
bg-white, text-gray-500, border-gray-200 | palette.surface / text / border |
shadow-sm, shadow-md, shadow-lg on cards | shadow-none |
Inline animate-spin div | <Spinner /> |
text-md, text-[11px], font-[600] | text-sm/text-xs, font-semibold |
<Button variant="ghost" size="icon"> no label | Add aria-label or <span className="sr-only"> |
toast({ title, description }) (radix API) | toast.success("...") (sonner) |
@heroicons/react import | lucide-react |
hover:bg-gray-100 | hover:bg-muted/50 |
<Badge variant="outline">Use case: X</Badge> page metadata | Dot-separated palette.textMuted text |
focus:bg-gray-100 dark:focus:bg-[#2a2a2a] on DropdownMenuItem | Remove — palette handles it |
mx-2 sm:mx-0 page wrapper | mx-4 sm:mx-0 space-y-4 |
<Card className="p-6"> | p-4 (p-6 reserved for dialogs) |
Custom rolled-own-pagination div | <Pagination> from components/ui/pagination |
New dialog without rounded-2xl | rounded-2xl |
28. Settings Canonical Reference
Use these exact patterns for any dashboard settings page, popup, or form. Settings UIs must look interchangeable — same row pattern, same dialog shell, same heading sizes everywhere.
Row-pattern card (settings sections)
<SettingsSection
heading="Sentence-case title"
description="One-line context."
actions={<Button size="sm" className="h-8 text-xs hover:opacity-90" style={primaryBtn}>…</Button>}
bare
>
<div
className="rounded-md border overflow-hidden"
style={{ borderColor: palette.border, backgroundColor: palette.surface }}
>
{/* First row */}
<div className="flex items-center justify-between gap-4 px-4 py-4">
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: palette.text }}>Row label</p>
<p className="text-xs mt-0.5" style={{ color: palette.textMuted }}>Helper text.</p>
</div>
<div className="flex items-center gap-2">{/* control */}</div>
</div>
{/* Divider rows */}
<div className="flex items-center justify-between gap-4 px-4 py-4" style={{ borderTop: `1px solid ${palette.border}` }}>
{/* … */}
</div>
</div>
</SettingsSection>
Dialog shell (popups)
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="sm:!max-w-[X]px p-0 overflow-hidden"
style={{ backgroundColor: palette.surface, borderColor: palette.border, color: palette.text }}
>
<DialogHeader className="px-6 pt-6 pb-3">
<DialogTitle className="text-lg font-semibold" style={{ color: palette.text }}>Sentence case</DialogTitle>
</DialogHeader>
<div className="px-6 pb-2 space-y-4 max-h-[60vh] overflow-y-auto">
{/* body */}
</div>
<DialogFooter
className="px-6 py-4 mt-2 border-t gap-2"
style={{ borderColor: palette.border, backgroundColor: palette.surfaceSoft }}
>
<Button variant="outline" size="sm" className="h-9" style={outlineBtn}>Cancel</Button>
<Button size="sm" className="h-9 hover:opacity-90" style={primaryBtn}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Dialog widths by content density:
- Compact (single input / confirm):
sm:!max-w-[440px] - Form (rows + actions):
sm:!max-w-[560px] - Table / multi-step / dense:
sm:!max-w-[640px] - Compare layouts (4-col):
sm:!max-w-[920px]
Button variants (settings context)
| Use | Class | Style |
|---|---|---|
| Section action (primary) | h-8 text-xs hover:opacity-90 | palette.accent + palette.accentText |
| Section action (outline) | h-8 text-xs | palette.surface + palette.border + palette.text |
| Row inline action | h-8 text-xs | matches section action |
| Dialog footer | h-9 | primary or outline as above |
| Destructive row | h-8 text-xs outline w/ color: palette.destructive |
Status pills
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: palette.successSoft, color: palette.success }}
>
<CheckCircle2 className="size-3" /> Connected
</span>
Tone map: success (successSoft+success) · warning (warningSoft+warning) · destructive (warningSoft+destructive) · muted (surfaceSoft+textMuted) · accent (accentSoft+accent).
Step indicator (multi-step dialogs)
<div className="px-6 pb-2 space-y-2">
<div className="flex gap-1">
{steps.map((_, i) => (
<div
key={i}
className="flex-1 h-1 rounded-full transition-colors"
style={{ backgroundColor: i + 1 <= step ? palette.accent : palette.surfaceSoft }}
/>
))}
</div>
<p className="text-xs" style={{ color: palette.textMuted }}>
Step {step} of {steps.length} · {steps[step - 1]}
</p>
</div>
Empty state inside a table
<TableRow>
<TableCell colSpan={N} className="h-70 px-4 text-center text-sm" style={{ color: palette.textMuted }}>
No items found. Create one to get started.
</TableCell>
</TableRow>
Tokens at a glance
| Token | Canonical |
|---|---|
| Page wrapper | mx-4 sm:mx-0 space-y-4 |
| Card radius | rounded-md |
| Avatar (row) | size-9 |
| Avatar (preview) | size-12 |
| Icon (pill) | size-3 |
| Icon (button) | size-3.5 |
| Icon (default) | size-4 |
| Row input | h-9 text-sm border + palette surface/border |
| Dialog body input | h-10 text-sm |
| Section description | text-sm palette.textMuted |
| Row label | text-sm font-medium |
| Row helper | text-xs mt-0.5 palette.textMuted |
| Toast | past-tense verb, no "successfully", no ! |
Currency
Use useOrgCurrency() from lib/hooks/use-org-currency.ts for any displayed price derived from a plan/INR constant:
const { symbol, isIndia, formatFromInr } = useOrgCurrency();
formatFromInr(400) // → "₹400" or "$5"
Backend-driven prices (invoices, wallet, transactions) must show the paid currency, not the org's preference.
Country defaults
CountryCodeSelector (.jsx + .tsx) reads orgDetails.country_iso from redux and seeds the dial-code default automatically. Pages with country pickers should not hardcode defaultCountry="US".
29. Mobile Responsiveness Canon
Patterns enshrined during 2026-05-19 mobile sweep. Apply to every new dashboard surface.
Page wrapper
<div className="mx-4 sm:mx-0 space-y-4">
Min-w-0 keystone
Every flex parent wrapping dashboard content must have min-w-0. Without it, inner content (heading-rule, tables, long content) silently pushes the flex track past the viewport.
// app/(protected)/layout.tsx
<div className="flex min-w-0 flex-1 flex-col">
<main className="min-w-0 flex-1 p-0 sm:p-3 lg:px-4 xl:px-8">
Heading + right-rule
flex-1 h-px right-rule must be hidden on <sm (it forces overflow). Title gets truncate. Actions wrap below heading on mobile.
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<h2 className="text-xl font-bold truncate">{title}</h2>
<div className="hidden sm:block flex-1 h-px" style={{ backgroundColor: palette.border }} />
</div>
{actions && <div className="shrink-0 flex items-center gap-2 flex-wrap">{actions}</div>}
</div>
SectionHeading and PageHeader primitives already implement this.
Row pattern (settings + form rows)
Stack on <sm, side-by-side on sm+. Right-side controls go to w-full sm:max-w-xs so they don't crowd labels.
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">Label</p>
<p className="text-xs mt-0.5" style={{ color: palette.textMuted }}>Helper.</p>
</div>
<div className="flex gap-2 items-center w-full sm:max-w-xs">{/* control */}</div>
</div>
Mobile card list for tables
Every dashboard table renders as a stacked card list under sm. Apply both surfaces, hide the other per breakpoint.
<div className="hidden sm:block">
<Table>…</Table>
</div>
<div className="sm:hidden space-y-2">
{items.map((item) => (
<div key={item.id} className="rounded-md border p-4 space-y-3" style={{ borderColor: palette.border, backgroundColor: palette.surface }}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{item.primary}</p>
<p className="text-xs mt-0.5" style={{ color: palette.textMuted }}>{item.secondary}</p>
</div>
{/* status pill or ⋮ menu */}
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<div>
<p className="uppercase tracking-wider" style={{ color: palette.textMuted }}>Label</p>
<p className="mt-0.5" style={{ color: palette.text }}>{value}</p>
</div>
{/* … */}
</div>
</div>
))}
</div>
Currently implemented on: phone-numbers · sessions · finns · audience · call-history · analytics · billing-history · invoices · compliance · training-table · live-call.
Dynamic viewport units
max-h-[60vh] recomputes when iOS Safari hides/shows URL bar — causes layout jumps. Use dvh.
<div className="px-6 pb-2 space-y-4 max-h-[60dvh] overflow-y-auto">
Safe-area insets
Fixed bottom CTAs (floating buttons, sticky save bars, dialog footers, mobile-nav drawer) must respect iOS home indicator + Dynamic Island.
style={{
paddingBottom: "env(safe-area-inset-bottom, 0px)",
paddingTop: "calc(env(safe-area-inset-top, 0px) + 4rem)",
}}
TalkToFinn, Toaster, mobile-nav.tsx use this pattern.
Pagination footer compact mode
Hide verbose count on mobile, show compact form. PaginationFooter primitive does this automatically.
<div className="hidden sm:block text-sm">Showing {start} to {end} of {total} entries</div>
<div className="block sm:hidden text-xs">{start}–{end} of {total}</div>
Dropdown / Popover / Select collision padding
Radix primitives default to no collision padding — content can clip viewport edge on mobile. All three primitives now default to collisionPadding={12} + max-w-[calc(100vw-1.5rem)].
Form inputs — prevent iOS zoom
iOS Safari zooms into inputs below 16px font. <Input> primitive uses text-base md:text-sm (16px mobile, 14px desktop). Manual overrides must use text-base sm:text-sm, never bare text-sm on inputs.
Touch targets
Apple HIG ≥44×44px. Settings buttons commonly use h-8 text-xs (32px) for visual density. Per-site review if going to enforce — before: pseudo padding has caused layout regressions. Currently documented as P2 backlog (deferred).
Settings sidebar mobile sheet
Sheet primitive provides native backdrop-tap close + Esc handler. Swipe-left gesture added via onPointerDown handler on SheetContent (touch only). Max width capped at max-w-[85vw] so a sliver of underlying content stays visible.
30. Brand Naming
Brand = Finn. The domain hirefinn.ai is the company URL. Never use "HireFinn" or "FinnAI" in UI copy.
| Token | Use |
|---|---|
Finn | Product name everywhere UI |
hirefinn.ai / support@hirefinn.ai | Domain + emails only |
FinnAI Admin | Default admin role label — keep as proper noun |
101 occurrences renamed across 41 files in 2026-05-19 sweep.
31. State Triplet (Loading · Empty · Error)
Every async view renders exactly one of these states. Never ad-hoc; always the canonical primitive.
| State | Primitive | Where |
|---|---|---|
| Loading | <Skeleton> | components/ui/skeleton.tsx (warm-tone) |
| Empty (success, no data) | <EmptyState> | components/ui/empty-state.tsx |
| Error (request failed) | <ErrorState> | components/ui/error-state.tsx |
EmptyState
import { EmptyState } from "@/components/ui/empty-state";
import { Inbox } from "lucide-react";
<EmptyState
icon={Inbox}
title="No calls yet"
subtitle="Calls will appear here once your Finn starts taking calls."
variant="dashed" // pair with first-create CTA
size="default" // or "compact" inline
action={<Button>Create Finn</Button>}
/>
variant: "solid" (default — finished card) · "dashed" (empty-state-with-CTA).
size: "default" (page-level, py-20) · "compact" (inline, py-8).
ErrorState
import { ErrorState } from "@/components/ui/error-state";
<ErrorState
title="Couldn't load deployments"
message="The server returned an error. Please retry."
onRetry={() => refetch()}
error={err} // dev-only logged, never displayed
/>
Pair with try { … } catch (e) { setError(e) } in data hooks. Never show raw error messages or stack traces to the user.
App-level boundaries
| Path | Catches |
|---|---|
app/error.tsx | Root-tree rendering errors (theme-independent fallback) |
app/(protected)/dashboard/error.tsx | Errors inside the dashboard segment (shell preserved, uses <ErrorState>) |
Add a route-segment error.tsx where the catch-all dashboard boundary is too coarse (e.g. settings sub-tab that should fail without taking down the whole dashboard).
Anti-patterns
- ❌ Custom "Something went wrong" text wrapped in random divs
- ❌ Hardcoded
text-gray-500/bg-gray-100empty state cards - ❌ Loading spinner in a corner with no skeleton context (use
<Spinner>only inside buttons / overlays) - ❌
try/catchthat silently swallows the error and renders normal UI
Migration
When you migrate an ad-hoc empty/error block, prefer keeping the surrounding card chrome and only replacing the inner state. ~30 ad-hoc empty-state call sites still exist (see docs/STYLE_GUIDE_COMPLIANCE_AUDIT.md); migrate opportunistically while touching the file.
32. Component Primitive Map
When building a new screen, reach for these first. Inventing a one-off when a primitive exists is the #1 source of compliance drift.
| Need | Primitive | Path |
|---|---|---|
| Page H1 + subtitle + actions row | <PageHeader> | components/ui/page-header.tsx |
| Section H2 + right-side rule | <SectionHeading> | components/ui/section-heading.tsx |
| Stat / KPI card (label + value + delta) | <KpiCard> | components/ui/kpi-card.tsx |
| Status pill (live/draft/error/etc) | <StatusBadge> | components/ui/status-badge.tsx |
| Empty (success, no data) | <EmptyState> | components/ui/empty-state.tsx |
| Error (request failed) | <ErrorState> | components/ui/error-state.tsx |
| Loading | <Skeleton> (warm tones) | components/ui/skeleton.tsx |
| Tiny inline spinner | <Spinner> | components/ui/spinner.tsx |
| Mobile-aware table (cards <640px) | <ResponsiveTable> | components/ui/responsive-table.tsx |
| Sheet / mobile drawer | <Sheet> (shadcn) | components/ui/sheet.tsx |
| Promo / CTA card | <InfoCard> | components/dashboard/info-card.tsx |
When to add a new primitive
Add to components/ui/ when:
- Three or more components copy the same JSX block, and
- The pattern is documented in this guide (or is being added here), and
- The theme is palette-driven (uses
useDashboardTheme()not hardcoded colors).
If only two sites share a pattern, prefer a local helper inside the closest shared parent until a third use case emerges.
33. Phone Number Health Check primitives
Spam-rotation telemetry surface in components/phone-number/. Use these when adding any UI that surfaces number reputation, paused/isolated state, or threshold config.
| Primitive | Purpose | Path |
|---|---|---|
<HealthBadge> | Band + score chip with hover tooltip (dual scores) | components/phone-number/HealthBadge.tsx |
<HealthOverviewPanel> | Stat strip + 14d timeline chart + thresholds CTA | components/phone-number/HealthOverviewPanel.tsx |
<HealthDrawer> | Per-number drill-down sheet (timeline + actions) | components/phone-number/HealthDrawer.tsx |
<ThresholdsDialog> | Region preset + per-signal warn/crit inputs | components/phone-number/ThresholdsDialog.tsx |
Hooks
| Hook | Path | What it owns |
|---|---|---|
usePausedPhoneNumbers() | lib/use-paused-phone-numbers.ts | localStorage phone-health:paused (E.164 set). Cross-tab via storage + custom event. Warn-only in deploy selectors. |
useIsolatedPhoneNumbers() | lib/use-isolated-phone-numbers.ts | localStorage phone-health:isolated (E.164 → { ts, reason }). Hard-blocks deploy selectors. Drawer shows cooldown age. |
useHealthThresholds(orgId) | lib/use-health-thresholds.ts | Per-org threshold config (phone-health:thresholds:{orgId}). Region preset (IN/US/EU) or custom. |
Scoring model — dual score
The library lib/phone-number-health.ts returns BOTH a relative score (vs org baseline) and an absolute score (vs hard thresholds). Final score = max(relative, absolute). Always surface both in UIs so users can see WHY a number is flagged. The badge tooltip pattern:
Watch (47/100)
Relative 18 · Absolute 47 (driver: absolute floors)
- Connect rate 14% < critical floor 15%.
- 22% of answered calls were ≤5s.
Click for details →
The "driver" field tells the user which model ranked higher. Critical for transparency.
Paused vs Isolated — semantics
These are SEPARATE flags with different gates:
| State | Persisted | Deploy gate | Recovery |
|---|---|---|---|
| Paused | phone-health:paused | Warn-only — selectable with toast warning | User toggle |
| Isolated | phone-health:isolated | Hard-block — must release | User releases after cooldown; drawer shows age + suggests 7d recovery period |
Apply isolation when a number is suspected spam-flagged. Apply pause when the user wants temporary off without quarantine semantics.
Patterns enforced
- Bands: Healthy (0–30) / Watch (31–60) / At risk (61–100) / Insufficient (<minCallsForScore).
- Band colors via palette:
success/successSoft(healthy),warning/warningSoft(watch),destructive/warningSoft(at-risk). - Timeline charts use recharts with palette tokens. Always include a 50% baseline
<ReferenceLine>for visual anchor. - Voicemail rate is a first-class signal — show it in the stat strip and chart, not just per-number.
- Min calls for reliable score is configurable per-org via
thresholds.minCallsForScore. Never hardcode "30+" in reason text — read from thresholds.
34. Cross-tab localStorage sync pattern
When a flag affects multiple surfaces (deploy selectors, settings page, drawer), wire it through a hook that:
- Reads from
localStorageon mount - Writes via
localStorage.setItem(...)+window.dispatchEvent(new Event(EVENT))for same-tab sync - Listens to both the custom event AND the native
storageevent for cross-tab sync
Template lives in lib/use-paused-phone-numbers.ts. Reuse for any feature flag that needs to propagate without a backend round-trip.
const EVENT = "feature-name:update";
function writeToStorage(set: Set<string>) {
window.localStorage.setItem(KEY, JSON.stringify([...set]));
window.dispatchEvent(new Event(EVENT));
}
useEffect(() => {
const handler = () => setState(readFromStorage());
window.addEventListener(EVENT, handler);
window.addEventListener("storage", handler);
return () => {
window.removeEventListener(EVENT, handler);
window.removeEventListener("storage", handler);
};
}, []);
35. Status-badge styles helper
When a status badge has more than 2 states, define a getStatusBadgeStyle(status): React.CSSProperties helper (NOT a className-returning function). Pass to <Badge style={...}>. This guarantees theme-aware colors.
const getStatusBadgeStyle = (status: string): React.CSSProperties => {
switch (status.toLowerCase()) {
case "completed":
return { backgroundColor: palette.successSoft, color: palette.success };
case "failed":
return { backgroundColor: palette.warningSoft, color: palette.destructive };
default:
return { backgroundColor: palette.surfaceSoft, color: palette.textMuted };
}
};
Anti-pattern: getStatusColor(status) → "bg-green-100 text-green-800". Tailwind static color classes are light-only and break in dark/aurora/aroma themes.
37. Z-index scale (canonical)
Z-index sprawl was caught during pre-release audit. Use these tiers when adding any new layered UI. Never invent a new bespoke number — pick from this table.
| Tier | Range | Use |
|---|---|---|
| Base | z-0, z-10, z-20, z-30, z-40, z-50 | In-flow stacking inside a container (cards, sticky headers, dropdowns) |
| Sticky | z-[60] | Sticky page headers / footers inside scroll containers |
| Tooltip / Popover (Radix default) | z-[999] | Inline floating UI |
| Sidebar overlay (mobile) | z-[9998] | Background scrim under the mobile sidebar drawer |
| Modal / Dialog (Radix default) | z-[10000] | Standard <Dialog> content |
| Modal interior popover | z-[10001] | Popover/Select rendered INSIDE a Dialog |
| Modal nested dialog | z-[10002] | Confirm dialog opened from inside a modal |
| Deploy modal popover layer | z-[10005] | Phone-input country menu inside settings modal |
| Deploy modal select layer | z-[10006] | Inbound number / call-type Select inside deploy modal |
| Emergency top | z-[99999] | Toast root (Sonner). Reserved — do not use for anything else. |
Use Tailwind arbitrary syntax z-[10001] not raw inline zIndex: 10001 so the scale is greppable.
Anti-patterns observed during audit:
- ❌ Inventing
z-9orz-[1000]mid-tier - ❌ Inline
style={{ zIndex: 9999 }}— bypasses scale - ❌ Stacking
z-50inside a modal — covered by modal backdrop
38. Documented theme exceptions
Some UIs deliberately opt out of the palette system. Audit these before adding more:
| File | Reason |
|---|---|
components/modals/realtime-voice-modal.tsx | Immersive voice-chat modal. Full-bleed dark gradient + frequency wave background. Designed as branded hero modal, not a themed surface. |
app/(protected)/dashboard/chat-bot/[finnId]/page.tsx (call-type badges) | Inbound (purple) / Outbound (blue) semantic data-viz badges per §18. Uses fixed hex with rgba alpha so themes pass through. |
components/deployment-analytics/data-extractor-sidebar.tsx (PDF export branches) | jsPDF needs literal hex — palette tokens won't serialize. Documented in CLAUDE.md. |
Invoice pages (viewsubscriptionrazorpay/, viewsubscriptiondodopayments/) | Print-to-PDF output. White paper with dark text is the deliverable, not a UI. Documented in CLAUDE.md. |
Adding to this list requires a comment in the file explaining why the palette is bypassed. Default answer is always "use the palette".
39. Brand logos (Simple Icons + gilbarbara/logos)
Source of truth: Simple Icons (CC0, 3000+ brand SVGs). Default fetch path for ANY brand icon needed in marketing surfaces.
Package already installed: simple-icons (v16+).
Where logos live
All brand SVGs vendored to public/logos/{slug}.svg. Never inline raw brand SVG in components — always go through the vendored asset + BrandLogo wrapper so dark-mode inversion + alt text + fallbacks are consistent.
Adding a new brand
- Try Simple Icons first via the npm package:
const si = await import("simple-icons"); const icon = si.siHubspot; // → { title, hex, path, ... } const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><title>${icon.title}</title><path d="${icon.path}"/></svg>`; fs.writeFileSync(`public/logos/${slug}.svg`, svg); - If Simple Icons doesn't have it (Salesforce, Slack, Twilio, etc. removed per brand-policy): fetch from
gilbarbara/logos(MIT-licensed):curl -sS -o public/logos/${slug}.svg "https://cdn.jsdelivr.net/gh/gilbarbara/logos/logos/${slug}.svg" - If neither has it (e.g. Plivo): handwrite a minimal wordmark SVG using brand color hex from the official site. Keep it under 200 bytes.
- Add slug → file mapping in
components/brand-logo.tsxSLUG_MAP. - If the mark is dark/monochrome (Cal.com, Linear, Notion): add the slug to
INVERT_IN_DARKso dark mode inverts it.
Usage
import { BrandLogo, BrandChip } from "@/components/brand-logo";
// Just the logo (alt-tagged img, height-locked)
<BrandLogo name="Salesforce" size={20} />
// Pill with logo + label, matches our integrations strip style
<BrandChip name="HubSpot" />
Both accept the brand's natural-language name (case-insensitive). Unknown brands gracefully fall back to a styled text chip — never error, never render a broken image.
What NOT to do
- Don't reach for Devicon, FontAwesome Brands, or react-icons/si for marketing pages — those bundle into JS and force consumers to pay the runtime cost. Vendored SVGs in
/publicare free. - Don't hand-paste raw brand SVGs into JSX (legal trail unclear, dark-mode handling is per-component instead of centralized).
- Don't ship official compliance marks (SOC 2, HIPAA, ISO, AICPA, etc.) until legal confirms we hold the certification + the license to use the mark. Until then keep the generic
ShieldCheck+ text-label pattern.
Was this page helpful?
Still stuck or have feedback?
Email support@hirefinn.ai or use the chat bubble in the bottom-right corner — it's a Finn that knows the Academy cold.