diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 60fd9c7..aef43f7 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; diff --git a/apps/web/components/ui/SegmentedControl.tsx b/apps/web/components/ui/SegmentedControl.tsx index 68d392d..2231452 100644 --- a/apps/web/components/ui/SegmentedControl.tsx +++ b/apps/web/components/ui/SegmentedControl.tsx @@ -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 { value: T; @@ -33,7 +33,9 @@ export function SegmentedControl({ 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", diff --git a/apps/web/lib/ui/variants.test.ts b/apps/web/lib/ui/variants.test.ts index ddfaec7..b88137a 100644 --- a/apps/web/lib/ui/variants.test.ts +++ b/apps/web/lib/ui/variants.test.ts @@ -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", () => { diff --git a/apps/web/lib/ui/variants.ts b/apps/web/lib/ui/variants.ts index 133d4a6..4555720 100644 --- a/apps/web/lib/ui/variants.ts +++ b/apps/web/lib/ui/variants.ts @@ -23,30 +23,31 @@ export function cn(...classes: Array): 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 = { 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 = { @@ -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 = { 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 = { default: "border-line focus:border-cinnabar", diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 5cc4ceb..271d647 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -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" }, }, },