Design Conventions
SkillMediaFollow and maintain the app's UI conventions. Use for any UI, styling, or component work (spacing, color, cursor, layout, typography), or when making or changing a design-system convention.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Design Conventions skill
What this skill tells your AI
The instructions your AI receives, as published by nrjdalal/zerostarter in .agents/skills/design/SKILL.md and read by ahel’s review.
When a change establishes or alters a convention, update this file in the same change so it never drifts. Propose a genuinely new design-token choice before committing; the maintainer owns the design language.
Principles
- Defaults first. Use primitives bare at their defaults and add a class only where a spot genuinely needs it. Per-instance overrides are how drift starts. Example:
<Spinner />, not<Spinner className="size-5" />. - One source per concern. Shared styling lives in the component or its variant, never copy-pasted across call sites. Brand identity (name, description, social links) is
@packages/config/site.
Cursor
cursor-pointer is for navigation only: links, anchors, a Button rendered as <Link> or <a>, a router.push. It signals "this changes the route."
- Action controls (form submit, dialog/menu triggers, toggles, mutation buttons, sign-in, sign-out) keep the native arrow even when the action eventually navigates: classify by element, not by side effect.
- In practice you need no
cursor-pointerclass:<a href>shows the pointer natively,<button>shows the arrow natively, andbuttonVariantssets no cursor. A readOnly button-like input (the docs search trigger,DocsSearchincomponents/docs/sidebar.tsx) usescursor-defaultto avoid the text I-beam. - Exception: some primitives set their own cursor (
DropdownMenuItemhard-codescursor-default). A navigation item inside one (arender={<Link/>}menu item) needs an explicitcursor-pointerto restore the pointer the base overrode.
Spacing
- Stay on the Tailwind scale and snap to the nearest step; no off-ladder one-offs (
gap-7.5,size-4.5,w-45,mb-18,text-[0.6rem]). gap-2is the workhorse for tight clusters.- Dashboard and console pages use the collapsible
SidebarShell(components/shell/sidebar-shell.tsx) and wrap content inPageShell(components/shell/page-shell.tsx), which ownsmx-auto+ width +p-4 sm:p-6via asizevariant (sm/md/lg/full, defaultmd=max-w-4xl). The title/description/actions row isPageHeader(components/shell/page-header.tsx). Never hand-rollmx-auto/max-w-*/p-*or the header layout. - Marketing pages share one vertical scale:
py-24sections and apx-4 md:px-6container gutter.
Typography and headings
- Exactly one
<h1>per page (the page title); sections use<h2>and below, never skipping a level. - Use the existing type scale and tokens; no off-scale font sizes.
- Marketing-page headings are
font-bold. A sub-heading within a section stays lighter (afont-semiboldh3) to preserve hierarchy; non-heading display text (a stat value) follows its own weight.
Color and theming
- Semantic tokens only:
text-muted-foreground,bg-card,border-border,bg-sidebar, and friends. No hardcoded hex, rgb, or hsl in classNames or inline styles. The one exception is takumi-rendered OG images, which have no theme context. - Dark mode is
next-themes(attribute="class",app/providers.tsx); pair everydark:with a token. - Success uses the
--successtoken (green-600 light, green-500 dark, mirroring--destructive):text-success,bg-success/10,border-success/20. It is foreground-less, like--destructive.
Layout and landmarks
- Each top-level page wraps its content in a single
<main>. Route-group layouts (dashboard viaSidebarShell, docs, blog) already render their own<main>, so add none to the root layout or you nest landmarks. - A surface that has footer content renders it in a real
<footer>as a sibling of<main>, never a<div>inside it, so the page exposes acontentinfolandmark. A surface with nothing to put there renders no footer rather than an empty one. - Name a
<section>that has a visible heading witharia-labelledbypointing at that heading's id, not a hand-writtenaria-label: internal authoring vocabulary ("Hero", "Call to action") otherwise leaks into the accessibility tree as the region's name. A wrapper with no visible heading (arole="region"scroll container) still takesaria-label. - A scrollable region (a code block, a wide table) needs
tabIndex={0}and an accessible name, or its overflow is unreachable by keyboard. - Top-level full-height surfaces (the body, marketing pages, the
SidebarShellroot) usemin-h-svh, matching the shadcn sidebar; nodvh. A surface nested inside the shell content pane (routeerror/loading, dashboard/console content) fills it withflex-1: the shell<main>isflex min-h-svh min-w-0 flex-1 flex-col, so do not re-assertmin-h-svhinside an already-full-height parent.
Motion
- Every looping or auto-playing animation is gated on reduced motion. For a Tailwind utility use the
motion-safe:variant (motion-safe:animate-pulse). For a keyframe Tailwind does not ship, declare it next to the section that uses it and wrap the rule in@media (prefers-reduced-motion: no-preference); marketing keyframes stay out ofglobals.css. - Anything that moves for more than five seconds also needs a pause affordance, on both hover and
focus-within, so a keyboard user can stop it (WCAG 2.2.2).
Components
-
Loading:
<Spinner />, bare, at its defaultsize-4. Never hand-rollRiLoaderLine. It goes where the wait is, inside the surface that is waiting, never as a routeloading.tsx: the authed areas carry none on purpose (#790). A segment'sloading.tsxwraps its page, not its own layout, so the console's slow part (theforce-dynamiclayout awaiting the session) renders above the boundary and is never covered by it, while the page below reads that session back from Reactcache()and resolves at once. The fallback ends up painting a full black pane in front of a page that was not the thing being waited for. Without one the router holds the current page until the next is ready, then paints it whole. -
Empty states: the
Emptyprimitive (EmptyHeader/EmptyMedia/EmptyTitle/ ...). Do not hand-roll empty messages. -
Badges and pills: prefer
<Badge>(with a variant, plus className for a semantic color liketext-success) over a hand-rolled rounded-full span. Identity rows (avatar + name + email) useItem/ItemMedia/ItemContent. Exceptions: the sidebar trigger identity stays hand-rolled insideSidebarMenuButton(the chevron is a sibling there); the marketing landing (web/next/src/app/(marketing)/page.tsx) hand-rolls a largerEyebrowpill for section eyebrows and the hero badge, since<Badge>is sized for compact UI (h-5,text-xs). -
Forms: native
<form>then<FieldGroup>then<form.Field>then<Field>+<FieldLabel>+<Input>+ conditional<FieldError>, with@tanstack/react-form+ zod. LetFieldGroupown the vertical rhythm (no secondspace-y-*). Do not hand-roll labels or error markup. -
Dialogs: bare
<DialogContent>is centered atsm:max-w-sm. The auth dialog (components/common/access.tsx) usesmax-w-md. -
Icons:
@remixicon/reactonly.size-4inside buttons by default. -
shadcn (
components/ui/*): customize only via.github/scripts/shadcn-customize.ts(the sync wipes and re-scaffoldsui/). Extend the primitive in place; do not fork a copy.
Data tables
Every table uses the components/data-table.tsx family; do not hand-roll a <Table> with its own state. The full architecture and behaviors live in the manage/data-tables docs page; the sharp-edge rationale is commented in the module at each site it bites. The pure table logic (width measurement, the column-config fold, the slack rule, and the sort-whitelist lookup) lives in lib/data-table-layout.ts and is unit-tested, so change it there and add a case rather than tweaking widths in the renderer. lib/data-table-features.ts is the other half: the one tableFeatures(...) value every ColumnDef, Column and Table type is generic over. TanStack v9 bundles nothing by default, so a method exists only where its feature is registered, a row model only where its slot is filled, and a filterFn/sortFn named by string only where that map holds it, an unregistered name being a compile error. Register there, not per table.
- Toasts are raised with
toast.add({ title, type })from@/components/ui/toast, the manager the component itself exports, and are rendered by theToastermounted inproviders.tsx.typeiserror,success,warning,infoorloading. No wrapper and no toast library: the component is used the way it ships, so a sync never has to re-apply anything for it. The surface stays neutral and the kind shows in the icon, red for an error. - Selection actions live in
selectionActionsonDataTable, which floats them in a bar over the bottom of the table while rows are selected and always ends them with Cancel. Do not put them in the toolbar, whose right side is for table-level actions like adding a row. Add a select column only when the table has such an action. - Compose headless: the page owns the TanStack Table instance (
useDataTablefor server-driven,useDataTableState+ the sharedfeaturesfor client-side) and rendersDataTableToolbar+DataTable. Infinite scroll with virtualized rows everywhere; never numbered pagination. - Layout lives in a colocated
Record<string, ColumnConfig>in the table'sdata-columns.tsx, written in column order; never put widths inline in column defs. Widths are Tailwind spacing units; omitwidthto size from the measuredmeta.labelplusextra(default 10, snapped to the 3-unit grid), measured from build-time font metrics rather than at runtime. - One
flex: truecolumn grows; the capability reaches back to every column before it and the last visible capable column takes the slack. Keep a select column left-aligned so inherited growth reads as gap. No trailing spacer columns. - Prefer
extraover an explicitwidthon a sortable column: the auto path reserves the allowance for the sort button, a fixed width reserves nothing and the header cell clips. - A time column renders a relative time through
relativeTimefrom@/lib/time(5 min. ago,just nowunder a minute), not a formatted date, because a reader scanning a log wants how long ago rather than a calendar lookup. Absolute times are for what leaves the screen: a copy, an export, an API response. A row with nothing to show renders-rather than an empty cell. - Column ids say what the column shows, not the backing field (
statusoverbanned), andmeta.labelis the single source for header text, measured width, and the view-options entry (DataTableColumnHeadertakes no title prop). Cells are plain text viaDataTableCellText(truncate + tooltip by default,wrap: trueto fold); no badges in cells for now. The exception is a cell whose control is the row's primary action and belongs on the row rather than behind a menu: the console's role cell is aSelect, because changing a rung is the one thing that table exists to do. One control, never a cluster. - Sort UI is a plain label plus a bare icon-only sort button toggling asc/desc (icon before the label on right-aligned columns); hiding lives in
DataTableViewOptions. Give the table a visibledefaultSorting; tables are single-sort. - Any file that reads a
table/columninstance needs the"use no memo"directive, or the React Compiler freezes it one render behind. - A page hosting a full-height table passes
className="flex h-svh flex-col"toPageShelland keepsflex-1 min-h-0on every wrapper down to the region;min-h-svhancestors are not definite, so the flex chain would not fill.
File and export naming
- Components are grouped by domain folder (
common/,shell/,console/,dashboard/,docs/,blog/,marketing/,ui/), with kebab-case file names. A single-component file's basename matches its export; a multi-export slot file is named<area>/sidebar.tsx(console, dashboard, docs) and its exports follow the sidebar-slot rule below.docs/holds one of each (docs/sidebar.tsx+docs/copy-as-markdown.tsx). A domain folder may also hold a context module named for what it carries, whose exports share that name as their prefix (console/role.tsxexportsConsoleRoleProvideranduseConsoleRole). A cross-domain family follows the shadcn single-module pattern as one top-level file whose exports share the family prefix:data-table.tsxexportsDataTable,DataTableToolbar,useDataTable, and friends. - Sidebar slot exports follow one rule: domain-prefix the generic-role names (
Nav,Header,Footer,Search) so they read unambiguously and never collide across areas (console/sidebar.tsximportsDocsNav). SoConsoleNav,ConsoleHeader,DashboardFooter,DocsNav,DocsFooter,DocsSearch. Leave a distinctive content name bare (OrgSwitcher,CopyAsMarkdown): a domain prefix on a self-explaining name is redundant. shell/holds the shared app-shell chrome as two families,Sidebar*(SidebarShell,SidebarAdaptive,SidebarFloatingTrigger,SidebarDropdownMenu,SidebarUserMenu) andPage*(PageShell,PageHeader). "Shell" denotes structural layout scaffolding, not one specific component.
Open decisions
None open. Resolved decisions fold into the sections above; add new ones here (move up once chosen).
Signals
- GitHub stars
- 63
- Forks
- 11
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
design-nrjdalal- Source
- github.com/nrjdalal/zerostarter