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