# DocsGPT frontend design system The UI is Tailwind v4 plus the shadcn-style components in `src/components/ui/`. `@shadcn/lint` enforces the rules below through `npm run lint`; its messages point here. Everything in this file is either a token in `src/index.css` or a variant in a `ui/` component, so an agent or a contributor can always find the concrete thing to use. ## Rules in one paragraph Pages compose `ui/` components and choose their look through props (`variant`, `size`, `shape`). `className` on a component is for placement only: margin, width, flex and grid, positioning. Colours come from the theme tokens, never from the raw Tailwind palette. Spacing, type and radii come from the Tailwind scale, never from arbitrary `[...]` values. Runtime values go in CSS custom properties or, when unavoidable, an inline style with a disable comment. Conditional classes go through `cn(...)`, never a template literal (prettier sorts the classes inside `cn`, and `cn` merges conflicts). Every user-visible string, attributes included (`aria-label`, IconButton `label`, `placeholder`, `title`, `alt`, `hint`), is a `t()` key in all seven locales (`de en es jp ru zh zh-TW`); admin pages are English by design. Interpolated user text (a name, a timezone, a date) passes `interpolation: { escapeValue: false }`, or `/` renders as `/`. ## Colour tokens Defined in `src/index.css` (`:root` for light, `.dark` for dark) and exposed through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and `stroke-` all accept them, including opacity modifiers such as `bg-success/10`. One token class replaces a light + dark pair. | Token | Use for | Replaces | | --------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `background`, `foreground` | page surface and primary text | `bg-white`, `text-gray-700/800/900`, `dark:text-gray-200/300` | | `card`, `card-foreground` | raised surfaces (cards, dialogs, dropdowns) | `bg-white dark:bg-[#2b2c31]` | | `popover`, `popover-foreground` | floating menus and popovers | same as card | | `muted`, `muted-foreground` | quiet fills and secondary text, icons, placeholders, timestamps | `bg-gray-100/200`, `text-gray-400/500/600`, `dark:text-gray-400` | | `accent`, `accent-foreground` | hover and selected fills | `hover:bg-gray-100`, `dark:hover:bg-gray-700` | | `border`, `input`, `ring` | dividers and field borders (fields use `border-border`), focus rings | `border-gray-200/300`, `dark:border-gray-600/700` | | `primary`, `primary-foreground` | brand purple: primary actions, links, selected states | `bg-purple-*`, `text-purple-*`, `#7D54D1`, `#A076F6` | | `secondary`, `secondary-foreground` | pressed and active toggles (a brand tint), secondary buttons, the question bubble | `bg-accent` on an active icon button | | `destructive`, `destructive-foreground` | errors, failed states, dangerous actions | `text-red-*`, `bg-red-50/100`, `#B42318`, `#E60000` | | `success`, `success-foreground` | completed, active, healthy | `text-green-*`, `text-emerald-*`, `bg-green-50/100` | | `warning`, `warning-foreground` | paused, pending review, degraded | `text-amber-*`, `text-yellow-*`, `text-orange-*`, `bg-amber-50/100` | | `info`, `info-foreground` | running, informational | `text-blue-*`, `bg-blue-50/100` | | `sidebar-*` | the navigation rail | | | `chart-1` to `chart-5` | data series only, never UI chrome (see below) | | | `answer-bubble` | assistant message background | | Status colours are tuned to stay vivid rather than turning brown or olive, so their contrast is low. Against white in light mode, `destructive` and `success` are about 3.8:1, `warning` is 2.9:1 and `info` is 5.2:1. On the dark card, `destructive` is 2.9:1 and the others are 5.5:1 or more. Do not use them for long body text; they are for badges, icons, short labels and fills. The dark `primary` (#8855f1) is set so white text on it reaches 4.55:1 on every default Button. As text on the dark surfaces it is below 4.5:1 (3.45:1 on background, 3.06:1 on card), so links and `text-primary` in dark pass only as large or UI text; a fix for that needs its own link token. `ring`, `secondary` and `sidebar-primary` keep the lighter #976af3. Chart colours are not a separate palette. `chart-1` to `chart-5` alias `primary`, `info`, `success`, `warning` and `destructive`, in that order, so the charts use the same colours as the rest of the app in both themes. A single series uses `chart-1`, the brand colour. When a series has a meaning, use the token for that meaning, not the next one in order: failed tool calls use `destructive`, succeeded ones `success`, pending ones `warning`. Series with no meaning (models, agents, sources) take `chart-1` to `chart-5` in order. When there are more than five, the four largest keep `chart-1` to `chart-4` and the rest are summed into one "Other" series in `chart-5`, so no colour repeats (`settings/foldSeries.ts`). `secondary` is never a chart colour. The status set is `success | warning | destructive | info`. `default` is the component's own base tone (brand on a Badge or Button, quiet on an Alert or Toast). `neutral` is the grey pill or box. One tint scale, by purpose: - Wash `/5`: a whole surface that is selected or receiving a drop (Card `selected`, Dropzone drag-active and drag-reject). The border carries the state; the wash only warms the surface. Never on a chip or a text fill. - Brand soft fill: `bg-secondary text-secondary-foreground` (Badge `default`, Avatar `primary`, the OptionCard icon square), never `bg-primary/10`. - Status soft fill: `bg-/10` in both themes (Badge, Alert, ToastHeader, a danger-zone panel); no `dark:` twin. - Status border: `border-/50`. - Tinted hover on a row that is already accent: `/15` light, `/20` dark (`ghost-on-accent`, `ghost-destructive-on-accent`, a destructive menu item). - Neutral hover: solid `bg-accent` in both themes (ghost buttons, combobox, SelectTrigger, Card `interactive`, Dropzone, every list-row highlight). - Quiet panel inside a page or card: `bg-muted`, not `bg-muted/40` or `/60`. - Dividers: `border-border`, not `border-border/60`. Patterns: - Status pill: ``. Status box: ``. Reach for `bg-success/10 text-success` directly only on dots and borders. - Solid status fill: `bg-warning text-warning-foreground`. - Guardrail outcomes: block `destructive`, flag `warning`, redact `info`, not evaluated `neutral`. - Status border: `border-destructive/50`. - De-emphasised text: `text-muted-foreground`; go lighter with an opacity modifier (`text-muted-foreground/70`) rather than a lighter grey. - Text on the brand colour is `text-primary-foreground`, not `text-white`. - `secondary` is a brand tint, not a grey: primary at 10% (light) or 15% (dark) over whatever sits behind it, with `primary` text in light and a lighter purple (#b89cf8) in dark. So a pressed toggle (CopyButton's copied state, text-to-speech while speaking) shows on card, background and muted alike, and never reads as the neutral ghost hover. Use it as `variant={active ? 'secondary' : 'ghost-muted'}`. That is for one thing switched on or off. Picking one value of several (a 7d / 30d / 90d range, a schedule's frequency, a filter row) is a `ToggleGroup`, whose on item has the `outline` look with no hue. - A brand chip, a small action that opens something an answer produced (an artifact chip under an answer, a citation pill in its text), is also `variant="secondary" shape="pill"`: the default size with a lucide icon first for artifacts, `size="xs"` for citations. It is the same tint as a pressed toggle; the difference is that a chip never toggles and always opens or scrolls to something. The citation pill keeps `h-5 min-w-5` (20px) so it sits in a line of text. - The user's question bubble is `bg-secondary text-foreground`: the brand tint as the fill, with body text in `foreground` (15.7:1 light, 12.4:1 dark) rather than `secondary-foreground`, which is only 4.5:1 in light. Controls on it (the collapse chevron) are plain `ghost` with a lucide icon in `currentColor`. - `white`, `black`, `transparent`, `current` and `inherit` are allowed; use them only for overlays and imagery, not for text or surfaces. - Swapping a state's colour for its same-hue token (amber to `warning`, red to `destructive`) is fine without review; changing the hue of a state the user sees (an error, a recording state, a selected tab) is a design decision. Non-state UI follows the token even when its hue shifts. ## Components and their variants ### Button (`ui/button.tsx`) | Prop | Values | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `variant` | `default`, `secondary`, `outline`, `outline-primary`, `ghost`, `ghost-muted`, `ghost-destructive`, `ghost-on-accent`, `ghost-destructive-on-accent`, `link`, `destructive`, `destructive-outline`, `combobox`, `sidebar-item`, `tab`, `section-toggle` | | `size` | `xs`, `sm`, `default`, `lg`, `field`, `icon-xs`, `icon-sm`, `icon`, `icon-lg`, `inline` | | `shape` | `default` (rounded-md), `pill` (rounded-full, wider padding) | - Round brand buttons (`rounded-3xl px-5`, `rounded-full px-6`): `shape="pill"`, plus `size="lg"` if they were `px-6`. - Muted icon buttons (`text-muted-foreground hover:text-foreground`): `variant="ghost-muted"`. - Icon actions that remove something (a trash can on a member or shared resource row) are `variant="ghost-destructive"`: muted at rest, red on hover, so the warning shows even where the click deletes without asking. - Icon buttons on a row that is already `bg-accent` or `bg-sidebar-accent` while you point at it (a highlighted Command item, a card or tile with `hover:bg-accent`, a hovered sidebar row) are `variant="ghost-on-accent"` (delete: `ghost-destructive-on-accent`). They hover with a `foreground/15` (`destructive/15`) tint, which shows on accent where ghost's accent square would not. - Icons are `lucide-react` at the default stroke (2). Don't pass `strokeWidth`, and don't use an `` for a glyph lucide has. The one exception is a tiny check mark inside a status circle (the custom-model test result, the artifact and research step ticks), drawn at 2.5 to 3 so it stays legible at 10 to 12px; raw progress-ring ``s are not icons. - Icon size is one `size-N` class (never `h-N w-N`, never lucide's `size` prop); colour goes on the icon (`text-muted-foreground`), spacing on the parent (`gap-*`, not `mr-2` on the icon). Use current lucide names (`TriangleAlert`, `CircleAlert`, `CircleCheck`, `CircleX`, `Trash2`, `CodeXml`, `Search`), no `as` aliases. Inside a Button the size comes from the Button: 16px, 14px at `xs`, 20px at `size="icon"`; write a `size-N` only to differ from it, since any `size-` class switches the default off. - Bordered purple buttons (`border-primary text-primary hover:bg-primary`): `variant="outline-primary"`. - Tiny inline actions (`h-auto px-2 py-1 text-xs`): `size="xs"`. - A link inside running text (an artifact link in an answer, a link in markdown, a hint's "Learn more") is `variant="link" size="inline"`, with `asChild` around the ``: no height or padding, underlined on hover, the shared focus ring. Standalone links ("Learn more" with `ExternalLink`, "Go to Tools" with `ArrowRight`) are the same, the icon a child. Toggles and crumbs keep a normal size. The base is `text-sm font-medium`, so a link in a 12px hint passes `text-xs font-normal` (an approved exception), and a link that must keep the colour of what it sits in (a status Alert, a dark overlay, a source card's URL row) passes `text-current`. Never style a raw `` as a link. - Icon-only buttons are `IconButton` (below), never a `Button` with a `title`. - Roles for dangerous and dismissive actions: a delete on a page (a "Danger zone" card's Delete agent or Revoke, Delete all) is `destructive-outline`; the submit of a confirm dialog is `destructive` (`ModalActions destructive`, `ConfirmationModal variant="destructive"`); Cancel is `ghost` at the size and shape of the button beside it, in a modal footer, a form header or an inline editor. A Cancel inside a line of text (the composer's queued send) is `link inline`. - The composer controls under the chat field (Attach, Voice, Tools, Sources) are `size="sm" shape="pill"`. Their icons are `size-3.5 sm:size-4` with no margin; the size's `gap-1.5` spaces them, and the label span keeps `text-xs sm:text-sm` so the row still fits on a phone. - Popover comboboxes (`role="combobox"` + `Command`) use `variant="combobox"`, which matches `SelectTrigger`: card fill, normal weight, and muted text while `data-placeholder` is set (`data-placeholder={value ? undefined : ''}`). Pass only layout (`w-full justify-between`) and keep the chevron as the last child. - A button or picker that sits in a row of fields is `size="field"`: 38px. `Input` and `SelectTrigger` take `size="field"` too (the same 38px as Input `default`), so a form column has one name for one height. Page actions beside a page's search field (Add Source, Add Tool, Test retrieval, Sync) are `size="field" shape="pill"` too, with no min-width or hand height. With `shape="pill"` its text starts 21px in, like the Input and Select pills beside it. The agent form's pickers are `combobox field pill`, its Add button `outline-primary field pill`. - Rows in the navigation sidebar (`hover:bg-sidebar-accent … pl-3 gap-2.5 rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal weight, `bg-sidebar-accent` on hover and while `aria-current="page"`. Use it with `asChild` around a `` for navigation rows, the label in a `truncate` span. A row that also holds buttons (an agent's pin, a conversation's menu, rename's Save / Cancel) puts the link and the buttons side by side in a `group relative` wrapper, never buttons inside the link; the link keeps its fill while the pointer is on a sibling with `group-hover:bg-sidebar-accent`. The section sidebar's "Back to app" row is the same variant. - Inline disclosure toggles ("Advanced settings", "Show advanced options") are `variant="link" size="sm"` with only `-ml-3 w-fit justify-start`; a chevron, when the toggle has one, is a lucide `ChevronRight` (so it takes the link colour) as the first child. - Underline tabs (FilePicker's My Files / Shared with Me, the agent page sub-nav) are `variant="tab"`: muted text on a transparent 2px bottom border, square corners, no hover fill. Mark the current tab with `data-active`, which gives it `foreground` text and a `primary` underline. Padding-free tabs (a sub-nav with `gap-6`) add `size="inline"`, which keeps 4px above the line; the row draws the 1px baseline, and `-mb-px` lays the underline over it. Use them for a row of route links (the agent sub-nav: a `
` (inset, because a scrolling column clips an outset ring). Status badges go beside the button, not inside it, or the hover underline runs under them. - Drop `text-white` on default and destructive buttons; the foreground token already provides it. Drop `disabled:cursor-not-allowed` on buttons; the base disables pointer events. Fields are the other way round (see Focus, elevation and stacking: "Disabled"). A disabled primary button just fades (`disabled:opacity-50`); never hand-roll a grey disabled state. - A text button that removes something (Remove on a row) is `ghost-destructive size="xs"`: grey at rest, red on hover. - A button that is busy (saving, creating, testing) takes `loading`: it disables itself, sets `aria-busy`, and draws a 16px spinner over the label, which stays in the layout (invisible) so the width doesn't jump. Keep the idle label; never hand-place a `Spinner` in a button, swap the label for "Saving…", or pin a fixed width to stop the jump. The one exception is a button whose busy state says something the user needs: a progress figure (a connector's Sync shows "42%") or a mode (the composer's Voice button shows "Transcribing"). It keeps its busy label with a `Spinner size="xs"` (the 16px icon step, with a `label` so it doesn't announce "Loading") where its icon was. ### Card (`ui/card.tsx`) | Prop | Values | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `variant` | `outline` (border + card surface, the default), `filled` (muted fill, no border, for tiles on a card-coloured page), `subtle` (border on the page background) | | `tone` | `default`, `destructive` (the status soft fill and border, `border-destructive/50 bg-destructive/10`, over any variant) | | `padding` | `none`, `sm` (p-3, row boxes and code blocks), `default` (p-4), `lg` (p-6, tiles, chart panels and stat tiles) | | `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `