Cards standartization

This commit is contained in:
Pavel committed 2026-09-27 16:17:59 +04:00
1 parent d37e0bd1ad
commit ed804a14e9
19 files changed
+535 -233

No files matched your search

+62 -19
View File
@@ -91,6 +91,8 @@ One tint scale, by purpose:
- 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`.
It is a small box inside a panel (a well, a guardrail stage), never a
page-sized panel around cards (see Card surfaces).
- Dividers: `border-border`, not `border-border/60`.
Patterns:
@@ -242,10 +244,11 @@ rounded-3xl`) are `variant="sidebar-item"`: left-aligned, full-radius, normal
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,
so keyboard focus outlines the whole white block:
`has-[[data-variant=section-toggle]:focus-visible]:ring-3 …:ring-ring/50
…:ring-inset` on the panel `<div>` (inset, because a scrolling column clips
an outset ring). Status badges go beside the button, not inside it, or the
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`,
inset because a scrolling column clips an outset ring), so pages pass
nothing for it. 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
@@ -267,28 +270,68 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
### Card (`ui/card.tsx`)
| Prop | Values |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `variant` | `outline` (border + card surface, the default), `filled` (muted fill, no border, for tiles on a card-coloured page), `subtle` (border on the page background) |
| `tone` | `default`, `destructive` (the status soft fill and border, `border-destructive/50 bg-destructive/10`, over any variant) |
| `padding` | `none`, `sm` (p-3, row boxes and code blocks), `default` (p-4), `lg` (p-6, tiles, chart panels and stat tiles) |
| `interactive` | whole card is the target: hover, focus ring and `selected` highlight; pair with `asChild` around a `<button>` or `<Link>` |
| Prop | Values |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `variant` | `outline` (border + card surface, the default), `filled` (muted fill, no border), `subtle` (border on the page background); which one is decided by role, below |
| `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>` |
Parts: `CardHeader` (title left, `CardAction` top-right), `CardTitle`,
`CardDescription`, `CardContent`, `CardFooter` (meta row, sticks to the
bottom). One radius for every card (`rounded-2xl`); pass only layout and
`gap-*` on `Card`. Children are spaced by Card's `gap-3`, so they carry no
`mt-*`. Replaces every hand-rolled
`rounded-(md|lg|xl|2xl|3xl|4xl) border bg-(card|muted) p-*` box:
`rounded-(md|lg|xl|2xl|3xl|4xl) border bg-(card|muted) p-*` box.
- Tiles (sources, tools, custom models, agents, folders) are `filled`, `lg`
(a folder row `default`), `interactive` only when a click navigates (a
draft agent is not). The Add tool picker tiles sit on the modal's card, so
they are `outline interactive lg` with `asChild` around a `<button>`.
#### Card surfaces: a thing or a place
The variant follows what the card is, not where it looks nice. Ask one
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, `outline` on a card (modal, drawer) | 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) |
- 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.
- **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
in `CardFooter` instead of a muted header strip. The neutral Badge and the
muted Avatar are `bg-muted-foreground/15`, a tint that shows on the fill,
so they are fine on a tile. `no-restricted-syntax` enforces this for
children written in the same JSX as the `<Card variant="filled">`
(`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`.
Recipes by role:
- Tiles are `filled`, `lg` (a folder row `default`), `interactive` only when a
click navigates (a draft agent is not). The Add tool picker tiles are
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`).
- Row boxes inside a form or panel (a guardrail check, the schedule's
timezone box, the discovered MCP tools) are `padding="sm"`.
- 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).
- 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
@@ -296,8 +339,8 @@ tone="destructive"`. Muted text fails AA on the red fill, so the tone turns
lighter colour back. Icon buttons inside a destructive-tone row are
`ghost-destructive-on-accent`, whose red tint shows on the fill where
ghost's grey square would not.
- A card on the page background that must not look raised (the shared
agent card) is `subtle lg`. Cards never take a shadow.
- The shared agent card (its page and a new agent chat) is a thing:
`filled lg`. Cards never take a shadow.
- The shared agent card's description is `line-clamp-3` with no inner
scroller (`max-h-* overflow-y-auto`) and no hover hint for the cut text.
+5
View File
@@ -7,6 +7,8 @@ import unusedImports from 'eslint-plugin-unused-imports';
import prettier from 'eslint-plugin-prettier';
import globals from 'globals';
import { cardSurfaceSelectors } from './eslint/card-surfaces.js';
export default [
{
ignores: [
@@ -113,6 +115,9 @@ export default [
message:
'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".',
},
// Nothing on a filled tile repeats its muted fill (DESIGN.md "Card
// surfaces").
...cardSurfaceSelectors,
],
// Design-system rules (@shadcn/lint). Tokens, variants and the
// approved exceptions are documented in DESIGN.md.
+4
View File
@@ -0,0 +1,4 @@
export declare const cardSurfaceSelectors: {
selector: string;
message: string;
}[];
+36
View File
@@ -0,0 +1,36 @@
// Card surface rule (DESIGN.md "Card surfaces"): a tile is `filled`, and
// nothing on a tile repeats its muted fill, or it disappears into it. The
// neutral Badge and the muted Avatar are `bg-muted-foreground/15`, a tint
// that shows on muted, so they are fine; a literal `bg-muted` is not. These
// are `no-restricted-syntax` entries; they see children written in the same
// JSX tree as the `<Card variant="filled">`, not ones a child component
// renders.
const FILLED_CARD =
'JSXElement:has(> JSXOpeningElement[name.name="Card"]:has(> JSXAttribute[name.name="variant"][value.value="filled"]))';
// `bg-muted` or `bg-muted/NN`, with any variant prefix, but not
// `bg-muted-foreground`.
const BG_MUTED = '/(^|[\\s:])bg-muted([^-a-z]|$)/';
/** @type {{ selector: string, message: string }[]} */
export const cardSurfaceSelectors = [
{
selector: `${FILLED_CARD} JSXElement > JSXOpeningElement[name.name="Card"] > JSXAttribute[name.name="variant"][value.value="filled"]`,
message:
'A filled Card inside a filled Card is a fill on a fill. A well inside a tile needs a panel around it, or none. See DESIGN.md "Card surfaces".',
},
...[
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] Literal[value=${BG_MUTED}]`,
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] TemplateElement[value.raw=${BG_MUTED}]`,
].map((selector) => ({
selector,
message:
'bg-muted inside a filled tile is a fill on a fill and disappears. Drop the fill (use a Separator or border-border for structure). See DESIGN.md "Card surfaces".',
})),
{
selector: `${FILLED_CARD} JSXOpeningElement[name.name="Skeleton"]:not(:has(JSXAttribute[name.name="surface"]))`,
message:
'Skeleton bars on a filled tile need surface="muted", or they vanish into the fill. See DESIGN.md "Skeleton".',
},
];
+44 -1
View File
@@ -221,7 +221,7 @@ describe('NewAgent form', () => {
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
expect(toggle.querySelector('h2')).toBeNull();
expect(toggle.parentElement!.tagName).toBe('H2');
const panel = toggle.closest('.bg-card')!;
const panel = toggle.closest('[data-slot="card"]')!;
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
@@ -327,6 +327,49 @@ describe('NewAgent form', () => {
expect(valid.querySelector('svg.lucide-circle-check')).not.toBeNull();
});
// Card surfaces: the form is a place, so each section is a subtle panel
// straight on the page, with no muted panel around the form.
it('draws each form section as a subtle panel on the page', async () => {
await render();
const titles = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="section-header"]'),
);
expect(titles.length).toBeGreaterThan(0);
for (const title of titles) {
const panel = title.closest<HTMLElement>('[data-slot="card"]')!;
expect(panel.dataset.variant).toBe(
title.closest('[data-tone="destructive"]')
? panel.dataset.variant
: 'subtle',
);
}
const advanced = buttonByText('agents.form.sections.advanced');
const panel = advanced.closest<HTMLElement>('[data-slot="card"]')!;
expect(panel.dataset.variant).toBe('subtle');
expect(panel.dataset.padding).toBe('lg');
expect(container.querySelector('.bg-muted.rounded-2xl')).toBeNull();
});
it('notches floating labels on the page background', async () => {
await render();
const labels = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="form-field-label"]'),
);
expect(labels.length).toBeGreaterThan(0);
for (const label of labels) {
expect(label.className).toContain('bg-background');
}
});
it('draws the preview area as a subtle panel', async () => {
await render();
const hint = Array.from(container.querySelectorAll('p')).find(
(p) => p.textContent === 'agents.form.preview.publishedPreview',
)!;
const panel = hint.closest<HTMLElement>('[data-slot="card"]')!;
expect(panel.dataset.variant).toBe('subtle');
});
it('shows the preview placeholder illustration as decorative images', async () => {
await render();
const images = container.querySelectorAll('img[alt=""]');
+35 -21
View File
@@ -873,12 +873,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
<div className="bg-muted mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-2xl p-5 xl:grid xl:gap-5 xl:overflow-hidden">
<div className="mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 xl:grid xl:gap-5 xl:overflow-hidden">
<div className="scrollbar-overlay col-span-2 flex flex-col gap-5 xl:max-h-full xl:overflow-y-auto xl:pr-3">
<div className="bg-card flex flex-col gap-5 rounded-2xl px-6 py-3">
<Card variant="subtle" padding="lg" className="gap-5">
<SectionHeader title={t('agents.form.sections.meta')} />
<div className="flex flex-col gap-5">
<FormField label={t('agents.form.labels.name')}>
<FormField
labelSurface="background"
label={t('agents.form.labels.name')}
>
<Input
shape="pill"
type="text"
@@ -887,7 +890,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
</FormField>
<FormField label={t('agents.form.labels.description')}>
<FormField
labelSurface="background"
label={t('agents.form.labels.description')}
>
<Textarea
size="lg"
className="h-32"
@@ -915,8 +921,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
]}
/>
</div>
</div>
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
</Card>
<Card variant="subtle" padding="lg">
<SectionHeader title={t('agents.form.sections.source')} />
<div>
<div className="flex flex-wrap items-center gap-1">
@@ -970,8 +976,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</p>
)}
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
</Card>
<Card variant="subtle" padding="lg">
<div className="flex flex-wrap items-end gap-1">
<div className="min-w-20 grow basis-full sm:basis-0">
<Prompts
@@ -1015,8 +1021,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{t('agents.form.buttons.add')}
</Button>
</div>
</div>
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
</Card>
<Card variant="subtle" padding="lg">
<SectionHeader title={t('agents.form.sections.tools')} />
<div className="flex flex-wrap items-center gap-1">
<MultiSelectPopover
@@ -1069,8 +1075,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
/>
</div>
</div>
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
</Card>
<Card variant="subtle" padding="lg">
<SectionHeader title={t('agents.form.sections.agentType')} />
<div>
<Select
@@ -1093,8 +1099,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</SelectContent>
</Select>
</div>
</div>
<div className="bg-card flex flex-col gap-3 rounded-2xl px-6 py-3">
</Card>
<Card variant="subtle" padding="lg">
<SectionHeader title={t('agents.form.sections.models')} />
<div className="flex flex-col gap-3">
<MultiSelectPopover
@@ -1154,7 +1160,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
/>
{selectedModelIds.size > 0 && (
<FormField label={t('agents.form.labels.defaultModel')}>
<FormField
labelSurface="background"
label={t('agents.form.labels.defaultModel')}
>
<Select
value={agent.default_model_id || undefined}
onValueChange={(value) =>
@@ -1181,8 +1190,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</FormField>
)}
</div>
</div>
<div className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset">
</Card>
<Card variant="subtle" padding="lg" className="gap-5">
{/* The heading wraps the toggle: a button's children are
presentational, so a heading inside it is lost to screen readers. */}
<h2>
@@ -1209,8 +1218,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</Button>
</h2>
{isAdvancedSectionExpanded && (
<div className="mt-5">
<div>
<FormField
labelSurface="background"
label={t('agents.form.advanced.jsonSchema')}
hint={t('agents.form.advanced.jsonSchemaDescription')}
>
@@ -1355,7 +1365,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</SettingRows>
</div>
)}
</div>
</Card>
<GuardrailsSection
value={agent.config?.guardrails}
token={token}
@@ -1460,7 +1470,11 @@ function AgentPreviewArea() {
const { t } = useTranslation();
const selectedAgent = useSelector(selectSelectedAgent);
return (
<div className="bg-card border-border h-[600px] w-full rounded-2xl border xl:h-full">
<Card
variant="subtle"
padding="none"
className="h-[600px] w-full xl:h-full"
>
{selectedAgent?.status === 'published' ? (
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl">
<AgentPreview />
@@ -1484,7 +1498,7 @@ function AgentPreviewArea() {
</p>
</div>
)}
</div>
</Card>
);
}
@@ -0,0 +1,22 @@
import { renderToStaticMarkup } from 'react-dom/server';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import SharedAgentCard from './SharedAgentCard';
describe('SharedAgentCard', () => {
// Card surfaces: a summary of one agent is a thing, so it is a filled tile.
it('is a filled tile', () => {
const html = renderToStaticMarkup(
<SharedAgentCard
agent={
{ id: 'a1', name: 'Carrier FAQ', description: 'Answers' } as never
}
/>,
);
expect(html).toContain('data-slot="card"');
expect(html).toContain('data-variant="filled"');
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ export default function SharedAgentCard({
Object.keys(agent.shared_metadata).length > 0;
return (
<Card
variant="subtle"
variant="filled"
padding="lg"
className="w-full max-w-[720px] sm:w-fit sm:min-w-[480px]"
>
@@ -179,12 +179,16 @@ describe('GuardrailsSection', () => {
expect(active.parentElement).toBe(heading.parentElement);
expect(heading.parentElement?.firstElementChild).toBe(heading);
// A place, not a thing: the section is a subtle panel on the page, and
// Card draws the ring for the section-toggle inside it.
const panel = q('guardrails-section')!;
expect(panel.contains(toggle)).toBe(true);
expect(panel.dataset.slot).toBe('card');
expect(panel.dataset.variant).toBe('subtle');
expect(panel.dataset.padding).toBe('lg');
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
expect(panel.className).toContain('bg-card');
await act(async () => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('false');
@@ -343,6 +347,7 @@ describe('GuardrailsSection', () => {
await render();
const card = q('guardrail-check-pii');
expect(card?.dataset.slot).toBe('card');
expect(card?.dataset.variant).toBe('subtle');
expect(card?.dataset.padding).toBe('sm');
});
@@ -216,10 +216,7 @@ export default function GuardrailsSection({
const instanceDisabled = catalog !== null && catalog.enabled === false;
return (
<div
className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset"
data-testid="guardrails-section"
>
<Card variant="subtle" padding="lg" data-testid="guardrails-section">
<div className="flex flex-wrap items-center gap-2">
{/* The heading wraps the toggle: a button's children are
presentational, so a heading inside it is lost to screen readers. */}
@@ -265,7 +262,7 @@ export default function GuardrailsSection({
</div>
{expanded && (
<div className="mt-3 pb-3">
<div>
{loadError && (
<EmptyState
tone="destructive"
@@ -339,6 +336,7 @@ export default function GuardrailsSection({
<>
<div className="mt-5">
<FormField
labelSurface="background"
label={t('agents.form.guardrails.mode')}
hint={
config.mode === 'monitor_only'
@@ -426,6 +424,7 @@ export default function GuardrailsSection({
</div>
<FormField
labelSurface="background"
className="mt-6"
label={t('agents.form.guardrails.blockMessage')}
hint={t('agents.form.guardrails.blockMessageDescription')}
@@ -457,6 +456,7 @@ export default function GuardrailsSection({
</SettingRow>
<FormField
labelSurface="background"
className="mt-5"
label={t('agents.form.guardrails.timeout')}
disabled={disabled}
@@ -476,7 +476,7 @@ export default function GuardrailsSection({
)}
</div>
)}
</div>
</Card>
);
}
@@ -597,7 +597,11 @@ function CheckCard({
);
return (
<Card padding="sm" data-testid={`guardrail-check-${info.name}`}>
<Card
variant="subtle"
padding="sm"
data-testid={`guardrail-check-${info.name}`}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<SectionHeader as="h4" size="xs" title={info.label} />
+150 -139
View File
@@ -7,6 +7,7 @@ import userService from '../../api/services/userService';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import { Button } from '../../components/ui/button';
import { Card } from '../../components/ui/card';
import ConfirmationModal from '../../modals/ConfirmationModal';
import { ActiveState } from '../../models/misc';
import { selectToken } from '../../preferences/preferenceSlice';
@@ -193,112 +194,117 @@ export default function SchedulesView() {
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
<Card
asChild
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
variant="subtle"
padding="sm"
className="gap-0"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
<li>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron, t)} ·{' '}
{t('agents.schedules.timezoneMeta', {
...NO_ESCAPE,
timezone: schedule.timezone,
})}{' '}
·{' '}
{t('agents.schedules.nextRunMeta', {
...NO_ESCAPE,
time: formatTimestamp(schedule.next_run_at),
})}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron, t)} ·{' '}
{t('agents.schedules.timezoneMeta', {
...NO_ESCAPE,
timezone: schedule.timezone,
})}{' '}
·{' '}
{t('agents.schedules.nextRunMeta', {
...NO_ESCAPE,
time: formatTimestamp(schedule.next_run_at),
})}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.delete')}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.delete')}
</Button>
</div>
</div>
<Button
type="button"
variant="link"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
<Button
type="button"
variant="link"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
</Card>
))}
</ul>
)}
@@ -316,49 +322,54 @@ export default function SchedulesView() {
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
<Card
asChild
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
variant="subtle"
padding="sm"
className="gap-0"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
<li>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.cancel')}
</Button>
)}
</div>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.cancel')}
</Button>
)}
</div>
</div>
</li>
</li>
</Card>
))}
</ul>
)}
+13
View File
@@ -111,6 +111,19 @@ describe('Chunks', () => {
expect(tile!.className).not.toContain('hover:scale-105');
});
it('draws each chunk as a filled tile with its token count in the footer', async () => {
await render();
const tile = container.querySelector<HTMLButtonElement>(
'button[data-slot="card"]',
)!;
expect(tile.dataset.variant).toBe('filled');
expect(tile.dataset.padding).toBe('lg');
const footer = tile.querySelector('[data-slot="card-footer"]')!;
expect(footer.textContent).toContain('settings.sources.tokensUnit');
// No muted strip on the muted tile: it would disappear.
expect(tile.querySelector('.bg-muted')).toBeNull();
});
const renderPath = async (props: {
onPathSelect?: (depth: number) => void;
handleGoBack?: () => void;
+11 -16
View File
@@ -20,7 +20,7 @@ import { formatChunkTokens } from './chunkUtils';
import SkeletonLoader from './SkeletonLoader';
import PathHeader from './tree/PathHeader';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Card, CardFooter } from './ui/card';
import {
Command,
CommandEmpty,
@@ -611,10 +611,11 @@ const Chunks: React.FC<ChunksProps> = ({
filteredChunks.map((chunk, index) => (
<Card
key={index}
variant="filled"
padding="lg"
interactive
padding="none"
asChild
className="relative h-[197px] w-full max-w-[487px] justify-between gap-0 overflow-hidden"
className="h-50 w-full justify-between"
>
<button
type="button"
@@ -624,19 +625,13 @@ const Chunks: React.FC<ChunksProps> = ({
setEditingText(chunk.text || '');
}}
>
<div className="w-full">
<div className="border-border bg-muted flex w-full items-center justify-between border-b px-4 py-3">
<div className="text-muted-foreground text-sm">
{formatChunkTokens(chunk.metadata)}{' '}
{t('settings.sources.tokensUnit')}
</div>
</div>
<div className="px-4 pt-3 pb-6">
<p className="text-foreground line-clamp-6 text-sm leading-5 font-normal">
{chunk.text}
</p>
</div>
</div>
<p className="text-foreground line-clamp-6 text-sm leading-5 font-normal">
{chunk.text}
</p>
<CardFooter>
{formatChunkTokens(chunk.metadata)}{' '}
{t('settings.sources.tokensUnit')}
</CardFooter>
</button>
</Card>
))
+14 -15
View File
@@ -185,27 +185,26 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
</>
);
// Mirrors the chunk tile: a filled Card with text lines, then the token
// count in the footer.
const renderChunkCards = () => (
<>
{Array.from({ length: count }).map((_, index) => (
<div
<Card
key={`chunk-skel-${index}`}
className="border-border relative flex h-[197px] w-full max-w-[487px] animate-pulse flex-col overflow-hidden rounded-2xl border"
variant="filled"
padding="lg"
className="h-50 w-full justify-between"
>
<div className="w-full">
<div className="border-border bg-muted flex w-full items-center justify-between border-b px-4 py-3">
<div className="bg-muted-foreground/20 h-4 w-20 rounded"></div>
</div>
<div className="flex flex-col gap-3 px-4 pt-4 pb-6">
<div className="bg-muted h-3 w-full rounded"></div>
<div className="bg-muted h-3 w-11/12 rounded"></div>
<div className="bg-muted h-3 w-5/6 rounded"></div>
<div className="bg-muted h-3 w-4/5 rounded"></div>
<div className="bg-muted h-3 w-3/4 rounded"></div>
<div className="bg-muted h-3 w-2/3 rounded"></div>
</div>
<div className="flex flex-col gap-3">
<Skeleton surface="muted" className="h-3 w-full" />
<Skeleton surface="muted" className="h-3 w-11/12" />
<Skeleton surface="muted" className="h-3 w-5/6" />
<Skeleton surface="muted" className="h-3 w-4/5" />
<Skeleton surface="muted" className="h-3 w-2/3" />
</div>
</div>
<Skeleton surface="muted" className="h-3 w-20" />
</Card>
))}
</>
);
+10
View File
@@ -32,6 +32,16 @@ describe('Card', () => {
expect(html).toContain('data-tone="destructive"');
});
it('rings the whole card while a section-toggle inside it has keyboard focus', () => {
const classes = cardVariants({ variant: 'subtle' });
expect(classes).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
expect(classes).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-inset',
);
});
it('destructive tone turns muted text inside it to foreground', () => {
const classes = cardVariants({ tone: 'destructive' });
expect(classes).toContain('[&_.text-muted-foreground]:text-foreground');
+15 -4
View File
@@ -4,16 +4,27 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn, focusRing } from '@/lib/utils';
// A card holding a section-toggle (a disclosure panel's header) draws the
// toggle's keyboard focus as a ring round the whole panel; the toggle itself
// has none. Inset, because a scrolling column clips an outset ring.
const sectionToggleRing =
'has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 has-[[data-variant=section-toggle]:focus-visible]:ring-inset';
// Variants by role (DESIGN.md "Card surfaces"): a thing is `filled`, a place
// is `subtle` on the page or `outline` on a card-coloured surface.
const cardVariants = cva(
'text-card-foreground flex flex-col gap-3 rounded-2xl text-sm transition-colors',
`text-card-foreground flex flex-col gap-3 rounded-2xl text-sm transition-colors ${sectionToggleRing}`,
{
variants: {
variant: {
// Bordered surface: lists of entities, panels, headers.
// A place on a card-coloured surface (a modal, a floating panel), and
// picker choices, whose selection is the border.
outline: 'border-border bg-card border',
// Filled, no border: tiles on a card-coloured page (sources, tools).
// A thing: a tile you open, move, share or delete as a whole
// (agents, sources, tools, teams, chunks). Also a well (code,
// output) inside a panel.
filled: 'bg-muted',
// Bordered on a muted page background.
// A place on the page: form sections, charts, tables, logs.
subtle: 'border-border bg-background border',
},
tone: {
+9 -9
View File
@@ -856,10 +856,10 @@ export default function DesignSystem() {
</Example>
<Example
title="Section panel toggle"
code='<div className="has-[[data-variant=section-toggle]:focus-visible]:ring-3 …"><Button variant="section-toggle" size="sm" aria-expanded>'
code='<Card variant="subtle" padding="lg"><Button variant="section-toggle" size="sm" aria-expanded> (Card draws the focus ring)'
>
<div className="bg-muted max-w-md rounded-2xl p-4">
<div className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset">
<div className="max-w-md">
<Card variant="subtle" padding="lg">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="section-toggle"
@@ -878,11 +878,11 @@ export default function DesignSystem() {
<Badge variant="destructive">1 needs setup</Badge>
</div>
{sectionOpen && (
<p className="text-muted-foreground mt-2 text-sm">
<p className="text-muted-foreground text-sm">
Run the selected checks on this agent&apos;s runs.
</p>
)}
</div>
</Card>
</div>
</Example>
<Example
@@ -1536,14 +1536,14 @@ export default function DesignSystem() {
</Example>
<Example
title="Subtle surface and paddings"
code='<Card variant="subtle"> on bg-muted · tone="destructive" · padding="lg" · padding="none"'
code='<Card variant="subtle"> a place on the page · tone="destructive" · padding="lg" · padding="none"'
>
<div className="bg-muted grid items-start gap-4 rounded-2xl p-4 md:grid-cols-2">
<div className="grid items-start gap-4 md:grid-cols-2">
<Card variant="subtle" padding="lg">
<CardTitle>Subtle</CardTitle>
<CardDescription>
subtle is a bordered background-coloured box on a muted page;
lg pads it 24px.
subtle is a place: a bordered panel on the page background
(form sections, charts, logs); lg pads it 24px.
</CardDescription>
</Card>
<Card tone="destructive" padding="lg">
@@ -0,0 +1,85 @@
// @vitest-environment node
import tsParser from '@typescript-eslint/parser';
import { Linter } from 'eslint';
import { cardSurfaceSelectors } from '../../eslint/card-surfaces.js';
const linter = new Linter({ configType: 'flat' });
function lint(code: string): string[] {
return linter
.verify(
code,
[
{
files: ['**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
rules: { 'no-restricted-syntax': ['error', ...cardSurfaceSelectors] },
},
],
'fixture.tsx',
)
.map((m) => m.message);
}
describe('card surface lint: no fill on a fill', () => {
it('rejects bg-muted on an element inside a filled tile, in cn() too', () => {
expect(
lint(
`<Card variant="filled"><div className="bg-muted px-4">x</div></Card>`,
),
).toHaveLength(1);
expect(
lint(
`<Card variant="filled"><div className={cn('hover:bg-muted/60', a && 'p-2')}>x</div></Card>`,
),
).toHaveLength(1);
});
it('rejects a filled Card nested in a filled Card', () => {
expect(
lint(
`<Card variant="filled"><Card variant="filled" padding="sm">x</Card></Card>`,
),
).toHaveLength(1);
});
it('rejects a Skeleton without surface="muted" on a filled tile', () => {
expect(
lint(`<Card variant="filled"><Skeleton className="h-4" /></Card>`),
).toHaveLength(1);
expect(
lint(
`<Card variant="filled"><Skeleton surface="muted" className="h-4" /></Card>`,
),
).toHaveLength(0);
});
it('allows the neutral Badge and muted Avatar tints on a filled tile', () => {
expect(
lint(`<Card variant="filled">
<Badge variant="neutral">Member</Badge>
<Avatar variant="muted">M</Avatar>
</Card>`),
).toHaveLength(0);
});
it('allows the same children on panels and allows muted-foreground', () => {
expect(
lint(`<Card variant="subtle">
<Badge variant="neutral">2</Badge>
<Avatar variant="muted">M</Avatar>
<div className="bg-muted">well</div>
<Card variant="filled" padding="sm"><pre className="text-muted-foreground">x</pre></Card>
</Card>`),
).toHaveLength(0);
expect(
lint(
`<Card variant="filled"><p className="text-muted-foreground bg-muted-foreground/20">x</p></Card>`,
),
).toHaveLength(0);
});
});
+2
View File
@@ -543,6 +543,8 @@ export default function Teams() {
<Card
key={team.id}
asChild
variant="filled"
padding="lg"
interactive
className="group h-full"
>