Agent menu in new style

This commit is contained in:
Pavel committed 2026-09-27 19:45:05 +04:00
1 parent 20bcd92942
commit d4c97a49ce
102 files changed
+4476 -1580

No files matched your search

+308 -189
View File
@@ -1,8 +1,9 @@
# 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
`src/components/ui/`. `@shadcn/lint` and the `no-restricted-syntax`
selectors in `eslint/` enforce the rules below through `npm run lint`; their
messages point here. Everything in this file is either a
token in `src/index.css` or a variant in a `ui/` component, so an agent or a
contributor can always find the concrete thing to use.
@@ -36,7 +37,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 and field borders (fields use `border-border`), focus rings | `border-gray-200/300`, `dark:border-gray-600/700` |
| `border`, `input`, `ring` | dividers, field borders (fields use `border-input`, `fieldFrame`), focus rings | `border-gray-200/300`, `dark:border-gray-600/700` |
| `primary`, `primary-foreground` | brand purple: primary actions, links, selected states | `bg-purple-*`, `text-purple-*`, `#7D54D1`, `#A076F6` |
| `secondary`, `secondary-foreground` | pressed and active toggles (a brand tint), secondary buttons, the question bubble | `bg-accent` on an active icon button |
| `destructive`, `destructive-foreground` | errors, failed states, dangerous actions | `text-red-*`, `bg-red-50/100`, `#B42318`, `#E60000` |
@@ -45,7 +46,7 @@ through `@theme inline`, so `bg-`, `text-`, `border-`, `ring-`, `fill-` and
| `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 | |
| `answer-bubble` | the source cards under an answer and the answer-side panels (AnswerFlow) | |
Status colours are tuned to stay vivid rather than turning brown or olive,
so their contrast is low. Against white in light mode, `destructive` and
@@ -82,7 +83,8 @@ One tint scale, by purpose:
`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`.
Avatar `primary`, the OptionCard icon square), never `bg-primary/10`
(enforced).
- Status soft fill: `bg-<role>/10` in both themes (Badge, Alert, ToastHeader,
a danger-zone panel); no `dark:` twin.
- Status border: `border-<role>/50`.
@@ -102,12 +104,11 @@ Patterns:
- Solid status fill: `bg-warning text-warning-foreground`.
- Guardrail outcomes: block `destructive`, flag `warning`, redact `info`,
not evaluated `neutral`.
- Status border: `border-destructive/50`.
- De-emphasised text: `text-muted-foreground`; go lighter with an opacity
modifier (`text-muted-foreground/70`) rather than a lighter grey.
- Text on the brand colour is `text-primary-foreground`, not `text-white`.
- `secondary` is a brand tint, not a grey: primary at 10% (light) or 15%
(dark) over whatever sits behind it, with `primary` text in light and a
- `secondary` is a brand tint, not a grey: primary at 10% (light) or the
lighter #976af3 at 15% (dark) over whatever sits behind it, with `primary` text in light and a
lighter purple (#b89cf8) in dark. So a pressed toggle (CopyButton's copied
state, text-to-speech while speaking) shows on card, background and muted
alike, and never reads as the neutral ghost hover. Use it as
@@ -142,7 +143,7 @@ Patterns:
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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) |
| `shape` | `default` (rounded-md), `pill` (rounded-full, wider padding at `default`, `lg` and `field`) |
- Round brand buttons (`rounded-3xl px-5`, `rounded-full px-6`): `shape="pill"`,
plus `size="lg"` if they were `px-6`.
@@ -159,9 +160,10 @@ Patterns:
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. 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.
exception is a tiny glyph inside a status circle or chip (the custom-model
test result and capability chips, the artifact and research step ticks,
`ToastStatus`'s four status icons), drawn at 2.5 to 3 so it stays legible
at 10 to 12px; raw progress-ring `<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
@@ -181,9 +183,9 @@ Patterns:
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`.
link (enforced).
- Icon-only buttons: see IconButton below. A `title` on a `Button` is
rejected (enforced).
- An action whose label doesn't fit beside a name on a phone (the shared
agent card's Edit, in a long locale) is two elements: an `IconButton`
with `sm:hidden` and the labelled `Button` with `hidden sm:inline-flex`.
@@ -196,7 +198,7 @@ Patterns:
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`
Sources) are `outline sm pill`. Their icons are `size-3.5 sm:size-4`
with no margin; the size's `gap-1.5` spaces them, and the label span keeps
`text-xs sm:text-sm` so the row still fits on a phone.
- Popover comboboxes (`role="combobox"` + `Command`) use `variant="combobox"`,
@@ -211,7 +213,28 @@ Patterns:
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`.
`combobox field pill`, each labelled by a `FormField` (the Prompt picker
through `Prompts titleAs="field"`), its Add button `outline-primary field
pill`, the only `outline-primary` on the agent pages themselves (the Access
details modal they open has its own).
- One variant and size per role on the agent pages (Overview, Logs,
Schedules, the workflow builder's toolbar): the page's primary action is
`default field pill` (Publish, Save, New schedule), a secondary action
`outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost
field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`)
at the end of the title row, through `SectionShell titleAction`: Access
details and Share with team on Overview, and Preview until the agent is
published (before that the preview only says "Publish to preview"). So the
row never holds more than three buttons and fits a phone, where the main
one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot
spans the stacked row below `sm`). The workflow builder's toolbar has no
title row and keeps its ⋯ at the row's end. The
agent's Delete is in Overview's danger zone; only the workflow builder's
toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is
`agents/components/AgentPageToolbar` (see Page chrome). A row's common
action is `outline sm pill` (a schedule's Run now or Resume) with its other
actions in the row's `ActionMenu`; a delete there is a `destructive` item
that opens the confirm modal.
- Rows in the navigation sidebar (`hover:bg-sidebar-accent … pl-3 gap-2.5
rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal
weight, `bg-sidebar-accent` on hover and while `aria-current="page"`. Use it
@@ -226,24 +249,30 @@ rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal
are `variant="link" size="sm"` with only `-ml-3 w-fit justify-start`;
a chevron, when the toggle has one, is a lucide `ChevronRight` (so it
takes the link colour) as the first child.
- Underline tabs (FilePicker's My Files / Shared with Me, the agent page
sub-nav) are `variant="tab"`: muted text on a transparent 2px bottom border,
square corners, no hover fill. Mark the current tab with `data-active`, which
gives it `foreground` text and a `primary` underline. Padding-free tabs (a
sub-nav with `gap-6`) add `size="inline"`, which keeps 4px above the line;
the row draws the 1px baseline, and `-mb-px` lays the underline over it.
Use them for a row of route links (the agent sub-nav: a `<nav>` whose
current link carries `aria-current="page"`). Tabs that switch a panel in
place are `ui/tabs` with `variant="underline"` on `TabsList` and each
`TabsTrigger`: the same pixels, plus `role="tablist"`/`"tab"`,
`aria-selected` and arrow-key focus; wrap the panel in `TabsContent`
(FilePicker's My Files / Shared with Me). The `default` variant is a pill
tab, unused in the app.
- Underline route tabs are `variant="tab"`: muted text on a transparent 2px
bottom border, square corners, no hover fill. Mark the current tab with
`data-active`, which gives it `foreground` text and a `primary` underline.
Padding-free tabs (a sub-nav with `gap-6`) add `size="inline"`, which keeps
4px above the line; the row draws the 1px baseline, and `-mb-px` lays the
underline over it. Its one use is `agents/AgentPageHeader.tsx`, the workflow
builder's fixed toolbar: a breadcrumb and the Overview / Logs / Schedules
tabs in a `<nav>`, the others `<Link>`s and the current one a `<span
aria-current="page">` with `data-active`. The
agent section pages switch with the section sidebar (the phone menu below
`lg`) and carry no pill row; the agent tile's ⋯ also opens Logs. Tabs that switch a panel in place are
`ui/tabs` with `variant="underline"` on `TabsList` and each `TabsTrigger`:
the same pixels, plus `role="tablist"`/`"tab"`, `aria-selected` and
arrow-key focus; wrap the panel in `TabsContent` (FilePicker's My Files /
Shared with Me, Schedules' Recurring / One-time in
`agents/schedules/SchedulesView.tsx`). The `default` variant 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-start` and `aria-expanded`: a lucide `ChevronRight` first
(`rotate-90` while open) in primary, then the foreground title, with a
primary underline on hover. The button draws no focus ring; the panel does,
primary underline on hover. Both call sites wrap the Button in an `<h2>`
(a button flattens heading children, so the heading goes outside) and put
the title in a `text-lg font-semibold` span. The button draws no focus ring; the panel does,
so keyboard focus outlines the whole panel. The panel is a `Card` (a place,
`subtle lg`), and every Card carries that ring
(`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`,
@@ -276,6 +305,7 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
| `tone` | `default`, `destructive` (the status soft fill and border, `border-destructive/50 bg-destructive/10`, over any variant) |
| `padding` | `none`, `sm` (p-3, row boxes and code blocks), `default` (p-4), `lg` (p-6, tiles, panels and stat tiles) |
| `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `<button>` or `<Link>` |
| `selected` | an `interactive` card that is the current choice: `border-primary` and the `/5` wash |
Parts: `CardHeader` (title left, `CardAction` top-right), `CardTitle`,
`CardDescription`, `CardContent`, `CardFooter` (meta row, sticks to the
@@ -291,19 +321,19 @@ question: is it a **thing** you open, move, share or delete as a whole, or a
**place** where you read or edit content? The fill marks objects; the border
marks structure.
| Role | Variant | Examples |
| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Thing (a tile) | `filled`, on a page or in a modal | agents, folders, sources, tools, custom models, teams, chunks, the shared agent card |
| Place (a panel) | `subtle` on the page or a right drawer, `outline` on a card (modal, bottom sheet) | the agent form's sections, chart panels, stat tiles, log tables, schedule rows, the agent preview |
| Choice (picker tile) | `outline interactive`, or `OptionCard` | the Add tool tiles; selection is drawn by the border, so the border stays |
| Well | `filled padding="sm"` | code, a token to copy, run output, inside a panel (see Code blocks below) |
| Role | Variant | Examples |
| -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Thing (a tile) | `filled`, on a page or in a modal | agents, folders, sources, tools, custom models, teams, chunks, the shared agent card |
| Place (a panel) | `subtle` on the page or a right drawer, `outline` on a card (modal, bottom sheet) | the agent form's sections, chart panels, stat tiles, log tables, schedule rows |
| Choice (picker tile) | `outline interactive`, or `OptionCard` | the Add tool tiles; selection is drawn by the border, so the border stays |
| Well | `filled padding="sm"` | code, a token to copy, run output, inside a panel (see Code blocks below) |
- Layers go page → panel (border) → well (fill), or page → tile (fill).
`subtle` and `outline` are one role on two surfaces; in light mode they
look the same.
- An item that you read inside, rather than open elsewhere, is a place: a
schedule is an item, but its row expands into a run table, so it is a
`subtle` panel. The tile look is for a summary you open.
schedule is an item, but its row (`agents/schedules/ScheduleRow`) expands
into a run table, so it is a `subtle` panel. The tile look is for a summary you open.
- **No fill on a fill.** Nothing on a `filled` tile repeats its muted fill,
or it disappears: no `bg-muted` strip or box, no filled Card inside it, and
Skeleton bars take `surface="muted"`. Put a meta line such as a token count
@@ -314,9 +344,8 @@ marks structure.
(`eslint/card-surfaces.js`).
- Never a page-sized `bg-muted` panel around a page's cards: panels sit on
the page background, as on Logs and Analytics.
- A field in a `subtle` panel sits on the page background, so its floating
label passes `labelSurface="background"`; in an `outline` panel it keeps
the default `card`.
- A field in a `subtle` panel passes `labelSurface="background"` (see
FormField).
Recipes by role:
@@ -325,13 +354,20 @@ Recipes by role:
choices: `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`).
- Form sections (the agent form's Meta, Source, Prompt, Advanced,
Guardrails) are `subtle lg`; a disclosure section's header is a
`section-toggle`, and Card draws its focus ring.
- Row boxes inside a form or panel (a guardrail check, a schedule row, the
schedule's timezone box, the discovered MCP tools) are `padding="sm"`, in
the panel variant of the surface they sit on (`subtle` in a page panel,
`outline` in a modal).
- Form sections (the agent form's Basics, Knowledge and behaviour, Model,
Advanced, Guardrails) are `subtle lg` with `gap-5`; Basics, Knowledge and
behaviour and Model are titled by a `SectionHeader`, Advanced and
Guardrails by a `section-toggle` (see Button). A disclosure panel's body
under its toggle is one `flex flex-col gap-5` wrapper, its rows carrying
no `mt-*`, so every section has 20px under its title. The destructive
danger-zone Card follows them.
- Row boxes inside a form or panel (a guardrail check, the schedule's
timezone box, the discovered MCP tools) are `padding="sm"`, in the panel
variant of the surface they sit on (`subtle` in a page panel, `outline` in
a modal).
- A schedule is `agents/schedules/ScheduleRow`: `subtle`, `padding="none"`,
with its run log flush under it; a chevron `IconButton ghost-muted
icon-xs` toggles the runs.
- 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
@@ -358,8 +394,10 @@ 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`.
`<pre>` takes the recipe with no Card, so boxes don't nest. A scroll cap
goes on an inner `<div className="scrollbar-overlay max-h-* overflow-y-auto">`
around the `<pre>`, inside the Card's padding, never on the Card: on the Card
the scrollbar runs into its rounded corners. Never `break-all` (enforced).
This recipe is for app output. A fenced code block in a markdown answer is
source code: it keeps its lines (`white-space: pre`, indentation intact) and
scrolls sideways inside its own bordered frame (see Chat answer column).
@@ -408,7 +446,8 @@ 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, trace chips and statuses,
pill, including schedule and run status pills (`agents/schedules/StatusBadge.tsx`
maps schedule and run statuses to Badge variants), 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
@@ -419,7 +458,22 @@ stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their
`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"`.
`Checkbox size="sm"`. Inside a Modal pass `modal`, or its popover can't
scroll and doesn't close on an outside click.
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.
A picker's footer (`MultiSelectPopover footer`) links to the page that manages
the list, as a `link inline` Button with a 12px `ArrowRight` (Go to Sources, Go
to Tools). A shortcut action (Upload new, an `outline-primary pill`) sits at the
right end of the same row: `flex flex-wrap items-center justify-between gap-3`,
so on a narrow sheet or in a long locale it wraps under the link rather than
taking a row of its own everywhere (`SourcesPopoverFooter`).
### Input (`ui/input.tsx`)
@@ -431,11 +485,8 @@ wrap, with SelectTrigger's turning chevron; each row in its list shows a
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
one-field `FormField`: the same floating label on the border and the same
`labelSurface` (see FormField). 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
@@ -475,9 +526,11 @@ variable highlighter and the chunk editor behind its line-number gutter.
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
as its only child. The label sits on the field's border (12px muted), and
`labelSurface` matches the surface behind the field so the notch hides the
line: `card` (the default: forms in cards, modals and `outline` panels),
`background` (fields straight on a page or in a `subtle` panel), `muted`
(fields on a muted panel). Never pass a background class for it. 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
@@ -526,13 +579,30 @@ 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`
`<Checkbox checked onCheckedChange>` (Radix), never `<input type="checkbox">`
(enforced): 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.
### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`)
`Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names
it. The track is `primary` when on and `bg-input` when off, with a white
thumb in both themes (see Elevation).
`TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes,
with a `value`/`onChange` pair in 24-hour `"HH:MM"`. `minuteStep` sets the
minute list's step (default 1), `ariaLabel` names the group and each select,
and `showIcon={false}` drops the leading clock. Never a native
`<input type="time">`, which ignores dark mode.
`Calendar` is react-day-picker styled with the theme tokens, its days `ghost`
Buttons; open it in a `Popover` from a field (ScheduleFormModal's date). Its
day button carries the one approved lint exception in the file (see Approved
exceptions).
### Tooltip (`ui/tooltip.tsx`) and IconButton (`ui/icon-button.tsx`)
Every icon-only button is an `IconButton`: `<IconButton label icon hint?
@@ -579,24 +649,32 @@ Replaces every `<hr>` and every `border-b` or `h-px` div that is only a line.
`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
use lucide `LoaderCircle`/`Loader2` or a hand-drawn SVG as a loader (the
`Loader`, `Loader2` and `LoaderCircle` imports are enforced). `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.
itself never pulses, only the bars do. A loader inside a panel that is
already on screen (a chart panel whose title shows while its data loads) is
bars only, shaped like the content; the panel is the Card. A loader for an
`Alert` or row box is an `outline sm` Card in a modal with bars inside. 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
height: the artifact panel, a drawer body), `screen` (`h-dvh`, 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
Convert to wiki, Enable GraphRAG); it is also the ring's name. `size` takes
the Spinner sizes (`default` unless set): `sm` inside a picker (the
MultiSelect popover's list), `lg` for a full panel (a remote device's
config). 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
@@ -618,20 +696,33 @@ width-percent divs in quota, indexing and guardrail views.
`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
initials on `secondary`), `muted` (a grey `bg-muted-foreground/15` box with
`foreground` initials). Initials boxes pass the letters as
children.
### Dropzone (`ui/dropzone.tsx`)
`size`: `default` (tall target), `compact` (one row, for forms and modals).
`size`: `default` (tall target, for a step whose whole job is the file: Upload
from device, Import agent, Import API specification), `compact` (one row, a
file field among other fields),
`tile` (a square beside a form's fields, the agent's avatar: 64px and
icon-only on a phone beside the Name field, 88px with a one-word title from
`sm` beside Name and Description; no description). `FileUpload size="tile"` fills the tile
with the picked image, or with `currentImage` (the saved one) until then.
Props: `onDrop`, `accept`, `multiple`, `maxFiles`, `maxSize`, `disabled`,
`title`, `description`, `icon`, `error`. Border and fill follow the drag
`title`, `description`, `icon`, `error`, `validator` (react-dropzone's
per-file check) and `children`, which replaces the icon, title and
description block. 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`.
around `useDropzone`. Picked files show under it as `ListRows` (name and
size) in an `outline` Card, and rejected files (too large, wrong type) as the
Dropzone's `error`, never dropped silently. The chat composer is the one other
`useDropzone` host: the whole composer is the drop area, with its own
overlay, whose prompt is a Title (see Typography roles).
### Toast (`ui/toast.tsx`)
@@ -644,8 +735,8 @@ overlay. The app has one
(`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
`UploadToast` and `ActionToast`, and it moves to the bottom-left while any
agent preview drawer is open (workflow or classic). 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
@@ -770,15 +861,42 @@ Previous / Next buttons. Don't hand-roll a Previous / Next row.
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
`narrow` 5xl; `title` replaces the section title; `titleAction` puts a page-level
control, such as a toolbar `ActionMenu`, at the title row's end;
`header={false}` drops the title for the phone section index, which draws
its own);
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
shape="pill"`), then `children` (notices that belong to the action, such as
a limit warning), then `divider` (a `Separator`). With no `search` the intro
moves into the row's left slot (`max-w-2xl`) beside the action. A page search
is `components/SearchInput`: the 38px pill (`size="sm"` for 32px) with a
search icon and a floating `label` on `labelSurface="background"` (its
default); a placeholder-only search is named by its placeholder. The chunk viewer's and file tree's searches stay `CommandInput` in
their frame, because their results are `CommandItem`s.
An agent's tabs (Overview, Logs, Schedules) share one header,
`agents/components/AgentPageToolbar` on top of `PageToolbar`, under
`SectionShell`'s title (the tab's name, or "New agent" through SectionShell's
`title` prop). Its props: `name` (the agent, first in a muted byline),
`status` (a Badge beside the name, Overview's Published / Draft), `meta` (the
tab's context after a middle dot, "Last used" on Logs and Schedules),
`actions` (the tab's buttons on the right, at most three; the page's ⋯ goes
in the title row, not here), `intro` (a line that replaces
the byline: the new-agent form) and `children` (notices between the row and
the rule, such as the failed-save `Alert`). With a status badge the meta
takes its own line on a phone. The row keeps the 38px field height when a
tab has no actions, so the rule lands in the same place on every tab.
`AgentPageHeader` remains only in the workflow builder's fixed toolbar. All
three tabs are `SectionShell` pages at the default width; the agent preview
is a drawer (see Modal, not Dialog). Overview groups its fields into three
`subtle lg` panels (Basics, Knowledge and behaviour, Model) with the two-up
field grid, then Advanced, Guardrails and the danger zone. Schedules shows a
`StatCard` row, Recurring / One-time as `ui/tabs variant="underline"`, and
each schedule as a `ScheduleRow` (see Card surfaces).
### App chrome: the phone top bar and New Chat
Below `lg` the top of every page is `navigation/MobileTopBar` (56px, `h-14`):
@@ -829,12 +947,15 @@ wrapper).
### Grids
Two recipes, no component. Tiles (sources, tools, custom models, agents):
Three 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
(five tiles: `md:grid-cols-3 lg:grid-cols-5`; a dialog: `grid-cols-3`). Text
tiles whose body is the content (chunks) follow their container, since the
same list renders beside a file-search column: `grid grid-cols-1 gap-4
sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]`. 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.
@@ -853,12 +974,12 @@ 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
(a form or a one-column list: Move to folder), `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
(`cn` drops the body's `overflow-y-auto` for it) is only for a body whose popover must escape it (the
prompt editor, Share conversation, Move to folder). `className` is placement
only.
@@ -894,23 +1015,31 @@ 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,
scroller) and the same blurred overlay. Modal, DialogContent, every Sheet and
the phone sidebar's backdrop (`Navigation.tsx`, at `z-20` so an open sidebar
dims the top bar, with SheetOverlay's `animate-in fade-in-0`) share one scrim,
`overlayScrim` in `lib/utils.ts` (`bg-black/25 backdrop-blur-xs
dark:bg-black/50`).
A right drawer (`SheetContent side="right"`) is on `bg-background`, like the
page: its content paints no surface of its own, and panels in it are
`subtle` (see Card surfaces). An agent is previewed in one shared drawer,
`agents/components/AgentPreviewSheet`, for workflow and classic agents alike:
`w-full sm:max-w-[600px] md:max-w-[700px] lg:max-w-[800px]`, a `SheetTitle`
`subtle` (see Card surfaces). Its width is `size`, by role, never a `w-*` or
`max-w-*` class (enforced): `default` (384px, 75% on a phone) for a companion
list read beside the chat (an answer's sources, the phone artifact sheet);
`detail` (576px, full width on a phone) for one record's fields (a schedule
run); `wide` (600 / 700 / 800px at `sm` / `md` / `lg`, full width on a phone)
for a working surface (a trace, an agent preview). An agent is previewed in
one shared drawer, `agents/components/AgentPreviewSheet`, for workflow and
classic agents alike: `size="wide"`, a `SheetTitle`
and a one-line `SheetDescription` (the agent's name) in a header padded
`pr-12` to clear the close X, an `info` Running badge while it answers, a
`Separator`, then the preview. The workflow preview's Execution details and
Artifacts rows are the answer's step-row recipe (see Alert), each step a
`subtle sm` panel with its output in a `filled sm` well.
Every phone bottom sheet has one shape: `bg-card`, 16px top corners, no top
border, `max-h-sheet` (the visible viewport less the top safe-area inset and a
Every phone bottom sheet has one shape: `bg-card`, 18px top corners (`rounded-t-2xl`), no top
border, `shadow-lg` (both `SheetContent side="bottom"` and Modal's phone
sheet), `max-h-sheet` (the visible viewport less the top safe-area inset and a
3rem strip, so the scrim above it can always be tapped to close), and bottom
padding that clears the iPhone home indicator. Never cap a sheet with `vh` (see
Viewport heights): a `90vh` sheet slides under Safari's URL bar and covers its
@@ -918,8 +1047,9 @@ scrim. `SheetContent side="bottom"` gives the
shape with `pb-safe-0` (the bare inset); pass `handle` for the grab bar, which
also hides the X (the overlay dismisses it; the handle is only a cue and doesn't
drag; pass `showCloseButton` to keep an X).
`Modal mobileVariant="sheet"` shares the shape and the `SheetHandle`, with
`pb-safe` (the inset, at least 1rem) under its footer. `pb-safe`, `pb-safe-0`
`Modal mobileVariant="sheet"` shares the shape and the `SheetHandle` (and,
like a handled sheet, has no X), with `pb-safe` (the inset, at least 1rem)
under its footer. `pb-safe`, `pb-safe-0`
and `max-h-sheet` are the `index.css` utilities for the safe-area insets; never
spell `env()` in a class.
@@ -933,35 +1063,14 @@ unmounts, and Safari samples the page colour again. `useDarkTheme` calls
`resetBottomTint()` and `resetTopTint()` (a 16px strip on the top edge) when the
theme changes. Anything else fixed to the bottom edge that can go away (a
drawer, a bottom banner) calls `resetBottomTint()` when it is removed. The sizes
and the one-frame life were measured on an iPhone: 4px at the bottom, 6px at
the top and a 16ms timer all failed, and a strip behind the content,
transparent or at `opacity-0` isn't sampled, so don't shrink or hide it.
and the one-frame life were measured on an iPhone (6px at the bottom, 16px at
the top), and a strip behind the content, transparent or at `opacity-0` isn't
sampled, so don't shrink or hide the strip.
**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.
A picker's footer (`MultiSelectPopover footer`) links to the page that manages
the list, as a `link inline` Button with a 12px `ArrowRight` (Go to Sources, Go
to Tools). A shortcut action (Upload new, an `outline-primary pill`) sits at the
right end of the same row: `flex flex-wrap items-center justify-between gap-3`,
so on a narrow sheet or in a long locale it wraps under the link rather than
taking a row of its own everywhere (`SourcesPopoverFooter`).
Content read alongside the chat (notes, todos, files) opens in
`components/ArtifactSidebar`: a split column beside the chat on desktop, a
right `Sheet` on phones. An answer's full source list is a right `Sheet`.
Don't add a third pattern.
### ActionMenu (`ui/dropdown-menu.tsx`)
@@ -970,23 +1079,30 @@ The three-dots menu on a card, tile or row. Pass `options: MenuOption[]`
`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;
In a page header or toolbar on a plain surface (the agent Overview's title
row, through `SectionShell titleAction`; the workflow builder's toolbar), pass
`size="toolbar"`: the trigger is a 36px `ghost-muted` icon button like the
others there, hovering to `accent`, with its tooltip below. The default
`row` trigger's darker hover is only for hosts that turn `accent` themselves.
`align` (default `end`) places the menu, `disabled` greys the trigger, and
`triggerTestId` puts a `data-testid` on it. `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
Every table is `ui/table`; never a raw `<table>` (enforced) or a table
utility class (the chat's markdown tables are the one exception, see Approved
exceptions). The header row is dense by
default (`px-2 py-1 lg:px-3 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"`.
`minWidth="min-w-0"`. A fixed icon column is `width="40px" align="center"`.
`TableCell` and `TableHeader` accept typography and alignment classes and
`text-muted-foreground` (RunLog's headers pass the eyebrow). `Label`, `DialogTitle`, `DialogDescription`,
`text-muted-foreground`. `Label`, `DialogTitle`, `DialogDescription`,
`SheetTitle` and `SheetDescription` accept typography plus
`text-foreground` and `text-muted-foreground`. `DialogContent`,
`PopoverContent`, `SheetContent`, `SheetHeader`, `SheetFooter`,
@@ -1019,8 +1135,8 @@ runs under the tab bar, and the app shell can be dragged and bounce. ESLint
(`no-restricted-syntax`) rejects both. Pick by what the height does:
- **`dvh`** (the visible viewport, follows the toolbars): the app shell
(`h-dvh`), full-screen states (`min-h-dvh`: 404, MobileBlocker, the `screen`
`LoadingState`), and caps on things that pop up over the page (a search
and the `screen` `LoadingState` (`h-dvh`), full-screen states (`min-h-dvh`:
404, MobileBlocker), and caps on things that pop up over the page (a search
dropdown's `max-h-[calc(100dvh-200px)]`, `Modal`'s `85dvh`).
- **`svh`** (the smallest viewport, steady): a fixed-size panel inside a page
that scrolls (the Logs panel's `h-[55svh]`, a list's `max-h-[45svh]`, the
@@ -1033,12 +1149,16 @@ runs under the tab bar, and the app shell can be dragged and bounce. ESLint
### Typography roles
- **Page title**: `SectionPageHeader` (24px bold); the only other text at
that size is a stat figure (`StatCard`).
- **Page title**: `SectionPageHeader` (24px bold; the phone section index,
`SectionIndexPage`, draws the same `h1`); 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.
sub-heading, not a title. The composer's drag-and-drop prompt is a Title
in `foreground`. The artifact panel's title (phone sheet and desktop split
column alike) is a sub-heading, `text-sm font-semibold wrap-anywhere`: the
panel sits beside the chat and its title is often a long generated name.
- **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
@@ -1064,7 +1184,7 @@ leading-tight font-semibold`. `DialogTitle` and `SheetTitle` default to it;
is `wrap-anywhere`, which also lets a table cell or flex item shrink below
the token instead of widening the table. Both break at spaces first and
split a token only when it would overflow. Never `break-all`: it splits
ordinary words mid-word ("notif / ications").
ordinary words mid-word ("notif / ications"); the lint rejects it.
### Rhythm
@@ -1078,11 +1198,14 @@ 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-xl` 14px, `rounded-2xl` 18px, `rounded-3xl` 22px, `rounded-4xl`
26px (from `--radius`); bare `rounded` is a fixed 4px, so don't use it
(enforced). `3xl` is the chat's own shapes (the composer, the question
bubble) and `4xl` the answer's source cards and the landing page's model
picker; app chrome stops at `2xl`. 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.
`size-20`. `rounded-full` only for pills (`shape="pill"` controls and
fields), avatars, status dots and the workflow palette pills.
## Motion
@@ -1093,8 +1216,8 @@ 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.
resize, are the exception) and no `hover:scale-*`, both enforced: 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
@@ -1106,16 +1229,16 @@ fade-in slide-in-from-bottom-1.5 duration-260 ease-out`, both with
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`
that need more room (Analytics' chart rows) go two-up at `xl`. No custom
breakpoints (`min-[…]`, `max-[…]`, `[@media(…)]`; enforced). 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
`focus-visible:border-ring` on fields. Never `focus:` (mouse users see it)
and never `ring-2` or `ring-[3px]`; the `ui/` components already carry it,
and never `ring-2` or `ring-[3px]` (a `focus:` or `focus-within:` ring
with `ring-2` is enforced); the `ui/` components already carry it,
so plain elements should become components rather than copy the classes.
Inside `ui/`, spell it with the `focusRing` constant from `lib/utils.ts`
(with `invalidState` and `fieldFrame` for fields) rather than retyping it.
@@ -1130,8 +1253,10 @@ shows.
A dialog panel is a `tabIndex=-1` focus target that Radix can focus, so it
carries `outline-none`: Safari draws its own blue `outline: auto` there and
ignores our `outline-color`. Any new focusable container needs the same.
- **Close buttons** on Modal, Sheet and DialogContent are a `ghost-muted`
`size="icon-sm"` Button (32px, accent square on hover) at `top-2 right-2`.
- **Close buttons**: every Modal, Sheet and DialogContent uses its built-in
close (a phone sheet with a handle has none), a `ghost-muted` `size="icon-sm"` Button (32px, accent square on
hover) at `top-2 right-2`, the artifact sheet included; never hand-place
another X.
- **Disabled**: buttons (Button, Accordion, Tabs) use
`disabled:pointer-events-none disabled:opacity-50`, so the pointer passes
through. Fields (Input, SelectTrigger, Switch, Textarea, CommandInput,
@@ -1150,15 +1275,18 @@ shadow-lg` in both themes: the knob is white on any track, and a white knob
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`,
the search frame); the sliding panel in `navigation/SidebarLevel.tsx`,
which casts a horizontal shadow while it slides; and the Hero model
picker's menu (both Approved exceptions). 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` overlays, modals, sheets, toasts and the one-frame
bar-tint strips; `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. The app
select, tooltip), so it opens above a Modal without an override. Do not
invent values in between: a `z-*` outside 0, 10, 20, 50, 200 and `auto` is
rejected (enforced). The app
shell uses the low layers too: the phone top bar is `z-10`, and the
sidebar and its phone backdrop are `z-20`, so an open sidebar dims the bar.
@@ -1189,7 +1317,7 @@ list stays reviewable.
| `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). |
| `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. |
| `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. |
@@ -1197,35 +1325,23 @@ list stays reviewable.
| `settings/PersonalAccessTokens.tsx` | `shadcn/no-restyle` | Token scope chips are identifiers, so the `neutral` Badge is set in mono (`font-mono`). One disable. |
| `agents/workflow/WorkflowPreview.tsx` | `shadcn/no-restyle` | A step's state changes (keys and values the app serialised) are `neutral` Badges set in mono (`font-mono`), like token scopes. 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). |
| `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. |
| `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. |
| `conversation/SharedConversation.tsx` | `shadcn/no-restyle` | The "DocsGPT" link sits in the `/share/:id` page's regular-weight byline (`font-normal`). One disable. |
| `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. |
| `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. |
| `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. |
| `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. |
| `conversation/MarkdownAnswer.tsx` | `no-restricted-syntax` | Markdown answer tables render the model's own table markup with its own styling, so they stay a raw `<table>`. |
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), so `xs:px-3`, `xs:text-xs` never applied. Remove them rather than
declaring the breakpoint, which would change layouts that were never seen.
- `text-s`, `dark:text-gray`, `w-inherit` are typos for `text-sm`,
`dark:text-gray-*` and `w-[inherit]`.
- `agents-container`, `conversations-container`, `loader` and `mermaid` are
unused hooks; nothing selects them. Before deleting a class like these,
grep for it in `querySelector` and `classList` calls too. `star` was
defined at runtime by a `<style>` element in `components/Notification.tsx`;
it is now the `notification-star` utility in `src/index.css`. `msc-spacer`
was a lookup hook for `ConversationMessages`' `querySelector`; the lookup
now uses the primitive's `[data-message-scroller-spacer]` attribute.
`no-unknown-classes` is at `error`. Before deleting a class the lint calls
unknown, grep for it in `querySelector`, `classList` and `data-` lookups; a
class can be a hook for code rather than a style.
## Live style guide
@@ -1237,20 +1353,21 @@ 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
Every `shadcn/*` rule is at `error`, and on commit `lint-staged` runs
`npm run lint-fix` and `npm run format` (both over the whole `src`), 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
every warning in one file with the fix the message proposes;
1. **See the state.** `npm run lint:design` prints violations (`shadcn/*`
and the design `no-restricted-syntax` selectors) by rule and the worst
files. `npm run lint:design -- --file src/settings/Teams.tsx`
lists every violation in one file with the fix the message proposes;
`-- --rule no-raw-colors` lists files for one rule.
2. **Pick a file, read it whole**, then fix all of its design warnings in
2. **Pick a file, read it whole**, then fix all of its design violations 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 onto `variant`, `size`, `shape`.
Delete the `dark:` twin of every class you tokenise. Remove classes the
lint calls unknown (`xs:*`, `text-s`, `dark:text-gray`, unused hooks).
Delete the `dark:` twin of every class you tokenise.
3. **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 to `src/design/DesignSystem.tsx`, and document it
@@ -1259,26 +1376,23 @@ keep every step green.
translations (`t(...)` keys stay). Only classes and component choice
change. Prettier with the Tailwind plugin reorders classes; run
`npm run lint-fix` rather than fighting it.
5. **Verify per file**: `npx eslint <file>` (zero `shadcn/*` warnings),
5. **Verify per file**: `npx eslint <file>` (zero errors),
`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 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.
- `npm run lint` reports 0 errors and about 325 `@typescript-eslint`
warnings (mostly `no-explicit-any`) unrelated to design; `tsc` is clean.
- 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 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.)
- `components/SkeletonLoader.tsx`: the chunk, source, tool, add-tool and
agent layouts are Card mirrors with `Skeleton` bars, and the `chatbot`,
table, logs, default, analysis, connected-state and files layouts use
`Skeleton`; none pulses its own surface. `renderFilesSection` (and the
GoogleDrivePicker box it mirrors) is still a raw `border rounded-lg` box;
move both onto `Card` when you touch them.
## Working with the lint
@@ -1286,6 +1400,11 @@ Known traps:
cd frontend && npm run lint
```
All `shadcn/*` rules are `error`. Add a variant or token only when a treatment
All `shadcn/*` rules are `error`, and so are the DESIGN.md selectors under
`no-restricted-syntax`: `eslint/design-rules.js` (class and markup rules,
tested in `src/design/designRules.lint.test.ts`), `eslint/card-surfaces.js`
(tested in `src/design/cardSurfaces.lint.test.ts`) and the viewport-height
and focus-return entries in `eslint.config.js`. Add a new checkable rule as
a selector there, with a test and a line here. 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.