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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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 trap:Tab/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
|
||||
|
||||
@@ -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,
|
||||
)}`}
|
||||
>
|
||||
|
||||
53
apps/web/lib/ui/useMountTransition.test.ts
Normal file
53
apps/web/lib/ui/useMountTransition.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
apps/web/lib/ui/useMountTransition.ts
Normal file
29
apps/web/lib/ui/useMountTransition.ts
Normal 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 };
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user