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) */
|
/* 纸感设计 token(UX_SPEC §2.1) */
|
||||||
:root,
|
:root,
|
||||||
[data-theme="paper"] {
|
[data-theme="paper"] {
|
||||||
|
/* 面色阶:底 → 分隔带 → 内容卡(比底暗一步)→ 选中强调 → 浮起面板 */
|
||||||
--color-bg: #f5f1e8;
|
--color-bg: #f5f1e8;
|
||||||
|
--color-surface-soft: #efe9dc;
|
||||||
|
--color-surface-card: #ece4d3;
|
||||||
|
--color-surface-strong: #e4d9c4;
|
||||||
--color-panel: #fbf8f1;
|
--color-panel: #fbf8f1;
|
||||||
|
/* 文字色阶(5 级):标题 → 强调段 → 正文 → 次级 → 说明/细则 */
|
||||||
--color-ink: #2b2620;
|
--color-ink: #2b2620;
|
||||||
|
--color-body-strong: #3a342b;
|
||||||
|
--color-body: #4a4234;
|
||||||
--color-ink-soft: #6b6356;
|
--color-ink-soft: #6b6356;
|
||||||
|
--color-muted-soft: #8a8073;
|
||||||
|
/* 边框:主边框 + 同带内更弱分隔 */
|
||||||
--color-line: #e5ddcd;
|
--color-line: #e5ddcd;
|
||||||
|
--color-line-soft: #ece4d5;
|
||||||
|
/* 朱砂 coral:主色 + 按压变深 + 奶油化禁用 + 淡底 */
|
||||||
--color-cinnabar: #a23b2e;
|
--color-cinnabar: #a23b2e;
|
||||||
|
--color-cinnabar-active: #8a2f24;
|
||||||
|
--color-cinnabar-disabled: #d9cdbd;
|
||||||
--color-cinnabar-wash: #a23b2e14;
|
--color-cinnabar-wash: #a23b2e14;
|
||||||
--color-conflict: #b5543a;
|
--color-conflict: #b5543a;
|
||||||
--color-overdue: #c8893a;
|
--color-overdue: #c8893a;
|
||||||
@@ -22,13 +35,33 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 动效 token(与主题无关,单处定义;夜读模式不覆盖) */
|
||||||
|
:root {
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
--dur-fast: 120ms;
|
||||||
|
--dur-base: 180ms;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="night"] {
|
[data-theme="night"] {
|
||||||
|
/* 面色阶:底 → 分隔带 → 浮起面板 → 内容卡(暗面向上抬)→ 选中强调 */
|
||||||
--color-bg: #181614;
|
--color-bg: #181614;
|
||||||
|
--color-surface-soft: #1e1b17;
|
||||||
|
--color-surface-card: #2a2620;
|
||||||
|
--color-surface-strong: #332e26;
|
||||||
--color-panel: #221f1b;
|
--color-panel: #221f1b;
|
||||||
|
/* 文字色阶(5 级) */
|
||||||
--color-ink: #efe6d7;
|
--color-ink: #efe6d7;
|
||||||
|
--color-body-strong: #ddd2c0;
|
||||||
|
--color-body: #cdc0ab;
|
||||||
--color-ink-soft: #b9ab96;
|
--color-ink-soft: #b9ab96;
|
||||||
|
--color-muted-soft: #8f8474;
|
||||||
|
/* 边框 */
|
||||||
--color-line: #3c352c;
|
--color-line: #3c352c;
|
||||||
|
--color-line-soft: #2f2a23;
|
||||||
|
/* 朱砂 coral */
|
||||||
--color-cinnabar: #e07866;
|
--color-cinnabar: #e07866;
|
||||||
|
--color-cinnabar-active: #c85f4d;
|
||||||
|
--color-cinnabar-disabled: #3c352c;
|
||||||
--color-cinnabar-wash: #e0786620;
|
--color-cinnabar-wash: #e0786620;
|
||||||
--color-conflict: #ef8a72;
|
--color-conflict: #ef8a72;
|
||||||
--color-overdue: #d8a65d;
|
--color-overdue: #d8a65d;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
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> {
|
export interface SegmentOption<T extends string> {
|
||||||
value: T;
|
value: T;
|
||||||
@@ -33,7 +33,9 @@ export function SegmentedControl<T extends string>({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={() => onChange(option.value)}
|
onClick={() => onChange(option.value)}
|
||||||
className={cn(
|
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
|
selected
|
||||||
? "bg-panel text-cinnabar shadow-paper"
|
? "bg-panel text-cinnabar shadow-paper"
|
||||||
: "text-ink-soft hover:text-cinnabar",
|
: "text-ink-soft hover:text-cinnabar",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
badgeClass,
|
badgeClass,
|
||||||
buttonClass,
|
buttonClass,
|
||||||
|
cardClass,
|
||||||
cn,
|
cn,
|
||||||
focusRing,
|
focusRing,
|
||||||
inputClass,
|
inputClass,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
proseBody,
|
proseBody,
|
||||||
segmentedClass,
|
segmentedClass,
|
||||||
statusNoteClass,
|
statusNoteClass,
|
||||||
|
transitionUi,
|
||||||
} from "./variants";
|
} from "./variants";
|
||||||
|
|
||||||
describe("ui variants", () => {
|
describe("ui variants", () => {
|
||||||
@@ -25,6 +27,29 @@ describe("ui variants", () => {
|
|||||||
expect(klass).toContain("focus-visible:ring-2");
|
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", () => {
|
it("keeps warning badges readable on the paper background", () => {
|
||||||
const klass = badgeClass({ variant: "warning" });
|
const klass = badgeClass({ variant: "warning" });
|
||||||
|
|
||||||
@@ -59,10 +84,9 @@ describe("ui variants", () => {
|
|||||||
expect(buttonClass({ variant: "primary" })).toContain(focusRing);
|
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("font-serif");
|
||||||
expect(proseBody).toContain("text-[18px]");
|
expect(proseBody).toContain("text-prose");
|
||||||
expect(proseBody).toContain("leading-[1.9]");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provides a full-screen overlay scrim below dialog chrome", () => {
|
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(" ");
|
return classes.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用。
|
// 统一焦点环(供按钮/输入/导航等所有可聚焦元素复用,统一到 cinnabar/35)。
|
||||||
export const focusRing =
|
export const focusRing =
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35";
|
||||||
|
|
||||||
// 手稿正文统一排版:衬线 18px、行高 1.9。
|
// 统一交互过渡(颜色变化 + 动效 token),供按钮/输入/导航复用,全局可调。
|
||||||
export const proseBody = "font-serif text-[18px] leading-[1.9]";
|
export const transitionUi = "transition-colors duration-fast ease-standard";
|
||||||
|
|
||||||
|
// 手稿正文统一排版:衬线 18px(text-prose 档)、行高 1.9。
|
||||||
|
export const proseBody = "font-serif text-prose";
|
||||||
|
|
||||||
// Drawer / CommandPalette 共用的全屏遮罩。
|
// Drawer / CommandPalette 共用的全屏遮罩。
|
||||||
export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
|
export const overlayScrim = "fixed inset-0 z-40 bg-black/30";
|
||||||
|
|
||||||
const buttonBase =
|
const buttonBase = `inline-flex items-center justify-center gap-1.5 rounded-md border text-sm disabled:cursor-not-allowed ${transitionUi} ${focusRing}`;
|
||||||
"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 disabledFade = "disabled:opacity-45";
|
||||||
|
|
||||||
const buttonVariants: Record<ButtonVariant, string> = {
|
const buttonVariants: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary:
|
||||||
"border-cinnabar bg-cinnabar text-panel shadow-paper hover:bg-cinnabar/95",
|
"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:
|
secondary: `border-line bg-panel text-ink hover:border-cinnabar hover:text-cinnabar ${disabledFade}`,
|
||||||
"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)] active:bg-[var(--color-cinnabar-wash)] ${disabledFade}`,
|
||||||
outline:
|
ghost: `border-transparent bg-transparent text-ink-soft hover:bg-[var(--color-cinnabar-wash)] hover:text-cinnabar ${disabledFade}`,
|
||||||
"border-cinnabar bg-transparent text-cinnabar hover:bg-[var(--color-cinnabar-wash)]",
|
danger: `border-conflict bg-transparent text-conflict hover:bg-conflict/10 ${disabledFade}`,
|
||||||
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",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonSizes: Record<ButtonSize, string> = {
|
const buttonSizes: Record<ButtonSize, string> = {
|
||||||
@@ -68,7 +69,7 @@ export function buttonClass({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const badgeBase =
|
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> = {
|
const badgeVariants: Record<BadgeVariant, string> = {
|
||||||
neutral: "border-line bg-bg text-ink-soft",
|
neutral: "border-line bg-bg text-ink-soft",
|
||||||
@@ -91,7 +92,7 @@ export function badgeClass({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cardClass(className?: string): string {
|
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";
|
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);
|
return cn("mt-1 text-xs leading-5 text-conflict", className);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputBase =
|
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}`;
|
||||||
"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 inputStates: Record<InputState, string> = {
|
const inputStates: Record<InputState, string> = {
|
||||||
default: "border-line focus:border-cinnabar",
|
default: "border-line focus:border-cinnabar",
|
||||||
|
|||||||
@@ -11,11 +11,20 @@ const config: Config = {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
bg: "var(--color-bg)",
|
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)",
|
panel: "var(--color-panel)",
|
||||||
ink: "var(--color-ink)",
|
ink: "var(--color-ink)",
|
||||||
|
"body-strong": "var(--color-body-strong)",
|
||||||
|
body: "var(--color-body)",
|
||||||
"ink-soft": "var(--color-ink-soft)",
|
"ink-soft": "var(--color-ink-soft)",
|
||||||
|
"muted-soft": "var(--color-muted-soft)",
|
||||||
line: "var(--color-line)",
|
line: "var(--color-line)",
|
||||||
|
"line-soft": "var(--color-line-soft)",
|
||||||
cinnabar: "var(--color-cinnabar)",
|
cinnabar: "var(--color-cinnabar)",
|
||||||
|
"cinnabar-active": "var(--color-cinnabar-active)",
|
||||||
|
"cinnabar-disabled": "var(--color-cinnabar-disabled)",
|
||||||
conflict: "var(--color-conflict)",
|
conflict: "var(--color-conflict)",
|
||||||
overdue: "var(--color-overdue)",
|
overdue: "var(--color-overdue)",
|
||||||
pass: "var(--color-pass)",
|
pass: "var(--color-pass)",
|
||||||
@@ -27,10 +36,35 @@ const config: Config = {
|
|||||||
mono: ['"JetBrains Mono"', "ui-monospace"],
|
mono: ['"JetBrains Mono"', "ui-monospace"],
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
|
// 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow)
|
||||||
"2xs": ["0.6875rem", { lineHeight: "1rem" }],
|
"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)" },
|
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" },
|
maxWidth: { prose: "720px" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user