# DocsGPT frontend design system The UI is Tailwind v4 plus the shadcn-style components in `src/components/ui/`. `@shadcn/lint` and the `no-restricted-syntax` selectors in `eslint/` enforce the rules below through `npm run lint`; their 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). What a role may do is gated with `can(item, action)` and a refused action is not rendered (see Access and roles). 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, field borders (fields use `border-input`, `fieldFrame`), 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` | the source cards under an answer and the answer-side panels (AnswerFlow) | | 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 knowledge graph is the one place Other is not `chart-5`: its entity types have no meaning to map, and half a graph often folds into Other, so red would read as "these failed". There the four largest types take `chart-1` to `chart-4` and Other is `muted-foreground` (`foldGraphTypes`, `readGraphPalette` in `components/graphViewUtils.ts`). 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` (enforced). - 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`. It is a small box inside a panel (a well, a guardrail stage), never a page-sized panel around cards (see Card surfaces). - 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`. - 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 the lighter #976af3 at 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 at `default`, `lg` and `field`) | - 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 glyph inside a status circle or chip (the custom-model test result and capability chips, the artifact and research step ticks, `ToastStatus`'s four status icons), 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 (enforced). - Icon-only buttons: see IconButton below. A `title` on a `Button` is rejected (enforced). - An action whose label doesn't fit beside a name on a phone (the shared agent card's Edit, in a long locale) is two elements: an `IconButton` with `sm:hidden` and the labelled `Button` with `hidden sm:inline-flex`. Never hide a Button's label span, which leaves an icon-only Button. - 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 `outline sm 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`, each labelled by a `FormField` (the Prompt picker through `Prompts titleAs="field"`), its Add button `outline-primary field pill`, the only `outline-primary` on the agent pages themselves (the Access details modal they open has its own). - One variant and size per role on the agent pages (Overview, Logs, Schedules, the workflow builder's toolbar): the page's primary action is `default field pill` (Publish, Save, New schedule), a secondary action `outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`) at the end of the title row, through `SectionShell titleAction`: Access details and Share with team on Overview (each only when the role allows it, see Access and roles), and Preview until the agent is published (before that the preview only says "Publish to preview"). So the row never holds more than three buttons and fits a phone, where the main one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot spans the stacked row below `sm`). The workflow builder's toolbar has no title row and keeps its ⋯ at the row's end, holding Edit details, Access details, Share with team and Delete (see Workflow builder). The agent's Delete is in Overview's danger zone; only the workflow builder's toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is `agents/components/AgentPageToolbar` (see Page chrome). A row's common action is `outline sm pill` (a schedule's Run now or Resume) with its other actions in the row's `ActionMenu`; a delete there is a `destructive` item that opens the confirm modal. - 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 route tabs 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. Its one use is `agents/AgentPageHeader.tsx`, the workflow builder's fixed toolbar: a breadcrumb, the status Badge and the Overview / Logs / Schedules tabs in a `` with `data-active`. The agent section pages switch with the section sidebar (the phone menu below `lg`) and carry no pill row; the agent tile's ⋯ also opens Logs. Tabs that switch a panel in place are `ui/tabs` with `variant="underline"` on `TabsList` and each `TabsTrigger`: the same pixels, plus `role="tablist"`/`"tab"`, `aria-selected` and arrow-key focus; wrap the panel in `TabsContent` (FilePicker's My Files / Shared with Me, Schedules' Recurring / One-time in `agents/schedules/SchedulesView.tsx`, the graph source view, the source edit drawer). Each trigger keeps its `px-4`, so the first label sits 16px in from the content edge and the underline runs past the label on both sides. That inset is deliberate (decided 2026-09-28): the tab row reads as its own strip, with a wider target per tab, so don't pull it flush with `-ml-4` or strip the padding. Only the workflow builder's toolbar, where the tabs share a row with a breadcrumb, uses the padding-free `Button variant="tab" size="inline"`. The `default` variant is a pill tab, unused in the app. Panels unmount when hidden, except one whose state is costly to rebuild (the graph source view's laid-out canvas and zoom): that `TabsContent` takes `forceMount` plus `data-[state=inactive]:hidden`. - A section panel's disclosure header (NewAgent's Advanced and Guardrails panels) is `variant="section-toggle" size="sm"` with `-ml-3 w-fit justify-start` and `aria-expanded`: a lucide `ChevronRight` first (`rotate-90` while open) in primary, then the foreground title, with a primary underline on hover. Both call sites wrap the Button in an `

` (a button flattens heading children, so the heading goes outside) and put the title in a `text-lg font-semibold` span. The button draws no focus ring; the panel does, so keyboard focus outlines the whole panel. The panel is a `Card` (a place, `subtle lg`), and every Card carries that ring (`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`, inset because a scrolling column clips an outset ring), so pages pass nothing for it. Status badges go beside the button, not inside it, or the hover underline runs under them. Because the ring comes from the Card, `section-toggle` only goes inside a Card: a collapsible group in a Modal or drawer (Share's Access settings) is the inline `link sm` disclosure toggle above. - 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), `subtle` (border on the page background); which one is decided by role, below | | `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, panels and stat tiles) | | `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `