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:
@@ -5,12 +5,25 @@
|
||||
/* 纸感设计 token(UX_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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
// 手稿正文统一排版:衬线 18px(text-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",
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user