Skip to main content

Stilguide

Finns UI-designsystem — tokens, komponenter, mönster.

43 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
  28. Mobile rules (marketing site)
  29. Mobile rules for the dashboard

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>

Marketing page titles (outside app/(protected))

Marketing routes don't use the dashboard page-header pattern above. They run three title tiers, each a clamp() so the ramp is continuous instead of jumping at breakpoints:

TierClassMobile → desktopUsed by
Herotext-[clamp(38px,4.5vw,56px)]38 → 56/, /platform, section hubs
Indextext-[clamp(30px,3.6vw,44px)]30 → 44/blog, /glossary, /customers, /careers
Articletext-[clamp(28px,3.4vw,36px)]28 → 36blog posts, academy and docs articles, roles, case studies

The floor matters more than the ceiling. An article h1 must stay above the body's own ## headings, which render at 24px. text-xl (20px) put the page title below its first section heading on phones — that was the academy article bug, not a matter of taste.

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 / [email protected]Domain + emails only
FinnAI AdminDefault admin role label — keep as proper noun

Machine identifiers are NOT brand copy

The rule above governs prose a reader sees. These are contracts — renaming any of them breaks something, and each was left deliberately:

IdentifierWhy it stays
@finnai/mcp-serverPublished npm package. Renaming hands users a command that 404s.
claude mcp add finnaiServer name users have already configured.
FINNAI_API_TOKENEnv var the MCP server reads. Absent from this repo's code — it is an external contract.
finnai-userRedux slice key, persisted to localStorage. Renaming logs every user out.
finnAIStar, finnAIIcon, finnAIArtefactsComponent identifiers in components/shared/icons.tsx. Internal, no reader sees them.
AIFORGE TECH PRIVATE LIMITED (Hire FinnAI)Registered legal entity, printed on invoices. Not ours to rename.

Finn AI with a space is not the old brand — it is Finn plus a noun ("Finn AI voice agents", "Finn AI Copilot"). 582 occurrences, all correct and SEO-load-bearing. Do not collapse them to "Finn voice agents".

Sweeps

  • 2026-05-19 — HireFinnFinn, 101 occurrences across 41 files.
  • 2026-08-10 — FinnAIFinn, 725 occurrences across 250 files: 234 academy markdown docs in all 9 locales, the glossary registry and all 9 message catalogs, plus content/, docs/ and app/ prose. 4 lines kept verbatim (the two table rows above and the legal entity, twice).

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.

40. Mobile rules (marketing + dashboard)

Everything here comes from a mobile audit of the marketing site (2026-08-13). Each rule is enforced by scripts/mobile-viewport-audit.mjs, which runs in CI against a production build at 320px and 390px — a violation fails the check with the offending element and page.

Run it locally against a build:

npm run build && npx next start -p 3000 & npm run audit:mobile

Touch targets

44px is the iOS thumb target; WCAG 2.5.8 (AA) sets the floor at 24px with spacing. Sitewide chrome should clear 36px on phones and may stay compact from sm up.

// ✅ thumb-sized on phones, unchanged on desktop
<Link className="inline-flex items-center py-2 text-[14px] sm:py-0">
<button className="size-10 sm:size-8">

A field's visual band is not its tap target. Padding belongs on the control, not on the row wrapping it — otherwise a tap on the padding hits the wrapper and focuses nothing:

// ❌ 20px control floating in a 44px band
<div className="flex items-center px-3 py-3"><input className="text-sm" /></div>

// ✅ the control owns the band
<div className="flex min-h-[44px] items-center px-3"><input className="py-3 text-sm" /></div>

Form fields: 16px on phones

iOS Safari zooms the viewport whenever a focused field renders below 16px, and the reader cannot undo that zoom without pinching back. styles/globals.css sets a 16px floor for input/select/textarea under 768px. Do not fight it with a more specific text-sm on a field — raise the breakpoint instead. Never fix this with maximum-scale=1 on the viewport meta; that disables pinch zoom for everyone.

line-clamp and min-height do not belong on the same element

A min-height taller than the clamped box lets an extra line render and get sliced mid-glyph by the box edge. line-clamp-3 at 14px/1.625 is 68px; min-h-[6rem] is 96px, so a fourth line appears.

// ❌ 96px box, 3-line clamp → a 4th line renders and is cut in half
<p className="line-clamp-3 min-h-[6rem] flex-1">{copy}</p>

// ✅ the wrapper reserves the height, the clamped element sizes to its lines
<div className="min-h-[6rem] flex-1">
  <p className="line-clamp-3">{copy}</p>
