diff --git a/AGENTS.md b/AGENTS.md index 6aea7be9..143cda22 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 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 005438b1..13f02e74 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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-/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: ``. @@ -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 `` for a glyph lucide has. + `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) 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 ``: 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 @@ -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 `` for navigation rows. + 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 @@ -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 `
`
+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
+`
` 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`)
+
+``. `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--100 px-2 py-0.5 text-xs text--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. `` 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 `` 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:
+`` 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 (``) label themselves and don't go in a
-FormField. Never hand-build a `