UI conventions and theming
shadcn/ui
components/ui/ holds generated primitives built on Radix. Never edit them directly — they are
regenerated by the shadcn CLI and any local change is lost. Extend by composition: wrap the primitive in a
feature component.
// components/campaign/campaign-status-badge.tsx
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
export function CampaignStatusBadge({ status }: { status: CampaignStatus }) {
return (
<Badge className={cn(status === "running" && "bg-emerald-600")}>
{status}
</Badge>
)
}
Styling
-
Tailwind utilities over custom CSS.
-
cn()from@/lib/utilsfor conditional classes — it merges Tailwind classes correctly viatailwind-merge, so a later class wins instead of both being emitted. -
Semantic tokens over palette values:
bg-background,text-foreground,border-border,bg-card,text-muted-foreground. A component usingbg-backgroundfollows the theme automatically; one usingbg-whitebreaks in dark mode. -
class-variance-authorityfor components with variants. -
tailwindcss-animateandtw-animate-cssfor transitions.
Theme tokens
Defined in app/globals.css as CSS custom properties in oklch, exposed to Tailwind as semantic colour
names.
Light
:root defines --background, --foreground, --card, --popover, --primary, --secondary,
--muted, --accent, --destructive, --border, --input, --ring, --chart-1 … --chart-5,
--radius (0.625rem), and the --sidebar-* family.
Dark
.dark introduces the canonical dark canvas:
.dark {
--payability-dark-canvas: oklch(0.16 0.02 250);
--background: var(--payability-dark-canvas);
--sidebar: var(--payability-dark-canvas);
--card: oklch(0.185 0.022 250);
--popover: oklch(0.195 0.022 250);
/* … */
}
--background and --sidebar both reference --payability-dark-canvas, so the page body and the sidebar
are one continuous surface with no seam between them. --card and --popover are deliberately a step
lighter, so panels and dialogs lift off that canvas.
Change the canvas in one place and both surfaces follow. Hardcoding either colour breaks that guarantee.
The dashboard shell
html:not(.dark) .dashboard-shell { /* navy Payability sidebar — light mode only */ }
html.dark .dashboard-shell { --sidebar: var(--payability-dark-canvas); /* + Payability green accents */ }
The navy sidebar is scoped to light mode because in dark mode the sidebar merges into the canvas instead. Payability green and its contrast tokens are applied to navigation in both.
Tables
The standard pattern is server-side pagination and filtering:
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [filters, setFilters] = useState<FilterState>({})
const { data, isLoading } = useCampaignTargets(campaignId, { page, pageSize, ...filters })
Shared filter components — CallsFilter for example — are reused across pages so the main Calls page and
the campaign Calls tab offer an identical filter set.
Timestamps in call views render in Eastern Time with the zone abbreviation (2026-01-15 07:30:45 EST),
matching the timezone the date filters use. A UTC display next to an Eastern filter is a reliable source of
"the filter is broken" reports.
Phone numbers are masked for display through lib/utils/mask-phone.ts.
Dialogs
-
shadcn
Dialogprimitive. -
React Hook Form with
zodResolverinside. -
Reset the form and close on mutation success.
-
Errors through form state, or a
sonnertoast for anything not tied to a field.
Charts
components/reports/ wraps Recharts. Colours for disposition nodes come from SANKEY_NODE_COLOR_MAP in
lib/constants.ts rather than being chosen per chart, so a disposition is the same colour everywhere.
html-to-image provides export.