Skip to main content

Style Guide

HireFinn UI design system — tokens, components, patterns.

36 min read

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

  1. Theme System
  2. Palette Tokens
  3. Typography
  4. Spacing & Layout
  5. Cards
  6. Tables
  7. Buttons
  8. Tabs & Filter Chips
  9. Search Input
  10. Empty States
  11. Loading States
  12. Sidebar Navigation
  13. Tooltips & Popovers
  14. Dialogs & Modals
  15. Toasts
  16. Icons
  17. Form Validation
  18. Hover States
  19. Accessibility
  20. Metadata Display
  21. Breadcrumbs
  22. Scrollbars & Gradient Fades
  23. Status Colors (Semantic)
  24. Charts
  25. Audio Player
  26. Accepted Exceptions
  27. 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();
VariableTypeNotes
theme"light" | "dark"Base mode. Use only for non-color logic (chart opacity, etc.)
activeThemeDashboardThemeVariantExact variant: light, dark, aroma, aurora, midnight, worldofai, nebula
paletteDashboardPaletteFull resolved color palette — use for ALL surface/text/border colors
VariantBaseCharacter
lightlightClean white/grey, brand green accent
darkdarkDeep black, brand green accent
aromalightLavender-blue, purple accent
auroralightBlue-tinted, purple accent
midnightdarkNavy space, indigo/purple accent
worldofaidarkDeep purple/cyan, violet accent
nebuladarkMidnight 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

TokenPurpose
palette.backgroundPage-level background
palette.backgroundAccentDecorative bg gradient (rare)
palette.headerBackgroundTop nav / header strip
palette.surfaceCard / modal / body content background
palette.surfaceElevatedFloating panel (currently = surface)
palette.surfaceSoftTable header row, input bg, hover bg, subtle fill

Text

TokenPurpose
palette.textPrimary content text
palette.textSoftSlightly muted headings
palette.textMutedLabels, metadata, hints, captions

Borders & rings

TokenPurpose
palette.borderAll standard borders (cards, tables, inputs, dividers)
palette.borderStrongAccent/emphasis borders
palette.ringFocus ring color

Brand accent

TokenPurpose
palette.accentPrimary action color
palette.accentHoverHover on accent-colored buttons
palette.accentSoftGhost badge bg / tinted highlight
palette.accentTextText on accent bg (typically white)

Sidebar (dedicated tokens — never use generic surface tokens for sidebar)

TokenPurpose
palette.sidebarBackgroundSidebar panel background
palette.sidebarHoverSidebar nav item hover bg
palette.sidebarActiveSidebar active nav item bg

Tooltips & popovers

TokenPurpose
palette.tooltipBackgroundTooltip/popover background
palette.tooltipBorderTooltip/popover border

Other

TokenPurpose
palette.shadowCard/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

LevelClassColor tokenUse
Page title (h1)text-xl font-semibold md:text-2xl md:font-bold lg:text-3xlpalette.textTop of every dashboard page
Page subtitletext-sm mt-1.5palette.textMutedBelow page title
Section heading (h2)text-xl font-bold whitespace-nowrappalette.textWith flex-1 h-px right rule
Card titletext-base font-semiboldpalette.text<CardHeader> labels
Sub-labeltext-sm font-semiboldpalette.textMutedGroup labels
Bodytext-smpalette.textGeneral content
Caption / labeltext-xspalette.textMutedMetadata, pill info
Mono / codefont-mono text-xspalette.textCall 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

ContextAllowedReason
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-semiboldDensity convention
Chart axis labelstext-[8px]Data viz convention
Marketing nav dropdownstext-[11..13.5px]Out of dashboard scope
shadcn primitives (ui/calendar.tsx etc)library defaultsVendor-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

PatternCanonicalRationale
Outer wrapper (full-bleed)mx-4 sm:mx-016px mobile margin, edge-to-edge from sm+
Outer wrapper (centered)container mx-auto max-w-{2xl,4xl,5xl} px-4 py-8Pick max-width by content density
Page header gapmt-4 mb-616px top, 24px bottom
Section spacingspace-y-416px between sections. space-y-6 only when justified
Card inner paddingp-4p-6 reserved for dialog content
4-up KPI gridgrid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4
3-up content card gridgrid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3
Inset table inside cardrounded-md border overflow-hiddenBorder collapses with parent
Max-width scrollable tablemax-w-[calc(100vw-3.5rem)] sm:max-w-[calc(100vw-16.5rem)]Prevents overflow with sidebar expanded

