feat(ui): 补齐分层设计 token 并迁移共享变体(对标 Claude DESIGN.md)

- 面色阶 surface-soft/card/strong + 文字 5 级 body-strong/body/muted-soft
- 边框 line-soft、coral 状态 cinnabar-active/disabled(按压变深 + 奶油化禁用)
- 圆角刻度 xs..xl/full:按钮 rounded-md、卡片 rounded-lg、徽章 pill
- 排版档 display/title/caption/eyebrow/prose(衬线负字距)
- 动效 token duration-fast/base + ease-standard,transitionUi 常量统一过渡
- 焦点环统一 cinnabar/35;双主题成对定义
This commit is contained in:
Yaojia Wang
2026-07-11 07:16:00 +02:00
parent 57b3183564
commit b9f9134748
5 changed files with 117 additions and 24 deletions

View File

@@ -5,12 +5,25 @@
/* 纸感设计 tokenUX_SPEC §2.1 */
:root,
[data-theme="paper"] {
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
--color-bg: #f5f1e8;
--color-surface-soft: #efe9dc;
--color-surface-card: #ece4d3;
--color-surface-strong: #e4d9c4;
--color-panel: #fbf8f1;
/* 文字色阶5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
--color-ink: #2b2620;
--color-body-strong: #3a342b;
--color-body: #4a4234;
--color-ink-soft: #6b6356;
--color-muted-soft: #8a8073;
/* 边框:主边框 + 同带内更弱分隔 */
--color-line: #e5ddcd;
--color-line-soft: #ece4d5;
/* 朱砂 coral主色 + 按压变深 + 奶油化禁用 + 淡底 */
--color-cinnabar: #a23b2e;
--color-cinnabar-active: #8a2f24;
--color-cinnabar-disabled: #d9cdbd;
--color-cinnabar-wash: #a23b2e14;
--color-conflict: #b5543a;
--color-overdue: #c8893a;
@@ -22,13 +35,33 @@
color-scheme: light;
}
/* 动效 token与主题无关单处定义夜读模式不覆盖 */
:root {
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--dur-fast: 120ms;
--dur-base: 180ms;
}
[data-theme="night"] {
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
--color-bg: #181614;
--color-surface-soft: #1e1b17;
--color-surface-card: #2a2620;
--color-surface-strong: #332e26;
--color-panel: #221f1b;
/* 文字色阶5 级) */
--color-ink: #efe6d7;
--color-body-strong: #ddd2c0;
--color-body: #cdc0ab;
--color-ink-soft: #b9ab96;
--color-muted-soft: #8f8474;
/* 边框 */
--color-line: #3c352c;
--color-line-soft: #2f2a23;
/* 朱砂 coral */
--color-cinnabar: #e07866;
--color-cinnabar-active: #c85f4d;
--color-cinnabar-disabled: #3c352c;
--color-cinnabar-wash: #e0786620;
--color-conflict: #ef8a72;
--color-overdue: #d8a65d;

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { cn, segmentedClass } from "@/lib/ui/variants";
import { cn, focusRing, segmentedClass, transitionUi } from "@/lib/ui/variants";
export interface SegmentOption<T extends string> {
value: T;
@@ -33,7 +33,9 @@ export function SegmentedControl<T extends string>({
aria-pressed={selected}
onClick={() => onChange(option.value)}
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
"rounded-md px-3 py-1.5 text-sm",
transitionUi,
focusRing,
selected
? "bg-panel text-cinnabar shadow-paper"
: "text-ink-soft hover:text-cinnabar",

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
badgeClass,
buttonClass,
cardClass,
cn,
focusRing,
inputClass,
@@ -10,6 +11,7 @@ import {
proseBody,
segmentedClass,
statusNoteClass,
transitionUi,
} from "./variants";
describe("ui variants", () => {
@@ -25,6 +27,29 @@ describe("ui variants", () => {
expect(klass).toContain("focus-visible:ring-2");
});
it("uses layered radius tokens (button md / card lg / badge pill)", () => {
expect(buttonClass({ variant: "primary" })).toContain("rounded-md");
expect(cardClass()).toContain("rounded-lg");
expect(badgeClass()).toContain("rounded-full");
expect(inputClass()).toContain("rounded-md");
});
it("gives the primary button a press-darken and cream disabled state", () => {
const klass = buttonClass({ variant: "primary" });
expect(klass).toContain("hover:bg-cinnabar-active");
expect(klass).toContain("active:bg-cinnabar-active");
expect(klass).toContain("disabled:bg-cinnabar-disabled");
expect(klass).not.toContain("hover:bg-cinnabar/95");
});
it("shares one interaction-transition token across controls", () => {
expect(transitionUi).toContain("duration-fast");
expect(transitionUi).toContain("ease-standard");
expect(buttonClass({ variant: "primary" })).toContain(transitionUi);
expect(inputClass()).toContain(transitionUi);
});
it("keeps warning badges readable on the paper background", () => {
const klass = badgeClass({ variant: "warning" });
@@ -59,10 +84,9 @@ describe("ui variants", () => {
expect(buttonClass({ variant: "primary" })).toContain(focusRing);
});
it("renders manuscript body as serif 18px with airy leading", () => {
it("renders manuscript body as serif prose scale", () => {
expect(proseBody).toContain("font-serif");
expect(proseBody).toContain("text-[18px]");
expect(proseBody).toContain("leading-[1.9]");
expect(proseBody).toContain("text-prose");
});
it("provides a full-screen overlay scrim below dialog chrome", () => {

View File

@@ -23,30 +23,31 @@ export function cn(...classes: Array<string | false | null | undefined>): string
return classes.filter(Boolean).join(" ");
}
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用
// 统一焦点环(供按钮/输入/导航等所有可聚焦元素复用,统一到 cinnabar/35
export const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
// 手稿正文统一排版:衬线 18px、行高 1.9
export const proseBody = "font-serif text-[18px] leading-[1.9]";
// 统一交互过渡(颜色变化 + 动效 token供按钮/输入/导航复用,全局可调
export const transitionUi = "transition-colors duration-fast ease-standard";
// 手稿正文统一排版:衬线 18pxtext-prose 档)、行高 1.9。
export const proseBody = "font-serif text-prose";
// Drawer / CommandPalette 共用的全屏遮罩。
export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
const buttonBase =
"inline-flex items-center justify-center gap-1.5 rounded border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35 disabled:cursor-not-allowed disabled:opacity-45";
const buttonBase = `inline-flex items-center justify-center gap-1.5 rounded-md border text-sm disabled:cursor-not-allowed ${transitionUi} ${focusRing}`;
// 非主按钮的禁用淡化(主按钮改用奶油化禁用底,见下)。
const disabledFade = "disabled:opacity-45";
const buttonVariants: Record<ButtonVariant, string> = {
primary:
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95",
secondary:
"border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar",
outline:
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]",
ghost:
"border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar",
danger:
"border-conflict bg-transparent text-conflict hover:bg-conflict/10",
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar-active active:bg-cinnabar-active disabled:border-cinnabar-disabled disabled:bg-cinnabar-disabled disabled:text-muted-soft",
secondary: `border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar ${disabledFade}`,
outline: `border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)] active:bg-[var(--color-cinnabar-wash)] ${disabledFade}`,
ghost: `border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${disabledFade}`,
danger: `border-conflict bg-transparent text-conflict hover:bg-conflict/10 ${disabledFade}`,
};
const buttonSizes: Record<ButtonSize, string> = {
@@ -68,7 +69,7 @@ export function buttonClass({
}
const badgeBase =
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs leading-5";
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs leading-5";
const badgeVariants: Record<BadgeVariant, string> = {
neutral: "border-line bg-bg text-ink-soft",
@@ -91,7 +92,7 @@ export function badgeClass({
}
export function cardClass(className?: string): string {
return cn("rounded border border-line bg-panel shadow-paper", className);
return cn("rounded-lg border border-line bg-panel shadow-paper", className);
}
const fieldTextBase = "block text-sm font-medium text-ink";
@@ -108,8 +109,7 @@ export function fieldErrorClass(className?: string): string {
return cn("mt-1 text-xs leading-5 text-conflict", className);
}
const inputBase =
"w-full rounded border bg-bg text-ink transition-colors placeholder:text-ink-soft/65 focus:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/30 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft";
const inputBase = `w-full rounded-md border bg-bg text-ink placeholder:text-ink-soft/80 disabled:cursor-not-allowed disabled:bg-line/20 disabled:text-ink-soft ${transitionUi} ${focusRing}`;
const inputStates: Record<InputState, string> = {
default: "border-line focus:border-cinnabar",

View File

@@ -11,11 +11,20 @@ const config: Config = {
extend: {
colors: {
bg: "var(--color-bg)",
"surface-soft": "var(--color-surface-soft)",
"surface-card": "var(--color-surface-card)",
"surface-strong": "var(--color-surface-strong)",
panel: "var(--color-panel)",
ink: "var(--color-ink)",
"body-strong": "var(--color-body-strong)",
body: "var(--color-body)",
"ink-soft": "var(--color-ink-soft)",
"muted-soft": "var(--color-muted-soft)",
line: "var(--color-line)",
"line-soft": "var(--color-line-soft)",
cinnabar: "var(--color-cinnabar)",
"cinnabar-active": "var(--color-cinnabar-active)",
"cinnabar-disabled": "var(--color-cinnabar-disabled)",
conflict: "var(--color-conflict)",
overdue: "var(--color-overdue)",
pass: "var(--color-pass)",
@@ -27,10 +36,35 @@ const config: Config = {
mono: ['"JetBrains Mono"', "ui-monospace"],
},
fontSize: {
// 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow
"2xs": ["0.6875rem", { lineHeight: "1rem" }],
eyebrow: ["0.75rem", { lineHeight: "1.4", letterSpacing: "0.08em" }],
caption: ["0.8125rem", { lineHeight: "1.4" }],
body: ["1rem", { lineHeight: "1.55" }],
prose: ["1.125rem", { lineHeight: "1.9" }],
"title-md": ["1.125rem", { lineHeight: "1.4" }],
"title-lg": ["1.375rem", { lineHeight: "1.3" }],
"display-sm": ["1.75rem", { lineHeight: "1.2", letterSpacing: "-0.01em" }],
"display-md": ["2.25rem", { lineHeight: "1.15", letterSpacing: "-0.015em" }],
"display-lg": ["3rem", { lineHeight: "1.1", letterSpacing: "-0.02em" }],
},
borderRadius: {
xs: "4px",
sm: "6px",
DEFAULT: "6px",
md: "8px",
lg: "12px",
xl: "16px",
full: "9999px",
},
borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px var(--shadow-paper)" },
transitionDuration: {
fast: "var(--dur-fast)",
base: "var(--dur-base)",
},
transitionTimingFunction: {
standard: "var(--ease-standard)",
},
maxWidth: { prose: "720px" },
},
},