diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 1b52c18..dc332cc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; + } } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 5c37b63..8c2c3c0 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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 ? ( ) : projects.length === 0 ? ( - - - - 直接开始写 - - - - 先立项 - - - } - /> + // 首次进入的暖深色编辑部英雄(对标 DESIGN.md cta-band-dark;仅零作品时出现,不干扰工作库)。 + + + + 从一句灵感,写成一本书 + + + 直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。 + + + + + 直接开始写 + + + + 先立项 + + + ) : ( )} diff --git a/apps/web/components/Drawer.tsx b/apps/web/components/Drawer.tsx index bccc439..91844ec 100644 --- a/apps/web/components/Drawer.tsx +++ b/apps/web/components/Drawer.tsx @@ -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(null); const closeRef = useRef(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 ( - + ( diff --git a/apps/web/lib/ui/useMountTransition.test.ts b/apps/web/lib/ui/useMountTransition.test.ts new file mode 100644 index 0000000..23de409 --- /dev/null +++ b/apps/web/lib/ui/useMountTransition.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/ui/useMountTransition.ts b/apps/web/lib/ui/useMountTransition.ts new file mode 100644 index 0000000..80c30ad --- /dev/null +++ b/apps/web/lib/ui/useMountTransition.ts @@ -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 }; +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index e2b0744..329588e 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -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)",
+ 直接开始写,落笔即成书;或先立项,从一句灵感搭好设定、大纲再动笔。 +