{
+ handleChange(nextPage);
+ setPage(nextPage);
+ }}
+ page={page}
+ pageSize={20}
+ total={240}
+ />
+ );
+ }
+
+ render();
+ await user.click(screen.getByRole("button", { name: "第 3 页" }));
+
+ expect(handleChange).toHaveBeenCalledWith(3);
+ expect(screen.getByRole("button", { name: "第 3 页" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("button", { name: "第 2 页" })).toHaveAttribute("data-state", "inactive");
+ });
+
+ it("supports arrow key focus movement across pagination controls", () => {
+ render();
+
+ const currentPage = screen.getByRole("button", { name: "第 3 页" });
+ currentPage.focus();
+ fireEvent.keyDown(screen.getByRole("list"), { key: "ArrowRight" });
+
+ expect(screen.getByRole("button", { name: "第 4 页" })).toHaveFocus();
+ });
+});
diff --git a/apps/web/src/test/shared-popover.test.tsx b/apps/web/src/test/shared-popover.test.tsx
new file mode 100644
index 0000000..859cc9d
--- /dev/null
+++ b/apps/web/src/test/shared-popover.test.tsx
@@ -0,0 +1,94 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { Popover, PopoverContent, PopoverTrigger } from "../shared/popover";
+
+describe("shared popover primitives", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("opens from the trigger and wires expanded/controls semantics", () => {
+ render(
+
+ 打开筛选器
+
+
+
+
+ );
+
+ const trigger = screen.getByRole("button", { name: "打开筛选器" });
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
+
+ fireEvent.click(trigger);
+
+ const popover = screen.getByRole("dialog", { name: "筛选器" });
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
+ expect(trigger).toHaveAttribute("aria-controls", popover.id);
+ expect(trigger).toHaveAttribute("data-state", "open");
+ expect(popover).toHaveAttribute("data-state", "open");
+ expect(screen.getByRole("button", { name: "保存筛选" })).toHaveFocus();
+ });
+
+ it("dismisses on escape and restores focus to the trigger", () => {
+ render(
+
+ 打开快捷操作
+
+
+
+
+ );
+
+ const trigger = screen.getByRole("button", { name: "打开快捷操作" });
+ fireEvent.click(trigger);
+
+ fireEvent.keyDown(screen.getByRole("dialog", { name: "快捷操作" }), { key: "Escape" });
+
+ expect(screen.queryByRole("dialog", { name: "快捷操作" })).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("dismisses when clicking outside the content", () => {
+ render(
+
+
+
+ 打开邮箱动作
+
+
+
+
+
+ );
+
+ const trigger = screen.getByRole("button", { name: "打开邮箱动作" });
+ fireEvent.click(trigger);
+ expect(screen.getByRole("dialog", { name: "邮箱动作" })).toBeInTheDocument();
+
+ fireEvent.mouseDown(screen.getByRole("button", { name: "外部元素" }));
+ fireEvent.click(screen.getByRole("button", { name: "外部元素" }));
+
+ expect(screen.queryByRole("dialog", { name: "邮箱动作" })).not.toBeInTheDocument();
+ expect(trigger).toHaveFocus();
+ });
+
+ it("renders the popover content inside the shared layer portal", () => {
+ render(
+
+ 打开筛选器
+
+
+
+
+ );
+
+ const portalRoot = document.getElementById("wemail-layer-root");
+ const popover = screen.getByRole("dialog", { name: "筛选器" });
+
+ expect(portalRoot).not.toBeNull();
+ expect(portalRoot?.contains(popover)).toBe(true);
+ });
+});
diff --git a/apps/web/src/test/shared-progress.test.tsx b/apps/web/src/test/shared-progress.test.tsx
new file mode 100644
index 0000000..8e6055d
--- /dev/null
+++ b/apps/web/src/test/shared-progress.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Progress } from "../shared/progress";
+
+describe("shared progress primitive", () => {
+ it("renders determinate progress with aria values and unified classes", () => {
+ render();
+
+ const progress = screen.getByRole("progressbar", { name: "上传进度" });
+ expect(progress).toHaveClass("ui-progress", "ui-progress-success");
+ expect(progress).toHaveAttribute("aria-valuenow", "32");
+ expect(progress).toHaveTextContent("32%");
+ });
+
+ it("renders indeterminate progress without aria-valuenow", () => {
+ render();
+
+ const progress = screen.getByRole("progressbar", { name: "同步中" });
+ expect(progress).toHaveAttribute("data-state", "indeterminate");
+ expect(progress).not.toHaveAttribute("aria-valuenow");
+ });
+});
diff --git a/apps/web/src/test/shared-scroll-area.test.tsx b/apps/web/src/test/shared-scroll-area.test.tsx
new file mode 100644
index 0000000..41caa2a
--- /dev/null
+++ b/apps/web/src/test/shared-scroll-area.test.tsx
@@ -0,0 +1,37 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ ScrollArea,
+ ScrollAreaScrollbar,
+ ScrollAreaThumb,
+ ScrollAreaViewport
+} from "../shared/scroll-area";
+
+describe("shared scroll area primitives", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders a labeled scroll region with viewport and scrollbar parts", () => {
+ render(
+
+
+ 很多邮件
+
+
+
+
+
+ );
+
+ const region = screen.getByRole("region", { name: "邮件列表" });
+ expect(region).toHaveClass("ui-scroll-area");
+ expect(region).toHaveAttribute("data-orientation", "vertical");
+ expect(region.querySelector(".ui-scroll-area-viewport")).toBeInTheDocument();
+
+ const scrollbar = region.querySelector(".ui-scroll-area-scrollbar");
+ expect(scrollbar).toHaveAttribute("data-orientation", "vertical");
+ expect(scrollbar?.querySelector(".ui-scroll-area-thumb")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/test/shared-skeleton.test.tsx b/apps/web/src/test/shared-skeleton.test.tsx
new file mode 100644
index 0000000..83e5f4f
--- /dev/null
+++ b/apps/web/src/test/shared-skeleton.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Skeleton } from "../shared/skeleton";
+
+describe("shared skeleton primitive", () => {
+ it("renders a decorative rectangular skeleton by default", () => {
+ render();
+
+ const skeleton = screen.getByTestId("skeleton");
+ expect(skeleton).toHaveClass("ui-skeleton", "ui-skeleton-rect");
+ expect(skeleton).toHaveAttribute("aria-hidden", "true");
+ expect(skeleton).toHaveAttribute("data-state", "idle");
+ });
+
+ it("supports animated text skeletons with custom sizing", () => {
+ render();
+
+ const skeleton = screen.getByTestId("text-skeleton");
+ expect(skeleton).toHaveClass("ui-skeleton-text", "ui-skeleton-animated");
+ expect(skeleton).toHaveAttribute("data-state", "loading");
+ expect(skeleton).toHaveStyle({ height: "12px", width: "72%" });
+ });
+
+ it("can expose loading status semantics with Chinese default copy", () => {
+ render();
+
+ const skeleton = screen.getByRole("status", { name: "内容加载中" });
+ expect(skeleton).toHaveClass("ui-skeleton", "ui-skeleton-circle");
+ expect(skeleton).toHaveAttribute("data-shape", "circle");
+ });
+});
diff --git a/apps/web/src/test/shared-spinner.test.tsx b/apps/web/src/test/shared-spinner.test.tsx
new file mode 100644
index 0000000..8306b80
--- /dev/null
+++ b/apps/web/src/test/shared-spinner.test.tsx
@@ -0,0 +1,34 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { Spinner } from "../shared/spinner";
+
+describe("shared spinner primitive", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders an indeterminate status spinner with Chinese default copy", () => {
+ render();
+
+ const spinner = screen.getByRole("status", { name: "加载中" });
+ expect(spinner).toHaveClass("ui-spinner", "ui-spinner-md", "ui-tone-default");
+ expect(spinner).toHaveAttribute("data-state", "indeterminate");
+ });
+
+ it("supports decorative spinners that stay hidden from assistive technology", () => {
+ render();
+
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ expect(screen.getByTestId("decorative-spinner")).toHaveAttribute("aria-hidden", "true");
+ expect(screen.getByTestId("decorative-spinner")).toHaveClass("ui-spinner-sm", "ui-tone-accent");
+ });
+
+ it("can show visible loading copy with an overridable label", () => {
+ render();
+
+ const spinner = screen.getByRole("status", { name: "同步中" });
+ expect(spinner).toHaveClass("ui-spinner-lg", "ui-tone-muted");
+ expect(screen.getByText("同步中")).toHaveClass("ui-spinner-label");
+ });
+});
diff --git a/apps/web/src/test/shared-steps.test.tsx b/apps/web/src/test/shared-steps.test.tsx
new file mode 100644
index 0000000..23ee82e
--- /dev/null
+++ b/apps/web/src/test/shared-steps.test.tsx
@@ -0,0 +1,82 @@
+import userEvent from "@testing-library/user-event";
+import { cleanup, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { StepItem, Steps, StepsList } from "../shared/steps";
+
+describe("shared steps primitives", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders current, complete, and upcoming step states with accessible navigation semantics", () => {
+ render(
+
+
+
+
+
+
+
+ );
+
+ expect(screen.getByRole("navigation", { name: "集成流程" })).toHaveClass("ui-steps");
+ expect(screen.getByRole("list")).toHaveClass("ui-steps-list");
+ expect(screen.getByText("绑定域名").closest("li")).toHaveAttribute("data-state", "complete");
+ expect(screen.getByRole("button", { name: "验证 DNS" })).toHaveAttribute("aria-current", "step");
+ expect(screen.getByRole("button", { name: "验证 DNS" })).toHaveAttribute("data-state", "current");
+ expect(screen.getByText("接收邮件").closest("li")).toHaveAttribute("data-state", "upcoming");
+ });
+
+ it("updates controlled currentStep when a new step is activated", async () => {
+ const user = userEvent.setup();
+
+ function Host() {
+ const [currentStep, setCurrentStep] = useState(1);
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ render();
+ await user.click(screen.getByRole("button", { name: "验证邮箱" }));
+
+ expect(screen.getByRole("button", { name: "验证邮箱" })).toHaveAttribute("aria-current", "step");
+ expect(screen.getByRole("button", { name: "填写账号" })).toHaveAttribute("data-state", "complete");
+ });
+
+ it("supports keyboard focus movement and activation across interactive steps", async () => {
+ const user = userEvent.setup();
+
+ function Host() {
+ const [currentStep, setCurrentStep] = useState(1);
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ render();
+ screen.getByRole("button", { name: "编写内容" }).focus();
+
+ await user.keyboard("{ArrowRight}");
+ expect(screen.getByRole("button", { name: "审核" })).toHaveFocus();
+
+ await user.keyboard("{Enter}");
+ expect(screen.getByRole("button", { name: "审核" })).toHaveAttribute("aria-current", "step");
+ });
+});
diff --git a/apps/web/src/test/shared-tabs.test.tsx b/apps/web/src/test/shared-tabs.test.tsx
new file mode 100644
index 0000000..760bb4e
--- /dev/null
+++ b/apps/web/src/test/shared-tabs.test.tsx
@@ -0,0 +1,90 @@
+import userEvent from "@testing-library/user-event";
+import { cleanup, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { Tabs, TabsList, TabsPanel, TabsTrigger } from "../shared/tabs";
+
+describe("shared tabs primitives", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders segmented tabs with correct tab and tabpanel relationships", () => {
+ render(
+
+
+ 概览
+ 活动
+
+ 概览内容
+ 活动内容
+
+ );
+
+ const overviewTab = screen.getByRole("tab", { name: "概览" });
+ const activityTab = screen.getByRole("tab", { name: "活动" });
+ const overviewPanel = screen.getByRole("tabpanel", { name: "概览" });
+
+ expect(screen.getByRole("tablist", { name: "邮箱详情" })).toHaveAttribute("data-variant", "segmented");
+ expect(overviewTab).toHaveAttribute("aria-selected", "true");
+ expect(overviewTab).toHaveAttribute("data-state", "active");
+ expect(activityTab).toHaveAttribute("aria-selected", "false");
+ expect(activityTab).toHaveAttribute("tabindex", "-1");
+ expect(overviewPanel).toBeVisible();
+ expect(screen.getByText("活动内容")).toHaveAttribute("hidden");
+ });
+
+ it("updates controlled state when a trigger is activated", async () => {
+ const user = userEvent.setup();
+
+ function Host() {
+ const [value, setValue] = useState("overview");
+
+ return (
+
+
+ 概览
+ 活动
+
+ 概览内容
+ 活动内容
+
+ );
+ }
+
+ render();
+ await user.click(screen.getByRole("tab", { name: "活动" }));
+
+ expect(screen.getByRole("tab", { name: "活动" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tabpanel", { name: "活动" })).toBeVisible();
+ });
+
+ it("supports roving keyboard navigation in automatic activation mode", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ 概览
+ 活动
+ 设置
+
+ 概览内容
+ 活动内容
+ 设置内容
+
+ );
+
+ screen.getByRole("tab", { name: "概览" }).focus();
+ await user.keyboard("{ArrowRight}");
+
+ expect(screen.getByRole("tab", { name: "活动" })).toHaveFocus();
+ expect(screen.getByRole("tab", { name: "活动" })).toHaveAttribute("aria-selected", "true");
+
+ await user.keyboard("{End}");
+
+ expect(screen.getByRole("tab", { name: "设置" })).toHaveFocus();
+ expect(screen.getByRole("tabpanel", { name: "设置" })).toBeVisible();
+ });
+});
diff --git a/apps/web/src/test/shared-tag.test.tsx b/apps/web/src/test/shared-tag.test.tsx
new file mode 100644
index 0000000..5aef163
--- /dev/null
+++ b/apps/web/src/test/shared-tag.test.tsx
@@ -0,0 +1,32 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { Tag } from "../shared/tag";
+
+describe("shared tag primitives", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("renders category tags with tone, size, shape, and optional dot/icon affordances", () => {
+ render(
+ <>
+
+ 品牌模板
+
+ #} shape="rounded" size="sm" variant="info">
+ API
+
+ >
+ );
+
+ const brandTag = screen.getByTestId("brand-tag");
+ expect(brandTag).toHaveClass("ui-tag", "ui-tag-brand", "ui-tag-size-md", "ui-tag-shape-pill");
+ expect(brandTag).toHaveAttribute("data-usage", "category");
+ expect(brandTag.querySelector(".ui-tag-dot")).toBeInTheDocument();
+
+ const infoTag = screen.getByTestId("info-tag");
+ expect(infoTag).toHaveClass("ui-tag", "ui-tag-info", "ui-tag-size-sm", "ui-tag-shape-rounded");
+ expect(screen.getByTestId("tag-icon")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/test/shared-tooltip.test.tsx b/apps/web/src/test/shared-tooltip.test.tsx
new file mode 100644
index 0000000..9fbf391
--- /dev/null
+++ b/apps/web/src/test/shared-tooltip.test.tsx
@@ -0,0 +1,69 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { Tooltip, TooltipContent, TooltipTrigger } from "../shared/tooltip";
+
+describe("shared tooltip primitives", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("opens on focus and links the trigger to the tooltip content", () => {
+ render(
+
+ 邮箱地址
+ 复制后可分享给同事
+
+ );
+
+ const trigger = screen.getByRole("button", { name: "邮箱地址" });
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+
+ fireEvent.focus(trigger);
+
+ const tooltip = screen.getByRole("tooltip");
+ expect(trigger).toHaveAttribute("aria-describedby", tooltip.id);
+ expect(trigger).toHaveAttribute("data-state", "open");
+ expect(tooltip).toHaveAttribute("data-state", "open");
+ });
+
+ it("closes on escape and blur", () => {
+ render(
+
+ 查看说明
+ 最多保留 7 天
+
+ );
+
+ const trigger = screen.getByRole("button", { name: "查看说明" });
+
+ fireEvent.focus(trigger);
+ expect(screen.getByRole("tooltip")).toBeInTheDocument();
+
+ fireEvent.keyDown(trigger, { key: "Escape" });
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ expect(trigger).toHaveAttribute("data-state", "closed");
+
+ fireEvent.focus(trigger);
+ expect(screen.getByRole("tooltip")).toBeInTheDocument();
+
+ fireEvent.blur(trigger);
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ expect(trigger).not.toHaveAttribute("aria-describedby");
+ });
+
+ it("renders tooltip content inside the shared layer portal", () => {
+ render(
+
+ 查看说明
+ 最多保留 7 天
+
+ );
+
+ const portalRoot = document.getElementById("wemail-layer-root");
+ const tooltip = screen.getByRole("tooltip");
+
+ expect(portalRoot).not.toBeNull();
+ expect(portalRoot?.contains(tooltip)).toBe(true);
+ });
+});
diff --git a/apps/web/src/test/shared-typography.test.tsx b/apps/web/src/test/shared-typography.test.tsx
new file mode 100644
index 0000000..068d840
--- /dev/null
+++ b/apps/web/src/test/shared-typography.test.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Code, Heading, Kbd, Label, Muted, Text } from "../shared/typography";
+
+describe("shared typography primitives", () => {
+ it("renders semantic heading tags with size and tone variants", () => {
+ render(
+
+ 收件箱总览
+
+ );
+
+ const heading = screen.getByRole("heading", { level: 1, name: "收件箱总览" });
+ expect(heading.tagName).toBe("H1");
+ expect(heading).toHaveClass("ui-heading", "ui-heading-display-md", "ui-tone-accent");
+ });
+
+ it("renders text and muted copy with shared typography classes", () => {
+ render(
+ <>
+
+ 共 12 封邮件
+
+ 最近 24 小时自动刷新
+ >
+ );
+
+ expect(screen.getByText("共 12 封邮件")).toHaveClass("ui-text", "ui-text-lg");
+ expect(screen.getByText("最近 24 小时自动刷新")).toHaveClass("ui-text", "ui-text-muted");
+ });
+
+ it("renders label, code, and kbd with their native semantics", () => {
+ render(
+
+
+
+ mailbox_id
+
+
+ );
+
+ expect(screen.getByText("搜索邮箱").tagName).toBe("LABEL");
+ expect(screen.getByLabelText("搜索邮箱")).toHaveAttribute("id", "mailbox-search");
+ expect(screen.getByText("mailbox_id")).toHaveClass("ui-code", "ui-tone-success");
+ expect(screen.getByText("Cmd")).toHaveClass("ui-kbd-key");
+ expect(screen.getByText("K")).toHaveClass("ui-kbd-key");
+ });
+});
diff --git a/apps/web/src/test/users-list-page.test.tsx b/apps/web/src/test/users-list-page.test.tsx
index 9e2cac4..b71f550 100644
--- a/apps/web/src/test/users-list-page.test.tsx
+++ b/apps/web/src/test/users-list-page.test.tsx
@@ -50,9 +50,9 @@ describe("UsersListPage", () => {
expect(screen.getByRole("columnheader", { name: "角色" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "操作" })).toHaveClass("ui-table-sticky-end");
expect(screen.getByRole("checkbox", { name: "选择全部用户" }).closest("th")).toHaveClass("ui-table-sticky-start");
- expect(screen.getAllByText("正常").find((element) => element.classList.contains("accounts-status-pill"))).toHaveClass(
- "accounts-status-pill",
- "accounts-status-pill--enabled"
+ expect(screen.getAllByText("正常").find((element) => element.classList.contains("ui-badge"))).toHaveClass(
+ "ui-badge",
+ "ui-badge-success"
);
expect(screen.getByLabelText("搜索用户")).toBeInTheDocument();
expect(screen.getByLabelText("角色筛选")).toBeInTheDocument();
diff --git a/apps/web/src/test/utils/index.ts b/apps/web/src/test/utils/index.ts
new file mode 100644
index 0000000..66b123a
--- /dev/null
+++ b/apps/web/src/test/utils/index.ts
@@ -0,0 +1,2 @@
+export { renderWithTheme, type SupportedTheme, type RenderWithThemeResult } from "./renderWithTheme";
+export { pressKey, type PressKeyOptions } from "./pressKey";
diff --git a/apps/web/src/test/utils/pressKey.ts b/apps/web/src/test/utils/pressKey.ts
new file mode 100644
index 0000000..f809869
--- /dev/null
+++ b/apps/web/src/test/utils/pressKey.ts
@@ -0,0 +1,25 @@
+import { fireEvent } from "@testing-library/react";
+
+export type PressKeyOptions = {
+ code?: string;
+ shiftKey?: boolean;
+ ctrlKey?: boolean;
+ altKey?: boolean;
+ metaKey?: boolean;
+};
+
+export function pressKey(element: Element, key: string, options: PressKeyOptions = {}) {
+ const init: KeyboardEventInit = {
+ key,
+ code: options.code ?? key,
+ shiftKey: options.shiftKey,
+ ctrlKey: options.ctrlKey,
+ altKey: options.altKey,
+ metaKey: options.metaKey,
+ bubbles: true,
+ cancelable: true
+ };
+
+ fireEvent.keyDown(element, init);
+ fireEvent.keyUp(element, init);
+}
diff --git a/apps/web/src/test/utils/renderWithTheme.ts b/apps/web/src/test/utils/renderWithTheme.ts
new file mode 100644
index 0000000..b5bdf43
--- /dev/null
+++ b/apps/web/src/test/utils/renderWithTheme.ts
@@ -0,0 +1,29 @@
+import { type ReactElement, type ReactNode } from "react";
+import { act, render, type RenderOptions, type RenderResult } from "@testing-library/react";
+
+export type SupportedTheme = "light" | "dark";
+
+type RenderWithThemeOptions = RenderOptions & {
+ theme?: SupportedTheme;
+};
+
+export type RenderWithThemeResult = RenderResult & {
+ setTheme: (next: SupportedTheme) => void;
+};
+
+export function renderWithTheme(
+ ui: ReactElement | ReactNode,
+ { theme = "light", ...rest }: RenderWithThemeOptions = {}
+): RenderWithThemeResult {
+ document.documentElement.dataset.theme = theme;
+
+ const result = render(ui as ReactElement, rest);
+
+ function setTheme(next: SupportedTheme) {
+ act(() => {
+ document.documentElement.dataset.theme = next;
+ });
+ }
+
+ return { ...result, setTheme };
+}
diff --git a/docs/design-system/CHANGELOG.md b/docs/design-system/CHANGELOG.md
new file mode 100644
index 0000000..ce4fb1b
--- /dev/null
+++ b/docs/design-system/CHANGELOG.md
@@ -0,0 +1,101 @@
+# Design System Changelog
+
+约定:格式参考 [Keep a Changelog](https://keepachangelog.com/)。日期使用 ISO 8601。版本号与设计系统独立演进。
+
+## [Unreleased]
+
+### Added — Public Preview & Layer Hardening (2026-04-23)
+
+- `apps/web/src/shared/overlay/layer-utils.ts` — 抽出 shared layer 能力:portal root、focusable 查询、overlay inert 管理、基础 floating 定位
+- `apps/web/src/test/shared-overlay.test.tsx` — 新增 focus trap / 背景 inert 回归
+- `apps/web/src/test/shared-popover.test.tsx` — 新增 portal 渲染回归
+- `apps/web/src/test/shared-tooltip.test.tsx` — 新增 portal 渲染回归
+- `apps/web/src/test/app.test.tsx` — 新增公开 `/design-system` 路由与首页入口回归
+
+### Changed
+
+- `apps/web/src/app/App.tsx` — `/design-system` 提前从应用壳层分流,变为公开独立页面
+- `apps/web/src/app/AppRoutes.tsx` — 移除后台工作台中的 `/design-system` 路由注册
+- `apps/web/src/features/landing/WemailLandingPage.tsx` — 首页顶部与移动菜单新增 `设计系统` 链接
+- `apps/web/src/pages/DesignSystemPage.tsx` — 补齐公开页头、主题切换、真实预览卡片与 live overlay demo,逐步替换 placeholder 区块
+- `apps/web/src/features/accounts/AccountsListPage.tsx`、`apps/web/src/pages/UsersListPage.tsx` — 接入 `PageLayout` 与 `FilterBar`,开始把重复页面结构收敛进 shared 原语
+- `apps/web/src/shared/overlay/OverlayPrimitives.tsx` — Overlay 改为 portal 渲染,支持 focus trap、背景 inert、body scroll lock、焦点归还
+- `apps/web/src/shared/popover/PopoverPrimitives.tsx` — Popover 改为 shared layer portal,并加入基础定位与碰撞回退
+- `apps/web/src/shared/tooltip/TooltipPrimitives.tsx` — Tooltip 改为 shared layer portal,并加入基础定位与 side 回退
+
+### Added — Page Layout & Filter Bar (2026-04-23)
+
+- `apps/web/src/shared/page-layout/*` — 新增页面级布局原语:`Page`、`PageHeader`、`PageToolbar`、`PageBody`、`PageMain`、`PageSidebar`
+- `apps/web/src/shared/filter-bar/*` — 新增筛选条原语:`FilterBar`、`FilterBarActions`、`FilterBarSummary`
+- `apps/web/src/shared/metric-card/*` — 新增统计卡原语:`MetricCard`
+- `apps/web/src/test/shared-page-layout.test.tsx` — 锁定页面级布局结构
+- `apps/web/src/test/shared-filter-bar.test.tsx` — 锁定筛选条布局与摘要区域
+- `apps/web/src/test/shared-metric-card.test.tsx` — 锁定统计卡结构
+
+### Added — Sprint 7 Preview & Docs (2026-04-23)
+
+- `apps/web/src/pages/DesignSystemPage.tsx` — 重构为 13 分区预览工作面,固定 section id、页内导航、回归检查点与“等待注入真实示例”槽位
+- `apps/web/src/test/design-system-page.test.tsx` — 锁定 13 分区结构、导航锚点和占位契约
+- `apps/web/e2e/design-system.spec.ts` — 新增 `/design-system` 专用 Playwright 用例,覆盖结构断言与 light/dark 视觉回归脚手架
+- `docs/design-system/README.md` — 新增 13 类分区映射、后续注入约定与视觉回归说明
+
+### Changed
+
+- `docs/design-system/CHANGELOG.md` — 开始记录 S7 预览与文档工作面变更
+- `apps/web/e2e/README.md` — 补充 design-system 专用 e2e/视觉回归说明
+
+### Fixed
+
+- 统一 `/design-system` 作为 design system 预览页路径,不再为后续文档和回归流程保留 `/_design` 旧命名
+
+### Added — Sprint 1 Foundation (2026-04-22)
+
+- `apps/web/src/shared/styles/tokens.css` — 新增 token 层
+ - 品牌色 9 阶(`--brand-50 … 700`)+ 辅助橙(`--brand-soft-500`)
+ - 语义色(success / warning / danger / info)各含 500 / 100 / 600 + `-bg` soft 变体
+ - 中性色 9 阶(`--neutral-0 … 900`),dark 主题反转
+ - 间距 `--space-1 … --space-32`(4px 网格)
+ - 圆角 `--radius-xs/sm/md/lg/xl/full`(与旧 `--radius-shell/card/pill/field` 并存)
+ - 阴影 `--elevation-xs … xl`(与旧 `--shadow-*` 并存)
+ - 字号 `--font-display-lg/md`、`--font-title-lg/md`、`--font-body-lg/md`、`--font-caption`、`--font-mono-size` + 对应行高
+ - 动效 `--duration-instant/fast/base/slow/slower` + 三条 ease 曲线
+ - 层级 `--z-base … --z-tooltip` 共 10 档
+ - 焦点环 `--focus-ring-width/offset/color/ring`
+ - 图表色板 `--chart-series-1 … 8`
+- `apps/web/src/shared/styles/primitives.css` — 空 barrel,供 S2-S6 component 追加自己的 `@import`
+- `apps/web/src/shared/styles/index.css` — 顶部新增 `@import "./tokens.css"; @import "./primitives.css";`;旧内容全部保留
+- `apps/web/src/test/utils/renderWithTheme.ts` — 基于 `@testing-library/react` 的 `render` 包装,挂 `data-theme`,支持运行时切换
+- `apps/web/src/test/utils/pressKey.ts` — 键盘按键测试辅助,封装 `keyDown + keyUp`
+- `apps/web/src/test/utils/index.ts` — 桶式导出
+- `apps/web/src/pages/DesignSystemPage.tsx` — 路由 `/design-system`,展示 token 与原语的可视预览骨架
+- `apps/web/src/app/AppRoutes.tsx` — 注册 `/design-system` 路由(后续已提升为公开独立页面)
+- `docs/design-system/README.md` — 索引与 token 速查表
+
+### Changed
+
+- (无 — S1 为 additive 落地,不破坏既有样式)
+
+### Removed
+
+- (无)
+
+---
+
+## 模板(后续 Sprint 填写)
+
+```md
+### Added — Sprint N Name (YYYY-MM-DD)
+- ...
+
+### Changed
+- ...
+
+### Deprecated
+- ...
+
+### Removed
+- ...
+
+### Fixed
+- ...
+```
diff --git a/docs/design-system/README.md b/docs/design-system/README.md
new file mode 100644
index 0000000..c083cbe
--- /dev/null
+++ b/docs/design-system/README.md
@@ -0,0 +1,103 @@
+# WeMail 设计系统
+
+参考 [`docs/plans/2026-04-22-design-system-v1.md`](../plans/2026-04-22-design-system-v1.md) 了解整体方案、实施节奏与验收清单。
+
+## 当前状态
+
+- 预览路由固定为 `/design-system`,不再使用 `/_design`
+- `/design-system` 为公开独立页面,不依赖登录,也不挂在后台工作台壳层里
+- 未登录首页顶部提供 `设计系统` 入口,方便直接预览与对照
+- 13 个分区已具备真实原语示例、公共页头、主题切换和 live overlay demo
+- Playwright 已锁定公开 `/design-system` 的结构性回归,light/dark 截图仍按环境变量开关保护
+
+## 范围
+
+- 仅 Web 端(`apps/web`),暂不覆盖移动端与邮件模板
+- 附加式(additive)落地:保留既有 CSS 变量兼容层,并把新增 token、原语和 page-level showcase 统一收敛到 shared 层
+- 现阶段重点是把 Web 端原语、公共预览和基础交互能力补齐;移动端、邮件模板和更高级的浮层定位仍在后续演进范围
+
+## 13 类预览分区
+
+`apps/web/src/pages/DesignSystemPage.tsx` 现在以稳定英文 section id 组织公开预览页。后续新增真实示例时,优先在现有分区内扩展示例,不要重排结构或另起平行入口。
+
+| Section id | 页面标题 | 承接内容 | 主要来源 Sprint |
+|---|---|---|---|
+| `foundations` | `Foundations` | 设计原则、发布节奏、锚点契约、回归入口 | S1 / S7 |
+| `color-theme` | `Color & Theme` | 品牌色、语义色、主题差异 | S1 |
+| `layout-spacing` | `Layout & Spacing` | 页面布局、容器节奏、间距尺度 | S1 |
+| `elevation-radius` | `Elevation & Radius` | 阴影、圆角、表面层级 | S1 |
+| `typography-content` | `Typography & Content` | Typography、Divider、Icon、Spinner、Skeleton | S2 |
+| `buttons-actions` | `Buttons & Actions` | Button 体系、动作矩阵、CopyButton | S2 / S5 |
+| `form-inputs` | `Form Inputs` | TextInput、Select、Textarea、SearchInput、MultiSelect | S5 |
+| `selection-controls` | `Selection Controls` | Switch、Checkbox、Radio、字段组选项 | S5 |
+| `navigation-wayfinding` | `Navigation & Wayfinding` | Breadcrumb、Pagination、Tabs、Steps | S4 |
+| `surfaces-cards` | `Surfaces & Cards` | Card、容器组合、EmptyState | S3 |
+| `data-display` | `Data Display & Charts` | Table、Chart、KVList、Avatar | S1 / S5 |
+| `feedback-status` | `Feedback & Status` | Tag、Badge、Progress、Alert、Spinner、Skeleton | S3 / S5 / S6 |
+| `overlays-utilities` | `Overlays & Utilities` | Overlay、Tooltip、Popover、ScrollArea、工具型原语 | S6 |
+
+## 预览页维护约定
+
+后续继续扩 `/design-system` 时遵守下面几条:
+
+1. 保持 13 个 section id 不变,Playwright 和文档都会引用这些锚点。
+2. 优先在现有 section 的 preview 区内扩真实示例,不要新增一整套平行布局。
+3. 每个分区都至少保留三类信息:覆盖范围、回归检查点、真实示例或占位。
+4. 新增原语示例后,同步更新本 README 的对应分区说明和 [`CHANGELOG.md`](./CHANGELOG.md)。
+
+## Token 快速索引
+
+所有 token 定义在 [`apps/web/src/shared/styles/tokens.css`](../../apps/web/src/shared/styles/tokens.css),按类别分组:
+
+| 类别 | 变量前缀 | 说明 |
+|---|---|---|
+| 品牌 | `--brand-50 … --brand-700`、`--brand-soft-500` | 主色梯度,`--brand-500` 与旧 `--accent` 等值 |
+| 语义 | `--success-*`、`--warning-*`、`--danger-*`、`--info-*` | 各自 500 / 100 / 600 + soft 变体 |
+| 中性 | `--neutral-0 … --neutral-900` | 9 阶灰度;dark 模式镜像反转 |
+| 间距 | `--space-1 … --space-32` | 4px 网格 |
+| 圆角 | `--radius-xs/sm/md/lg/xl/full` | 6 档 |
+| 阴影 | `--elevation-xs/sm/md/lg/xl` | 5 档 |
+| 字号 | `--font-display-lg/md`、`--font-title-lg/md`、`--font-body-lg/md`、`--font-caption`、`--font-mono-size` | 语义字号 |
+| 行高 | `--line-display/title/body/caption/mono` | 与字号配套 |
+| 动效 | `--duration-instant/fast/base/slow/slower`、`--ease-standard/emphasized/in-out` | 统一过渡时长与曲线 |
+| 层级 | `--z-base/raised/dropdown/sticky/fixed/overlay/modal/popover/toast/tooltip` | z-index 语义层 |
+| 焦点 | `--focus-ring-width/offset/color/ring` | 统一键盘焦点环 |
+| 图表 | `--chart-series-1 … --chart-series-8` | Nivo 主题消费 |
+
+## 组件清单
+
+| 名称 | 目录 | 状态 |
+|---|---|---|
+| Button / ButtonLink / ButtonAnchor | `shared/button` | ✅ 已补齐;含 `subtle`、`xs`、icon-only 等变体 |
+| TextInput / SelectInput / TextareaInput / CheckboxField / RadioGroupField / FormField / SearchInput / MultiSelect / Checkbox / Radio | `shared/form` | ✅ 已补齐 |
+| Table 全家桶 | `shared/table` | ✅ 已有 |
+| Switch | `shared/switch` | ✅ 已有 |
+| Chart(Nivo 主题) | `shared/chart` | ✅ 已有 |
+| Overlay(Modal/Drawer) | `shared/overlay` | ✅ 已有;现支持 portal、focus trap、背景 inert 和焦点归还 |
+| Typography / Divider / Icon / Spinner / Skeleton | `shared/{typography,divider,icon,spinner,skeleton}` | ✅ 已补齐 |
+| Card / Tag / Badge / EmptyState | `shared/{card,tag,badge,empty-state}` | ✅ 已补齐 |
+| Breadcrumb / Pagination / Tabs / Steps | `shared/{breadcrumb,pagination,tabs,steps}` | ✅ 已补齐 |
+| Progress / Alert / CopyButton / KVList / Avatar | `shared/...` | ✅ 已补齐 |
+| Tooltip / Popover / ScrollArea | `shared/{tooltip,popover,scroll-area}` | ✅ 已补齐;Tooltip / Popover 使用 shared layer portal 和基础碰撞处理 |
+| Page / PageHeader / PageToolbar / PageBody / PageMain / PageSidebar | `shared/page-layout` | ✅ 已补齐;用于统一页面级结构 |
+| FilterBar / FilterBarActions / FilterBarSummary | `shared/filter-bar` | ✅ 已补齐;用于搜索、筛选与结果摘要排布 |
+| MetricCard | `shared/metric-card` | ✅ 已补齐;用于 KPI / 摘要统计卡 |
+
+## 视觉回归脚手架
+
+- 专用 spec:[`apps/web/e2e/design-system.spec.ts`](../../apps/web/e2e/design-system.spec.ts)
+- 默认会跑结构性断言,确认 `/design-system` 能作为公开页面展示 13 个 section
+- light/dark 截图测试先以环境变量开关保护:`PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1`
+- 未来在示例稳定后使用 `pnpm test:e2e -- --update-snapshots apps/web/e2e/design-system.spec.ts` 生成或更新基线
+
+## A11y 契约(所有原语必须满足)
+
+- role / ARIA attrs 明确
+- 键盘可达(Tab / Enter / Space / Arrow / Esc)
+- 对比度 ≥ 4.5:1(正文)/ 3:1(大字或图形)
+- `prefers-reduced-motion` 下降级动画
+- Modal / Drawer / Popover 焦点陷阱 + 归还
+
+## 变更记录
+
+每次 token / 原语 / 预览工作面变更都写入 [`CHANGELOG.md`](./CHANGELOG.md)。
diff --git a/docs/plans/2026-04-22-design-system-v1.md b/docs/plans/2026-04-22-design-system-v1.md
new file mode 100644
index 0000000..fc72eae
--- /dev/null
+++ b/docs/plans/2026-04-22-design-system-v1.md
@@ -0,0 +1,428 @@
+# WeMail Web 端 UI 设计系统 v1.0 落地方案
+
+- 日期:2026-04-22
+- 分支:`feature/design-system-v1`
+- 范围:仅 Web 端(`apps/web`),不含移动端与邮件模板
+- 目标:把分散在各 feature 的视觉/交互规范收敛为一套可复用、可测试、可演进的 design token + 原语组件库,并提供一个可导航的预览页
+
+---
+
+## 1. 背景与目标
+
+- 参考图列出了 13 个模块(色彩、布局、卡片、按钮、输入、标签、导航、开关、复选框、单选、进度、提示、阴影、圆角、间距)
+- 现有仓库已有 `shared/{button,form,table,switch,chart,overlay}` 原语与一套 CSS 变量,但缺 Tag、Badge、Breadcrumb、Pagination、Tabs、Steps、Progress、Alert、Card、Typography 等常用原语
+- 新功能 / 重构页面时,团队频繁复制粘贴样式片段,偏差累积
+- **本方案目标**:一次性补齐缺口,保持对现有代码的零破坏(通过别名 / 渐进迁移),并让后续页面"只用原语、不写业务 CSS"
+
+### 非目标(本期不做)
+- 移动端适配、邮件 HTML 模板、打印样式
+- 从 lucide-react 迁移到自有图标库
+- Storybook / Ladle 预览工具(用自建 `/_design` 路由替代)
+- i18n 接入(只做"提供可覆盖 prop",不拉 i18n 库)
+
+---
+
+## 2. 现状盘点
+
+| 参考图模块 | 现有 | 缺口 |
+|---|---|---|
+| 色彩 | `--accent`、`--success-soft`、`--warning-soft`、中性 `--text/--text-muted/--text-soft` | 缺 `--success/--warning/--danger/--info` 主色梯度;缺中性灰 9 阶;缺品牌 9 阶 |
+| 圆角 | `--radius-shell/card/pill/field` | 缺系统化的 xs/sm/md/lg/xl 命名 |
+| 阴影 | `--shadow-soft/card/border` | 缺 xs/sm/md/lg/xl 梯度 |
+| 间距 | 无 token,散在各 css | 缺 4px 网格完整 `--space-*` |
+| 字号 | 浏览器默认 + 局部 clamp | 缺 display/title/body/caption 语义 token |
+| Button | `Button / ButtonLink / ButtonAnchor`(7 变体) | 需新增 xs icon-only 变体;可选新增 subtle 变体 |
+| 输入 | `TextInput / SelectInput / TextareaInput / FormField / CheckboxField / RadioGroupField` | 缺 `SearchInput`、独立 `Checkbox`/`Radio`、`MultiSelect` |
+| 表格 | 已有 `TableContainer / Table / ...` | OK |
+| Switch | 已有 | OK |
+| 卡片 | 只有 `.panel` 全局类 + 业务 css | 需 `Card / CardHeader / CardBody / CardFooter` |
+| 标签与状态 | 各页 inline chip | 需 `Tag`(分类)+ `Badge`(状态)两原语 |
+| 导航 | 局部 `workspace-pill-nav`、面包屑散落 | 需 `Breadcrumb / Pagination / Tabs / Steps` |
+| 进度 | 无 | `Progress` |
+| 提示 | `WemailToast`(飞出)/ 无静态 | `Alert`(页内静态) |
+| Overlay | `shared/overlay` 已有 | 需核对 Modal/Drawer/Dialog 一致 |
+| 排版 | `
`+ className | 需 `Heading / Text / Label / Muted` 排版原语 |
+
+---
+
+## 3. Design Tokens(新建 `apps/web/src/shared/styles/tokens.css`)
+
+### 3.1 颜色
+
+```css
+:root,
+:root[data-theme="light"] {
+ /* 品牌 — 保留 --accent=#ff7a00 作为 --brand-500 */
+ --brand-50: #fff2e6;
+ --brand-100: #ffe0c2;
+ --brand-200: #ffc78a;
+ --brand-300: #ffa74d;
+ --brand-400: #ff8b26;
+ --brand-500: #ff7a00;
+ --brand-600: #e66d00;
+ --brand-700: #b35400;
+
+ /* 辅助橙 */
+ --brand-soft-500: #ffb366;
+
+ /* 语义色 */
+ --success-500: #22c55e; --success-100: #d1fadf; --success-600: #16a34a; --success-soft: rgba(34,197,94,.14);
+ --warning-500: #f59e0b; --warning-100: #fef0c7; --warning-600: #d97706; --warning-soft: rgba(245,158,11,.14);
+ --danger-500: #ef4444; --danger-100: #fee2e2; --danger-600: #dc2626; --danger-soft: rgba(239,68,68,.14);
+ --info-500: #3b82f6; --info-100: #dbeafe; --info-600: #2563eb; --info-soft: rgba(59,130,246,.14);
+
+ /* 中性 9 阶 */
+ --neutral-0: #ffffff;
+ --neutral-50: #f9fafb;
+ --neutral-100:#f3f4f6;
+ --neutral-200:#e5e7eb;
+ --neutral-300:#d1d5db;
+ --neutral-400:#9ca3af;
+ --neutral-500:#6b7280; /* 图中"中性色" */
+ --neutral-700:#374151;
+ --neutral-900:#111827;
+
+ /* 兼容别名 — 保证现有 CSS 零回归 */
+ --accent: var(--brand-500);
+ --accent-strong: var(--brand-400);
+ --accent-soft: color-mix(in srgb, var(--brand-500) 14%, transparent);
+}
+
+:root[data-theme="dark"] { /* 同名重定义,保持语义色跨主题稳定 */ }
+```
+
+### 3.2 间距(4px 网格)
+```css
+--space-1:4; --space-2:8; --space-3:12; --space-4:16;
+--space-5:20; --space-6:24; --space-8:32; --space-10:40;
+--space-12:48;--space-14:56;--space-16:64;--space-18:72;
+--space-20:80;--space-24:96;--space-28:112;--space-32:128;
+```
+
+### 3.3 圆角
+```css
+--radius-xs:4px; --radius-sm:8px; --radius-md:12px;
+--radius-lg:16px; --radius-xl:24px; --radius-full:9999px;
+/* 现有别名重定向 */
+--radius-field: var(--radius-lg);
+--radius-card: var(--radius-xl);
+--radius-pill: var(--radius-full);
+```
+
+### 3.4 阴影
+```css
+--elevation-xs: 0 1px 2px rgba(17,17,17,.04);
+--elevation-sm: 0 2px 6px rgba(17,17,17,.06), 0 1px 2px rgba(17,17,17,.04);
+--elevation-md: 0 6px 16px rgba(17,17,17,.08), 0 2px 4px rgba(17,17,17,.04);
+--elevation-lg: 0 14px 32px rgba(17,17,17,.10), 0 4px 8px rgba(17,17,17,.04);
+--elevation-xl: 0 24px 64px rgba(17,17,17,.14), 0 8px 16px rgba(17,17,17,.06);
+```
+
+### 3.5 字号 / 行高语义
+```css
+--font-display-lg: 48px/1.1;
+--font-display-md: 32px/1.2;
+--font-title-lg: 24px/1.3;
+--font-title-md: 20px/1.35;
+--font-body-lg: 16px/1.55;
+--font-body-md: 14px/1.55;
+--font-caption: 12px/1.45;
+--font-mono: 13px/1.6;
+```
+
+### 3.6 Motion tokens(新增)
+```css
+--duration-instant: 80ms;
+--duration-fast: 150ms;
+--duration-base: 200ms;
+--duration-slow: 320ms;
+--duration-slower: 480ms;
+--ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
+--ease-emphasized: cubic-bezier(0.2, 1, 0.3, 1);
+--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+```
+
+### 3.7 Z-index 分层(新增,收敛各处散落的 z 值)
+```css
+--z-base: 0;
+--z-raised: 1;
+--z-dropdown: 10;
+--z-sticky: 20;
+--z-fixed: 30;
+--z-overlay: 40;
+--z-modal: 50;
+--z-popover: 60;
+--z-toast: 70;
+--z-tooltip: 80;
+```
+
+### 3.8 Focus ring(新增)
+```css
+--focus-ring-width: 3px;
+--focus-ring-offset: 2px;
+--focus-ring-color: color-mix(in srgb, var(--brand-500) 30%, transparent);
+--focus-ring: 0 0 0 var(--focus-ring-width) var(--focus-ring-color);
+```
+
+### 3.9 Data viz 色板(供 `shared/chart/nivoTheme` 消费)
+```css
+--chart-series-1: var(--brand-500);
+--chart-series-2: var(--info-500);
+--chart-series-3: var(--success-500);
+--chart-series-4: var(--warning-500);
+--chart-series-5: var(--danger-500);
+--chart-series-6: #8b5cf6;
+--chart-series-7: #06b6d4;
+--chart-series-8: #ec4899;
+```
+
+---
+
+## 4. 横向基础设施
+
+### 4.1 Icon 规范(`shared/icon/README.md` + lint 约定)
+- 继续用 `lucide-react`,不引入自有图标库
+- 尺寸 token:14(caption)、16(inline body)、20(button)、24(section)
+- 颜色默认 `currentColor`,stroke-width 1.5
+- 规则:所有图标必须 `aria-hidden="true"` 或带 `aria-label`
+- 在 `shared/icon/Icon.tsx` 导出 `Icon`(统一 size + aria 包装),而非到处裸用 lucide
+
+### 4.2 A11y 契约(每个原语 README 强制字段)
+- role / ARIA attrs
+- 键盘导航(Tab / Enter / Space / Arrow / Esc)
+- 对比度(≥ 4.5:1 正文 / 3:1 大字或图形)
+- `prefers-reduced-motion` 兜底
+- 关键组件(Modal/Drawer/Popover)焦点陷阱 + 归还
+
+### 4.3 i18n 接入点
+- 不引入 i18n 库;原语的 `aria-label`、`loadingLabel`、`closeLabel` 等暴露成 prop,允许页面层替换
+- 默认值用中文(WeMail 默认语言);禁止在原语内部硬编码需要翻译的动词
+
+### 4.4 测试工具(`apps/web/src/test/utils/`)
+- `renderWithTheme(ui, { theme })` — 挂载 `data-theme` 并等待样式就绪
+- `pressKey(el, key)` — 封装 fireEvent.keyDown/Up 的组合
+- `axeRun(container)` — 可选的 a11y 自动巡检(先留挂载点,不强求本期接)
+
+### 4.5 Reduced-motion 统一开关
+- 所有新原语的 transition 必须在 `@media (prefers-reduced-motion: reduce)` 下降级为 `none`
+- `base.css` 新增一条全局兜底:`*{ transition-duration: 0 !important }` 放 reduced-motion 媒体查询里(可被个别 opt-out)
+
+### 4.6 视觉回归(补丁,放最后一个 Sprint)
+- Playwright 跑 `/_design` 路由在 light/dark 两个主题下截图
+- 存到 `apps/web/tests/visual/__snapshots__/`,PR 校对
+
+---
+
+## 5. 组件清单
+
+> 命名:目录 `shared//`;文件 `Primitives.tsx + index.ts + README.md + test`;CSS 类 `ui--...`;状态 `is-` + `data-state`。
+
+### A. 已有 · 零改动
+Table、Switch、Chart、Overlay(需内部核对 Modal/Drawer 完整性)
+
+### B. 已有 · 扩展
+
+#### B1. `shared/button`
+- 新增 size `xs` + `iconOnly` 组合(32×32)
+- 新增 `variant="subtle"`(灰底 hover tint,对应图中"次要按钮·默认"浅色态)
+- README 补 xs 尺寸、subtle 变体用法
+
+#### B2. `shared/form`
+- 新增 `SearchInput`(`leadingIcon` + 清除按钮)
+- 拆出独立 `Checkbox` / `Radio` 原子(当前只有 `CheckboxField`/`RadioGroupField` 组合)
+- 新增 `MultiSelect`(headless 下拉 + 内部用 Checkbox)
+
+### C. 新增原语
+
+| 组件 | 目录 | 核心 Props | 变体 | 状态 |
+|---|---|---|---|---|
+| Card | `shared/card` | variant / padding / elevation / tone | base / data / status / accent | hover / is-interactive |
+| Tag | `shared/tag` | variant / shape / size / dot / icon | neutral / brand / info / success / warning / danger | — |
+| Badge | `shared/badge` | variant / appearance / size | soft / solid | — |
+| Breadcrumb | `shared/breadcrumb` | separator | — | is-current |
+| Pagination | `shared/pagination` | total / pageSize / page / onChange / siblings | size sm/md | is-active / is-disabled |
+| Tabs | `shared/tabs` | value / onChange / variant | segmented / underline | is-selected / is-disabled |
+| Steps | `shared/steps` | current / items | horizontal / vertical | upcoming / active / completed / error |
+| Progress | `shared/progress` | value / max / variant / size / showLabel | linear(环形延后) | indeterminate |
+| Alert | `shared/alert` | variant / appearance / title / onClose | soft / outline | dismissible |
+| Typography | `shared/typography` | as / size / tone | Heading / Text / Label / Muted / Code / Kbd | — |
+| Divider | `shared/divider` | orientation / inset / dashed | horizontal / vertical | — |
+| Avatar | `shared/avatar` | src / alt / size / fallback | circle / square | is-loading |
+| EmptyState | `shared/empty-state` | icon / title / description / actions | default / error / no-access | — |
+| Skeleton | `shared/skeleton` | shape / width / height / rounded | rect / text / circle | is-animating |
+| Spinner | `shared/spinner` | size / tone | — | — |
+| Tooltip | `shared/tooltip` | content / placement / openDelay | — | open / closed |
+| Popover | `shared/popover` | trigger / content / placement | — | open / closed |
+| Kbd | `shared/typography` 子组件 | — | — | — |
+| CopyButton | `shared/copy-button` | value / onCopy / children | — | is-copied |
+| KVList | `shared/kv-list` | items / density | — | — |
+| ScrollArea | `shared/scroll-area` | maxHeight / direction | — | has-scroll |
+| PageLayout | `shared/page-layout` | `` `` `` `` `` | — | — |
+| FilterBar | `shared/filter-bar` | fields 数组 | — | has-active-filters |
+
+### D. 合计
+- 新增 15 类(C1–C15),扩展 2 类(B1–B2),零动 3 类,总计 20 个原语
+- 所有原语统一:forwardRef + `cx()` + `data-state` + test 文件
+
+---
+
+## 6. 页面级抽象
+
+### 6.1 `PageLayout` 脚手架
+```tsx
+
+ ...}
+ title="账号列表"
+ description="..."
+ actions={}
+ />
+
+
+
+
+
+
+ ...
+
+
+```
+- 落地在 v1.1(本期只登记,不实施),避免一次性动太多页面
+
+### 6.2 FilterBar 抽象
+- 账号列表、邮件列表、发件箱、公告都有"搜索 + 状态 + 创建人 + 时间"的组合,抽出 `FilterBar`
+- props:`fields: Array<{ type: "search" | "select" | "date" | "toggle", label, ... }>`
+- 实现放 v1.1
+
+---
+
+## 7. 文件结构
+
+```
+apps/web/src/shared/
+ styles/
+ tokens.css ← 新建(所有 token)
+ base.css ← 抽出 reset / 全局排版(可选,为减轻 index.css)
+ index.css ← 顶部 @import "./tokens.css" + "./base.css"
+ button/ ← 扩展
+ form/ ← 扩展
+ icon/ ← 新增
+ typography/ ← 新增
+ card/ ← 新增
+ tag/ ← 新增
+ badge/ ← 新增
+ breadcrumb/ ← 新增
+ pagination/ ← 新增
+ tabs/ ← 新增
+ steps/ ← 新增
+ progress/ ← 新增
+ alert/ ← 新增
+ divider/ ← 新增
+ avatar/ ← 新增
+ empty-state/ ← 新增
+ skeleton/ ← 新增
+ spinner/ ← 新增
+ tooltip/ ← 新增
+ popover/ ← 新增
+ copy-button/ ← 新增
+ kv-list/ ← 新增
+ scroll-area/ ← 新增
+ table/ switch/ chart/ overlay/ ← 保持
+
+apps/web/src/pages/
+ DesignSystemPage.tsx ← 新增,路由 /_design
+
+apps/web/src/test/utils/
+ renderWithTheme.ts ← 新增
+ pressKey.ts ← 新增
+
+apps/web/tests/visual/
+ design-system.spec.ts ← 新增 Playwright 快照
+ __snapshots__/ ← 基线截图
+
+docs/
+ design-system/
+ README.md ← 设计原则 + token 清单 + 组件索引
+ CHANGELOG.md ← token / 原语变更日志
+ plans/
+ 2026-04-22-design-system-v1.md ← 本文档
+```
+
+---
+
+## 8. 实施顺序(7 个 Sprint)
+
+每个 Sprint 结束:`pnpm test:web && pnpm lint && pnpm typecheck && pnpm build` 必须全绿,已有页面视觉零回归。
+
+| # | Sprint | 内容 | 预估 |
+|---|---|---|---|
+| S1 | Token foundation | `tokens.css` + 兼容别名 + `base.css` 抽出 + `DesignSystemPage` 骨架路由(/_design)+ 测试 utils + `CHANGELOG.md` | 1 天 |
+| S2 | 排版与原子 | `typography / divider / spinner / skeleton / icon` + 单测 + 在预览页展示 | 1 天 |
+| S3 | 卡片与状态 | `card / tag / badge / empty-state` + 单测 + 账号列表接入 Badge | 1 天 |
+| S4 | 导航四件套 | `breadcrumb / pagination / tabs / steps` + 把 `.workspace-pill-nav` 与 `.landing-code-tab-switch` 迁入 `Tabs` | 1 天 |
+| S5 | 输入扩展 + 开关类 | `SearchInput / Checkbox / Radio / MultiSelect` + `progress / alert / copy-button / kv-list / avatar` | 1 天 |
+| S6 | 浮层补齐 | `tooltip / popover / scroll-area`;核对 `overlay/` 目录的 Modal/Drawer | 1 天 |
+| S7 | 文档 + 视觉回归 | 每原语 README 完整;`/_design` 预览完整化;Playwright 快照;`docs/design-system/README.md` 索引 | 1 天 |
+
+**每 Sprint 的提交约定**:
+- 每个原语独立 commit(feat: add shared/…)
+- PR 合并前跑 `pnpm --filter web test` + 预览页截图对比
+
+---
+
+## 9. 验收清单
+
+- [ ] `tokens.css` 落地,现有 `--accent/--border/...` 继续可用(via 兼容别名),历史页面零视觉回归
+- [ ] 20 个原语全部就位(第 5 节表格)
+- [ ] 每原语 3 件套齐全:`*Primitives.tsx + index.ts + README.md`
+- [ ] 每原语单测覆盖:渲染 / 受控 / disabled / 变体 class / a11y role;≥80% 行覆盖
+- [ ] `/_design` 预览页展示所有变体与状态,按图中 13 分区对齐排版
+- [ ] `docs/design-system/README.md` 索引 + `CHANGELOG.md` 记录 token 变更
+- [ ] `pnpm test:web && lint && typecheck && build` 全绿
+- [ ] Playwright `/design-system` light + dark 快照基线提交
+- [ ] 至少 2 个既有页面接入新原语(账号列表 → Badge + Pagination,Landing 开发者段 → Tabs),前后截图对比入 PR
+- [ ] Reduced-motion 媒体查询在所有动画组件生效
+- [ ] 焦点环可见,Tab 键顺序合理
+
+---
+
+## 10. 风险与降级
+
+| 风险 | 影响 | 降级 |
+|---|---|---|
+| tokens 重定向导致旧 CSS 色偏 | 高 | 只新增变量 + 别名指回,不改旧变量;S1 结束做 diff 截图对比 |
+| Tabs 原语迁移改到 `workspace-pill-nav` 破坏现有 topbar | 中 | 保留旧类名作"外观 shim",先让 Tabs 内部渲染相同 className;两周后废弃 |
+| `focus-visible` 样式与现有按钮冲突 | 低 | 新 focus-ring 变量只给新增组件使用;Button 等已有组件保留自身 focus 样式 |
+| 一次性新增 20 目录体量大,review 吃力 | 中 | 按 Sprint 拆 commit + PR;每组件独立评审单元 |
+| 视觉回归误报(字体/浏览器抗锯齿) | 中 | 截图生成设定 deviceScaleFactor=2 + 禁用动画;阈值允许 ≤1% 像素偏差 |
+| lucide-react 图标 tree-shaking 失败 | 低 | 通过 `Icon` 组件统一导入,便于将来替换 |
+
+---
+
+## 11. 待决策开放问题
+
+1. 预览页路径:`/_design` vs `/design-system` vs 仅 dev 环境可见?
+2. Dark 主题的阴影是否要独立一套(现 dark 只改 `--shadow-soft/card`)?建议 S1 统一重算
+3. `Card variant="data"` 的数值排版要不要用 `--font-mono`?参考图是等宽
+4. `Tabs.underline` 本期是否实现?现在只用到 segmented,建议延后
+5. `Skeleton` 要不要含 shimmer 动画?建议默认无动画,prop 开启
+6. `Tooltip` 用原生 `title` 属性 fallback 还是完全 JS 实现?建议完全 JS,title 仅作 SSR 兜底
+7. 是否顺带引入 `@radix-ui/react-*` 做 headless 基座(Popover/Tabs/Tooltip)?当前无第三方 UI 依赖,引入需 ADR。建议暂不,先自写;若 S6 成本高再换
+8. Visual regression 要不要在 CI 上强制?建议先跑但仅 warn,观察一周后转 block
+
+---
+
+## 12. 与现有规范的衔接
+
+- 架构约束:原语属 `apps/web/src/shared`,不跨依赖到 `features/*` 或 `pages/*`(见 `docs/code-standard.md`)
+- 测试策略:单测 + 预览页 + 视觉回归;E2E 由现有 Playwright 关键流程兜底(见 `docs/testing-strategy.md`)
+- 提交规范:feat/refactor/docs 前缀;每原语独立 commit;PR 体记录 before/after 截图
+- 一旦本方案 merge,后续页面若重复造轮子将在 code review 被拒
+
+---
+
+## 13. 下一步行动
+
+1. 本文档合并到 main 后(或批准后直接在分支内)开始 **S1 Token foundation**
+2. 每完成 Sprint,在 PR 描述附预览页截图 + 影响页清单
+3. 全部合并后,追写 ADR:`docs/adr/0003-design-system-v1.md` 记录本期决策
diff --git a/docs/plans/2026-04-25-design-system-component-docsite-sidebar.md b/docs/plans/2026-04-25-design-system-component-docsite-sidebar.md
new file mode 100644
index 0000000..7843dbe
--- /dev/null
+++ b/docs/plans/2026-04-25-design-system-component-docsite-sidebar.md
@@ -0,0 +1,434 @@
+# Design System Component Docsite Sidebar Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 把 `/design-system` 改造成后台风格左侧组件目录 + 右侧组件详情文档站,去掉所有分组概览入口,并为当前目录中的所有组件补齐“真实示例 + API 表 + 使用说明”。
+
+**Architecture:** 左侧仅保留分组标题和组件项,视觉风格对齐管理后台左侧栏,但不复用业务导航语义。右侧统一使用组件文档模板,按“标题区 → 真实示例 → API 表 → 使用说明 → 规范说明”渲染;所有组件内容从独立 schema 文件读取,页面层只负责目录状态与路由编排。
+
+**Tech Stack:** React 19, TypeScript, Vitest, Testing Library, Playwright, 现有 shared primitives(Button / Card / Form / Tabs / Tooltip / Overlay 等)
+
+---
+
+### Task 1: 重构左侧目录为后台风格组件目录
+
+**Files:**
+- Modify: `apps/web/src/pages/DesignSystemPage.tsx`
+- Modify: `apps/web/src/pages/design-system/designSystemStyles.ts`
+- Test: `apps/web/src/test/design-system-page.test.tsx`
+
+**Step 1: Write the failing test**
+
+在 `apps/web/src/test/design-system-page.test.tsx` 增加断言:
+- 左侧仍按分组展示
+- 但不存在任何“概览”按钮
+- 左侧每组下直接显示组件项,例如 `Button`、`Card`、`SearchInput`
+
+```tsx
+it("renders sidebar groups with component items only and no overview entries", () => {
+ render(
+
+
+
+ );
+
+ const sidebar = screen.getByRole("navigation", { name: "Design system sidebar" });
+ expect(within(sidebar).queryByRole("button", { name: /概览/i })).not.toBeInTheDocument();
+ expect(within(sidebar).getByRole("button", { name: "Button" })).toBeInTheDocument();
+ expect(within(sidebar).getByRole("button", { name: "Card" })).toBeInTheDocument();
+ expect(within(sidebar).getByRole("button", { name: "SearchInput" })).toBeInTheDocument();
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders sidebar groups with component items only and no overview entries"
+```
+Expected: FAIL,因为当前侧栏仍有 overview 入口。
+
+**Step 3: Write minimal implementation**
+
+- `DesignSystemPage.tsx`
+ - 去掉 group overview 入口
+ - 左侧改成“分组标题 + 组件项”目录
+ - 默认选中第一个组件而不是分组概览
+- `designSystemStyles.ts`
+ - 调整左侧样式,使其更接近后台左栏:
+ - 分组标题
+ - 当前项高亮
+ - 紧凑目录节奏
+ - sticky 行为保留
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders sidebar groups with component items only and no overview entries"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/DesignSystemPage.tsx apps/web/src/pages/design-system/designSystemStyles.ts apps/web/src/test/design-system-page.test.tsx
+git commit -m "feat: switch design system sidebar to component-only navigation"
+```
+
+---
+
+### Task 2: 统一组件详情模板为“示例优先”阅读流
+
+**Files:**
+- Modify: `apps/web/src/pages/design-system/DesignSystemDocContent.tsx`
+- Modify: `apps/web/src/pages/design-system/designSystemStyles.ts`
+- Test: `apps/web/src/test/design-system-page.test.tsx`
+
+**Step 1: Write the failing test**
+
+增加断言:点击 `Button` 后,右侧文档必须先出现“真实示例”,再出现 “API 接口”,再出现“使用说明”。
+
+```tsx
+it("renders component docs with examples first, then API, then usage guidance", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Button" }));
+
+ const headings = screen.getAllByRole("heading").map((node) => node.textContent);
+ const expected = ["真实示例", "API 接口", "使用说明"];
+ const indexes = expected.map((heading) => headings.indexOf(heading));
+
+ expect(indexes.every((index) => index >= 0)).toBe(true);
+ expect(indexes).toEqual([...indexes].sort((a, b) => a - b));
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders component docs with examples first, then API, then usage guidance"
+```
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+- `DesignSystemDocContent.tsx`
+ - 去掉 group overview 模式
+ - 统一所有组件页的顺序:
+ 1. 标题区
+ 2. 真实示例
+ 3. API 接口
+ 4. 使用说明
+ 5. 设计规范 / 补充说明
+- `designSystemStyles.ts`
+ - 强化文档段落和 API 表块的层级
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders component docs with examples first, then API, then usage guidance"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/design-system/DesignSystemDocContent.tsx apps/web/src/pages/design-system/designSystemStyles.ts apps/web/src/test/design-system-page.test.tsx
+git commit -m "feat: align component docs to example-first reading flow"
+```
+
+---
+
+### Task 3: 扩展文档 schema 支持 API 表
+
+**Files:**
+- Modify: `apps/web/src/pages/design-system/designSystemContent.tsx`
+- Modify: `apps/web/src/pages/design-system/DesignSystemDocContent.tsx`
+- Test: `apps/web/src/test/design-system-page.test.tsx`
+
+**Step 1: Write the failing test**
+
+增加断言:选中 `Button` 后,页面出现 API 表头 `prop / type / default / description`。
+
+```tsx
+it("renders a structured API table for a component", () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Button" }));
+
+ expect(screen.getByText("prop")).toBeInTheDocument();
+ expect(screen.getByText("type")).toBeInTheDocument();
+ expect(screen.getByText("default")).toBeInTheDocument();
+ expect(screen.getByText("description")).toBeInTheDocument();
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders a structured API table for a component"
+```
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+在 `designSystemContent.tsx` 扩展 schema:
+- `DesignSystemApiField`
+- `api` 字段
+
+```ts
+interface DesignSystemApiField {
+ prop: string;
+ type: string;
+ defaultValue: string;
+ description: string;
+}
+```
+
+然后在 `DesignSystemDocContent.tsx` 中渲染 API 表。
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders a structured API table for a component"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/design-system/designSystemContent.tsx apps/web/src/pages/design-system/DesignSystemDocContent.tsx apps/web/src/test/design-system-page.test.tsx
+git commit -m "feat: add API tables to design system docs"
+```
+
+---
+
+### Task 4: 为当前目录中的所有组件补齐完整页内容
+
+**Files:**
+- Modify: `apps/web/src/pages/design-system/designSystemContent.tsx`
+- Test: `apps/web/src/test/app.test.tsx`
+- Test: `apps/web/src/test/design-system-page.test.tsx`
+
+**Step 1: Write the failing test**
+
+增加断言:当前目录中的所有组件都必须能进入详情页,并显示至少 3 个块:
+- 真实示例
+- API 接口
+- 使用说明
+
+```tsx
+it("renders complete docs for every component listed in the sidebar", () => {
+ render(
+
+
+
+ );
+
+ const componentNames = [
+ "Design tokens",
+ "PageLayout",
+ "Typography",
+ "Button",
+ "Card",
+ "Data display",
+ "SearchInput",
+ "MultiSelect",
+ "Selection controls",
+ "Navigation",
+ "Feedback",
+ "Overlay",
+ "Tooltip & Popover",
+ "Copy utility"
+ ];
+
+ for (const name of componentNames) {
+ fireEvent.click(screen.getByRole("button", { name }));
+ expect(screen.getByRole("heading", { name: "真实示例" })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "API 接口" })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "使用说明" })).toBeInTheDocument();
+ }
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders complete docs for every component listed in the sidebar"
+```
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+把当前目录里的所有组件文档补到同一标准:
+- Design tokens
+- PageLayout
+- Typography
+- Button
+- Card
+- Data display
+- SearchInput
+- MultiSelect
+- Selection controls
+- Navigation
+- Feedback
+- Overlay
+- Tooltip & Popover
+- Copy utility
+
+每个都至少补齐:
+- `summary`
+- `examples`
+- `api`
+- `usage`
+- `notes`
+
+优先复用当前已有 preview 与文案,避免重写 demo。
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "renders complete docs for every component listed in the sidebar"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/design-system/designSystemContent.tsx apps/web/src/test/design-system-page.test.tsx apps/web/src/test/app.test.tsx
+git commit -m "feat: complete doc pages for all design system components"
+```
+
+---
+
+### Task 5: 调整桌面与移动端的目录/详情体验
+
+**Files:**
+- Modify: `apps/web/src/pages/DesignSystemPage.tsx`
+- Modify: `apps/web/src/pages/design-system/designSystemStyles.ts`
+- Test: `apps/web/src/test/design-system-page.test.tsx`
+
+**Step 1: Write the failing test**
+
+增加断言:
+- 默认进入第一个组件详情,而不是 overview
+- 移动端仍保持单列,不溢出
+
+```tsx
+it("defaults to the first component detail instead of a group overview", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole("heading", { name: "Design tokens" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /概览/i })).not.toBeInTheDocument();
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "defaults to the first component detail instead of a group overview"
+```
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+- 默认选中第一个组件详情
+- 左侧保持后台风格目录
+- 桌面端目录宽度、sticky 行为、当前项高亮稳定
+- 移动端继续保持单列 layout
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/design-system-page.test.tsx -t "defaults to the first component detail instead of a group overview"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/DesignSystemPage.tsx apps/web/src/pages/design-system/designSystemStyles.ts apps/web/src/test/design-system-page.test.tsx
+git commit -m "feat: polish component-doc navigation experience"
+```
+
+---
+
+### Task 6: 最终验证与代码审查
+
+**Files:**
+- Review only: `apps/web/src/pages/DesignSystemPage.tsx`
+- Review only: `apps/web/src/pages/design-system/designSystemContent.tsx`
+- Review only: `apps/web/src/pages/design-system/DesignSystemDocContent.tsx`
+- Review only: `apps/web/src/pages/design-system/designSystemStyles.ts`
+- Test: `apps/web/src/test/app.test.tsx`
+- Test: `apps/web/src/test/design-system-page.test.tsx`
+- Test: `apps/web/e2e/design-system.spec.ts`
+
+**Step 1: Run targeted unit tests**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx src/test/design-system-page.test.tsx src/test/shared-form.test.tsx src/test/shared-overlay.test.tsx
+```
+Expected: PASS
+
+**Step 2: Run E2E smoke**
+
+Run:
+```bash
+pnpm exec playwright test -c apps/web/playwright.config.ts apps/web/e2e/design-system.spec.ts
+```
+Expected: PASS
+
+**Step 3: Preview verification**
+
+手动确认:
+- 首页进入设计系统
+- 左侧为后台风格组件目录
+- 没有 overview
+- 默认进入组件详情
+- Button 页能看到真实示例 + API 表 + 使用说明
+- Overlay live demo 正常
+- 移动端单列正常
+
+**Step 4: Final code review**
+
+Use:
+- `code-reviewer`
+- 必要时补 `typescript-reviewer`
+
+Expected: 无 CRITICAL/HIGH 问题
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/DesignSystemPage.tsx \
+ apps/web/src/pages/design-system/designSystemContent.tsx \
+ apps/web/src/pages/design-system/DesignSystemDocContent.tsx \
+ apps/web/src/pages/design-system/designSystemStyles.ts \
+ apps/web/src/test/app.test.tsx \
+ apps/web/src/test/design-system-page.test.tsx \
+ apps/web/e2e/design-system.spec.ts
+git commit -m "feat: turn design system into component-first docsite"
+```
diff --git a/docs/plans/2026-04-25-design-system-docsite-upgrade.md b/docs/plans/2026-04-25-design-system-docsite-upgrade.md
new file mode 100644
index 0000000..68b5b94
--- /dev/null
+++ b/docs/plans/2026-04-25-design-system-docsite-upgrade.md
@@ -0,0 +1,402 @@
+# Design System Docsite Upgrade Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 把 `/design-system` 从组件预览页升级为真正可读、可维护的组件文档站,支持完整组件详情与更正式的文档化阅读体验。
+
+**Architecture:** 保留现有左侧混合导航(分组概览 + 组件项),但把右侧内容区统一收敛为文档模板。页面层只负责导航状态与模板编排,组件文档内容从独立配置对象读取;overview 与 component detail 共享同一阅读节奏,但字段不同。
+
+**Tech Stack:** React 19, TypeScript, Vitest, Testing Library, 现有 shared primitives(Button / Card / Typography / Tabs / Code-style blocks)
+
+---
+
+### Task 1: 抽离设计系统文档数据模型
+
+**Files:**
+- Create: `apps/web/src/pages/design-system/designSystemContent.ts`
+- Modify: `apps/web/src/pages/DesignSystemPage.tsx`
+- Test: `apps/web/src/test/app.test.tsx`
+
+**Step 1: Write the failing test**
+
+在 `apps/web/src/test/app.test.tsx` 增加一个最小断言:点击某个组件后,右侧详情区必须出现统一文档段落标题,例如“适用场景”或“设计规范”。
+
+```tsx
+it("renders structured documentation sections for a selected component", async () => {
+ window.history.pushState({}, "", "/design-system");
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("not authenticated"));
+ render();
+
+ const sidebar = await screen.findByRole("navigation", { name: "Design system sidebar" });
+ fireEvent.click(within(sidebar).getByRole("button", { name: "Button" }));
+
+ expect(screen.getByText("适用场景")).toBeInTheDocument();
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "renders structured documentation sections for a selected component"
+```
+Expected: FAIL,因为当前组件详情没有统一文档段落。
+
+**Step 3: Write minimal implementation**
+
+在 `apps/web/src/pages/design-system/designSystemContent.ts` 定义:
+- `DesignSystemDocSection`
+- `DesignSystemComponentDoc`
+- `DesignSystemGroupDoc`
+- Button / Card / SearchInput / Navigation / Feedback 等首批完整文档数据
+
+`DesignSystemPage.tsx` 改成从这些对象读取内容,而不是把所有说明散在页面 JSX 里。
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "renders structured documentation sections for a selected component"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/design-system/designSystemContent.ts apps/web/src/pages/DesignSystemPage.tsx apps/web/src/test/app.test.tsx
+git commit -m "feat: structure design system doc content"
+```
+
+---
+
+### Task 2: 统一右侧组件详情模板
+
+**Files:**
+- Create: `apps/web/src/pages/design-system/DesignSystemDocContent.tsx`
+- Modify: `apps/web/src/pages/DesignSystemPage.tsx`
+- Test: `apps/web/src/test/app.test.tsx`
+
+**Step 1: Write the failing test**
+
+增加断言,要求组件详情按固定顺序出现这些标题:
+- 组件介绍
+- 适用场景
+- 状态与变体
+- 交互示例
+- 代码片段
+- 设计规范
+
+```tsx
+it("renders component docs in the expected reading order", async () => {
+ window.history.pushState({}, "", "/design-system");
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("not authenticated"));
+ render();
+
+ const sidebar = await screen.findByRole("navigation", { name: "Design system sidebar" });
+ fireEvent.click(within(sidebar).getByRole("button", { name: "Button" }));
+
+ const headings = screen.getAllByRole("heading").map((node) => node.textContent);
+ expect(headings).toContain("适用场景");
+ expect(headings).toContain("状态与变体");
+ expect(headings).toContain("交互示例");
+ expect(headings).toContain("代码片段");
+ expect(headings).toContain("设计规范");
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "renders component docs in the expected reading order"
+```
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+创建 `DesignSystemDocContent.tsx`:
+- 接收一个 `componentDoc`
+- 用固定 section 顺序渲染文档
+- overview 模式与 component mode 共用节奏,但允许字段不同
+
+在 `DesignSystemPage.tsx` 中右侧区域只做:
+- 选中 overview → 传 groupDoc
+- 选中 component → 传 componentDoc
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "renders component docs in the expected reading order"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/design-system/DesignSystemDocContent.tsx apps/web/src/pages/DesignSystemPage.tsx apps/web/src/test/app.test.tsx
+git commit -m "feat: add structured doc template for components"
+```
+
+---
+
+### Task 3: 升级视觉层级,让右侧更像文档站
+
+**Files:**
+- Create: `apps/web/src/pages/design-system/designSystemStyles.ts`
+- Modify: `apps/web/src/pages/DesignSystemPage.tsx`
+- Test: `apps/web/src/test/app.test.tsx`
+
+**Step 1: Write the failing test**
+
+补一个结构性测试,要求右侧详情区存在独立的文档块,而不是全部混在一个大说明里。可用文本断言验证存在“代码片段”和“设计规范”区域。
+
+```tsx
+it("renders separate doc sections instead of a single summary block", async () => {
+ window.history.pushState({}, "", "/design-system");
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("not authenticated"));
+ render();
+
+ const sidebar = await screen.findByRole("navigation", { name: "Design system sidebar" });
+ fireEvent.click(within(sidebar).getByRole("button", { name: "Button" }));
+
+ expect(screen.getByText("代码片段")).toBeInTheDocument();
+ expect(screen.getByText("设计规范")).toBeInTheDocument();
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "renders separate doc sections instead of a single summary block"
+```
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+把目前散在 `DesignSystemPage.tsx` 的样式常量收敛一部分到 `designSystemStyles.ts`,至少拆出:
+- 页面级布局 style
+- 文档 section style
+- 示例区 style
+- 代码区 style
+- 规范信息块 style
+
+同时让右侧详情区减少 dashboard 感,强化文档阅读感:
+- 标题更明确
+- 分段更稳定
+- 卡片数量更少但层级更清晰
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "renders separate doc sections instead of a single summary block"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/design-system/designSystemStyles.ts apps/web/src/pages/DesignSystemPage.tsx apps/web/src/test/app.test.tsx
+git commit -m "feat: refine design system docsite layout"
+```
+
+---
+
+### Task 4: 首批组件详情补全到“可用文档”级别
+
+**Files:**
+- Modify: `apps/web/src/pages/design-system/designSystemContent.ts`
+- Modify: `apps/web/src/pages/DesignSystemPage.tsx`
+- Test: `apps/web/src/test/app.test.tsx`
+
+**Step 1: Write the failing test**
+
+至少为 Button 增加完整文档内容断言:
+- 有适用场景
+- 有至少一个不要使用场景
+- 有状态与变体项
+- 有代码片段标题
+- 有设计规范标题
+
+```tsx
+it("documents button with usage, variants, code, and design guidance", async () => {
+ window.history.pushState({}, "", "/design-system");
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("not authenticated"));
+ render();
+
+ const sidebar = await screen.findByRole("navigation", { name: "Design system sidebar" });
+ fireEvent.click(within(sidebar).getByRole("button", { name: "Button" }));
+
+ expect(screen.getByText("适用场景")).toBeInTheDocument();
+ expect(screen.getByText("不适用场景")).toBeInTheDocument();
+ expect(screen.getByText("状态与变体")).toBeInTheDocument();
+ expect(screen.getByText("代码片段")).toBeInTheDocument();
+ expect(screen.getByText("设计规范")).toBeInTheDocument();
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "documents button with usage, variants, code, and design guidance"
+```
+Expected: FAIL
+
+**Step 3: Write minimal implementation**
+
+先补齐以下组件或分组:
+- `Foundations` overview
+- `Button`
+- `Card`
+- `SearchInput`
+- `Navigation`
+- `Feedback`
+
+每个组件至少包含:
+- summary
+- use cases
+- donts
+- variants
+- examples
+- one static code sample
+- design notes
+
+**Step 4: Run test to verify it passes**
+
+Run:
+```bash
+pnpm --dir apps/web exec vitest run src/test/app.test.tsx -t "documents button with usage, variants, code, and design guidance"
+```
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add apps/web/src/pages/design-system/designSystemContent.ts apps/web/src/pages/DesignSystemPage.tsx apps/web/src/test/app.test.tsx
+git commit -m "feat: fill first design system component docs"
+```
+
+---
+
+### Task 5: 增加代码片段与示例区的稳定回归
+
+**Files:**
+- Modify: `apps/web/src/pages/design-system/DesignSystemDocContent.tsx`
+- Modify: `apps/web/src/test/app.test.tsx`
+- Optional Test: `apps/web/e2e/design-system.spec.ts`
+
+**Step 1: Write the failing test**
+
+增加断言,要求选中 Button 后:
+- 有一段代码片段文本
+- 示例区里的真实按钮仍然可见
+
+```tsx
+it("shows both live examples and static code snippets for a component", async () => {
+ window.history.pushState({}, "", "/design-system");
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("not authenticated"));
+ render();
+
+ const sidebar = await screen.findByRole("navigation", { name: "Design system sidebar" });
+ fireEvent.click(within(sidebar).getByRole("button", { name: "Button" }));
+
+ expect(screen.getByText(/