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