</div>

display: -webkit-box computing as flow-root in Chrome 140+ is not a symptom of this. That is how the modern engine reports the legacy value, and the clamp works normally on flex items.

Horizontal overflow

html, body { overflow-x: hidden } in styles/globals.css hides sideways scroll — it does not prevent the layout that causes it. Check element positions, not just document.scrollWidth; the audit script does both.

The usual culprit is an element that cannot shrink below its content: a <select> is sized by its longest <option>, so it needs min-w-0 max-w-full inside a flex row, and the row needs min-w-0 for flex-wrap to do anything.

Long pages

An unpaged grid is a mobile problem before it is a performance one: /assets ran to 54,718px — roughly 65 phone screens — with every thumbnail mounted. Batch long lists (/assets renders 24 at a time) and give long grouped indexes a jump nav (/glossary has sticky category chips). Derive anchor ids from untranslated keys so a shared link survives a locale switch.

Content that assumes a wide screen

  • Formulas wrap on phones (whitespace-pre-wrap break-words below sm); code to copy keeps whitespace-pre and scrolls, because wrapped HTML is harder to read, not easier.
  • Desktop screenshots in articles are illegible at ~350px wide. Article images go through components/academy/zoomable-image.tsx, which opens them full-screen at natural size.

41. Mobile rules for the dashboard

§40 covers the marketing site. The app surface fails differently: it is behind a login, so the CI viewport audit does not reach it, and everything here was found by auditing a signed-in session at 390px (2026-08-13). Nine of twenty-six routes scrolled sideways; settings laid out at 1,396px inside a 390px viewport.

min-w-0 on every flex column that holds page content

This is the one that broke nine routes at once. A flex item's automatic minimum size is its content, so a column with flex-1 refuses to shrink below its widest child and drags the whole document with it.

// ❌ one wide table anywhere inside makes the page scroll sideways
<div className="flex flex-1 flex-col">

// ✅
<div className="flex min-w-0 flex-1 flex-col">

The shell already does this (app/(protected)/dashboard-shell.tsx). Any new layout column that wraps page content needs it too.

Wide children scroll themselves

min-w-0 stops the page sliding; it does not make the content reachable. Anything intrinsically wider than a phone needs its own scroller:

ThingPattern
Tab stripsTabsList scrolls (max-w-full overflow-x-auto), triggers are shrink-0
Tablesthe shared <Table> wrapper scrolls and paints a .scroll-shadow edge fade
Data tables that should reflowcomponents/ui/responsive-table.tsx — renders cards below 640px instead
Button rowsflex-wrap

Never centre an overflowing strip. justify-center splits the overflow across both ends, so the strip opens mid-way with the active tab off the left edge. Use justify-start — on an inline-flex element it looks identical whenever the content fits.

Panels that park off-screen must be inert

Four side panels were mounted permanently and merely translated out of view. Their controls stayed focusable, hit-testable and in the accessibility tree, so keyboard users tabbed into a form that was not on the page.

<div
  inert={!isOpen}
  aria-hidden={!isOpen}
  className={`fixed right-0 ... ${isOpen ? "translate-x-0" : "translate-x-full"}`}
>

inert covers focus, pointer events and the accessibility tree in one attribute, and the open animation still runs because it drops before the transform does. hidden would kill the transition; pointer-events-none alone leaves the tab order broken.

Touch targets

36px minimum on phones for anything in app chrome — pagination, header controls, column pagers — restored to compact sizing from sm up:

className="min-h-9 min-w-9 px-3 py-2 sm:min-h-0 sm:min-w-0 sm:px-2 sm:py-1"

Exempt: links inside prose (the target-size guidance excludes inline text), and the sr-only skip link, which is only rendered when focused.

One h1 per route

Twelve routes had none, because the visible page title was rendered by SectionHeading, which always emitted an h2. Pass level={1} where the heading is the page title. Styling is identical; only the document outline changes.

Page padding

The shell leaves main unpadded below sm and each page supplies its own. If a new page renders flush against the screen edge on a phone, that is the reason — add px-4 sm:px-0 at the page's own wrapper rather than padding the shell, which would double up everywhere else.

Re-running the audit

The scripts live in the session scratchpad rather than the repo, because reaching these routes needs a signed-in session and CI has no credentials for one. Point them at a dev server with a captured session; see the audit report for the method.

Was this page helpful?

Still stuck or have feedback?

Email [email protected] or use the chat bubble in the bottom-right corner — it's a Finn that knows the Academy cold.