Border radius

SurfaceClass
Inputs, small cards, table containersrounded-md
Large cards, content sectionsrounded-xl
Modals, dialogsrounded-2xl
Badges, pills, avatars, status dotsrounded-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-none on in-flow cards — zero card shadows in dashboard
  • borderColor: palette.border always
  • backgroundColor: palette.surface always
  • 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].

<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

RuleExample
Sentence case for all button labels"Stop deployment" not "Stop Deployment"
Verb-only when the noun is obvious from contextInside 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 levelSoft 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:

ThemeColor
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

  • DialogContent always rounded-2xl — never rounded-xl/rounded-lg/default
  • Padding: p-5 default; p-6 only for data-heavy forms
  • DialogHeader always text-left sm:text-left — never centered
  • Title: text-lg font-semibold sm:text-xl
  • Description: text-xs sm:text-sm with palette.textMuted
  • Sentence case everywhere
  • Buttons: outline secondary left, primary accent right
  • All color via palette tokens

Width scale

UseClass~Width
Confirm / warningmax-w-[400px] md:max-w-[500px]400–500px
Default formmax-w-[500px] md:max-w-lg~512px
Multi-field formmax-w-2xl672px
Data viewer / previewmax-w-4xl896px
<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

ContextClassPixel
Inline-text (button label, table cell)h-4 w-4 / size-416
Small inline (badge, dense row)h-3 w-3 / size-312
Standalone (toolbar)h-5 w-5 / size-520
Page-header bigh-8 w-8 / size-832
Empty-state heroh-12 w-1248

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-sm minimum
  • 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.

ElementHover treatment
shadcn <Button> (any variant)Built-in via variant
Raw <button> (filter pill, dropdown item)hover:bg-muted/50
Table row lightweighttransition-colors last:border-b-0 hover:bg-muted/50
Table row statefulsetHoveredRowId + backgroundColor: palette.surfaceSoft
Sidebar nav itemonMouseEnterpalette.sidebarHover
<a> / <Link> text linkhover:underline
Icon-only buttonhover:opacity-80 + always aria-label
Card-as-buttonhover: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.

StatusColorUse
Live / success / positivepalette.accent (brand green)Active deployments
Failed / error#DC2626Errors, failed calls
Warning / scheduled / neutral#D97706Pending, scheduled
Draft#ea580c (light) / #fb923c (dark)Draft Finn status
Destructive button#ef4444 / hover #dc2626Delete/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.

PatternReason
jsPDF / PDF export hex colorsNo theme context in PDF render
{false && ...} dead codeInactive, will be removed
Pre-hydration SSR defaults in DashboardThemeToggleContext unavailable pre-mount
theme-toggle.tsx THEMES array swatchesData values representing themes themselves
Semantic status badges (green/amber/red)Per §23
PCA diff answer colorsSemantic evaluation UI
Chart series colorsPer §24
rgba(0,0,0,0.5) modal backdropUniversal overlay

27. Anti-Pattern Cheatsheet

Don'tDo
theme === "dark" ? "#xxx" : "#yyy"palette.token
bg-white, text-gray-500, border-gray-200palette.surface / text / border
shadow-sm, shadow-md, shadow-lg on cardsshadow-none
Inline animate-spin div<Spinner />
text-md, text-[11px], font-[600]text-sm/text-xs, font-semibold
<Button variant="ghost" size="icon"> no labelAdd aria-label or <span className="sr-only">
toast({ title, description }) (radix API)toast.success("...") (sonner)
@heroicons/react importlucide-react
hover:bg-gray-100hover:bg-muted/50
<Badge variant="outline">Use case: X</Badge> page metadataDot-separated palette.textMuted text
focus:bg-gray-100 dark:focus:bg-[#2a2a2a] on DropdownMenuItemRemove — palette handles it
mx-2 sm:mx-0 page wrappermx-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-2xlrounded-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)

