Skip to content

Commit f77e5cf

Browse files
feat(ui): restyle Card and improve tool error cards (anomalyco#16888)
Co-authored-by: Adam <[email protected]>
1 parent e6cdc21 commit f77e5cf

12 files changed

Lines changed: 476 additions & 90 deletions

File tree

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,94 @@
11
[data-component="card"] {
2+
--card-pad-y: 10px;
3+
--card-pad-r: 12px;
4+
--card-pad-l: 10px;
5+
26
width: 100%;
37
display: flex;
48
flex-direction: column;
5-
background-color: var(--surface-inset-base);
6-
border: 1px solid var(--border-weaker-base);
7-
transition: background-color 0.15s ease;
9+
position: relative;
10+
background: transparent;
11+
border: none;
812
border-radius: var(--radius-md);
9-
padding: 6px 12px;
10-
overflow: clip;
11-
12-
&[data-variant="error"] {
13-
background-color: var(--surface-critical-weak);
14-
border: 1px solid var(--border-critical-base);
15-
color: rgba(218, 51, 25, 0.6);
16-
17-
/* text-12-regular */
18-
font-family: var(--font-family-sans);
19-
font-size: var(--font-size-small);
20-
font-style: normal;
21-
font-weight: var(--font-weight-regular);
22-
line-height: var(--line-height-large); /* 166.667% */
23-
letter-spacing: var(--letter-spacing-normal);
24-
25-
&[data-component="icon"] {
26-
color: var(--icon-critical-active);
27-
}
13+
padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l);
14+
15+
/* text-14-regular */
16+
font-family: var(--font-family-sans);
17+
font-size: var(--font-size-base);
18+
font-style: normal;
19+
font-weight: var(--font-weight-regular);
20+
line-height: var(--line-height-large);
21+
letter-spacing: var(--letter-spacing-normal);
22+
color: var(--text-strong);
23+
24+
--card-gap: 8px;
25+
--card-icon: 16px;
26+
--card-indent: 0px;
27+
--card-line-pad: 8px;
28+
29+
--card-accent: var(--icon-active);
30+
31+
&:has([data-slot="card-title"]) {
32+
gap: 8px;
33+
}
34+
35+
&:has([data-slot="card-title-icon"]) {
36+
--card-indent: calc(var(--card-icon) + var(--card-gap));
37+
}
38+
39+
&::before {
40+
content: "";
41+
position: absolute;
42+
left: 0;
43+
top: var(--card-line-pad);
44+
bottom: var(--card-line-pad);
45+
width: 2px;
46+
border-radius: 2px;
47+
background-color: var(--card-accent);
48+
}
49+
50+
:where([data-card="title"], [data-slot="card-title"]) {
51+
color: var(--text-strong);
52+
font-weight: var(--font-weight-medium);
53+
}
54+
55+
:where([data-slot="card-title"]) {
56+
display: flex;
57+
align-items: center;
58+
gap: var(--card-gap);
59+
}
60+
61+
:where([data-slot="card-title"]) [data-component="icon"] {
62+
color: var(--card-accent);
63+
}
64+
65+
:where([data-slot="card-title-icon"]) {
66+
display: inline-flex;
67+
align-items: center;
68+
justify-content: center;
69+
width: var(--card-icon);
70+
height: var(--card-icon);
71+
flex: 0 0 auto;
72+
}
73+
74+
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
75+
color: var(--text-weak);
76+
}
77+
78+
:where([data-slot="card-title-icon"])
79+
[data-slot="icon-svg"]
80+
:is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
81+
stroke-width: 1.5px !important;
82+
}
83+
84+
:where([data-card="description"], [data-slot="card-description"]) {
85+
color: var(--text-base);
86+
white-space: pre-wrap;
87+
overflow-wrap: anywhere;
88+
word-break: break-word;
89+
}
90+
91+
:where([data-card="actions"], [data-slot="card-actions"]) {
92+
padding-left: var(--card-indent);
2893
}
2994
}

packages/ui/src/components/card.stories.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @ts-nocheck
2-
import { Card } from "./card"
2+
import { Card, CardActions, CardDescription, CardTitle } from "./card"
33
import { Button } from "./button"
44

