These rules govern every .tsx file in the project, including pages and client components under src/app/, not just src/components/.
Stack: Next.js 16 App Router, Tailwind CSS (mobile-first), Shadcn UI primitives in src/components/ui/, forms via react-hook-form + zod.
Before building anything, check src/components/ui/ for an existing primitive. There are 35+ of them. Reaching for a raw <div> where a primitive exists is the single most common source of visual drift in this codebase.
Any container that can overflow uses <ScrollArea> from @/components/ui/scroll-area. Never a raw overflow-y-auto div.
A raw overflow container renders the native OS scrollbar, which sits next to the styled Radix scrollbar used everywhere else and looks wrong - especially on Windows and in dark mode. Check this before finishing any dialog, dropdown, log viewer, list panel, or preview pane.
This is the part that silently fails. ScrollArea renders a Radix Root wrapping a Viewport, and the Viewport is the element that gets overflow-y: scroll - our wrapper sizes it with size-full, so its height is 100%. A percentage height cannot resolve against a parent that only carries a max-height, so a max-h-* on the root leaves the scrolling element unconstrained. Put it on the viewport:
<ScrollArea className="*:data-[slot=scroll-area-viewport]:max-h-[calc(90vh-9rem)]">*:data-[slot=scroll-area-viewport]: is the canonical selector - it matches the data-slot our wrapper sets in ui/scroll-area.tsx. A few older files use *:data-radix-scroll-area-viewport:. Both work at runtime, but do not copy the old form into new code.
Four files still set max-h on the root. tests/unit/lint-guards/design-system.test.ts holds that count as a baseline and fails the build if it grows, so new code cannot add to it.
<ScrollArea className="flex-1 min-h-0">min-h-0 is mandatory. Without it the flex child refuses to shrink below its content size and the scroll never engages - the page grows instead. This is the second most common ScrollArea bug here.
ScrollArea accepts a viewportRef prop (our addition, not stock Shadcn). Use it to scroll to bottom for live logs rather than reaching into the DOM.
Reference implementation: src/components/adapter/adapter-form.tsx - it is the one that gets both the layout and the viewport selector right. credential-profile-dialog.tsx has the layout below but still sets max-h on the ScrollArea root, so copy its structure, not its ScrollArea line.
<DialogContent className="sm:max-w-xl max-h-[90vh] p-0">
<div className="px-6 pt-6 pb-4 shrink-0">
<DialogHeader>
<DialogTitle>New Credential Profile</DialogTitle>
<DialogDescription>What this dialog is for.</DialogDescription>
</DialogHeader>
</div>
<ScrollArea className="*:data-[slot=scroll-area-viewport]:max-h-[calc(90vh-10rem)]">
<div className="space-y-4 px-6 pb-4">
{/* body */}
</div>
</ScrollArea>
<div className="px-6 pt-2 pb-6">
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={submit} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</div>
</DialogContent>Rules:
p-0onDialogContent, then padding on header / body / footer. Otherwise the scrollbar sits inside the dialog padding and looks detached from the edge.shrink-0on header and footer so only the body scrolls.- Viewport max-height subtracts the chrome: roughly
calc(90vh-10rem)with header and footer,calc(90vh-9rem)with header only. Match the dialog's ownmax-h. - Width via
sm:max-w-*. Common sizes here:sm:max-w-md(confirm),sm:max-w-xl,sm:max-w-2xl,sm:max-w-4xl(permission matrices, adapter forms).
Every DialogContent needs a description. If the visible design has no subtitle, use <DialogDescription className="sr-only"> or set aria-describedby={undefined} explicitly. Radix logs a warning otherwise.
Use AlertDialog, never Dialog, and never window.confirm() or alert(). The destructive button is <Button variant="destructive">.
react-hook-form + zod via @hookform/resolvers. Reference: src/components/adapter/adapter-form.tsx.
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl><Input placeholder="localhost" {...field} /></FormControl>
<FormDescription>Optional helper text.</FormDescription>
<FormMessage />
</FormItem>
)}
/>- Always include
<FormMessage />. A field without it fails validation silently. - Never hand-roll a
<label>- useFormLabel, orLabelwith a matchinghtmlForwhen outside aForm. - Submit buttons are disabled while pending and show
<Loader2 className="mr-2 h-4 w-4 animate-spin" />. - Field spacing is
space-y-4inside a form,space-y-2inside a single field group. - Use
z.coerce.number()for numeric inputs - the DOM gives you strings.
Use DataTable from @/components/ui/data-table for any list of records. It handles sorting, pagination, and faceted filters. Reference: src/components/audit/audit-table.tsx, src/app/dashboard/users/user-table.tsx.
- Define columns as
ColumnDef[]outside the render path where possible. - Row actions go in a
DropdownMenutriggered by<Button variant="ghost" className="h-8 w-8 p-0">with<MoreHorizontal className="h-4 w-4" />and an<span className="sr-only">Open menu</span>. - Filter chips use
data-table-faceted-filter. - A plain
<Table>is fine for small static, non-interactive data.
Use semantic tokens. They are already theme-aware:
bg-background bg-card bg-muted bg-popover text-foreground text-muted-foreground text-destructive border-border ring-ring bg-primary bg-secondary bg-accent
text-muted-foreground is used 600+ times here - it is the default for secondary text, helper copy, and icons that are not the focus.
Raw palette colors (text-green-600, bg-red-100, ...) must always ship a dark: variant. A bg-green-100 text-green-600 with no dark variant is unreadable in dark mode, and that bug already exists in a couple of older files. Correct form:
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">Prefer a Badge variant or HealthStatusBadge (@/components/ui/health-status-badge) over ad-hoc status colors.
- No inline
style={{...}}. Exception: genuinely computed values such as chart colors or progress percentages. Keep those on the one element that needs them. - Prefer standard utilities over arbitrary values:
h-pxnoth-[1px],w-4notw-[1rem]. - Mobile-first. Unprefixed classes are the small-screen case,
sm:/md:/lg:widen from there. - Page sections use
space-y-6, groups within a sectionspace-y-4, tight pairsspace-y-2. - Compose conditional classes with
cn()from@/lib/utils, not template strings.
lucide-react is the default (117 import sites). Standard sizes: h-4 w-4 inline with text, h-5 w-5 for standalone buttons, h-3 w-3 inside badges.
@iconify/react is used only for brand and product logos (adapter icons, OIDC providers) via @iconify-icons/simple-icons, -logos, and -mdi. New adapters register their icon in src/components/adapter/utils.ts (ADAPTER_ICON_MAP), otherwise the UI falls back to a generic icon.
Every user carries three display preferences on their session: timezone, dateFormat, and timeFormat. Timestamps are stored in UTC and only become a wall-clock time at render, so every place a date or time reaches the screen must go through the shared formatter. There is no exception for "just a tooltip" or "just a preview".
Forbidden: .toLocaleDateString(), .toLocaleTimeString(), and .toLocaleString() on a Date, plus any other direct locale formatting. These read the browser locale instead of the user's settings and look correct on your own machine, which is why they keep reappearing in chart tooltips, table cells, and preview components.
| Use | When |
|---|---|
<DateDisplay date={x} /> from @/components/utils/date-display |
Rendering a timestamp as its own element. Emits semantic <time dateTime={iso}> and handles the SSR hydration mismatch. |
useDateFormatter() from @/hooks/use-date-formatter |
You need the string itself - inside a template, a chart tooltip, a table cell, an aria-label. Returns { formatDate }. |
Both take a date-fns format string defaulting to "Pp", but the localized tokens are substituted with the user's preference, not passed through:
| Format you pass | What is actually used |
|---|---|
"P", "PP", "PPP" |
the user's dateFormat |
"p", "pp" |
the user's timeFormat |
"Pp", "PP pp" |
dateFormat + timeFormat |
"dd.MM.yyyy" or any other literal |
used verbatim - the user's format preference is ignored |
So formatDate(x, "dd.MM.yyyy") still converts into the user's timezone but overrides the format they chose. Only reach for a literal pattern when the format is genuinely fixed by context (a log line, a filename, an axis label that must stay compact), and treat it as a deliberate choice rather than a default.
DateDisplay also takes an explicit timezone prop that overrides the user's - used where a value is defined by the server's clock rather than the viewer's.
.toLocaleString() for thousands separators is fine - there is no timezone in a row count. Check formatBytes and formatDuration in @/lib/utils first, they likely already cover the case.
- Success and error feedback:
toastfromsonner. Neveralert(). - User-facing errors go through
toast. The logger is for diagnostics, not for the user - they are not interchangeable, and an error usually needs both. - Pending buttons:
disabled={isSaving}plus<Loader2 className="h-4 w-4 animate-spin" />. - Initial page and section loads:
Skeletonfrom@/components/ui/skeleton, shaped like the content it replaces. A bare centered spinner for a whole page is a last resort. - Empty states get a short explanation and, where it makes sense, the primary action - not a blank panel.
Never console.log / console.error / console.warn, including inside .catch(). Import logger from @/lib/logging/logger - it has no Node-only dependencies and is safe in both Server and Client Components.
Never log whole session, user, or config objects. Log the specific field ({ userId }, not user). Client logs land in the browser console where anyone can read them.
- Default to Server Components. Add
"use client"only for interactivity. page.tsxshould rarely be a Client Component. Fetch in the page, pass props to a child client component that owns the interactive part. Reference:src/app/dashboard/jobs/page.tsxfetching and handing off tojobs-client.tsx.- Before adding
"use client"to a page, check whether only a sub-tree actually needs it. - Type all props with explicit interfaces. No implicit
any. - Permission flags are resolved server-side (
getUserPermissions()) and passed down as booleans such ascanManage,canExecute. Do not re-check permissions in the client for security - client checks are for hiding UI only.
tests/unit/lint-guards/design-system.test.ts runs in pnpm test and pnpm validate:
| Rule | Mode |
|---|---|
Raw overflow-y-auto instead of ScrollArea |
Fails the build |
| Locale date formatting | Fails the build |
max-h on the ScrollArea root |
Baseline of 4, fails if it grows |
Palette color with no dark: variant |
Baseline of 126, fails if it grows |
Baselines may only be lowered. Fix violations, drop the number, and the guard locks the win in. Everything else in this guide is on you.
- Every overflowing container is a
ScrollArea, with the max-height on the viewport andmin-h-0on flex children. - Dialogs use
p-0+ padded header/body/footer, and have aDialogDescription. - No
console.*. Noalert()orconfirm(). - No
.toLocaleDateString()/.toLocaleTimeString()/.toLocaleString()on dates. - Raw palette colors carry a
dark:variant. Checked the page in dark mode. - Submit buttons disable and show a spinner while pending.
- No inline
styleoutside computed values. - Checked
src/components/ui/before writing a new primitive. pnpm run buildpasses.