UseClassStyle
Section action (primary)h-8 text-xs hover:opacity-90palette.accent + palette.accentText
Section action (outline)h-8 text-xspalette.surface + palette.border + palette.text
Row inline actionh-8 text-xsmatches section action
Dialog footerh-9primary or outline as above
Destructive rowh-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

TokenCanonical
Page wrappermx-4 sm:mx-0 space-y-4
Card radiusrounded-md
Avatar (row)size-9
Avatar (preview)size-12
Icon (pill)size-3
Icon (button)size-3.5
Icon (default)size-4
Row inputh-9 text-sm border + palette surface/border
Dialog body inputh-10 text-sm
Section descriptiontext-sm palette.textMuted
Row labeltext-sm font-medium
Row helpertext-xs mt-0.5 palette.textMuted
Toastpast-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.

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>

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.

TokenUse
FinnProduct name everywhere UI
hirefinn.ai / support@hirefinn.aiDomain + emails only
FinnAI AdminDefault 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.

StatePrimitiveWhere
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

PathCatches
app/error.tsxRoot-tree rendering errors (theme-independent fallback)
app/(protected)/dashboard/error.tsxErrors 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-100 empty state cards
  • ❌ Loading spinner in a corner with no skeleton context (use <Spinner> only inside buttons / overlays)
  • try/catch that 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.

NeedPrimitivePath
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:

  1. Three or more components copy the same JSX block, and
  2. The pattern is documented in this guide (or is being added here), and
  3. 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.

PrimitivePurposePath
<HealthBadge>Band + score chip with hover tooltip (dual scores)components/phone-number/HealthBadge.tsx
<HealthOverviewPanel>Stat strip + 14d timeline chart + thresholds CTAcomponents/phone-number/HealthOverviewPanel.tsx
<HealthDrawer>Per-number drill-down sheet (timeline + actions)components/phone-number/HealthDrawer.tsx
<ThresholdsDialog>Region preset + per-signal warn/crit inputscomponents/phone-number/ThresholdsDialog.tsx

Hooks

HookPathWhat it owns
usePausedPhoneNumbers()lib/use-paused-phone-numbers.tslocalStorage phone-health:paused (E.164 set). Cross-tab via storage + custom event. Warn-only in deploy selectors.
useIsolatedPhoneNumbers()lib/use-isolated-phone-numbers.tslocalStorage phone-health:isolated (E.164 → { ts, reason }). Hard-blocks deploy selectors. Drawer shows cooldown age.
useHealthThresholds(orgId)lib/use-health-thresholds.tsPer-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:

StatePersistedDeploy gateRecovery
Pausedphone-health:pausedWarn-only — selectable with toast warningUser toggle
Isolatedphone-health:isolatedHard-block — must releaseUser 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:

  1. Reads from localStorage on mount
  2. Writes via localStorage.setItem(...) + window.dispatchEvent(new Event(EVENT)) for same-tab sync
  3. Listens to both the custom event AND the native storage event 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.

TierRangeUse
Basez-0, z-10, z-20, z-30, z-40, z-50In-flow stacking inside a container (cards, sticky headers, dropdowns)
Stickyz-[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 popoverz-[10001]Popover/Select rendered INSIDE a Dialog
Modal nested dialogz-[10002]Confirm dialog opened from inside a modal
Deploy modal popover layerz-[10005]Phone-input country menu inside settings modal
Deploy modal select layerz-[10006]Inbound number / call-type Select inside deploy modal
Emergency topz-[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-9 or z-[1000] mid-tier
  • ❌ Inline style={{ zIndex: 9999 }} — bypasses scale
  • ❌ Stacking z-50 inside a modal — covered by modal backdrop

38. Documented theme exceptions

Some UIs deliberately opt out of the palette system. Audit these before adding more:

FileReason
components/modals/realtime-voice-modal.tsxImmersive 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

  1. 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);
    
  2. 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"
    
  3. 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.
  4. Add slug → file mapping in components/brand-logo.tsx SLUG_MAP.
  5. If the mark is dark/monochrome (Cal.com, Linear, Notion): add the slug to INVERT_IN_DARK so 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 /public are 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.