55
const docs = `### Overview
@@ -49,15 +49,13 @@ export default {
4949
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
5050
return (
5151
<Card variant={props.variant}>
52-
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
53-
<div style={{ flex: 1 }}>
54-
<div style={{ fontWeight: 500 }}>Card title</div>
55-
<div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
56-
</div>
57-
<Button size="small" variant="ghost">
52+
<CardTitle variant={props.variant}>Card title</CardTitle>
53+
<CardDescription>Small supporting text.</CardDescription>
54+
<CardActions>
55+
<Button size="small" variant="secondary">
5856
Action
5957
</Button>
60-
</div>
58+
</CardActions>
6159
</Card>
6260
)
6361
},
Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,57 @@
11
import { type ComponentProps, splitProps } from "solid-js"
2+
import { Icon, type IconProps } from "./icon"
3+
4+
type Variant = "normal" | "error" | "warning" | "success" | "info"
25

36
export interface CardProps extends ComponentProps<"div"> {
4-
variant?: "normal" | "error" | "warning" | "success" | "info"
7+
variant?: Variant
8+
}
9+
10+
export interface CardTitleProps extends ComponentProps<"div"> {
11+
variant?: Variant
12+
13+
/**
14+
* Optional title icon.
15+
*
16+
* - `undefined`: picks a default icon based on `variant` (error/warning/success/info)
17+
* - `false`/`null`: disables the icon
18+
* - `Icon` name: forces a specific icon
19+
*/
20+
icon?: IconProps["name"] | false | null
21+
}
22+
23+
function pick(variant: Variant) {
24+
if (variant === "error") return "circle-ban-sign" as const
25+
if (variant === "warning") return "warning" as const
26+
if (variant === "success") return "circle-check" as const
27+
if (variant === "info") return "help" as const
28+
return
29+
}
30+
31+
function mix(style: ComponentProps<"div">["style"], value?: string) {
32+
if (!value) return style
33+
if (!style) return { "--card-accent": value }
34+
if (typeof style === "string") return `${style};--card-accent:${value};`
35+
return { ...(style as Record<string, string | number>), "--card-accent": value }
536
}
637

738
export function Card(props: CardProps) {
8-
const [split, rest] = splitProps(props, ["variant", "class", "classList"])
39+
const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"])
40+
const variant = () => split.variant ?? "normal"
41+
const accent = () => {
42+
const v = variant()
43+
if (v === "error") return "var(--icon-critical-base)"
44+
if (v === "warning") return "var(--icon-warning-active)"
45+
if (v === "success") return "var(--icon-success-active)"
46+
if (v === "info") return "var(--icon-info-active)"
47+
return
48+
}
949
return (
1050
<div
1151
{...rest}
1252
data-component="card"
13-
data-variant={split.variant || "normal"}
53+
data-variant={variant()}
54+
style={mix(split.style, accent())}
1455
classList={{
1556
...(split.classList ?? {}),
1657
[split.class ?? ""]: !!split.class,
@@ -20,3 +61,63 @@ export function Card(props: CardProps) {
2061
</div>
2162
)
2263
}
64+
65+
export function CardTitle(props: CardTitleProps) {
66+
const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"])
67+
const show = () => split.icon !== false && split.icon !== null
68+
const name = () => {
69+
if (split.icon === false || split.icon === null) return
70+
if (typeof split.icon === "string") return split.icon
71+
return pick(split.variant ?? "normal")
72+
}
73+
const placeholder = () => !name()
74+
return (
75+
<div
76+
{...rest}
77+
data-slot="card-title"
78+
classList={{
79+
...(split.classList ?? {}),
80+
[split.class ?? ""]: !!split.class,
81+
}}
82+
>
83+
{show() ? (
84+
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
85+
<Icon name={name() ?? "dash"} size="small" />
86+
</span>
87+
) : null}
88+
{split.children}
89+
</div>
90+
)
91+
}
92+
93+
export function CardDescription(props: ComponentProps<"div">) {
94+
const [split, rest] = splitProps(props, ["class", "classList", "children"])
95+
return (
96+
<div
97+
{...rest}
98+
data-slot="card-description"
99+
classList={{
100+
...(split.classList ?? {}),
101+
[split.class ?? ""]: !!split.class,
102+
}}
103+
>
104+
{split.children}
105+
</div>
106+
)
107+
}
108+
109+
export function CardActions(props: ComponentProps<"div">) {
110+
const [split, rest] = splitProps(props, ["class", "classList", "children"])
111+
return (
112+
<div
113+
{...rest}
114+
data-slot="card-actions"
115+
classList={{
116+
...(split.classList ?? {}),
117+
[split.class ?? ""]: !!split.class,
118+
}}
119+
>
120+
{split.children}
121+
</div>
122+
)
123+
}

packages/ui/src/components/markdown.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
ol {
6161
margin-top: 0.5rem;
6262
margin-bottom: 1rem;
63+
margin-left: 0;
6364
padding-left: 1.5rem;
6465
list-style-position: outside;
6566
}
@@ -70,6 +71,7 @@
7071

7172
ol {
7273
list-style-type: decimal;
74+
padding-left: 2.25rem;
7375
}
7476

7577
li {
@@ -98,6 +100,10 @@
98100
padding-left: 1rem; /* Minimal indent for nesting only */
99101
}
100102

103+
li > ol {
104+
padding-left: 1.75rem;
105+
}
106+
101107
/* Blockquotes */
102108
blockquote {
103109
border-left: 2px solid var(--border-weak-base);

packages/ui/src/components/message-part.css

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -309,41 +309,6 @@
309309
}
310310
}
311311

312-
[data-component="tool-error"] {
313-
display: flex;
314-
align-items: start;
315-
gap: 8px;
316-
317-
[data-slot="icon-svg"] {
318-
color: var(--icon-critical-base);
319-
margin-top: 4px;
320-
}
321-
322-
[data-slot="message-part-tool-error-content"] {
323-
display: flex;
324-
align-items: start;
325-
gap: 8px;
326-
}
327-
328-
[data-slot="message-part-tool-error-title"] {
329-
font-family: var(--font-family-sans);
330-
font-size: var(--font-size-base);
331-
font-style: normal;
332-
font-weight: var(--font-weight-medium);
333-
line-height: var(--line-height-large);
334-
letter-spacing: var(--letter-spacing-normal);
335-
color: var(--text-on-critical-base);
336-
white-space: nowrap;
337-
}
338-
339-
[data-slot="message-part-tool-error-message"] {
340-
color: var(--text-on-critical-weak);
341-
max-height: 240px;
342-
overflow-y: auto;
343-
word-break: break-word;
344-
}
345-
}
346-
347312
[data-component="tool-output"] {
348313
white-space: pre;
349314
padding: 0;
@@ -717,7 +682,6 @@
717682
[data-component="user-message"] [data-slot="user-message-text"],
718683
[data-component="text-part"],
719684
[data-component="reasoning-part"],
720-
[data-component="tool-error"],
721685
[data-component="tool-output"],
722686
[data-component="bash-output"],
723687
[data-component="edit-content"],

packages/ui/src/components/message-part.tsx

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { Card } from "./card"
3939
import { Collapsible } from "./collapsible"
4040
import { FileIcon } from "./file-icon"
4141
import { Icon } from "./icon"
42+
import { ToolErrorCard } from "./tool-error-card"
4243
import { Checkbox } from "./checkbox"
4344
import { DiffChanges } from "./diff-changes"
4445
import { Markdown } from "./markdown"
@@ -1189,25 +1190,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
11891190
</div>
11901191
)
11911192
}
1192-
const [title, ...rest] = cleaned.split(": ")
1193-
return (
1194-
<Card variant="error">
1195-
<div data-component="tool-error">
1196-
<Icon name="circle-ban-sign" size="small" />
1197-
<Switch>
1198-
<Match when={title && title.length < 30}>
1199-
<div data-slot="message-part-tool-error-content">
1200-
<div data-slot="message-part-tool-error-title">{title}</div>
1201-
<span data-slot="message-part-tool-error-message">{rest.join(": ")}</span>
1202-
</div>
1203-
</Match>
1204-
<Match when={true}>
1205-
<span data-slot="message-part-tool-error-message">{cleaned}</span>
1206-
</Match>
1207-
</Switch>
1208-
</div>
1209-
</Card>
1210-
)
1193+
return <ToolErrorCard tool={part().tool} error={error()} />
12111194
}}
12121195
</Match>
12131196
<Match when={true}>

0 commit comments

Comments
 (0)