From b9f913474874515d19883d885fdc842f73903a5c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 11 Jul 2026 07:16:00 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat(ui):=20=E8=A1=A5=E9=BD=90=E5=88=86?= =?UTF-8?q?=E5=B1=82=E8=AE=BE=E8=AE=A1=20token=20=E5=B9=B6=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=85=B1=E4=BA=AB=E5=8F=98=E4=BD=93=EF=BC=88=E5=AF=B9?= =?UTF-8?q?=E6=A0=87=20Claude=20DESIGN.md=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 面色阶 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;双主题成对定义 --- apps/web/app/globals.css | 33 +++++++++++++++++++ apps/web/components/ui/SegmentedControl.tsx | 6 ++-- apps/web/lib/ui/variants.test.ts | 30 +++++++++++++++-- apps/web/lib/ui/variants.ts | 36 ++++++++++----------- apps/web/tailwind.config.ts | 36 ++++++++++++++++++++- 5 files changed, 117 insertions(+), 24 deletions(-) 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" }, }, }, From fd6551b0badf57277c2672699b53cd06805ea697 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 11 Jul 2026 07:18:23 +0200 Subject: [PATCH 02/12] =?UTF-8?q?feat(ui):=20=E7=A8=B3=E5=81=A5=E8=B7=A8?= =?UTF-8?q?=20OS=20=E8=A1=AC=E7=BA=BF=E5=85=9C=E5=BA=95=E6=A0=88=20+=20?= =?UTF-8?q?=E5=8F=8C=E4=B8=BB=E9=A2=98=20token=20=E5=A5=87=E5=81=B6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 决策:不自托管 CJK webfont(数 MB + 构建期外拉,违反 CSP 友好/首屏) 改用跨 OS 系统衬线栈,保证中文标题可靠命中衬线,零 webfont 字节 - 新增 themeParity 测试:paper/night 两块 --color-* 必须成对,防漏配破面 --- apps/web/lib/ui/themeParity.test.ts | 42 +++++++++++++++++++++++++++++ apps/web/tailwind.config.ts | 26 +++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 apps/web/lib/ui/themeParity.test.ts diff --git a/apps/web/lib/ui/themeParity.test.ts b/apps/web/lib/ui/themeParity.test.ts new file mode 100644 index 0000000..90d33c0 --- /dev/null +++ b/apps/web/lib/ui/themeParity.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +// 双主题 token 奇偶校验(P1-8):paper 与 night 两块的 --color-* 变量必须成对, +// 任一模式漏配某个颜色 token 都会让该主题回退为无效变量而破面——此测试提前拦截。 + +const cssPath = fileURLToPath( + new URL("../../app/globals.css", import.meta.url), +); +const css = readFileSync(cssPath, "utf8"); + +function colorVarsInBlock(selector: string): Set { + const start = css.indexOf(selector); + if (start === -1) { + throw new Error(`未找到主题块选择器:${selector}`); + } + const open = css.indexOf("{", start); + const close = css.indexOf("}", open); + const body = css.slice(open + 1, close); + const names = body.match(/--color-[\w-]+/g) ?? []; + return new Set(names); +} + +describe("theme token parity", () => { + const paper = colorVarsInBlock('[data-theme="paper"]'); + const night = colorVarsInBlock('[data-theme="night"]'); + + it("defines at least the core palette in each theme", () => { + expect(paper.size).toBeGreaterThan(10); + expect(night.size).toBeGreaterThan(10); + }); + + it("keeps paper and night color tokens paired (no theme drops a --color-* var)", () => { + const missingInNight = [...paper].filter((name) => !night.has(name)); + const missingInPaper = [...night].filter((name) => !paper.has(name)); + + expect(missingInNight, "night 缺失(paper 有)").toEqual([]); + expect(missingInPaper, "paper 缺失(night 有)").toEqual([]); + }); +}); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 271d647..e2b0744 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -31,9 +31,29 @@ const config: Config = { info: "var(--color-info)", }, fontFamily: { - serif: ['"Noto Serif SC"', '"Songti SC"', "serif"], - sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"], - mono: ['"JetBrains Mono"', "ui-monospace"], + // 衬线 display 与 sans 分工是品牌声音的一部分(对标 DESIGN.md)。 + // 决策:不自托管 CJK webfont —— Noto Serif SC 全量子集达数 MB,会拖慢 + // 首屏且需构建期外部拉取(违反 CSP 友好)。改用跨 OS 系统衬线兜底栈, + // 保证中文标题在任意设备可靠命中高质量衬线,零 webfont 字节。 + // macOS/iOS → Songti/STSong;Windows → SimSun/YaHei;Linux/Android → Noto CJK。 + serif: [ + '"Noto Serif SC"', + '"Source Han Serif SC"', + '"Songti SC"', + "STSong", + "SimSun", + '"Noto Serif CJK SC"', + "serif", + ], + sans: [ + '"Noto Sans SC"', + '"PingFang SC"', + '"Source Han Sans SC"', + '"Microsoft YaHei"', + "system-ui", + "sans-serif", + ], + mono: ['"JetBrains Mono"', "ui-monospace", "monospace"], }, fontSize: { // 编辑部字号刻度(衬线 display 用负字距;标签用正字距 eyebrow) From 83ba47ab8f09c4f96a23c151bf784b4b7bdd2561 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 11 Jul 2026 07:24:08 +0200 Subject: [PATCH 03/12] =?UTF-8?q?feat(ui):=20=E5=85=B1=E4=BA=AB=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E8=A1=A5=E9=BD=90=E2=80=94=E2=80=94Card=20=E9=9D=A2?= =?UTF-8?q?=E8=89=B2=E5=88=86=E5=B1=82=20+=20Button=20link/lg=20+=204=20?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Card 新增 tone(panel/card/soft)/flat/interactive;cardClass 兼容旧字符串签名 - Button 新增 link(coral 文字链) 变体与 lg 尺寸 - 新增原子件 Eyebrow(统一眉题) / StatusDot(状态点·读屏可见) / Skeleton(motion-safe 占位) / Meter(进度条·progressbar) - PageHeader/SectionHeader 用 display/title 字阶 + Eyebrow 眉题 --- apps/web/components/ui/Card.tsx | 13 +++++-- apps/web/components/ui/Eyebrow.tsx | 23 ++++++++++++ apps/web/components/ui/Meter.tsx | 45 ++++++++++++++++++++++++ apps/web/components/ui/PageHeader.tsx | 10 +++--- apps/web/components/ui/SectionHeader.tsx | 7 +++- apps/web/components/ui/Skeleton.tsx | 18 ++++++++++ apps/web/components/ui/StatusDot.tsx | 41 +++++++++++++++++++++ apps/web/lib/ui/variants.test.ts | 27 ++++++++++++++ apps/web/lib/ui/variants.ts | 35 +++++++++++++++--- 9 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 apps/web/components/ui/Eyebrow.tsx create mode 100644 apps/web/components/ui/Meter.tsx create mode 100644 apps/web/components/ui/Skeleton.tsx create mode 100644 apps/web/components/ui/StatusDot.tsx diff --git a/apps/web/components/ui/Card.tsx b/apps/web/components/ui/Card.tsx index fac42b5..8a42324 100644 --- a/apps/web/components/ui/Card.tsx +++ b/apps/web/components/ui/Card.tsx @@ -1,20 +1,29 @@ import type { HTMLAttributes, ReactNode } from "react"; -import { cardClass } from "@/lib/ui/variants"; +import { cardClass, type CardTone } from "@/lib/ui/variants"; interface CardProps extends HTMLAttributes { children: ReactNode; as?: "article" | "div" | "section"; + tone?: CardTone; + flat?: boolean; + interactive?: boolean; } export function Card({ as: Component = "div", children, className, + tone, + flat, + interactive, ...props }: CardProps) { return ( - + {children} ); diff --git a/apps/web/components/ui/Eyebrow.tsx b/apps/web/components/ui/Eyebrow.tsx new file mode 100644 index 0000000..d61935b --- /dev/null +++ b/apps/web/components/ui/Eyebrow.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/ui/variants"; + +interface EyebrowProps { + children: ReactNode; + className?: string; +} + +// 眉题/小标签:无衬线、eyebrow 字号(含正字距 0.08em)、说明色、大写。 +// 统一各页此前 mono/uppercase 互不一致的小标签写法。 +export function Eyebrow({ children, className }: EyebrowProps) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/web/components/ui/Meter.tsx b/apps/web/components/ui/Meter.tsx new file mode 100644 index 0000000..af09301 --- /dev/null +++ b/apps/web/components/ui/Meter.tsx @@ -0,0 +1,45 @@ +import { cn } from "@/lib/ui/variants"; + +export type MeterTone = "accent" | "success" | "warning" | "danger" | "info"; + +const meterTones: Record = { + accent: "bg-cinnabar", + success: "bg-pass", + warning: "bg-overdue", + danger: "bg-conflict", + info: "bg-info", +}; + +interface MeterProps { + /** 0..1,超出范围自动夹取。 */ + value: number; + tone?: MeterTone; + label?: string; + className?: string; +} + +// 进度条:语义色填充,宽度过渡走动效 token;对读屏暴露 progressbar。 +export function Meter({ value, tone = "accent", label, className }: MeterProps) { + const pct = Math.max(0, Math.min(1, value)) * 100; + return ( +
+
+
+ ); +} diff --git a/apps/web/components/ui/PageHeader.tsx b/apps/web/components/ui/PageHeader.tsx index 641fae0..6747ca8 100644 --- a/apps/web/components/ui/PageHeader.tsx +++ b/apps/web/components/ui/PageHeader.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +import { Eyebrow } from "./Eyebrow"; + interface PageHeaderProps { title: string; eyebrow?: string; @@ -16,12 +18,8 @@ export function PageHeader({ return (
- {eyebrow ? ( -

- {eyebrow} -

- ) : null} -

{title}

+ {eyebrow ? {eyebrow} : null} +

{title}

{description ? (

{description} diff --git a/apps/web/components/ui/SectionHeader.tsx b/apps/web/components/ui/SectionHeader.tsx index 4bafb40..54c855c 100644 --- a/apps/web/components/ui/SectionHeader.tsx +++ b/apps/web/components/ui/SectionHeader.tsx @@ -2,8 +2,11 @@ import type { ReactNode } from "react"; import { cn } from "@/lib/ui/variants"; +import { Eyebrow } from "./Eyebrow"; + interface SectionHeaderProps { title: string; + eyebrow?: string; description?: ReactNode; action?: ReactNode; className?: string; @@ -11,6 +14,7 @@ interface SectionHeaderProps { export function SectionHeader({ title, + eyebrow, description, action, className, @@ -18,7 +22,8 @@ export function SectionHeader({ return (

-

{title}

+ {eyebrow ? {eyebrow} : null} +

{title}

{description ? (

{description}

) : null} diff --git a/apps/web/components/ui/Skeleton.tsx b/apps/web/components/ui/Skeleton.tsx new file mode 100644 index 0000000..2919bd3 --- /dev/null +++ b/apps/web/components/ui/Skeleton.tsx @@ -0,0 +1,18 @@ +import { cn } from "@/lib/ui/variants"; + +interface SkeletonProps { + className?: string; +} + +// 加载占位:柔和线条底 + motion-safe 脉冲(尊重 prefers-reduced-motion)。 +export function Skeleton({ className }: SkeletonProps) { + return ( +