mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 02:14:23 +00:00
Cards standartization
This commit is contained in:
1 parent
d37e0bd1ad
commit
ed804a14e9
19 files changed
+535
-233
No files matched your search
+62
-19
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export declare const cardSurfaceSelectors: {
|
||||
selector: string;
|
||||
message: string;
|
||||
}[];
|
||||
@@ -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".',
|
||||
},
|
||||
];
|
||||
@@ -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=""]');
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'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);
|
||||
});
|
||||
});
|
||||
@@ -543,6 +543,8 @@ export default function Teams() {
|
||||
<Card
|
||||
key={team.id}
|
||||
asChild
|
||||
variant="filled"
|
||||
padding="lg"
|
||||
interactive
|
||||
className="group h-full"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user