This commit is contained in:
Pavel committed 2026-09-26 12:18:05 +04:00
1 parent 0dd379c271
commit f66dc50eed
289 files changed
+17148 -8081

No files matched your search

+576 -118
View File
@@ -14,7 +14,13 @@ 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.
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
@@ -30,7 +36,7 @@ through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and
| `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, focus rings | `border-gray-200/300`, `dark:border-gray-600/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` |
@@ -48,6 +54,12 @@ 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.
@@ -60,6 +72,27 @@ 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-<role>/10` in both themes (Badge, Alert, ToastHeader,
a danger-zone panel); no `dark:` twin.
- Status border: `border-<role>/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: `<Badge variant="success">`. Status box: `<Alert variant="warning">`.
@@ -76,7 +109,17 @@ Patterns:
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'}`.
`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.
@@ -84,6 +127,10 @@ Patterns:
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
@@ -109,13 +156,39 @@ Patterns:
`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 `<img>` for a glyph lucide has.
`strokeWidth`, 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-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) is
`variant="link" size="inline"`: no height or padding, so it wraps with the
sentence. Standalone links (toggles, crumbs) keep a normal size.
- 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 `<a>`: 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 `<a>` 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
@@ -125,16 +198,24 @@ Patterns:
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"`: 42px.
`Input` and `SelectTrigger` take `size="field"` too (the same 42px as
Input `default` and SelectTrigger `lg`), so a form column has one name for
one height; prefer `field` in new form rows. With `shape="pill"` its text starts 21px in, like
the Input and Select pills beside it. The agent form's pickers are
- 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 `<Link>` for navigation rows.
with `asChild` around a `<Link>` 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
@@ -165,7 +246,10 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
- 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").
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
@@ -173,25 +257,88 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
"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 spinning mark where its
icon was: Sync uses `Spinner size="sm"` at icon size (`size-4`); Voice still
spins lucide's `LoaderCircle`.
"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) |
| `padding` | `none`, `sm` (p-3, stat tiles and panels), `default` (p-4), `lg` (p-6) |
| `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`. Replaces every hand-rolled
`rounded-(md|lg|xl|2xl|3xl|4xl) border bg-(card|muted) p-*` box: source and
tool tiles, team and agent cards, analytics stat tiles and chart panels,
source-type and agent-type picker tiles.
`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 row `default`), `interactive` only when a click navigates (a
draft agent is not). The Add tool picker tiles sit on the modal's card, so
they are `outline interactive lg` with `asChild` around a `<button>`.
- Chart panels are `subtle lg` with fixed heights passed as layout; a chart
panel inside a modal is `outline` (the modal is already `bg-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 is `SectionHeader
tone="destructive"`. Muted text fails AA on the red fill, so the tone turns
every `text-muted-foreground` inside it to `foreground`; don't pass a
lighter colour back. Icon buttons inside a destructive-tone row are
`ghost-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`)
@@ -209,30 +356,36 @@ modal) leave it out and stay plain buttons. Only layout classes on it.
`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. HTTP method pills take their variant from
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 42px when the chips
wrap.
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-10.5, the historical 42px), `lg` (h-12), `field` (h-10.5, the form-row name) |
| `shape` | `default`, `pill` |
| `variant` | `default`, `bare` (no border, padding, radius, shadow or ring), `filled` (card fill) |
| 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. A floating
`label` sits on the field's border, so its background must match the surface
behind the field: `labelSurface` is `card` (default, forms in cards and
modals), `background` (fields straight on a page) or `muted` (fields on a
muted panel). Never pass a background class for it. The chat and hero
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 (42px, the form-row height) pads `px-5` like the large
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
@@ -245,11 +398,16 @@ instead of showing the panel through; never pass `bg-card` for it.
### SelectTrigger (`ui/select.tsx`)
`size`: `sm` (32px), `default` (36px), `lg` (42px), `field` (42px, the
form-row name); `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. The large rounded selects
(`rounded-3xl px-5 py-3`) are `size="lg" shape="pill"`.
`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`)
@@ -263,23 +421,39 @@ variable highlighter and the chunk editor behind its line-number gutter.
### FormField (`ui/form-field.tsx`)
A labelled field is `<FormField label required hint error disabled>` with
the field as its only child. It stacks `Label`, the field, a muted `text-xs`
hint and a red `text-xs` error (`role="alert"`) 6px apart (`gap-1.5`), puts
the required star 4px after the label (`aria-hidden`; the field gets
`aria-required`, not native `required`), and dims the label while
`disabled`. `Input`, `Textarea`, `SelectTrigger` (even nested in `Select`),
`MultiSelect` and `Checkbox` read their `id`, `aria-invalid`, `aria-describedby`,
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 (an "Add ref" box under a picker) needs its own `id`, or it
takes the field's. Popovers reset the wiring (`FormFieldBoundary`), so a
picker's search box is safe. There is one size: labels are `text-sm`
everywhere, also in dense panels (Guardrails). `className` is layout only.
Floating-label Inputs (`<Input label>`) label themselves and don't go in a
FormField. Never hand-build a `<label className="mb-2 block …">` or a
`div.flex-col` + `Label` + `<p>` stack.
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`)
@@ -303,19 +477,79 @@ native boxes take the OS accent and ignore dark mode. `size`: `default`
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`)
### Tooltip (`ui/tooltip.tsx`) and IconButton (`ui/icon-button.tsx`)
`Tooltip` + `TooltipTrigger asChild` + `TooltipContent side`. Each tooltip
carries its own provider. Use it on every icon-only button and wherever a
`title=` attribute was standing in for a hint; keep `aria-label` on the
button as well.
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 `Button`s 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="sm | default | lg"` draws in `currentColor`, so colour it
with a `text-*` token on it or its parent. `Skeleton` is a pulsing muted
block sized with layout classes and rounded as needed. Never put one inside
an element that pulses itself; the two animations multiply.
`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`)
@@ -325,9 +559,10 @@ width-percent divs in quota, indexing and guardrail views.
### Avatar (`ui/avatar.tsx`)
`size`: `none` (image decides, the default), `sm`, `default`, `lg`, `xl`;
`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), `muted` (grey initials). Initials boxes pass the letters as
initials on `secondary`), `muted` (grey initials). Initials boxes pass the letters as
children.
### Dropzone (`ui/dropzone.tsx`)
@@ -335,15 +570,20 @@ children.
`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`. Replaces every
hand-rolled `border-2 border-dashed rounded-3xl` target around
`useDropzone`; `components/FileUpload.tsx` keeps its preview and validation
logic and should render this underneath.
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 (uploads, runs, approvals, team events)
is a toast in the bottom-right stack, never an inline box. The app has one
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
@@ -371,15 +611,53 @@ 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 `Alert` in 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, with `Retry`.
- 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 passes `role="note"`.
- Status text of a sentence or more is an `Alert` with a lucide icon, never a
coloured paragraph. A long notice about an old run inside a collapsed panel
is `Alert role="status"`, not `alert`.
- 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 `Alert` floating at `z-20` on an opaque
`bg-card rounded-xl shadow-md` wrapper 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 or modal only, for something the user must
read before acting (an expiring token above the field, a warning in a
settings panel). Not for feedback on an action; that is a Toast.
`variant`: `default`, `success`, `warning`, `info`, `destructive`. Every
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>`, icon in the role colour; `default` sits on `bg-background`.
Icon first, then `AlertTitle` and `AlertDescription`. Every variant is
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.
@@ -392,7 +670,59 @@ an ellipsis. Pages pass only its width (`w-[16ch]`, `max-w-[32ch]`) and a
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 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
`IconButton`s. `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 `CommandItem`s.
### 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`)
@@ -407,12 +737,29 @@ rounded box) on a wrapper around `Accordion`.
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 (MoveToFolderModal's breadcrumb,
Upload's step headings, ScheduleFormModal's editable name, the search
palette).
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
@@ -430,13 +777,17 @@ 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) and the same blurred overlay.
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, and `showCloseButton={false}`
when the handle is the only dismiss affordance besides the overlay.
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)`;
@@ -462,13 +813,25 @@ 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`. `Label`, `DialogTitle`, `DialogDescription`,
`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`, `DropdownMenuContent` and `Modal` accept
`p-0` for edge-to-edge content (MoveToFolderModal's banded folder picker);
Modal merges it after its own `p-8`, so no `!` is needed. Everything else on these components is layout only.
`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
@@ -483,6 +846,79 @@ values (`h-[calc(100dvh-64px)]`, `max-w-[520px]`) and motion values
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`. `DialogTitle` and `SheetTitle` default to it;
`font-bold` is 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"`; never `uppercase` on 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 as
`text-muted-foreground mt-1.5 text-xs`. Hints are muted, never a status
colour; a warning that needs attention is an `Alert`. **Description** under
a heading: `text-sm text-muted-foreground`.
- **Markdown**: every renderer spreads `markdownHeadings` from
`lib/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 is
`tabular-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/50` plus
@@ -501,14 +937,25 @@ or add a token.
- **Elevation**: cards have no shadow; fields and outline buttons
`shadow-xs`; popovers, menus and select lists `shadow-md`; sheets
`shadow-lg`; modals (Modal and DialogContent) `shadow-modal`; toasts
`shadow-toast`. The one exception is the Switch thumb, `shadow-lg`: a white
knob on a white card needs the lift to read as raised. Nothing else uses
a shadow class; accordions are panels and have none.
`shadow-toast`. The one exception in `ui/` is the Switch thumb, `bg-white
shadow-lg` in 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 is
`bg-input`. Accordions are
panels and have none. Outside `ui/` 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-page `z-20`, their results are `CommandItem`s inside
the search frame); and the Hero model picker's menu (an Approved
exception). Don't add new ones: a floating panel is a `Popover`,
`DropdownMenu` or `Modal`, which carry their own shadow and stacking; never
an `absolute` div with a shadow and a hand-picked `z-*`.
- **Stacking**: `z-10` sticky headers and table heads inside a page;
`z-20` in-page floating chrome (banners, scroll-to-bottom, drag
handles); `z-50` everything portalled (overlay, modal, sheet, popover,
menu, tooltip); `z-200` only for select lists that must open above a
modal. Do not invent values in between.
handles); `z-50` overlays, modals, sheets and toasts; `z-200` every
portalled floating list (popover, menu, select, tooltip), so it opens above
a Modal without an override. Do not invent values in between.
## Inline styles
@@ -536,15 +983,30 @@ list stays reviewable.
| `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 (`dark:hover:bg-white/20` too, to beat ghost's dark hover). Two disables. |
| `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.
## Known dead classes
## Removed classes
The lint reports classes Tailwind cannot generate. In this codebase they are
bugs, not missing configuration:
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), so `xs:px-3`, `xs:text-xs` never applied. Remove them rather than
@@ -569,8 +1031,9 @@ variant or token, add it there too.
## Cleanup playbook
The design lint is at `warn` while the app is migrated. Work one file at a
time, smallest first, and keep every step green.
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.
1. **See the state.** `npm run lint:design` prints warnings by rule and the
worst files. `npm run lint:design -- --file src/settings/Teams.tsx` lists
@@ -594,23 +1057,22 @@ time, smallest first, and keep every step green.
`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.
6. **Promote rules** in `eslint.config.js` from `warn` to `error` when
`npm run lint:design -- --rule <rule>` reports zero, in this order:
`no-unknown-classes`, `require-static-classes`, `no-inline-styles`,
`no-arbitrary-values`, `no-raw-colors`, `no-restyle`.
Known traps:
- `npm run lint` also reports 187 pre-existing Prettier errors in nine
files unrelated to design; `npm run lint-fix` clears them.
- `npm run lint` also reports 6 pre-existing Prettier errors (union-type
line breaks in `cronBuilder.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-fix` clears 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.tsx` renders `Skeleton` in its table, logs,
default and files layouts. Its analysis, dropdown, chunk-card and
connected-state layouts, and the four tile mirrors, pulse their own
surface, so they wait for the Card work. `components/FileUpload.tsx`
keeps its logic but should render `Dropzone`. (`agents/schedules/StatusBadge.tsx`
is a status→variant map over `Badge`, not a wrapper; keep it.)
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 + `Skeleton` the same way when you touch
them. (`agents/schedules/StatusBadge.tsx` is a status→variant map over
`Badge`, not a wrapper; keep it.)
## Working with the lint
@@ -618,10 +1080,6 @@ Known traps:
cd frontend && npm run lint
```
All `shadcn/*` rules are `warn` while the backlog is cleaned up. Promote a
rule to `error` in `eslint.config.js` once `npm run lint` reports zero
warnings for it; the intended order is `no-unknown-classes`,
`require-static-classes`, `no-inline-styles`, `no-arbitrary-values`,
`no-raw-colors`, `no-restyle`. Add a variant or token only when a treatment
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.