81 KiB
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 (Cardselected, 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(Badgedefault, Avatarprimary, the OptionCard icon square), neverbg-primary/10. - Status soft fill:
bg-<role>/10in both themes (Badge, Alert, ToastHeader, a danger-zone panel); nodark:twin. - Status border:
border-<role>/50. - Tinted hover on a row that is already accent:
/15light,/20dark (ghost-on-accent,ghost-destructive-on-accent, a destructive menu item). - Neutral hover: solid
bg-accentin both themes (ghost buttons, combobox, SelectTrigger, Cardinteractive, Dropzone, every list-row highlight). - Quiet panel inside a page or card:
bg-muted, notbg-muted/40or/60. - Dividers:
border-border, notborder-border/60.
Patterns:
- Status pill:
<Badge variant="success">. Status box:<Alert variant="warning">. Reach forbg-success/10 text-successdirectly only on dots and borders. - Solid status fill:
bg-warning text-warning-foreground. - Guardrail outcomes: block
destructive, flagwarning, redactinfo, not evaluatedneutral. - 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, nottext-white. secondaryis a brand tint, not a grey: primary at 10% (light) or 15% (dark) over whatever sits behind it, withprimarytext 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 asvariant={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 aToggleGroup, whose on item has theoutlinelook 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 keepsh-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 inforeground(15.7:1 light, 12.4:1 dark) rather thansecondary-foreground, which is only 4.5:1 in light. Controls on it (the collapse chevron) are plainghostwith a lucide icon incurrentColor. white,black,transparent,currentandinheritare 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 todestructive) 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", plussize="lg"if they werepx-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-accentorbg-sidebar-accentwhile you point at it (a highlighted Command item, a card or tile withhover:bg-accent, a hovered sidebar row) arevariant="ghost-on-accent"(delete:ghost-destructive-on-accent). They hover with aforeground/15(destructive/15) tint, which shows on accent where ghost's accent square would not. - Icons are
lucide-reactat the default stroke (2). Don't passstrokeWidth, and don't use an<img>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<svg>s are not icons. - Icon size is one
size-Nclass (neverh-N w-N, never lucide'ssizeprop); colour goes on the icon (text-muted-foreground), spacing on the parent (gap-*, notmr-2on the icon). Use current lucide names (TriangleAlert,CircleAlert,CircleCheck,CircleX,Trash2,CodeXml,Search), noasaliases. Inside a Button the size comes from the Button: 16px, 14px atxs, 20px atsize="icon"; write asize-Nonly to differ from it, since anysize-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", withasChildaround the<a>: no height or padding, underlined on hover, the shared focus ring. Standalone links ("Learn more" withExternalLink, "Go to Tools" withArrowRight) are the same, the icon a child. Toggles and crumbs keep a normal size. The base istext-sm font-medium, so a link in a 12px hint passestext-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) passestext-current. Never style a raw<a>as a link. - Icon-only buttons are
IconButton(below), never aButtonwith atitle. - 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 isdestructive(ModalActions destructive,ConfirmationModal variant="destructive"); Cancel isghostat 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) islink inline. - The composer controls under the chat field (Attach, Voice, Tools,
Sources) are
size="sm" shape="pill". Their icons aresize-3.5 sm:size-4with no margin; the size'sgap-1.5spaces them, and the label span keepstext-xs sm:text-smso the row still fits on a phone. - Popover comboboxes (
role="combobox"+Command) usevariant="combobox", which matchesSelectTrigger: card fill, normal weight, and muted text whiledata-placeholderis 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.InputandSelectTriggertakesize="field"too (the same 38px as Inputdefault), 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) aresize="field" shape="pill"too, with no min-width or hand height. Withshape="pill"its text starts 21px in, like the Input and Select pills beside it. The agent form's pickers arecombobox field pill, its Add buttonoutline-primary field pill. - Rows in the navigation sidebar (
hover:bg-sidebar-accent … pl-3 gap-2.5 rounded-3xl) arevariant="sidebar-item": left-aligned, full-radius, normal weight,bg-sidebar-accenton hover and whilearia-current="page". Use it withasChildaround a<Link>for navigation rows, the label in atruncatespan. 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 agroup relativewrapper, never buttons inside the link; the link keeps its fill while the pointer is on a sibling withgroup-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 lucideChevronRight(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 withdata-active, which gives itforegroundtext and aprimaryunderline. Padding-free tabs (a sub-nav withgap-6) addsize="inline", which keeps 4px above the line; the row draws the 1px baseline, and-mb-pxlays the underline over it. Use them for a row of route links (the agent sub-nav: a<nav>whose current link carriesaria-current="page"). Tabs that switch a panel in place areui/tabswithvariant="underline"onTabsListand eachTabsTrigger: the same pixels, plusrole="tablist"/"tab",aria-selectedand arrow-key focus; wrap the panel inTabsContent(FilePicker's My Files / Shared with Me). Thedefaultvariant is a pill tab, unused in the app. - A section panel's disclosure header (NewAgent's Advanced and Guardrails
panels) is
variant="section-toggle" size="sm"with-ml-3 w-fit justify-startandaria-expanded: a lucideChevronRightfirst (rotate-90while open) in primary, then the foreground title, with a primary underline on hover. The button draws no focus ring; the panel does, so keyboard focus outlines the whole white block:has-[[data-variant=section-toggle]:focus-visible]:ring-3 …:ring-ring/50 …:ring-inseton the panel<div>(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-whiteon default and destructive buttons; the foreground token already provides it. Dropdisabled:cursor-not-allowedon 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, setsaria-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 aSpinnerin 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 aSpinner size="xs"(the 16px icon step, with alabelso 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 <button> or <Link> |
Parts: CardHeader (title left, CardAction top-right), CardTitle,
CardDescription, CardContent, CardFooter (meta row, sticks to the
bottom). One radius for every card (rounded-2xl); pass only layout and
gap-* on Card. Children are spaced by Card's gap-3, so they carry no
mt-*. Replaces every hand-rolled
rounded-(md|lg|xl|2xl|3xl|4xl) border bg-(card|muted) p-* box:
- Tiles (sources, tools, custom models, agents, folders) are
filled,lg(a folder rowdefault),interactiveonly when a click navigates (a draft agent is not). The Add tool picker tiles sit on the modal's card, so they areoutline interactive lgwithasChildaround a<button>. - Chart panels are
subtle lgwith fixed heights passed as layout; a chart panel inside a modal isoutline(the modal is alreadybg-card). - Row boxes inside a form or panel (a guardrail check, the schedule's
timezone box, the discovered MCP tools) are
padding="sm". - A danger zone (Delete agent, Revoke a device) and a failing stat are
tone="destructive"; the title beside it isSectionHeader tone="destructive". Muted text fails AA on the red fill, so the tone turns everytext-muted-foregroundinside it toforeground; don't pass a lighter colour back. Icon buttons inside a destructive-tone row areghost-destructive-on-accent, whose red tint shows on the fill where ghost's grey square would not. - A card on the page background that must not look raised (the shared
agent card) is
subtle lg. Cards never take a shadow.
Stat tiles are components/StatCard.tsx, never a hand-rolled Card: label,
value (24px bold tabular-nums), sub (a 12px muted line or link),
hint (a native title with the help cursor), variant (subtle on a
page, outline inside a modal), tone="destructive" for a failing figure,
valueTone (destructive | warning | info | muted) to colour the figure by
meaning, and loading for a figure-sized Skeleton.
Code blocks (a run's output, an error trace, a token or command to copy) are
a <pre className="font-mono text-xs whitespace-pre-wrap wrap-break-word">
in a Card picked by the surface underneath: filled padding="sm" (a muted
well) on a card or background surface, subtle padding="sm" on a muted one
(the tool-approval card, an expanded log row). A copy row is the same Card
with className="flex-row items-start gap-2" and the CopyButton inside.
Inside an Alert or a trace's tool panel, and in a full-pane viewer, the
<pre> takes the recipe with no Card, so boxes don't nest. Put scroll caps
(max-h-* overflow-y-auto) on the Card; never break-all.
Tile text: the name is CardTitle (14px semibold from Card's text-sm; pass
as="h2" on a page that goes from its title straight to a tile grid, but
never a heading inside a clickable tile, whose children a button flattens), the
description CardDescription size="xs" (12px muted, leading-relaxed), and
meta lines (a date, a token count, a model id or host) go in CardFooter at
regular weight. font-medium is for list rows (ListRow), not tiles.
SectionHeader (ui/section-header.tsx)
<SectionHeader as title description actions size tone>. size: default
(18px text-lg font-semibold, a section title on a page or panel), sm (the
eyebrow, text-muted-foreground text-xs font-semibold uppercase tracking-wider, a label set in caps above a group or a table head), xs
(14px text-sm font-semibold, a sub-heading inside a panel, drawer or modal).
tone="destructive" for a danger zone only. as is the level in the outline
(h2 default). The spacing below belongs to the parent's gap-*, never an
mb-* on the heading. Not for dialog and sheet titles (their own title) or a
page byline (a muted text-sm paragraph).
A title with controls beside it (Add, a filter Select, Import spec) passes
them as actions, never a hand-rolled flex justify-between row around the
header: the row centres the title on the buttons and wraps them under it on
a phone (flex-wrap items-center gap-x-4 gap-y-2). Panel and chart titles
are size="xs" with as="h3"; a title row that also holds a legend or a
"Resets" line keeps its wrapper and swaps only the heading. Disclosure
headers (Button variant="section-toggle") and the Agents breadcrumb are not
SectionHeaders.
OptionCard (ui/option-card.tsx)
The picker tile: a whole-card <button> with an icon in a tinted square, a
title and an optional description; selected fills the icon square and
outlines the card. Use it wherever the user picks one of several ways to
proceed (agent type, source type). Title-only tiles keep the same layout.
Pass selected (true or false) only in a
single-select picker inside a role="radiogroup"; the tile is then a radio.
Tiles that advance a step or navigate (Upload's source types, the agent-type
modal) leave it out and stay plain buttons. Only layout classes on it.
Badge (ui/badge.tsx)
variant: default (brand), neutral, success, warning, destructive,
info, outline. Replaces every hand-rolled
rounded-full bg-<colour>-100 px-2 py-0.5 text-xs text-<colour>-700 dark:...
pill, including schedule and run status pills, trace chips and statuses,
token scope chips, "Disabled" and tool chips. A grey chip is neutral, never
a bg-muted pill. The admin role is default wherever it shows (Teams, Admin
→ Users); every other role is neutral. Chips that show code (token scopes) pass font-mono, and
stat chips tabular-nums, as approved exceptions. HTTP method pills take their variant from
getMethodBadgeVariant (utils/httpMethodColors.ts): GET success, POST
info, PUT warning, DELETE destructive, PATCH default, anything else
neutral. MultiSelect (ui/multi-select.tsx) shows its first two picks as
default Badges with a remove X, then "+N more", on a Button variant="combobox" size="field" trigger that grows past 38px when the chips
wrap, with SelectTrigger's turning chevron; each row in its list shows a
Checkbox size="sm".
Input (ui/input.tsx)
| Prop | Values |
|---|---|
size |
sm (h-8), default (h-9.5, 38px), lg (h-12), field (h-9.5, the form-row name) |
shape |
default, pill |
variant |
default, bare (no border, padding, radius, shadow or ring), filled (card fill) |
Text alignment classes (text-right) and font-mono (code fields) are
allowed on Input and Textarea. <Input label> is the shorthand for a
one-field FormField: the same floating label on the border, the same
labelSurface (card default, forms in cards and modals; background,
fields straight on a page; muted, fields on a muted panel). Never pass a
background class for it. A placeholder under a floating label is an example:
it stays hidden while the label rests inside and shows on focus. The chat and hero
fields (rounded-3xl px-5 py-3) are size="lg" shape="pill". A
default-size pill (38px, the form-row height) pads px-5 like the large
one, so its text lines up with the Select pills. Compact table
filters (h-auto px-2 py-1 text-sm) are size="sm". An inset icon (a
search glass) is leftIcon, with or without a label; it pads the field
pl-10, so never hand-place an icon over an Input. A field inside a host
that already draws the frame (the renaming sidebar row, a search strip in a
bordered panel) is variant="bare"; the host shows focus, and any inset
padding goes on the host, not the field. A field on a muted panel (the
ImportSpec Base URL box) is variant="filled", so it keeps the card fill
instead of showing the panel through; never pass bg-card for it.
SelectTrigger (ui/select.tsx)
size: sm (32px), default (36px), field (38px, the form-row
height); variant: default, ghost; shape: default, pill. Pills
pad px-5 at every size but sm (px-3), so their text starts 21px in
like the Input and Button field pills. A select in a form is
size="field", labelled by FormField. SelectTrigger is w-fit; pass
w-full in a form column. Fields that take typing or sit beside one are 16px
below md (iOS zooms on focus under 16px) and 14px from md: Input,
Textarea, SelectTrigger default | field, Button combobox default | field | lg and CommandInput. The sm sizes stay 14px. A highlighted list row is
bg-accent in Select, Command and DropdownMenu alike.
Textarea (ui/textarea.tsx)
size: sm, default, lg (rounded-2xl, for prompt editors); resize:
none, vertical (default), both. Same border, ring and invalid styling
as Input. variant: default (transparent), filled (card fill), with
the same rule as Input: a textarea on a muted panel is variant="filled",
never bg-card. Three raw <textarea> elements stay, because each sits
under an overlay it must line up with: the chat composer, PromptTextArea's
variable highlighter and the chunk editor behind its line-number gutter.
FormField (ui/form-field.tsx)
Every boxed form control is labelled by a floating label:
<FormField label required hint error disabled labelSurface> with the field
as its only child. The label sits on the field's border (12px muted,
labelSurface card | background | muted to match the surface behind
the field, so the notch hides the line). It rests inside an empty, unfocused
Input or Textarea and moves up on focus; on a SelectTrigger, combobox,
MultiSelect, number field or Dropzone it stays on the border. It turns
red (text-destructive) with an error, and dims while disabled. Under
the field come a muted text-xs hint and a red text-xs error
(role="alert"), 6px apart. The required star follows the label
(aria-hidden; the field gets aria-required, not native required). A
placeholder is an example: hidden while the label rests, shown on focus.
Stack floating fields with gap-5 so each label clears the field above.
Input, Textarea, SelectTrigger (even nested in Select), MultiSelect,
Dropzone and Checkbox read their id, aria-invalid, aria-describedby,
aria-required and disabled from it, so don't set those by hand; an id
the field already has wins. Any other control: pass id to FormField and
the same id to the control. One FormField holds one control: a second field
inside it needs its own id, or it takes the field's. Popovers reset the
wiring (FormFieldBoundary), so a picker's search box is safe.
className is layout only. <Input label> is the one-field shorthand; never
put it inside a FormField (two labels).
float={false} puts the label above instead (14px medium, 6px up), for a
FormField with no single box to sit on: a list of checkboxes, several
controls in a row, a loading or error line in place of the field. Controls
whose label sits beside them (Switch, Checkbox, radio) use SettingRow or
an inline Label, not FormField. An editing surface that fills its area
(the chat composer, the chunk and wiki editors, editing a sent question) and
repeated rows in a list (workflow expressions) have no visible label and
must have an aria-label. Never hand-build a label above a field, a
hand-positioned floating label, or a div.flex-col + Label + <p> stack.
SettingRow (ui/setting-row.tsx)
A setting with a control on the right (a Switch, a short Input) is
<SettingRow label description htmlFor alignStart stack as after>{control}</SettingRow>,
grouped in <SettingRows>, which splits rows with divide-border/50 and pads
each 12px (none at the group's ends). The title is a Label for the control
(htmlFor = the control's id), so every switch has a name and clicking the
title toggles it; as="h2" | "h3" keeps a heading tag, and the control then
needs its own aria-label. alignStart top-aligns the control for wrapping
descriptions. stack puts a control too wide for a phone row (a 224px
picker) under the title below sm at full width; give the picker
w-full sm:w-56. after holds a field that belongs to the row, 8px under it
(the agent form's limit Inputs). Settings → General is the page-level example:
PageToolbar intro and rule, then SectionHeadered groups of SettingRows in a
max-w-3xl column. Inline "switch + label" pairs (a filter
toggle) are not SettingRows.
Checkbox (ui/checkbox.tsx)
<Checkbox checked onCheckedChange> (Radix), never <input type="checkbox">:
native boxes take the OS accent and ignore dark mode. size: default
(16px, option rows), sm (14px, table cells). Unchecked it is an
border-input box; checked, primary with a primary-foreground check.
Name it with a Label htmlFor or aria-label. For an on/off setting with a
description use a Switch in a SettingRow instead.
Tooltip (ui/tooltip.tsx) and IconButton (ui/icon-button.tsx)
Every icon-only button is an IconButton: <IconButton label icon hint? side? variant size />. label is the accessible name and the tooltip text;
hint replaces the tooltip text when it says more than the name ("Undo
(Ctrl+Z)"); icon is a lucide icon, rendered aria-hidden, or pass
children for a swapping or custom glyph. It never sets title. Tooltip
side: bottom for buttons in a header or toolbar at the top of a page, panel
or dialog; right on the collapsed sidebar rail, where a top tooltip would
cover the button above; the default top everywhere else (under an answer,
in the composer, in rows). Toast close and collapse buttons are plain Buttons with
an aria-label and no tooltip.
Tooltips open after 400ms. One TooltipProvider is mounted in main.tsx,
so moving along a row of icon buttons opens each at once after the first
(Radix's skip-delay); a Tooltip outside it (tests, a portal root) adds its
own provider. For a hint on something that is not an icon button, compose
Tooltip + TooltipTrigger asChild + TooltipContent. title= stays only
on truncating text (span, p, div), where it shows the full name.
ToggleGroup (ui/toggle-group.tsx)
The segmented control for picking one value of several (type="single") or
several of several (type="multiple").
Items are pills: the on item is the outline look (bg-background, border,
shadow-xs), the others ghost-muted. size is sm (32px, the default) or
xs (28px, inside a muted track: a wrapper <div className="bg-muted rounded-full p-1"> around the group, whose own className takes layout only). A single group is a radiogroup with one Tab
stop and arrow keys; it sends "" when the on item is clicked again, so
ignore that in onValueChange ((v) => v && setRange(v)). Route links in a
row (the Agents filter pills) are not a ToggleGroup: they are Button asChild variant={active ? 'outline' : 'ghost-muted'} size="sm" shape="pill" around
each Link, with aria-current="page" on the current one.
Separator (ui/separator.tsx)
A 1px bg-border rule, horizontal or orientation="vertical", decorative
(role="none") unless decorative={false}. Pass margins and width only.
Replaces every <hr> and every border-b or h-px div that is only a line.
Spinner and Skeleton (ui/spinner.tsx, ui/skeleton.tsx)
Spinner size="xs | sm | default | lg" (16, 20, 28, 40px) draws in
currentColor, so colour it with a text-* token on it or its parent. xs
is the icon-sized step: a busy Button, a step's status in a Preview or
research row. Never shrink a larger size with className="size-*", and don't
use lucide LoaderCircle (formerly Loader2) or a hand-drawn SVG as a loader. Skeleton is a pulsing
muted block sized with layout classes; its default radius (rounded-sm, 6px)
is the bar radius, so pass none (rounded-full for an avatar or switch
stand-in). Bars inside a muted surface (a Card variant="filled" tile) take
surface="muted" (bg-muted-foreground/20), since bg-muted would vanish
there. A tile's loading mirror is the same Card as the tile (variant,
padding, height) with Skeleton bars laid out like its content; the Card
itself never pulses, only the bars do. Never put a Skeleton inside an
element that pulses itself; the two animations multiply.
LoadingState and EmptyState (ui/loading-state.tsx, ui/empty-state.tsx)
A page, panel or dialog that is still loading shows LoadingState, never a
hand-centred Spinner. fill: parent (h-full, needs a parent with a
height: the artifact panel, a drawer body), screen (h-screen, the app and
admin guards) or block (py-10, a page section or dialog body with no
height of its own). label puts a muted caption under the ring (a long job:
Convert to wiki, Enable GraphRAG); it is also the ring's name. Spinners
inside a control (a busy Button, a picker's sm ring) stay Spinner.
Nothing to show is EmptyState: size default | sm | xs (128 / 96 / 64px
art, page / panel / popover), illustration no-files | none (a "no
results" line is size="xs" illustration="none"), title, description
(plain muted-foreground), action. A page or panel whose fetch failed is
EmptyState tone="destructive" illustration="none" with a Retry action
(t('retry')): a red CircleAlert, a red title, role="alert". Never a bare
text-destructive paragraph.
Progress (ui/progress.tsx)
value 0 to 100, variant: default, success, warning,
destructive, info; size: sm, default, lg. Replaces the
width-percent divs in quota, indexing and guardrail views.
Avatar (ui/avatar.tsx)
size: none (image decides, the default), xs (28px), sm (32px),
default (36px), lg (40px): Button's names at Button's heights;
shape: none, circle, square; variant: default, primary (brand
initials on secondary), muted (grey initials). Initials boxes pass the letters as
children.
Dropzone (ui/dropzone.tsx)
size: default (tall target), compact (one row, for forms and modals).
Props: onDrop, accept, multiple, maxFiles, maxSize, disabled,
title, description, icon, error. Border and fill follow the drag
state through data-drag-active and data-drag-reject (the /5 wash);
it hovers to solid accent. Every drop
target renders it, including components/FileUpload.tsx (which keeps its
preview and validation logic) and the Import agent / Import API
specification dialogs; don't hand-roll a border-2 border-dashed target
around useDropzone.
Toast (ui/toast.tsx)
Feedback on anything the user did outside a modal (uploads, runs,
approvals, team events, a page action's result) is a toast in the
bottom-right stack; a result inside an open modal is an Alert there (see
"Where a message lives"), since the toast stack paints under the modal's
overlay. The app has one
ToastViewport, mounted in App.tsx; it is the live region
(role="status", aria-live="polite") and the fixed stack, so Toast
cards carry no role and no toast renders its own rail or positioning. Top
to bottom it holds TeamNotificationToast, ToolApprovalToast,
UploadToast and ActionToast, and it moves to the bottom-left while the
workflow Preview drawer is open. A new toast component returns only its
Toast cards and is added to that viewport. A page that reports the result
of an action (the admin Users actions) dispatches
showActionToast({ variant: 'success' | 'destructive', message }) from
notifications/actionToastSlice.ts; ActionToast shows it and dismisses
it after 4.5s, and a new result replaces the previous one.
Compose Toast > ToastHeader variant (default, success, warning,
destructive, info) with ToastTitle and ToastActions (collapse and
close as Button variant="ghost-muted" size="icon-sm"), then
ToastContent (add scrollable for long lists) with ToastItem label meta
rows (optional icon before the label), a ToastStatus status circle per
row (pending, success, warning, destructive, info),
ToastMessage variant for an explanation and ToastFooter for action
buttons. ToastTitle truncates to one line; wrap lets a long title (a
localised string, a team name) wrap instead. ToastMessage size is xs
(default, the note under a row) or sm (text-sm leading-4.5, the whole
body of a notice). A message that is the only content under the header
gets its top padding on its own, and a message right after a ToastItem
shares that row's divider. Toasts own their width, radius and shadow; pass
no width or colour to them.
A clickable card that holds a link
A card that opens something and also holds a link (a source card under an
answer, with its URL) is a plain relative container: a <button type="button"> inside it covers the card with after:absolute after:inset-0
(the card's radius on after:), and the link is a sibling after it with
relative z-10, so both are real controls, one tab stop each, and neither
sits inside the other. The container draws the focus ring for the button
(has-[>button:focus-visible]:ring-3 …ring-ring/50). Never put a link inside
a role="button" or a <button>.
Where a message lives: FormField, Alert or Toast
- A message about one control is
FormField error(it also turns the floating label red). - A result the user must read before closing the modal (a failed share, a
failed import, a Test connection result) is an
Alertin the modal body. - A fire-and-forget result, or any result on a page rather than in a modal,
is a toast (
showActionToast). - A page or panel that failed to load its own content is
EmptyState tone="destructive"in place of that content, withRetry. - A notice the user must read before acting (an expiring token, a policy that
forces a setting, models without a price) is an
Alert; one that is only informative and should not be announced passesrole="note". - Status text of a sentence or more is an
Alertwith a lucide icon, never a coloured paragraph. A long notice about an old run inside a collapsed panel isAlert role="status", notalert. - An action's error on a full-page form (saving an agent) is an
Alert variant="destructive"above the form, not text in or beside the button. - A list of errors the user must read on a canvas or page (the workflow's
publish validation) is a destructive
Alertfloating atz-20on an opaquebg-card rounded-xl shadow-mdwrapper that stays until closed; a toast would truncate each error to one line and dismiss itself.
Alert (ui/alert.tsx)
Inline notice inside a form, modal or panel (see above). It is never a
hand-rolled rounded-lg bg-<role>/10 box.
variant: default, neutral (the same quiet box, by name: a guardrail
"not evaluated" outcome), success, warning, info, destructive. 14px
corners (rounded-xl), like a popover. Every
coloured variant is the same shape: border-<role>/50 bg-<role>/10 text-<role>; default sits on bg-background. Icon first (a lucide icon,
no classes: the Alert sizes it to 16px and colours it with the text), then
AlertTitle and AlertDescription. The icon has its own column and sits
centred on the text block, beside a single line, a wrapped paragraph or a
title with its description. Every variant is
role="alert" except success, which is role="status" so a confirmation
is announced politely; pass role only to override that. Replaces the hand-rolled
rounded-lg border bg-amber-50 text-amber-800 boxes.
A failed chat answer is an Alert variant="destructive" on the answer's
mr-5 ml-6 column: CircleAlert, the fixed title conversation.failedTitle,
and the backend's error (often a raw provider exception) as font-mono text-xs
detail in AlertDescription. Its action row is Retry (RotateCcw) and Copy,
both ghost-muted icon-sm pill like every other answer action.
The rows in an answer's step column (Sources, Reasoning, each tool step) are
one recipe: Button variant="ghost" size="sm" at ml-3.5 w-fit, which puts a
16px muted icon on the ml-6 text column, then muted 14px text and a chevron.
Sources adds its count and a right chevron, and opens the All sources sheet.
Breadcrumb (ui/breadcrumb.tsx)
BreadcrumbPage, the current crumb, is always one line and truncates with
an ellipsis. Pages pass only its width (w-[16ch], max-w-[32ch]) and a
title with the full text; never truncate or typography. Give every
current crumb a width cap so a long name cannot push the row past the
screen. A crumb that runs a handler instead of navigating is
BreadcrumbLink asChild around a <button type="button">; the link carries
the focus ring. The current crumb is never a disabled button. The path row above a
source's file tree and chunk viewer is components/tree/PathHeader
(back button, chevron crumbs, the last capped at max-w-[32ch], actions on
the right); don't hand-roll a /-separated path.
ListRow and DescriptionList (ui/list-row.tsx, ui/description-list.tsx)
An identity row (avatar or icon square, a truncating title, one muted meta
line, a trailing control) is ListRow inside ListRows (divide-y divide-border, no box of its own; wrap it in Card padding="none" or a
bordered list for one). Rows are px-4 py-3, the title text-sm font-medium. interactive (with asChild around a <Link> or <button>)
hovers to bg-accent and draws an inset focus ring. An icon square in
leading is a plain bg-muted text-muted-foreground size-8 rounded-md span.
Key/value rows are DescriptionList + DescriptionItem, never a hand-rolled
flex of label and value. layout="columns" (default) is an 8rem label
column beside the values (drawers, detail panels); layout="justified"
right-aligns the values (a phone card; columns={2} for a stats dialog).
size sm | xs; mono on an item for ids and URLs. Values wrap
(break-words), they never run past the column.
Pagination (ui/pagination.tsx)
The pager under a table, tile grid or list: "Page N of M" and four chevron
IconButtons. pageSize adds the Rows per page select; summary puts a
count on the left ("1,024 users"). labels="text" swaps the chevrons for
Previous / Next buttons. Don't hand-roll a Previous / Next row.
Page chrome: SectionShell, PageToolbar, SearchInput
Every section page (settings, admin, agents, teams) is wrapped in
navigation/SectionShell (width default 6xl, wide 7xl for admin,
narrow 5xl; pills for the phone destination pills); it draws the title and
starts the content 32px below it, so pages carry no root mt-*. The block
under the title is components/PageToolbar: intro, then the page search
(left, max-w-md) and the page action (right, Button size="field" shape="pill"), then divider (a Separator). A page search is
components/SearchInput: the 38px pill with a search icon and a floating
label. The chunk viewer's and file tree's searches stay CommandInput in
their frame, because their results are CommandItems.
Grids
Two recipes, no component. Tiles (sources, tools, custom models, agents):
grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4; tiles
take the column width, never a fixed w-[300px]. Team cards stop at three
columns (lg:grid-cols-3): their header row holds an initial, the name, a
role badge and a chevron. Stat rows: grid grid-cols-2 gap-4 md:grid-cols-4
(five tiles: md:grid-cols-3 lg:grid-cols-5; a dialog: grid-cols-3). A
grid inside a Modal keeps its own column counts, because breakpoints follow
the window, not the dialog.
Skeleton mirrors follow the grid they stand in for.
Accordion (ui/accordion.tsx)
AccordionTrigger carries its own inset and type (px-4 py-3 text-sm font-medium) and an inset focus ring, since AccordionItem clips anything
outside it. Pages pass nothing to the trigger; put the frame (a bordered,
rounded box) on a wrapper around Accordion.
Modal, not Dialog
ui/dialog.tsx is the Radix primitive and is private to ui/; ESLint
rejects imports of it elsewhere. App code uses Modal (sizes sm to
full, mobileVariant="sheet") or CommandDialog.
The width comes from size only: sm 384, md 512 (the default), lg 672
(a form or a one-column list: Move to folder, Upload), xl 896 (a grid of
tiles or a wide editor: Add tool, Test retrieval, the prompt editor), full.
Never add a width or height class. The dialog caps itself at 85dvh and
its body is the one scroller, so the header and footer stay put; don't cap
the body with contentClassName. contentClassName="overflow-visible"
(!overflow-visible) is only for a body whose popover must escape it (the
prompt editor, Share conversation, Move to folder). className is placement
only.
A modal's body is flex flex-col gap-5 when it stacks floating fields,
with gap-6 between labelled groups (a SectionHeader size="xs" and its
fields); FormField supplies the 6px to its hint. The body carries no padding:
Modal's scroll area already insets it (px-1 pt-3 pb-0.5) so focus rings
aren't clipped. Never space-y-* on a body.
A modal's heading is its title (20px, text-xl leading-tight font-semibold) and description (muted text-sm, 8px under the title).
Don't pass hideTitle to draw your own <h2>; hideTitle is only for
dialogs whose top line is not a title (Upload's step headings,
ScheduleFormModal's editable name, the search palette). A step heading
under a Back button uses the title's classes (text-xl leading-tight font-semibold), not a larger size.
Buttons go in footer, never in children. The footer stacks full width on
phones (primary on top) and sits in a right-aligned row from sm up. The
standard pair is footer={<ModalActions cancelLabel onCancel submitLabel onSubmit pending disabled destructive />}: a ghost Cancel and a primary
(or destructive) submit, both size="lg" shape="pill"; pending shows
the submit's loading spinner. A left-hand extra (Test connection) is
footerStart, and submitProps / cancelProps carry type="submit",
form or a test id. A lone button is a size="lg" shape="pill" Button.
A search palette is CommandDialog on desktop. Pass cmdk options to its
inner Command through commandProps (shouldFilter={false} when results
come from a server search, a controlled value). On phones the same palette
goes in Modal mobileVariant="sheet" as <Command variant="palette">, which
gives it the dialog's 48px input row and row spacing, so both widths look
the same (modals/SearchConversationsModal.tsx). CommandDialog sits on
DialogContent, which has Modal's surface (bg-card rounded-2xl shadow-modal, no border, p-8, a 20px title, a gap-3 footer, a left-aligned
header, and the flex-col max-h-[85dvh] column whose body the caller makes the
scroller) and the same blurred overlay. Modal, DialogContent and every Sheet share one scrim,
overlayScrim in lib/utils.ts (bg-black/25 backdrop-blur-xs dark:bg-black/50).
Every phone bottom sheet has one shape: bg-card, 16px top corners, no top
border, at most 90% of the viewport, and bottom padding that clears the iPhone
home indicator. SheetContent side="bottom" gives it with pb-safe-0 (the
bare inset); pass handle for the grab bar, which also hides the X (the handle and
the overlay dismiss it; pass showCloseButton to keep one).
Modal mobileVariant="sheet" shares the shape and the SheetHandle, with
pb-safe (the inset, at least 1rem) under its footer. pb-safe and
pb-safe-0 are the index.css utilities for env(safe-area-inset-bottom);
never spell env() in a class.
Open question: side panel or right sheet for chat content. Chat has two
ways to show something beside an answer. Notes, todos and files open in
components/ArtifactSidebar, a panel that takes a column and leaves the chat
usable. An answer's full source list opens in a right Sheet, which blurs
and blocks the chat, so the answer being checked is hidden while its sources
are read. No rule picks between them yet. The leading proposal is one
surface: content read alongside the chat (artifacts, sources, a cited
source) opens in the side panel, with citation chips opening it at that
source; overlays stay for tasks that interrupt (forms, confirmations,
pickers); phones keep the bottom sheet. Until that is decided, don't add a
third pattern: new "read beside the chat" content uses the side panel.
In a picker list, mark the item that is currently chosen with
CommandItem checked (a secondary brand tint through data-checked), not
with bg-accent: cmdk's own data-selected highlight is bg-accent and
follows the pointer and arrow keys, so an accent fill would look like hover.
While a checked item is also highlighted it keeps its tint and text and gains
a 1px inset primary ring, so the chosen row never turns plain grey.
ActionMenu (ui/dropdown-menu.tsx)
The three-dots menu on a card, tile or row. Pass options: MenuOption[]
(label, onClick, optional lucide icon, variant: 'destructive',
disabled) and a triggerLabel. It renders a ghost-on-accent icon-xs
trigger with EllipsisVertical, and stops clicks and keys on the trigger and
the menu from reaching the host, so it can sit inside a clickable card.
className takes layout only (position, margin) and lands on the trigger;
the menu is at least 144px wide and grows with its labels. open/onOpenChange make it controlled. Don't hand-build
this menu from DropdownMenu, and don't declare a local option type.
Table, Label, dialog text
Every table is ui/table; never a raw <table> or a table utility class (the
chat's markdown tables are the one exception). The header row is dense by
default (px-2 py-1 text-sm font-normal text-foreground, 28px, on
TableHead's sticky bg-muted strip). A TableRow hovers (bg-accent,
pointer) only when it has an onClick; a read-only row does not. Use
TableContainer for the bordered, scrolling frame; a table that already sits
in a frame renders Table alone. Table keeps a 600px minimum so wide
tables scroll sideways; a narrow one inside a panel passes
minWidth="min-w-0". A fixed icon column is width="50px" align="center".
TableCell and TableHeader accept typography and alignment classes and
text-muted-foreground (RunLog's headers pass the eyebrow). Label, DialogTitle, DialogDescription,
SheetTitle and SheetDescription accept typography plus
text-foreground and text-muted-foreground. DialogContent,
PopoverContent, SheetContent, SheetHeader, SheetFooter,
DropdownMenuContent, DropdownMenuSubContent, SelectContent,
TooltipContent and Modal accept p-0 for edge-to-edge content; Modal
merges it after its own p-8, so no ! is needed. A modal that needs a
full-bleed band under its title keeps the padding and bleeds the band out
with negative margin (Move to folder's breadcrumb band). Everything else on these components is layout only.
MessageScrollerViewport and MessageScrollerContent accept layout and
spacing: the primitive measures Content's padding-block for its scroll and
spacer math, and the Viewport's top gap must scroll with the messages, so
padding there cannot move to a margin or a wrapper.
Spacing, type and radii
Use the Tailwind scale. Tailwind v4 accepts fractional steps, so
h-10.5 is 42px and ring-3 is a 3px ring; neither needs brackets. Layout
values (h-[calc(100dvh-64px)], max-w-[520px]) and motion values
(transition-[color,box-shadow], custom easings) are allowed. Font sizes,
padding, colours and radii in brackets are not; pick the nearest scale step
or add a token.
Typography roles
- Page title:
SectionPageHeader(24px bold); the only other text at that size is a stat figure (StatCard). - Title of a dialog, sheet, drawer header or detail page:
text-xl leading-tight font-semibold.DialogTitleandSheetTitledefault to it;font-boldis never a title weight. A picker popover's header is a sub-heading, not a title. - Section title:
SectionHeader(18px). Sub-heading inside a panel, drawer or modal:SectionHeader size="xs"(14px semibold). Eyebrow (anything set in caps):SectionHeader size="sm"; neveruppercaseon a value (a variable name, a mode, an agent type). - Tile text:
CardTitle+CardDescription size="xs"(see Card). - Hint under a control: FormField's
hint(12px muted, 6px under the field); a control with no FormField writes the same line astext-muted-foreground mt-1.5 text-xs. Hints are muted, never a status colour; a warning that needs attention is anAlert. Description under a heading:text-sm text-muted-foreground. - Markdown: every renderer spreads
markdownHeadingsfromlib/markdown.tsx(h1 20px, h2 18px, h3 16px, semibold,mt-4|3 mb-2); don't declare a local heading map. - Mono: ids, keys and code snippets are
font-mono text-xs; code fields (textareas) follow the field size; code blocks follow the Card recipe (see Card). A preview of text a person or a model wrote (a trace's query or output) stays proportional at the same size; only what the app serialised (arguments, results, attributes) is mono. Every stat figure istabular-nums.
Rhythm
32px between the page title and the content (SectionShell); 24px (gap-6)
between sections inside a panel or drawer; 20px (gap-5) between floating
fields; 8px (gap-2) in a button row, 12px (gap-3) in a modal footer. A
two-up field grid is grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2. Stack
with flex flex-col gap-*, not space-y-* (a child's own margin adds to a
gap, so drop it).
Radius by role
rounded-xs 2px, rounded-sm 6px, rounded-md 8px, rounded-lg 10px,
rounded-xl 14px, rounded-2xl 18px (from --radius); bare rounded is a
fixed 4px, so don't use it. Skeleton bars: the default. Tinted icon squares:
rounded-md at size-7|8, rounded-xl at size-12|14, rounded-2xl at
size-20. rounded-full only for avatars, status dots and the workflow
palette pills.
Motion
Transition only the property that changes: transition-colors by default,
transition-transform duration-200 for chevrons,
transition-[grid-template-rows,opacity] duration-300 ease-out for
collapsibles, duration-300 ease-in-out on the named property for the shell
(sidebar, main column, top buttons); a shadow or ring change is
transition-shadow, several at once a bare transition. No transition-all
(the floating labels in form-field.tsx and input.tsx, which move and
resize, are the exception) and no hover:scale: hover is a fill, border or
text-colour change.
New entrances are animate-in fade-in duration-200 motion-reduce:animate-none
(SectionRail). Two chat timings are decided exceptions: the disclosure fade
animate-in fade-in duration-160 ease-out and the answer bubble animate-in fade-in slide-in-from-bottom-1.5 duration-260 ease-out, both with
motion-reduce:animate-none. ui/ overlays keep their own enter and exit.
Breakpoints
Phone / desktop is lg (1024px) in classes and isDesktop in JS
(useMediaQuery); sm and md only reflow content. Wide two-column layouts
that need more room (Analytics' chart rows, the agent form beside its preview)
go two-up at xl. No custom breakpoints (min-[…], max-[…],
[@media(…)]). The workflow builder needs lg; below it MobileBlocker
shows.
Focus, elevation and stacking
- Focus ring:
focus-visible:ring-3 focus-visible:ring-ring/50plusfocus-visible:border-ringon fields. Neverfocus:(mouse users see it) and neverring-2orring-[3px]; theui/components already carry it, so plain elements should become components rather than copy the classes. Insideui/, spell it with thefocusRingconstant fromlib/utils.ts(withinvalidStateandfieldFramefor fields) rather than retyping it. - Close buttons on Modal, Sheet and DialogContent are a
ghost-mutedsize="icon-sm"Button (32px, accent square on hover) attop-2 right-2. - Disabled: buttons (Button, Accordion, Tabs) use
disabled:pointer-events-none disabled:opacity-50, so the pointer passes through. Fields (Input, SelectTrigger, Switch, Textarea, CommandInput, Label) usedisabled:cursor-not-allowed disabled:opacity-50and neverpointer-events-none, which would hide the cursor. - Elevation: cards have no shadow; fields and outline buttons
shadow-xs; popovers, menus and select listsshadow-md; sheetsshadow-lg; modals (Modal and DialogContent)shadow-modal; toastsshadow-toast. The one exception inui/is the Switch thumb,bg-white shadow-lgin both themes: the knob is white on any track, and a white knob on a white card needs the lift to read as raised. The off track isbg-input. Accordions are panels and have none. Outsideui/the only shadows are: the workflow canvas nodes (shadow-md,hover:shadow-lg: a node lifts off the canvas while you drag it); the workflow builder's node-settings panel and its publish-error panel, which float over the canvas at popover elevation (shadow-md,z-20); the chunk viewer's and file tree's search result lists (shadow-md, in-pagez-20, their results areCommandItems inside the search frame); and the Hero model picker's menu (an Approved exception). Don't add new ones: a floating panel is aPopover,DropdownMenuorModal, which carry their own shadow and stacking; never anabsolutediv with a shadow and a hand-pickedz-*. - Stacking:
z-10sticky headers and table heads inside a page;z-20in-page floating chrome (banners, scroll-to-bottom, drag handles);z-50overlays, modals, sheets and toasts;z-200every portalled floating list (popover, menu, select, tooltip), so it opens above a Modal without an override. Do not invent values in between.
Inline styles
Allowed without comment in src/components/MermaidRenderer.tsx and
src/agents/workflow/nodes/** (third-party renderers and canvas
positions). Elsewhere, prefer a CSS custom property
(style={{ '--progress': pct }} with w-(--progress)), and when a value
truly is computed at runtime, keep the inline style and add
// eslint-disable-next-line shadcn/no-inline-styles -- <reason>.
Approved exceptions
Places where a rule is knowingly disabled. Each one carries the same reason as a comment at the call site; add a row here when you add a disable so the list stays reviewable.
| Where | Rule | Why |
|---|---|---|
components/Notification.tsx |
shadcn/no-restyle |
The promo banner's close X (ghost icon-xs) sits on the purple gradient. Any fill would be a grey square on it, so hover dims the icon instead (hover:bg-transparent hover:opacity-70, plus dark:hover:bg-transparent to beat ghost's dark hover), and text-primary-foreground keeps it white, because the button inherits the page colour. One user, so it isn't a variant. |
components/ui/calendar.tsx |
shadcn/require-static-classes |
The day button merges defaultClassNames.day, which react-day-picker returns from getDefaultClassNames() at runtime; no static form exists. |
navigation/SidebarLevel.tsx |
shadcn/no-arbitrary-values |
The incoming sidebar panel casts a strong shadow off its left edge while it slides (shadow-[-12px_0_24px_-6px_rgba(0,0,0,0.45)]); no scale shadow is horizontal, and the container clips it once the panel comes to rest. |
components/MessageInput.tsx |
shadcn/no-restyle |
The empty composer's send button is a grey circle (bg-muted, dark:bg-accent), not a faded brand one; no variant is neutral while disabled, and secondary is the brand-tinted pressed state. |
Hero.tsx |
shadcn/no-restyle |
The landing page's model picker keeps its hero look: a borderless muted pill at 16px (rounded-4xl px-6 py-4 text-base) whose menu hangs from it as one shape. Three disables: SelectTrigger, SelectContent, SelectItem. |
agents/AgentsList.tsx |
shadcn/no-restyle |
Inside a folder, the breadcrumb trail replaces the section <h2>, so its BreadcrumbList keeps heading typography (text-foreground text-lg font-semibold gap-2). It's the only breadcrumb that does. |
conversation/MarkdownAnswer.tsx, components/ArtifactSidebar.tsx |
shadcn/no-inline-styles |
SyntaxHighlighter's style prop is its Prism theme object (oneLight / vscDarkPlus), picked by theme at runtime. It is not CSS, so no class or custom property can replace it. One disable per file. |
agents/workflow/WorkflowPreview.tsx |
shadcn/no-restyle |
The Preview minimap's node rows are status tiles: the fill, border and ring follow the step (success, primary running + pulse, destructive, muted pending; a ring on the active row) and stay pinned on hover, pending and running rows stay unfaded while disabled, and clickable rows dim to 80% on hover. No Button variant is status-tinted. The rule reports each string inside cn(...), so it's a /* eslint-disable */ … /* eslint-enable */ pair around the className attribute. |
agents/schedules/ScheduleFormModal.tsx |
shadcn/no-restyle |
The schedule's name is the dialog's editable title: a bare Input with title type (text-xl font-semibold), so the dialog passes hideTitle. One disable. |
components/MermaidRenderer.tsx |
shadcn/no-restyle |
The zoom − / + buttons sit on the diagram's bg-black/70 overlay, where ghost's accent hover paints a light square with dark text; they hover to white/20 with white text instead, in both themes. Two disables. |
Hero.tsx |
shadcn/no-restyle |
The landing page's demo cards are Button outline lg pill, but each is a two-line pill (a title over a clamped 12px query), so it undoes lg's height, the base's one-row layout, weight and nowrap: h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal. One disable (phase 7, 36a). |
Navigation.tsx, conversation/ConversationTile.tsx |
shadcn/no-restyle |
A sidebar row whose link has sibling buttons (an agent's pin, a conversation's menu and rename Save / Cancel) keeps its fill while the pointer is on a sibling or the menu is open (group-hover:bg-sidebar-accent, bg-sidebar-accent), and pr-10 keeps the label clear of the buttons. ConversationTile's cn(...) needs a /* eslint-disable */ … /* eslint-enable */ pair. |
admin/Usage.tsx |
shadcn/no-restyle |
The Top users id is a link inline Button inside a mono table cell; it keeps the cell's type and wraps (font-mono text-xs font-normal whitespace-normal text-left). One disable. |
agents/workflow/WorkflowBuilder.tsx |
shadcn/no-restyle |
The "Learn more" links in the Set state and Condition nodes' 12px hints keep the sentence's size and weight (text-xs font-normal on link inline). Two disables. |
components/MessageInput.tsx |
shadcn/no-restyle |
The queued-send Cancel is a link inline inside the composer's 12px status line, so it takes the line's size (text-xs). One disable, beside the send button's. |
settings/PersonalAccessTokens.tsx |
shadcn/no-restyle |
Token scope chips are identifiers, so the neutral Badge is set in mono (font-mono). One disable. |
settings/traces/TraceChips.tsx |
shadcn/no-restyle |
Trace stat chips (durations, counts) use tabular figures so they don't jitter between rows (tabular-nums on Badge). One disable. |
modals/MCPServerModal.tsx |
shadcn/no-restyle |
The authorization link inside the test-result Alert keeps the Alert's status colour (text-current on link inline). One disable (phase 11, 63g). |
components/MermaidRenderer.tsx |
shadcn/no-restyle |
The zoom readout between − and + is a link inline Button on the bg-black/70 overlay; it keeps the overlay's white 12px regular text (text-xs font-normal text-current). One disable, beside the two zoom-button ones above (phase 11, 63g). |
conversation/SharedConversation.tsx |
shadcn/no-restyle |
The "DocsGPT" link sits in the /share/:id page's regular-weight byline (font-normal). One disable (phase 11, 63g). |
conversation/ConversationBubble.tsx |
shadcn/no-restyle |
A source card's URL row is a link inline around an <a>: foreground at rest, primary on hover, regular weight, truncating (text-current font-normal hover:text-primary underline-offset-2 max-w-full justify-start). One disable (phase 11, 63g). |
admin/Overview.tsx |
shadcn/no-restyle |
"View in Audit" under the denied-sign-ins tile keeps the tile's destructive tone at hint size (text-destructive text-xs font-normal). One disable (phase 11, 63g). |
settings/PairDeviceModal.tsx |
shadcn/no-restyle |
The install link in Pair a remote machine sits in a 12px hint (text-xs font-normal, self-start in its column). One disable (phase 11, 63g). |
agents/workflow/WorkflowBuilder.tsx |
shadcn/no-restyle |
The publish-error Alert floats over the canvas with its close button in the top-right corner, so it pads pr-10 to keep a long title clear of the button. One disable. |
Note that a multi-line reason has to be a /* ... */ block comment;
consecutive // lines only disable the next comment line, not the code.
Removed classes
The lint reports classes Tailwind cannot generate; no-unknown-classes is at
error, so none remain. For the record, they were bugs, not missing
configuration:
xs:has never been a breakpoint here (not in the old Tailwind v3 config either), soxs:px-3,xs:text-xsnever applied. Remove them rather than declaring the breakpoint, which would change layouts that were never seen.text-s,dark:text-gray,w-inheritare typos fortext-sm,dark:text-gray-*andw-[inherit].agents-container,conversations-container,loaderandmermaidare unused hooks; nothing selects them. Before deleting a class like these, grep for it inquerySelectorandclassListcalls too.starwas defined at runtime by a<style>element incomponents/Notification.tsx; it is now thenotification-starutility insrc/index.css.msc-spacerwas a lookup hook forConversationMessages'querySelector; the lookup now uses the primitive's[data-message-scroller-spacer]attribute.
Live style guide
npm run dev, then open /design. The page (src/design/DesignSystem.tsx)
renders every ui/ component with the variants above, shows the resolved
value of each colour token and has a theme toggle. It is registered only
when Vite runs in development mode, so it never ships. When you add a
variant or token, add it there too.
Cleanup playbook
Every shadcn/* rule is at error, and lint-staged runs the lint on
staged files, so a violation blocks the commit. Work one file at a time and
keep every step green.
- See the state.
npm run lint:designprints warnings by rule and the worst files.npm run lint:design -- --file src/settings/Teams.tsxlists every warning in one file with the fix the message proposes;-- --rule no-raw-colorslists files for one rule. - Pick a file, read it whole, then fix all of its design warnings in
one pass. Map raw colours to tokens (table above), replace hand-rolled
boxes, pills, tiles, spinners and dropzones with the
ui/components, and move Button/Input/Select overrides ontovariant,size,shape. Delete thedark:twin of every class you tokenise. Remove classes the lint calls unknown (xs:*,text-s,dark:text-gray, unused hooks). - Do not restyle
ui/components from a page. If a treatment is used in more than one place and no variant fits, add the variant in the component file, add it tosrc/design/DesignSystem.tsx, and document it here. A one-off gets// eslint-disable-next-line shadcn/<rule> -- reason. - Keep behaviour identical. Same DOM order, same handlers, same
translations (
t(...)keys stay). Only classes and component choice change. Prettier with the Tailwind plugin reorders classes; runnpm run lint-fixrather than fighting it. - Verify per file:
npx eslint <file>(zeroshadcn/*warnings),npx tsc --noEmit -p tsconfig.json,npm test, and look at the screen in the running app (npm run dev, then the page that renders the file) in both themes.
Known traps:
npm run lintalso reports 6 pre-existing Prettier errors (union-type line breaks incronBuilder.ts,types/schedule.ts,types/workflow.ts,api/client.ts,models/misc.ts,models/types.ts) and about 330 TypeScript warnings unrelated to design;npm run lint-fixclears the Prettier ones.- Port 5173 may be held by another checkout; check the Vite banner shows this repo's path before trusting what you see.
components/SkeletonLoader.tsxrendersSkeletonin its table, logs, default and files layouts and its four tile mirrors (Card + bars). Its analysis, dropdown, chunk-card and connected-state layouts still pulse their own surface; move them onto Card +Skeletonthe same way when you touch them. (agents/schedules/StatusBadge.tsxis a status→variant map overBadge, not a wrapper; keep it.)
Working with the lint
cd frontend && npm run lint
All shadcn/* rules are error. Add a variant or token only when a treatment
is used in more than one place and none of the existing ones fits; a single
special case gets a disable comment with a reason.