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-info: #4a5a6b;
|
||||||
--color-conflict-mark: #b5543a26;
|
--color-conflict-mark: #b5543a26;
|
||||||
--color-conflict-mark-strong: #b5543a8c;
|
--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;
|
--shadow-paper: #2b26200f;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@@ -71,6 +75,10 @@
|
|||||||
--color-info: #8ca8ca;
|
--color-info: #8ca8ca;
|
||||||
--color-conflict-mark: #ef8a7230;
|
--color-conflict-mark: #ef8a7230;
|
||||||
--color-conflict-mark-strong: #ef8a7290;
|
--color-conflict-mark-strong: #ef8a7290;
|
||||||
|
/* 夜读下 callout 为更深的凹陷暖井,仍与 bg 拉开层次。 */
|
||||||
|
--color-callout: #100e0c;
|
||||||
|
--color-on-callout: #efe6d7;
|
||||||
|
--color-on-callout-soft: #a89d8a;
|
||||||
--shadow-paper: #00000045;
|
--shadow-paper: #00000045;
|
||||||
color-scheme: dark;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.typewriter-cursor {
|
.typewriter-cursor {
|
||||||
animation: none;
|
animation: none;
|
||||||
@@ -137,4 +160,7 @@ body {
|
|||||||
animation: none;
|
animation: none;
|
||||||
outline: 2px solid var(--color-conflict);
|
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 { AppShell } from "@/components/AppShell";
|
||||||
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
import { BackendDownNotice } from "@/components/BackendDownNotice";
|
||||||
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
|
import { ProjectLibrary } from "@/components/projects/ProjectLibrary";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
|
||||||
import { PageContainer } from "@/components/ui/PageContainer";
|
import { PageContainer } from "@/components/ui/PageContainer";
|
||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import { fetchProjects } from "@/lib/api/server";
|
import { fetchProjects } from "@/lib/api/server";
|
||||||
@@ -51,29 +50,32 @@ export default async function DashboardPage() {
|
|||||||
{loadError ? (
|
{loadError ? (
|
||||||
<BackendDownNotice />
|
<BackendDownNotice />
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<EmptyState
|
// 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark;仅零作品时出现,不干扰工作库)。
|
||||||
icon={BookOpen}
|
<div className="rounded-xl border border-line/50 bg-callout px-8 py-14 text-center shadow-paper sm:px-12 sm:py-20">
|
||||||
title="还没有作品"
|
<BookOpen className="mx-auto h-9 w-9 text-cinnabar" aria-hidden="true" />
|
||||||
description="直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。"
|
<h2 className="mt-5 font-serif text-display-sm text-on-callout">
|
||||||
action={
|
从一句灵感,写成一本书
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
</h2>
|
||||||
<Link
|
<p className="mx-auto mt-3 max-w-md text-body leading-7 text-on-callout-soft">
|
||||||
href="/write"
|
直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。
|
||||||
className={buttonClass({ variant: "primary" })}
|
</p>
|
||||||
>
|
<div className="mt-7 flex flex-wrap items-center justify-center gap-2">
|
||||||
<PenLine className="h-4 w-4" aria-hidden="true" />
|
<Link
|
||||||
直接开始写
|
href="/write"
|
||||||
</Link>
|
className={buttonClass({ variant: "primary", size: "lg" })}
|
||||||
<Link
|
>
|
||||||
href="/projects/new"
|
<PenLine className="h-4 w-4" aria-hidden="true" />
|
||||||
className={buttonClass({ variant: "secondary" })}
|
直接开始写
|
||||||
>
|
</Link>
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<Link
|
||||||
先立项
|
href="/projects/new"
|
||||||
</Link>
|
className={buttonClass({ variant: "secondary", size: "lg" })}
|
||||||
</div>
|
>
|
||||||
}
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
/>
|
先立项
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ProjectLibrary projects={projects} />
|
<ProjectLibrary projects={projects} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
import { handleTabTrap } from "@/lib/a11y/focusTrap";
|
||||||
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
import { useRestoreFocus } from "@/lib/a11y/useRestoreFocus";
|
||||||
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
|
||||||
|
import { useMountTransition } from "@/lib/ui/useMountTransition";
|
||||||
import { overlayScrim } from "@/lib/ui/variants";
|
import { overlayScrim } from "@/lib/ui/variants";
|
||||||
|
|
||||||
|
// 抽屉进出场时长(与 --dur-base 一致)。
|
||||||
|
const DRAWER_MS = 180;
|
||||||
|
|
||||||
interface DrawerProps {
|
interface DrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -36,11 +40,15 @@ export function Drawer({
|
|||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const { shouldRender, isVisible } = useMountTransition(open, DRAWER_MS);
|
||||||
|
|
||||||
useBodyScrollLock(open);
|
useBodyScrollLock(open);
|
||||||
useRestoreFocus(open, triggerRef);
|
useRestoreFocus(open, triggerRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
// 面板真正挂载(shouldRender)且处于打开态时才装监听/落焦——
|
||||||
|
// 因进出场延迟了卸载,若仅依赖 open 会错过挂载帧导致首焦点丢失。
|
||||||
|
if (!open || !shouldRender) return;
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -51,14 +59,25 @@ export function Drawer({
|
|||||||
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
// 焦点陷阱首焦点落在具名关闭按钮上(而非整个面板)。
|
||||||
closeRef.current?.focus();
|
closeRef.current?.focus();
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
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 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 (
|
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
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
id={id}
|
id={id}
|
||||||
@@ -70,7 +89,7 @@ export function Drawer({
|
|||||||
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
// focus trap:Tab/Shift+Tab 在抽屉内循环,不逃逸到背景(WCAG 2.1.2)。
|
||||||
if (panelRef.current) handleTabTrap(panelRef.current, e);
|
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">
|
<div className="mb-2 flex justify-end px-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
const renderItem = (t: ToastItem) => (
|
const renderItem = (t: ToastItem) => (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
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,
|
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: "var(--color-cinnabar)",
|
||||||
"cinnabar-active": "var(--color-cinnabar-active)",
|
"cinnabar-active": "var(--color-cinnabar-active)",
|
||||||
"cinnabar-disabled": "var(--color-cinnabar-disabled)",
|
"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)",
|
conflict: "var(--color-conflict)",
|
||||||
overdue: "var(--color-overdue)",
|
overdue: "var(--color-overdue)",
|
||||||
pass: "var(--color-pass)",
|
pass: "var(--color-pass)",
|
||||||
|
|||||||
Reference in New Issue
Block a user