feat(ui): 弹层进出场过渡 + 首屏暖深色英雄(P2-6/P4-6)

- useMountTransition hook(延迟卸载播放退场,尊重 reduced-motion)+ vitest
- Drawer 淡入+滑入进出场;focus/scroll-lock 仍由 open 驱动、focus 键 shouldRender 防丢首焦
- Toast 入场 toast-in 动画(motion-safe)
- P4-6 首屏零作品用 callout 暖深色编辑部英雄(对标 cta-band-dark,不干扰工作库)
  新增 callout/on-callout/on-callout-soft 双主题成对 token
This commit is contained in:
Yaojia Wang
2026-07-11 14:04:14 +02:00
parent 355a2d11cd
commit f4bea7f26d
7 changed files with 162 additions and 30 deletions

View File

@@ -32,6 +32,10 @@
--color-info: #4a5a6b;
--color-conflict-mark: #b5543a26;
--color-conflict-mark-strong: #b5543a8c;
/* 暖深色 callout仅用于英雄/CTA 时刻(对标 DESIGN.md cta-band-dark非内容底。 */
--color-callout: #201d18;
--color-on-callout: #f2ede2;
--color-on-callout-soft: #b8ae9c;
--shadow-paper: #2b26200f;
color-scheme: light;
}
@@ -71,6 +75,10 @@
--color-info: #8ca8ca;
--color-conflict-mark: #ef8a7230;
--color-conflict-mark-strong: #ef8a7290;
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
--color-callout: #100e0c;
--color-on-callout: #efe6d7;
--color-on-callout-soft: #a89d8a;
--shadow-paper: #00000045;
color-scheme: dark;
}
@@ -126,6 +134,21 @@ body {
}
}
/* Toast 入场:淡入 + 轻微上移(尊重 prefers-reduced-motion见下。 */
.toast-enter {
animation: toast-in var(--dur-base) var(--ease-standard);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.typewriter-cursor {
animation: none;
@@ -137,4 +160,7 @@ body {
animation: none;
outline: 2px solid var(--color-conflict);
}
.toast-enter {
animation: none;
}
}

View File

@@ -4,7 +4,6 @@ import { BookOpen, PenLine, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { BackendDownNotice } from "@/components/BackendDownNotice";
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageContainer } from "@/components/ui/PageContainer";
import { PageHeader } from "@/components/ui/PageHeader";
import { fetchProjects } from "@/lib/api/server";
@@ -51,29 +50,32 @@ export default async function DashboardPage() {
{loadError ? (
<BackendDownNotice />
) : projects.length === 0 ? (
<EmptyState
icon={BookOpen}
title="还没有作品"
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。"
action={
<div className="flex flex-wrap items-center justify-center gap-2">
<Link
href="/write"
className={buttonClass({ variant: "primary" })}
>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href="/projects/new"
className={buttonClass({ variant: "secondary" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
}
/>
// 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark仅零作品时出现不干扰工作库
<div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
<BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
<h2 className="mt-5 font-serif text-display-sm text-on-callout">
</h2>
<p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
<Link
href="/write"
className={buttonClass({ variant: "primary", size: "lg" })}
>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href="/projects/new"
className={buttonClass({ variant: "secondary", size: "lg" })}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</div>
) : (
<ProjectLibrary projects={projects} />
)}

View File

@@ -7,8 +7,12 @@ import { Button } from "@/components/ui/Button";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { useMountTransition } from "@/lib/ui/useMountTransition";
import { overlayScrim } from "@/lib/ui/variants";
// 抽屉进出场时长(与 --dur-base 一致)。
const DRAWER_MS = 180;
interface DrawerProps {
open: boolean;
onClose: () => void;
@@ -36,11 +40,15 @@ export function Drawer({
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
useBodyScrollLock(open);
useRestoreFocus(open, triggerRef);
useEffect(() => {
if (!open) return;
// 面板真正挂载shouldRender且处于打开态时才装监听/落焦——
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
if (!open || !shouldRender) return;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
@@ -51,14 +59,25 @@ export function Drawer({
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
}, [open, shouldRender, onClose]);
if (!open) return null;
if (!shouldRender) return null;
const sideClass = side === "left" ? "left-0 border-r" : "right-0 border-l";
const slideClass =
side === "left"
? isVisible
? "translate-x-0"
: "-translate-x-full"
: isVisible
? "translate-x-0"
: "translate-x-full";
return (
<div className={`${overlayScrim} lg:hidden`} onClick={onClose}>
<div
className={`${overlayScrim} lg:hidden motion-safe:transition-opacity motion-safe:duration-base ${isVisible ? "opacity-100" : "opacity-0"}`}
onClick={onClose}
>
<div
ref={panelRef}
id={id}
@@ -70,7 +89,7 @@ export function Drawer({
// focus trapTab/Shift+Tab 在抽屉内循环不逃逸到背景WCAG 2.1.2)。
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none ${sideClass}`}
className={`absolute top-0 h-full w-64 max-w-[80vw] overflow-auto overscroll-contain border-line bg-panel py-4 shadow-paper outline-none motion-safe:transition-transform motion-safe:duration-base ease-standard ${sideClass} ${slideClass}`}
>
<div className="mb-2 flex justify-end px-2">
<Button

View File

@@ -98,7 +98,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const renderItem = (t: ToastItem) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-3 rounded border px-4 py-2 text-sm shadow-paper ${toastShellClass(
className={`toast-enter pointer-events-auto flex items-center gap-3 rounded-md border px-4 py-2 text-sm shadow-paper ${toastShellClass(
t.kind,
)}`}
>

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMountTransition } from "./useMountTransition";
beforeEach(() => {
vi.useFakeTimers();
// 让 rAF 同步执行,便于断言 isVisible 翻转。
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("useMountTransition", () => {
it("renders nothing while closed", () => {
const { result } = renderHook(() => useMountTransition(false, 180));
expect(result.current.shouldRender).toBe(false);
expect(result.current.isVisible).toBe(false);
});
it("mounts and becomes visible when opened", () => {
const { result, rerender } = renderHook(
({ open }) => useMountTransition(open, 180),
{ initialProps: { open: false } },
);
act(() => rerender({ open: true }));
expect(result.current.shouldRender).toBe(true);
expect(result.current.isVisible).toBe(true);
});
it("keeps rendering during exit, then unmounts after the duration", () => {
const { result, rerender } = renderHook(
({ open }) => useMountTransition(open, 180),
{ initialProps: { open: true } },
);
act(() => rerender({ open: false }));
expect(result.current.isVisible).toBe(false);
expect(result.current.shouldRender).toBe(true);
act(() => {
vi.advanceTimersByTime(180);
});
expect(result.current.shouldRender).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
// 弹层进出场:打开即挂载并在下一帧置为可见(触发入场过渡);关闭先置不可见播放出场,
// 延迟 durationMs 后再卸载。调用方用 motion-safe: 门控过渡类——reduced-motion 下无动画,
// 仍功能正常(仅多挂载 durationMs。守住既有 focus-trap/scroll-lock它们仍由 open 驱动,
// 本 hook 只负责“延迟卸载”以便播放退场。
export function useMountTransition(
isOpen: boolean,
durationMs: number,
): { shouldRender: boolean; isVisible: boolean } {
const [shouldRender, setShouldRender] = useState(isOpen);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
// 下一帧再置可见,确保入场从初始(隐藏)态开始过渡。
const raf = requestAnimationFrame(() => setIsVisible(true));
return () => cancelAnimationFrame(raf);
}
setIsVisible(false);
const timer = window.setTimeout(() => setShouldRender(false), durationMs);
return () => window.clearTimeout(timer);
}, [isOpen, durationMs]);
return { shouldRender, isVisible };
}

View File

@@ -25,6 +25,9 @@ const config: Config = {
cinnabar: "var(--color-cinnabar)",
"cinnabar-active": "var(--color-cinnabar-active)",
"cinnabar-disabled": "var(--color-cinnabar-disabled)",
callout: "var(--color-callout)",
"on-callout": "var(--color-on-callout)",
"on-callout-soft": "var(--color-on-callout-soft)",
conflict: "var(--color-conflict)",
overdue: "var(--color-overdue)",
pass: "var(--color-pass)",