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/utils for conditional classes — it merges Tailwind classes correctly via tailwind-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 using bg-background follows the theme automatically; one using bg-white breaks in dark mode.

  • class-variance-authority for components with variants.

  • tailwindcss-animate and tw-animate-css for 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.

Theme switching

next-themes toggles the .dark class on <html>. Because every colour is a token, no component needs to know which theme is active.

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 Dialog primitive.

  • React Hook Form with zodResolver inside.

  • Reset the form and close on mutation success.

  • Errors through form state, or a sonner toast 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.

Editors

  • lexical powers the rich-text email editor for variations and templates.

  • json-edit-react edits raw JSON payloads (payloadJson, configJson, extraConfigs).

  • react-diff-viewer-continued shows diffs between agent prompt versions.

Accessibility

Radix primitives supply focus management, keyboard navigation and ARIA attributes. Keep them: replacing a Radix component with a bare div loses all of it silently.