feat(ui): add v7 design tokens and shared primitives

Foundation for the v7 redesign; no layout changes yet.

- Add the v7 tokens (surfaces, text, action, inverse, direct / route / stack,
  status, chart, shadows) to index.css and expose them in Tailwind together
  with v7 radius, shadow and type-role scales; teach tailwind-merge the new
  names so text-body no longer cancels text-fg-1.
- Align the shadcn HSL tokens (background, card, muted, border, ring) with the
  v7 palette and use the neutral ring for focus outlines.
- Rename light / dark to Light / Dark Side of the Moon with System first, and
  pause transitions while switching so colors don't fade in piece by piece.
- Add v7 button variants (solid, neutral, quiet, direct, route, stack) and
  sizes, plus HelpTip / DisabledReason (the "?" card), SegmentedControl,
  Notice / NoticeSlot and Sheet.
- Respect the OS reduced-motion setting for framer-motion.
This commit is contained in:
Jason committed 2026-10-03 23:28:58 +08:00
1 parent 98798552c0
commit 947a8a8b06
16 files changed
+938 -65

No files matched your search

+7 -7
View File
@@ -17,6 +17,13 @@ export function ThemeSettings() {
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
<ThemeButton
active={theme === "system"}
onClick={() => setTheme("system")}
icon={Monitor}
>
{t("settings.themeSystem")}
</ThemeButton>
<ThemeButton
active={theme === "light"}
onClick={() => setTheme("light")}
@@ -31,13 +38,6 @@ export function ThemeSettings() {
>
{t("settings.themeDark")}
</ThemeButton>
<ThemeButton
active={theme === "system"}
onClick={() => setTheme("system")}
icon={Monitor}
>
{t("settings.themeSystem")}
</ThemeButton>
</div>
</section>
);
+31 -15
View File
@@ -24,6 +24,30 @@ const ThemeProviderContext = createContext<ThemeContextValue | undefined>(
undefined,
);
/**
* 切换 html 上的 light / dark。切换期间暂停所有过渡(`.theme-switching`),
* 否则各处的 transition-colors 会让颜色一块块慢慢变过去。
*/
function applyColorScheme(isDark: boolean) {
const root = window.document.documentElement;
if (
root.classList.contains("dark") === isDark &&
root.classList.contains("light") === !isDark
) {
return;
}
root.classList.add("theme-switching");
root.classList.toggle("dark", isDark);
root.classList.toggle("light", !isDark);
// 强制重排一次,让不带过渡的新颜色先落定,再在第二帧恢复过渡。
void window.document.body?.offsetHeight;
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
root.classList.remove("theme-switching");
});
});
}
export function ThemeProvider({
children,
defaultTheme = "system",
@@ -57,18 +81,12 @@ export function ThemeProvider({
return;
}
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const isDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(isDark ? "dark" : "light");
return;
}
root.classList.add(theme);
const isDark =
theme === "system"
? window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
: theme === "dark";
applyColorScheme(isDark);
}, [theme]);
useEffect(() => {
@@ -82,9 +100,7 @@ export function ThemeProvider({
return;
}
const root = window.document.documentElement;
root.classList.toggle("dark", mediaQuery.matches);
root.classList.toggle("light", !mediaQuery.matches);
applyColorScheme(mediaQuery.matches);
};
if (theme === "system") {
+23
View File
@@ -27,12 +27,35 @@ const buttonVariants = cva(
mcp: "bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
// 链接按钮
link: "text-blue-500 underline-offset-4 hover:underline dark:text-blue-400",
// ── v7(tokens.css 的 btn-*):一个视图只有一个实心按钮,其余用 neutral ──
// 中性实心:页头主操作(操作色,以后的主题色只改这一组变量)
solid:
"border border-transparent bg-action text-action-fg hover:bg-action-hover",
// 中性描边:卡片上的「切换」「添加」「设为默认」等
neutral:
"border border-border-strong bg-surface text-fg-1 hover:bg-subtle disabled:opacity-100 disabled:border-border disabled:bg-transparent disabled:text-fg-3",
// 透明:页头里带 ⌄ / › 的次要操作
quiet: "border border-transparent text-fg-1 hover:bg-subtle",
// 模式色实心:只用于模式切换的主操作
direct:
"border border-transparent bg-direct-solid text-direct-on font-semibold hover:brightness-110",
route:
"border border-transparent bg-route-solid text-route-on font-semibold hover:brightness-110",
stack:
"border border-transparent bg-stack-solid text-stack-on font-semibold hover:brightness-110",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9 p-1.5",
// v7:高 28(卡片、对话框里)/ 高 32(页头、模式切换)/ 28 见方的图标按钮
compact:
"h-7 rounded-control px-3 text-body font-medium transition-[background-color,filter,scale] active:scale-[0.96]",
regular:
"h-8 rounded-control px-3.5 text-body font-medium transition-[background-color,filter,scale] active:scale-[0.96]",
"icon-compact":
"h-7 w-7 rounded-control p-0 text-fg-2 hover:text-fg-1 transition-[background-color,color,scale] active:scale-[0.96]",
},
},
defaultVariants: {
+250
View File
@@ -0,0 +1,250 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { CircleHelp } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 说明卡(v7 第八轮「?」,规格见 docs/research/2026-09-30-ui-design-v7/HELP_BRIEF.md)。
*
* - 悬停 300ms 打开,移开 200ms 关闭(移进卡片不关);键盘聚焦立即打开。
* - 点一下固定,Escape 或点别处关闭。
* - 不抢焦点(modal=false),卡片里不放按钮、表单、链接。
* - 只放规则和术语解释;会丢东西、要重启这类后果写在按钮、确认框或 toast 里,不进这里。
*
* 深色小气泡(tooltip.tsx)只给纯图标按钮报名字,不放说明句子。
*/
const OPEN_DELAY = 300;
const CLOSE_DELAY = 200;
function useHoverCard() {
const [open, setOpen] = React.useState(false);
const [pinned, setPinned] = React.useState(false);
const timer = React.useRef<number | undefined>(undefined);
const clear = () => {
if (timer.current !== undefined) {
window.clearTimeout(timer.current);
timer.current = undefined;
}
};
React.useEffect(() => clear, []);
const schedule = (next: boolean, delay: number) => {
clear();
timer.current = window.setTimeout(() => setOpen(next), delay);
};
return {
open: open || pinned,
pinned,
onPointerEnter: () => {
if (!pinned) schedule(true, OPEN_DELAY);
},
onPointerLeave: () => {
if (!pinned) schedule(false, CLOSE_DELAY);
},
onFocus: () => {
clear();
setOpen(true);
},
onBlur: () => {
if (!pinned) {
clear();
setOpen(false);
}
},
onClick: () => {
clear();
setPinned((value) => !value);
setOpen(true);
},
onOpenChange: (next: boolean) => {
// Escape / 点别处
if (!next) {
clear();
setPinned(false);
setOpen(false);
}
},
};
}
interface HelpCardProps {
id: string;
title?: React.ReactNode;
children: React.ReactNode;
side?: "top" | "bottom";
align?: "start" | "end";
onPointerEnter: () => void;
onPointerLeave: () => void;
}
function HelpCard({
id,
title,
children,
side = "bottom",
align = "start",
onPointerEnter,
onPointerLeave,
}: HelpCardProps) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
id={id}
role="tooltip"
side={side}
align={align}
sideOffset={6}
alignOffset={-8}
collisionPadding={8}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
className="z-[120] w-max max-w-[300px] rounded-[8px] border border-border bg-surface px-3 pb-2.5 pt-[9px] text-caption text-fg-1 shadow-v7-md outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0"
>
{title ? (
<span className="mb-0.5 block font-semibold">{title}</span>
) : null}
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
}
export interface HelpTipProps {
/** 卡片标题,同时是「?」按钮的无障碍名字。写成问题或名词。 */
title: string;
/** 正文,一句话,≤ 80 字。 */
children: React.ReactNode;
side?: "top" | "bottom";
align?: "start" | "end";
className?: string;
}
/** 放在被解释的词后面的「?」按钮。 */
export function HelpTip({
title,
children,
side,
align,
className,
}: HelpTipProps) {
const id = React.useId();
const descId = `${id}-desc`;
const hover = useHoverCard();
return (
<PopoverPrimitive.Root open={hover.open} onOpenChange={hover.onOpenChange}>
{/* 卡片只在打开时挂载;屏幕阅读器读这份一直在的副本 */}
<span id={descId} className="sr-only">
{children}
</span>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
aria-label={title}
aria-describedby={descId}
onPointerEnter={hover.onPointerEnter}
onPointerLeave={hover.onPointerLeave}
onFocus={hover.onFocus}
onBlur={hover.onBlur}
onClick={(event) => {
event.preventDefault();
hover.onClick();
}}
className={cn(
"-my-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full align-middle text-fg-3 transition-colors hover:bg-subtle hover:text-fg-1 data-[state=open]:bg-subtle data-[state=open]:text-fg-1",
className,
)}
>
<CircleHelp className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
</PopoverPrimitive.Trigger>
<HelpCard
id={id}
title={title}
side={side}
align={align}
onPointerEnter={hover.onPointerEnter}
onPointerLeave={hover.onPointerLeave}
>
{children}
</HelpCard>
</PopoverPrimitive.Root>
);
}
export interface DisabledReasonProps {
/** 为什么不能点。为空时原样渲染子元素。 */
reason?: React.ReactNode;
/** 一个按钮。有原因时会被设成 aria-disabled(不用原生 disabled,否则收不到悬停也进不了 Tab 顺序),点击无效。 */
children: React.ReactElement<{
onClick?: React.MouseEventHandler;
"aria-disabled"?: boolean | "true" | "false";
"aria-describedby"?: string;
disabled?: boolean;
className?: string;
}>;
side?: "top" | "bottom";
align?: "start" | "end";
}
/**
* 禁用原因挂在按钮本身上:悬停 / 聚焦按钮时出现说明卡。
* 菜单、对话框、抽屉这类浮层里的禁用原因照旧写成看得见的第二行,不用这个组件。
*/
export function DisabledReason({
reason,
children,
side,
align,
}: DisabledReasonProps) {
const id = React.useId();
const descId = `${id}-desc`;
const hover = useHoverCard();
if (!reason) return children;
const button = React.cloneElement(children, {
disabled: false,
"aria-disabled": "true",
"aria-describedby": descId,
onClick: (event: React.MouseEvent) => {
event.preventDefault();
hover.onClick();
},
className: cn(children.props.className, "cursor-not-allowed opacity-45"),
});
return (
<PopoverPrimitive.Root open={hover.open} onOpenChange={hover.onOpenChange}>
<PopoverPrimitive.Anchor asChild>
<span
className="inline-flex"
onPointerEnter={hover.onPointerEnter}
onPointerLeave={hover.onPointerLeave}
onFocus={hover.onFocus}
onBlur={hover.onBlur}
>
{button}
<span id={descId} className="sr-only">
{reason}
</span>
</span>
</PopoverPrimitive.Anchor>
<HelpCard
id={id}
side={side}
align={align}
onPointerEnter={hover.onPointerEnter}
onPointerLeave={hover.onPointerLeave}
>
{reason}
</HelpCard>
</PopoverPrimitive.Root>
);
}
+129
View File
@@ -0,0 +1,129 @@
import * as React from "react";
import {
AlertTriangle,
CircleAlert,
Info,
Layers,
Route,
X,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 通知条(v7 AUTHORING「通知条」):圆角 10,左侧 16px 图标,不用左边框强调色。
* 应用页的通知槽(NoticeSlot)里排成一列,按严重程度从上到下。
*/
export type NoticeTone =
| "neutral"
| "warning"
| "danger"
| "route"
| "stack"
| "direct";
const TONE: Record<
NoticeTone,
{ box: string; icon: string; Icon: LucideIcon }
> = {
neutral: { box: "bg-subtle", icon: "text-fg-2", Icon: Info },
warning: {
box: "bg-warning-soft",
icon: "text-warning-text",
Icon: AlertTriangle,
},
danger: {
box: "bg-danger-soft",
icon: "text-danger-text",
Icon: CircleAlert,
},
route: { box: "bg-route-soft", icon: "text-route-text", Icon: Route },
stack: { box: "bg-stack-soft", icon: "text-stack-text", Icon: Layers },
direct: { box: "bg-direct-soft", icon: "text-direct-text", Icon: Info },
};
export interface NoticeProps {
tone?: NoticeTone;
icon?: LucideIcon;
/** 第一行,13px */
title: React.ReactNode;
/** 第二行,12px --text-2 */
children?: React.ReactNode;
/** 右侧按钮(用 Button size="compact") */
actions?: React.ReactNode;
/** 可关闭时传;关闭按钮的无障碍名字 */
onDismiss?: () => void;
dismissLabel?: string;
className?: string;
}
export function Notice({
tone = "neutral",
icon,
title,
children,
actions,
onDismiss,
dismissLabel,
className,
}: NoticeProps) {
const style = TONE[tone];
const Icon = icon ?? style.Icon;
return (
<div
className={cn(
"flex items-start gap-2.5 rounded-panel py-2.5 pe-3.5 ps-[19px] text-body text-fg-1",
style.box,
className,
)}
>
<Icon
aria-hidden="true"
strokeWidth={1.5}
className={cn("mt-0.5 h-4 w-4 shrink-0", style.icon)}
/>
<div className="min-w-0 flex-1">
<div>{title}</div>
{children ? (
<div className="mt-0.5 text-caption text-fg-2">{children}</div>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 items-center gap-2 self-center">
{actions}
</div>
) : null}
{onDismiss ? (
<button
type="button"
aria-label={dismissLabel}
onClick={onDismiss}
className="-me-1 inline-flex h-6 w-6 shrink-0 items-center justify-center self-center rounded-control text-fg-2 transition-colors hover:bg-black/5 hover:text-fg-1 dark:hover:bg-white/10"
>
<X className="h-4 w-4" strokeWidth={1.5} />
</button>
) : null}
</div>
);
}
/**
* 通知槽:一直渲染的 role="status" 容器(R4),操作后出现的提示放进来才会被读出。
* 页面一打开就在的提示不要用 role="alert"。
*/
export function NoticeSlot({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) {
return (
<div
role="status"
className={cn("flex flex-col gap-2 empty:hidden", className)}
>
{children}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* 分段控件(v7 AUTHORING「分段控件」+ R1):按钮组 + aria-pressed,不是 tablist。
* 轨道 --bg-subtle,内衬 3px;选中项 --bg-card + 小阴影。内圆角 = 外圆角 − 内衬。
*/
export interface SegmentedItem<T extends string> {
value: T;
label: React.ReactNode;
icon?: React.ComponentType<{ className?: string; strokeWidth?: number }>;
/** 跟在文字后面的附加内容(例如模式 tab 的「生效中」圆点) */
trailing?: React.ReactNode;
/** 选中项以外的附加样式(例如生效但没在看的模式格用模式 soft 底) */
className?: string;
disabled?: boolean;
}
export interface SegmentedControlProps<T extends string> {
items: SegmentedItem<T>[];
value: T;
onValueChange: (value: T) => void;
"aria-label": string;
/** 36:模式 tab、页头下的子页面切换;28:面板里的小切换 */
size?: "md" | "sm";
className?: string;
}
export function SegmentedControl<T extends string>({
items,
value,
onValueChange,
size = "md",
className,
...rest
}: SegmentedControlProps<T>) {
return (
<div
role="group"
aria-label={rest["aria-label"]}
className={cn(
"inline-flex items-stretch bg-subtle p-[3px]",
size === "md" ? "h-9 rounded-[10px]" : "h-7 rounded-[8px]",
className,
)}
>
{items.map((item) => {
const pressed = item.value === value;
const Icon = item.icon;
return (
<button
key={item.value}
type="button"
aria-pressed={pressed}
disabled={item.disabled}
onClick={() => onValueChange(item.value)}
className={cn(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium text-fg-2 transition-[background-color,color,box-shadow] duration-150 hover:text-fg-1 disabled:cursor-not-allowed disabled:opacity-45",
size === "md"
? "rounded-[7px] px-3.5 text-body"
: "rounded-[5px] px-2.5 text-caption",
item.className,
pressed && "bg-surface text-fg-1 shadow-v7-sm",
)}
>
{Icon ? (
<Icon
className={size === "md" ? "h-4 w-4" : "h-3.5 w-3.5"}
strokeWidth={2}
/>
) : null}
{item.label}
{item.trailing}
</button>
);
})}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 右侧抽屉(v7:圆角 14、大阴影,宽度按内容 420 / 560)。基于 Radix Dialog:
* Escape 关闭、打开时背景不可操作、关闭后焦点回到触发按钮。
*/
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
width?: number;
/** 右上角关闭按钮的无障碍名字 */
closeLabel: string;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ className, children, width = 420, closeLabel, style, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
ref={ref}
style={{ width, ...style }}
className={cn(
"fixed inset-y-2 end-2 z-50 flex max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-dialog border border-border bg-surface text-fg-1 shadow-v7-lg outline-none duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-right-4 data-[state=open]:slide-in-from-right-4",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
aria-label={closeLabel}
className="absolute end-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-control text-fg-2 transition-colors hover:bg-subtle hover:text-fg-1"
>
<X className="h-4 w-4" strokeWidth={1.5} />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
SheetContent.displayName = "SheetContent";
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("shrink-0 space-y-1 pe-12 ps-5 pt-4", className)}
{...props}
/>
);
const SheetBody = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4",
className,
)}
{...props}
/>
);
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex shrink-0 items-center justify-end gap-2 border-t border-border px-5 py-3",
className,
)}
{...props}
/>
);
const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-section", className)}
{...props}
/>
));
SheetTitle.displayName = "SheetTitle";
const SheetDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-caption text-fg-2", className)}
{...props}
/>
));
SheetDescription.displayName = "SheetDescription";
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetBody,
SheetFooter,
SheetTitle,
SheetDescription,
};
+3 -3
View File
@@ -447,10 +447,10 @@
},
"language": "Language",
"languageHint": "Preview interface language immediately after switching, takes effect permanently after saving.",
"theme": "Theme",
"theme": "Light & dark",
"themeHint": "Choose the appearance theme for the app, takes effect immediately.",
"themeLight": "Light",
"themeDark": "Dark",
"themeLight": "Light Side of the Moon",
"themeDark": "Dark Side of the Moon",
"themeSystem": "System",
"importExport": "SQL Import/Export",
"importExportHint": "Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).",
+4 -4
View File
@@ -447,11 +447,11 @@
},
"language": "言語",
"languageHint": "切り替えるとすぐにプレビューされ、保存後に永続化されます。",
"theme": "テーマ",
"theme": "明暗",
"themeHint": "アプリのテーマを選択します。すぐに反映されます。",
"themeLight": "ライト",
"themeDark": "ダーク",
"themeSystem": "システム",
"themeLight": "月の表側",
"themeDark": "月の裏側",
"themeSystem": "システムに合わせる",
"importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。",
"exportConfig": "SQL バックアップをエクスポート",
+3 -3
View File
@@ -447,10 +447,10 @@
},
"language": "介面語言",
"languageHint": "切換後立即預覽介面語言,儲存後永久生效。",
"theme": "外觀主題",
"theme": "明暗",
"themeHint": "選擇應用程式的外觀主題,立即生效。",
"themeLight": "淺色",
"themeDark": "深色",
"themeLight": "月之亮面",
"themeDark": "月之暗面",
"themeSystem": "跟隨系統",
"importExport": "SQL 匯入匯出",
"importExportHint": "匯入/匯出資料庫 SQL 備份(僅支援匯入由 CC Switch 匯出的備份),便於備份或遷移。",
+3 -3
View File
@@ -447,10 +447,10 @@
},
"language": "界面语言",
"languageHint": "切换后立即预览界面语言,保存后永久生效。",
"theme": "外观主题",
"theme": "明暗",
"themeHint": "选择应用的外观主题,立即生效。",
"themeLight": "浅色",
"themeDark": "深色",
"themeLight": "月之亮面",
"themeDark": "月之暗面",
"themeSystem": "跟随系统",
"importExport": "SQL 导入导出",
"importExportHint": "导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。",
+153 -28
View File
@@ -3,61 +3,185 @@
@tailwind utilities;
@layer base {
/* shadcn 组件用的 HSL token(可以带 /50 这类透明度),取值对齐 v7 设计稿的 tokens.css */
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--foreground: 240 5.9% 10%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--card-foreground: 240 5.9% 10%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--popover-foreground: 240 5.9% 10%;
--primary: 210 100% 56%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary: 240 10.3% 94.3%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--muted: 240 10.3% 94.3%;
--muted-foreground: 240 4.3% 41.4%;
--accent: 240 10.3% 94.3%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 210 100% 56%;
--border: 240 10.2% 90.4%;
--input: 240 10.2% 90.4%;
--ring: 240 3.8% 46.1%;
--radius: 0.5rem;
}
.dark {
--background: 240 5% 12%;
--foreground: 0 0% 98%;
--card: 240 5% 16%;
--card-foreground: 0 0% 98%;
--popover: 240 5% 16%;
--popover-foreground: 0 0% 98%;
--background: 240 3.4% 11.4%;
--foreground: 240 4.8% 95.9%;
--card: 240 5.2% 19%;
--card-foreground: 240 4.8% 95.9%;
--popover: 240 5.2% 19%;
--popover-foreground: 240 4.8% 95.9%;
--primary: 210 100% 54%;
--primary-foreground: 0 0% 100%;
--secondary: 240 5% 18%;
--secondary-foreground: 0 0% 98%;
--secondary: 240 5% 15.7%;
--secondary-foreground: 240 4.8% 95.9%;
--muted: 240 5% 18%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 5% 18%;
--accent-foreground: 0 0% 98%;
--muted: 240 5% 15.7%;
--muted-foreground: 240 5.7% 72.5%;
--accent: 240 5% 15.7%;
--accent-foreground: 240 4.8% 95.9%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 5% 24%;
--input: 240 5% 24%;
--ring: 210 100% 54%;
--border: 240 5.3% 25.7%;
--input: 240 5.3% 25.7%;
--ring: 240 5% 64.9%;
}
/* v7 设计 token(docs/research/2026-09-30-ui-design-v7/project/tokens.css)。
--border / --ring 沿用上面的 HSL 版本,其余照设计稿的名字。
--action-* 是实心按钮、开关的开、勾选框的操作色;以后做主题色只覆盖这一组和 --ring。 */
:root {
--bg-app: #ffffff;
--bg-sidebar: #f5f5f7;
--bg-subtle: #efeff2;
--bg-selected: #e4e4ea;
--bg-card: #ffffff;
--border-strong: #cfcfd6;
--text-1: #18181b;
--text-2: #52525b;
--text-3: #65656e;
--action-bg: #18181b;
--action-fg: #ffffff;
--action-hover: #2e2e33;
--inverse-bg: #18181b;
--inverse-fg: #ffffff;
--inverse-hover: #2e2e33;
--overlay: rgba(9, 9, 11, 0.36);
--control-off: #888890;
--direct-fill: #0a84ff;
--direct-text: #0063c7;
--direct-soft: #eaf3ff;
--direct-solid: #0066cc;
--direct-on: #ffffff;
--route-fill: #0d9488;
--route-text: #06726b;
--route-soft: #e7f6f4;
--route-solid: #0f766e;
--route-on: #ffffff;
--stack-fill: #7c3aed;
--stack-text: #6d28d9;
--stack-soft: #f2ecfe;
--stack-solid: #6d28d9;
--stack-on: #ffffff;
--success: #16a34a;
--success-text: #007634;
--success-soft: #e8f7ee;
--warning: #d97706;
--warning-text: #a64a00;
--warning-soft: #fef3c7;
--danger: #dc2626;
--danger-text: #b91c1c;
--danger-soft: #fee2e2;
--chart-1: #0891b2;
--chart-2: #db2777;
--chart-grid: #ececf0;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
--shadow-md:
0 4px 12px rgba(16, 24, 40, 0.08), 0 1px 3px rgba(16, 24, 40, 0.06);
--shadow-lg:
0 20px 40px rgba(16, 24, 40, 0.16), 0 4px 12px rgba(16, 24, 40, 0.08);
}
.dark {
--bg-app: #1c1c1e;
--bg-sidebar: #161618;
--bg-subtle: #26262a;
--bg-selected: #313136;
--bg-card: #2e2e33;
--border-strong: #4a4a52;
--text-1: #f4f4f5;
--text-2: #d0d0d8;
--text-3: #b5b5bd;
--action-bg: #f4f4f5;
--action-fg: #18181b;
--action-hover: #e4e4e7;
--inverse-bg: #f4f4f5;
--inverse-fg: #18181b;
--inverse-hover: #e4e4e7;
--overlay: rgba(0, 0, 0, 0.55);
--control-off: #6f6f77;
--direct-fill: #409cff;
--direct-text: #7cbbff;
--direct-soft: #102a45;
--direct-solid: #0f77d7;
--direct-on: #ffffff;
--route-fill: #2dd4bf;
--route-text: #5eead4;
--route-soft: #0c302c;
--route-solid: #2dd4bf;
--route-on: #062522;
--stack-fill: #a78bfa;
--stack-text: #c4b5fd;
--stack-soft: #271e45;
--stack-solid: #845be5;
--stack-on: #ffffff;
--success: #4ade80;
--success-text: #4ade80;
--success-soft: #0f2b1a;
--warning: #fbbf24;
--warning-text: #fcd34d;
--warning-soft: #33260a;
--danger: #f87171;
--danger-text: #fca5a5;
--danger-soft: #3a1414;
--chart-1: #38bdf8;
--chart-2: #f472b6;
--chart-grid: #2c2c31;
--shadow-sm:
0 0 0 1px rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.4);
}
}
/* 切换明暗期间暂停所有过渡(theme-provider 加上 theme-switching,两帧后移除) */
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
transition: none !important;
}
.glass {
@@ -231,15 +355,16 @@ html[data-window-active="false"] .status-heartbeat {
border: 2px solid transparent;
border-radius: 9999px;
background-clip: padding-box;
background-color: hsl(var(--muted-foreground) / 0.35);
background-color: var(--border-strong);
}
::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.6);
background-color: var(--text-3);
}
*:focus-visible {
@apply outline-2 outline-blue-500 outline-offset-2;
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
@layer utilities {
+22 -1
View File
@@ -1,5 +1,26 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { extendTailwindMerge } from "tailwind-merge";
// 告诉 tailwind-merge 哪些是 tailwind.config.cjs 里新加的字号 / 圆角 / 阴影:
// 否则 text-body 会被当成颜色,和 text-fg-1 互相覆盖。
const twMerge = extendTailwindMerge({
extend: {
theme: {
text: [
"badge",
"caption",
"body",
"strong",
"section",
"title",
"page",
"metric",
],
radius: ["control", "panel", "dialog"],
shadow: ["v7-sm", "v7-md", "v7-lg"],
},
},
});
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
+5 -1
View File
@@ -8,6 +8,7 @@ import "./index.css";
import i18n from "./i18n";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@/components/theme-provider";
import { MotionConfig } from "framer-motion";
import { queryClient } from "@/lib/query";
import { Toaster } from "@/components/ui/sonner";
import { listen } from "@tauri-apps/api/event";
@@ -123,7 +124,10 @@ async function bootstrap() {
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
<UpdateProvider>
<App />
{/* 系统开了「减少动态效果」时,framer-motion 的位移动画一律跳过 */}
<MotionConfig reducedMotion="user">
<App />
</MotionConfig>
<Toaster />
</UpdateProvider>
</ThemeProvider>
+91
View File
@@ -38,6 +38,71 @@ module.exports = {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
// v7 设计 token(见 src/index.css)。纯 var() 颜色不支持 /50 这类透明度修饰。
app: "var(--bg-app)",
sidebar: "var(--bg-sidebar)",
subtle: "var(--bg-subtle)",
selected: "var(--bg-selected)",
surface: "var(--bg-card)",
"border-strong": "var(--border-strong)",
fg: {
1: "var(--text-1)",
2: "var(--text-2)",
3: "var(--text-3)",
},
action: {
DEFAULT: "var(--action-bg)",
fg: "var(--action-fg)",
hover: "var(--action-hover)",
},
inverse: {
DEFAULT: "var(--inverse-bg)",
fg: "var(--inverse-fg)",
hover: "var(--inverse-hover)",
},
overlay: "var(--overlay)",
"control-off": "var(--control-off)",
direct: {
DEFAULT: "var(--direct-fill)",
text: "var(--direct-text)",
soft: "var(--direct-soft)",
solid: "var(--direct-solid)",
on: "var(--direct-on)",
},
route: {
DEFAULT: "var(--route-fill)",
text: "var(--route-text)",
soft: "var(--route-soft)",
solid: "var(--route-solid)",
on: "var(--route-on)",
},
stack: {
DEFAULT: "var(--stack-fill)",
text: "var(--stack-text)",
soft: "var(--stack-soft)",
solid: "var(--stack-solid)",
on: "var(--stack-on)",
},
success: {
DEFAULT: "var(--success)",
text: "var(--success-text)",
soft: "var(--success-soft)",
},
warning: {
DEFAULT: "var(--warning)",
text: "var(--warning-text)",
soft: "var(--warning-soft)",
},
danger: {
DEFAULT: "var(--danger)",
text: "var(--danger-text)",
soft: "var(--danger-soft)",
},
chart: {
1: "var(--chart-1)",
2: "var(--chart-2)",
grid: "var(--chart-grid)",
},
blue: {
400: "#409CFF",
500: "#0A84FF",
@@ -73,22 +138,48 @@ module.exports = {
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
// v7:分段控件凸起、卡片悬停 / 菜单、弹层 / 对话框、抽屉
"v7-sm": "var(--shadow-sm)",
"v7-md": "var(--shadow-md)",
"v7-lg": "var(--shadow-lg)",
},
borderRadius: {
sm: "0.375rem",
md: "0.5rem",
lg: "0.75rem",
xl: "0.875rem",
// v7:按钮、小控件、侧栏行 / 卡片、分段控件、输入框、弹层、通知条 / 对话框、抽屉
control: "6px",
panel: "10px",
dialog: "14px",
},
fontSize: {
// v7 字体角色(AUTHORING.md「颜色与字体」)
badge: ["11px", { lineHeight: "16px", fontWeight: "500" }],
caption: ["12px", { lineHeight: "18px" }],
body: ["13px", { lineHeight: "20px" }],
strong: ["14px", { lineHeight: "20px", fontWeight: "500" }],
section: ["15px", { lineHeight: "22px", fontWeight: "600" }],
title: ["16px", { lineHeight: "24px", fontWeight: "600" }],
page: ["18px", { lineHeight: "26px", fontWeight: "600" }],
metric: ["24px", { lineHeight: "32px", fontWeight: "600" }],
},
fontFamily: {
// 使用与之前版本保持一致的系统字体栈
sans: [
"-apple-system",
"BlinkMacSystemFont",
'"SF Pro Text"',
'"PingFang SC"',
'"Hiragino Sans GB"',
'"Segoe UI Variable Text"',
'"Segoe UI"',
'"Microsoft YaHei UI"',
'"Microsoft YaHei"',
"Roboto",
'"Helvetica Neue"',
"Arial",
"system-ui",
"sans-serif",
],
mono: [
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { cn } from "@/lib/utils";
describe("cn", () => {
it("keeps a v7 font size next to a v7 text color", () => {
expect(cn("text-body", "text-fg-1")).toBe("text-body text-fg-1");
});
it("lets a v7 font size replace a default one", () => {
expect(cn("text-sm", "text-body")).toBe("text-body");
});
it("treats v7 radius and shadow names as their own groups", () => {
expect(cn("rounded-lg", "rounded-control")).toBe("rounded-control");
expect(cn("shadow-sm", "shadow-v7-md")).toBe("shadow-v7-md");
});
});