Skip to content

Commit f61287e

Browse files
committed
feat(web): add theme-owned semantic icon theming
1 parent ad3c389 commit f61287e

67 files changed

Lines changed: 1536 additions & 389 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/web/src/components/ui/confirm-dialog/index.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,12 @@ describe("ConfirmDialog", () => {
7171
);
7272

7373
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass("btn", "btn-danger");
74-
expect(screen.getByText("Delete file").closest(".modal-title")).toContainElement(
75-
document.querySelector("svg")
76-
);
74+
expect(
75+
screen
76+
.getByText("Delete file")
77+
.closest(".modal-title")
78+
?.querySelector('[data-icon-semantic="state.warning"]')
79+
).toBeTruthy();
7780
});
7881

7982
it("accepts rich ReactNode descriptions", () => {

packages/web/src/components/ui/confirm-dialog/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import clsx from "clsx";
2-
import { AlertTriangle, X } from "lucide-react";
2+
import { X } from "lucide-react";
33
import type { ReactNode } from "react";
44
import { Button, type ButtonProps } from "../button";
55
import { IconButton } from "../icon-button";
66
import { Modal, ModalBody, ModalFooter, ModalHeader, type ModalProps, ModalTitle } from "../modal";
7+
import { ThemedIcon } from "../themed-icon";
78
import styles from "./index.module.css";
89

910
export type ConfirmDialogTone = "default" | "danger";
@@ -65,7 +66,12 @@ export function ConfirmDialog({
6566
<ModalHeader>
6667
<ModalTitle className={clsx(tone === "danger" ? styles.titleDanger : undefined)}>
6768
{tone === "danger" ? (
68-
<AlertTriangle aria-hidden="true" className={styles.iconDanger} size={16} />
69+
<ThemedIcon
70+
aria-hidden="true"
71+
className={styles.iconDanger}
72+
semantic="state.warning"
73+
size={16}
74+
/>
6975
) : null}
7076
<span>{title}</span>
7177
</ModalTitle>

packages/web/src/components/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export type { TagColor, TagProps, TagSize } from "./tag";
5454
export { Tag } from "./tag";
5555
export type { TextareaProps, TextareaSize } from "./textarea";
5656
export { Textarea } from "./textarea";
57+
export type { ThemedIconProps } from "./themed-icon";
58+
export { ThemedIcon } from "./themed-icon";
5759
export type { ToastProps, ToastTone, ToastViewportProps } from "./toast";
5860
export { Toast, ToastViewport } from "./toast";
5961
export type { TooltipProps } from "./tooltip";
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.root {
2+
display: inline-flex;
3+
align-items: center;
4+
justify-content: center;
5+
flex-shrink: 0;
6+
line-height: 0;
7+
color: inherit;
8+
}
9+
10+
.root svg {
11+
display: block;
12+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { createStore, Provider } from "jotai";
3+
import { describe, expect, it } from "vitest";
4+
import { themeAtom } from "../../../atoms/app-ui";
5+
import { ThemedIcon } from ".";
6+
7+
describe("ThemedIcon", () => {
8+
it("renders the resolved icon with stable tone and surface classes", () => {
9+
const store = createStore();
10+
store.set(themeAtom, "mint-dark");
11+
12+
render(
13+
<Provider store={store}>
14+
<ThemedIcon semantic="state.warning" size={16} />
15+
</Provider>
16+
);
17+
18+
const icon = screen.getByTestId("themed-icon");
19+
expect(icon).toHaveAttribute("data-icon-semantic", "state.warning");
20+
expect(icon).toHaveClass(
21+
"themed-icon",
22+
"themed-icon--tone-warning",
23+
"themed-icon--surface-warning"
24+
);
25+
expect(icon.querySelector("svg")).toBeInTheDocument();
26+
});
27+
28+
it("omits announcement for decorative icons", () => {
29+
const store = createStore();
30+
store.set(themeAtom, "mint-dark");
31+
32+
render(
33+
<Provider store={store}>
34+
<ThemedIcon semantic="nav.settings" />
35+
</Provider>
36+
);
37+
38+
expect(screen.getByTestId("themed-icon")).toHaveAttribute("aria-hidden", "true");
39+
});
40+
41+
it("reads the active theme and applies theme-specific presentation overrides", () => {
42+
const store = createStore();
43+
store.set(themeAtom, "hc-dark");
44+
45+
render(
46+
<Provider store={store}>
47+
<ThemedIcon semantic="file.folder.closed" size={18} />
48+
</Provider>
49+
);
50+
51+
const icon = screen.getByTestId("themed-icon");
52+
const svg = icon.querySelector("svg");
53+
54+
expect(icon).toHaveClass("themed-icon--tone-warning", "themed-icon--surface-none");
55+
expect(svg).toHaveAttribute("stroke-width", "2.25");
56+
expect(svg).toHaveAttribute("width", "18");
57+
expect(svg).toHaveAttribute("height", "18");
58+
});
59+
60+
it("allows semantic icons to participate in accessibility when decorative is false", () => {
61+
const store = createStore();
62+
store.set(themeAtom, "mint-dark");
63+
64+
render(
65+
<Provider store={store}>
66+
<ThemedIcon aria-label="Workspace warning" decorative={false} semantic="state.warning" />
67+
</Provider>
68+
);
69+
70+
const icon = screen.getByLabelText("Workspace warning");
71+
72+
expect(icon).toHaveAttribute("role", "img");
73+
expect(icon).not.toHaveAttribute("aria-hidden");
74+
});
75+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import clsx from "clsx";
2+
import { useAtomValue } from "jotai";
3+
import type { ComponentPropsWithoutRef } from "react";
4+
import { themeAtom } from "../../../atoms/app-ui";
5+
import { getIconPresentation, type IconSemantic } from "../../../theme";
6+
import styles from "./index.module.css";
7+
8+
export interface ThemedIconProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
9+
readonly decorative?: boolean;
10+
readonly semantic: IconSemantic;
11+
readonly size?: number;
12+
}
13+
14+
export function ThemedIcon({
15+
className,
16+
decorative = true,
17+
role,
18+
semantic,
19+
size = 14,
20+
...spanProps
21+
}: ThemedIconProps) {
22+
const themeId = useAtomValue(themeAtom);
23+
const presentation = getIconPresentation(themeId, semantic);
24+
const Icon = presentation.Icon;
25+
26+
return (
27+
<span
28+
{...spanProps}
29+
aria-hidden={decorative ? true : undefined}
30+
className={clsx(
31+
styles.root,
32+
"themed-icon",
33+
`themed-icon--tone-${presentation.tone}`,
34+
`themed-icon--surface-${presentation.surface}`,
35+
className
36+
)}
37+
data-icon-semantic={semantic}
38+
data-testid={spanProps["data-testid"] ?? "themed-icon"}
39+
role={decorative ? undefined : (role ?? "img")}
40+
>
41+
<Icon size={size} strokeWidth={presentation.strokeWidth} />
42+
</span>
43+
);
44+
}

packages/web/src/components/ui/toast/index.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ describe("Toast", () => {
4848
<Toast closeLabel="Close notification" onDismiss={onDismiss} title="Saved" tone="info" />
4949
);
5050

51+
expect(screen.getByTestId("themed-icon")).toHaveAttribute("data-icon-semantic", "state.info");
52+
5153
const closeButton = screen.getByRole("button", { name: "Close notification" });
5254
expect(closeButton).toHaveClass("btn", "btn-ghost", "btn-sm", "toast__close");
5355

@@ -80,6 +82,15 @@ describe("Toast", () => {
8082
expect(onClick).toHaveBeenCalledTimes(1);
8183
});
8284

85+
it("uses semantic default icons for toast tones when no custom icon is provided", () => {
86+
render(<Toast onDismiss={vi.fn()} title="Saved" tone="success" />);
87+
88+
expect(screen.getByTestId("themed-icon")).toHaveAttribute(
89+
"data-icon-semantic",
90+
"state.success"
91+
);
92+
});
93+
8394
it("does not make clickable alerts keyboard-focusable or route nested button key activation through the root click handler", async () => {
8495
const user = userEvent.setup();
8596
const onClick = vi.fn();

packages/web/src/components/ui/toast/index.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import clsx from "clsx";
22
import { X } from "lucide-react";
33
import type { HTMLAttributes, ReactNode } from "react";
44
import { IconButton } from "../icon-button";
5+
import { ThemedIcon } from "../themed-icon";
56
import styles from "./index.module.css";
67

78
export type ToastTone = "success" | "error" | "warning" | "info";
@@ -37,6 +38,13 @@ const legacyToneClassMap: Record<ToastTone, string> = {
3738
info: "toast--info",
3839
};
3940

41+
const toastSemanticByTone = {
42+
success: "state.success",
43+
error: "state.error",
44+
warning: "state.warning",
45+
info: "state.info",
46+
} as const;
47+
4048
export function ToastViewport({
4149
children,
4250
className,
@@ -74,6 +82,9 @@ export function Toast({
7482
}: ToastProps) {
7583
const clickable = typeof onClick === "function";
7684
const { ["aria-live"]: ariaLive, ...restProps } = props;
85+
const resolvedIcon = icon ?? (
86+
<ThemedIcon className="toast__icon-symbol" semantic={toastSemanticByTone[tone]} size={16} />
87+
);
7788

7889
return (
7990
<div
@@ -90,9 +101,9 @@ export function Toast({
90101
onClick={onClick}
91102
role="alert"
92103
>
93-
{icon ? (
104+
{resolvedIcon ? (
94105
<span aria-hidden="true" className={clsx(styles.icon, "toast__icon")}>
95-
{icon}
106+
{resolvedIcon}
96107
</span>
97108
) : null}
98109

packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,23 @@ describe("DraftLauncher", () => {
8686
expect(onSplitPane).toHaveBeenNthCalledWith(2, "pane-1", "vertical");
8787
expect(onClosePane).toHaveBeenCalledWith("pane-1");
8888
});
89+
90+
it("renders provider cards with semantic business icons", () => {
91+
const store = createStore();
92+
93+
store.set(localeAtom, "en");
94+
store.set(wsClientAtom, {
95+
sendCommand: vi.fn(),
96+
subscribe: vi.fn(() => () => {}),
97+
} as never);
98+
99+
const { container } = render(
100+
<Provider store={store}>
101+
<DraftLauncher workspaceId="ws-123" />
102+
</Provider>
103+
);
104+
105+
expect(container.querySelector('[data-icon-semantic="agent.provider.claude"]')).toBeTruthy();
106+
expect(container.querySelector('[data-icon-semantic="agent.provider.codex"]')).toBeTruthy();
107+
});
89108
});

packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Session } from "@coder-studio/core";
22
import { useAtomValue, useSetAtom } from "jotai";
3-
import { ArrowRight, Bot, FlipHorizontal, FlipVertical, Sparkles, X } from "lucide-react";
3+
import { ArrowRight, FlipHorizontal, FlipVertical, X } from "lucide-react";
44
import type { FC } from "react";
55
import { dispatchCommandAtom } from "../../../../atoms/connection";
66
import { sessionsAtom } from "../../../../atoms/sessions";
7-
import { Button, IconButton, StatusDot, Tag, Tooltip } from "../../../../components/ui";
7+
import { Button, IconButton, StatusDot, Tag, ThemedIcon, Tooltip } from "../../../../components/ui";
88
import { useTranslation } from "../../../../lib/i18n";
99
import { type ProviderId, useProviderLauncher } from "../../actions/use-provider-launcher";
1010

@@ -174,15 +174,15 @@ export const DraftLauncher: FC<DraftLauncherProps> = ({
174174
id: "claude",
175175
title: "Claude",
176176
meta: "analysis",
177-
icon: <Sparkles size={18} />,
177+
icon: <ThemedIcon semantic="agent.provider.claude" size={18} />,
178178
description: "更适合长上下文梳理、方案分析和代码审查。",
179179
className: "agent-provider-card-claude",
180180
},
181181
{
182182
id: "codex",
183183
title: "Codex",
184184
meta: "workspace",
185-
icon: <Bot size={18} />,
185+
icon: <ThemedIcon semantic="agent.provider.codex" size={18} />,
186186
description: "更适合终端操作、直接改文件和逐步修复问题。",
187187
className: "agent-provider-card-codex",
188188
},

0 commit comments

Comments
 (0)