diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000..ed01afb --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,37 @@ +"use client"; + +import Link from "next/link"; +import { TriangleAlert } from "lucide-react"; + +import { EmptyState } from "@/components/ui/EmptyState"; +import { Button } from "@/components/ui/Button"; +import { buttonClass } from "@/lib/ui/variants"; + +interface ErrorBoundaryProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function Error({ reset }: ErrorBoundaryProps) { + return ( +
+
+ + + + 返回作品库 + +
+ } + /> + +
+ ); +} diff --git a/apps/web/app/loading.tsx b/apps/web/app/loading.tsx new file mode 100644 index 0000000..9e27ca5 --- /dev/null +++ b/apps/web/app/loading.tsx @@ -0,0 +1,9 @@ +import { ThinkingIndicator } from "@/components/ThinkingIndicator"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..38e5f81 --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { FileQuestion } from "lucide-react"; + +import { EmptyState } from "@/components/ui/EmptyState"; +import { buttonClass } from "@/lib/ui/variants"; + +export default function NotFound() { + return ( +
+
+ + 返回作品库 + + } + /> +
+
+ ); +} diff --git a/apps/web/components/Toast.tsx b/apps/web/components/Toast.tsx index 78061c9..6959ea9 100644 --- a/apps/web/components/Toast.tsx +++ b/apps/web/components/Toast.tsx @@ -11,19 +11,51 @@ import { type ReactNode, } from "react"; +import { buttonClass } from "@/lib/ui/variants"; + type ToastKind = "info" | "error" | "success"; +interface ToastAction { + label: string; + onClick: () => void; +} + +interface ToastOptions { + action?: ToastAction; + durationMs?: number; +} + interface ToastItem { id: number; message: string; kind: ToastKind; + action?: ToastAction; } -type ShowToast = (message: string, kind?: ToastKind) => void; +type ShowToast = ( + message: string, + kind?: ToastKind, + options?: ToastOptions, +) => void; const ToastContext = createContext(null); +// 普通提示 4s 自动消失;错误更需被看到,默认延长到 7s。 const TOAST_TTL_MS = 4000; +const ERROR_TOAST_TTL_MS = 7000; + +// 屏读前缀:让无视觉的用户也能区分提示性质。 +const SR_PREFIX: Record = { + error: "错误:", + success: "成功:", + info: "", +}; + +function toastShellClass(kind: ToastKind): string { + if (kind === "error") return "border-conflict bg-panel text-conflict"; + if (kind === "success") return "border-pass bg-panel text-pass"; + return "border-line bg-panel text-ink"; +} export function ToastProvider({ children }: { children: ReactNode }) { const [items, setItems] = useState([]); @@ -32,13 +64,20 @@ export function ToastProvider({ children }: { children: ReactNode }) { // 在途定时器集合:卸载时统一清理,避免 setState-after-unmount 泄漏。 const timersRef = useRef>>(new Set()); - const show = useCallback((message, kind = "info") => { + const dismiss = useCallback((id: number) => { + setItems((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const show = useCallback((message, kind = "info", options) => { const id = nextIdRef.current++; - setItems((prev) => [...prev, { id, message, kind }]); + setItems((prev) => [...prev, { id, message, kind, action: options?.action }]); + const ttl = + options?.durationMs ?? + (kind === "error" ? ERROR_TOAST_TTL_MS : TOAST_TTL_MS); const timer = setTimeout(() => { timersRef.current.delete(timer); setItems((prev) => prev.filter((t) => t.id !== id)); - }, TOAST_TTL_MS); + }, ttl); timersRef.current.add(timer); }, []); @@ -53,28 +92,46 @@ export function ToastProvider({ children }: { children: ReactNode }) { const value = useMemo(() => show, [show]); + const errorItems = items.filter((t) => t.kind === "error"); + const politeItems = items.filter((t) => t.kind !== "error"); + + const renderItem = (t: ToastItem) => ( +
+ + {SR_PREFIX[t.kind]} + {t.message} + + {t.action ? ( + + ) : null} +
+ ); + return ( {children} -
- {items.map((t) => ( -
- {t.message} -
- ))} + {/* error 用 assertive 立即播报;其余 info/success 用 polite 礼貌播报。两区共用同一锚点容器避免重叠。 */} +
+
+ {errorItems.map(renderItem)} +
+
+ {politeItems.map(renderItem)} +
); diff --git a/apps/web/lib/ui/useBodyScrollLock.test.ts b/apps/web/lib/ui/useBodyScrollLock.test.ts new file mode 100644 index 0000000..b157f03 --- /dev/null +++ b/apps/web/lib/ui/useBodyScrollLock.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useBodyScrollLock } from "./useBodyScrollLock"; + +describe("useBodyScrollLock", () => { + afterEach(() => { + document.body.style.overflow = ""; + }); + + it("active 时锁定 body 滚动", () => { + renderHook(() => useBodyScrollLock(true)); + expect(document.body.style.overflow).toBe("hidden"); + }); + + it("active 为假时不改动 overflow", () => { + document.body.style.overflow = "scroll"; + renderHook(() => useBodyScrollLock(false)); + expect(document.body.style.overflow).toBe("scroll"); + }); + + it("卸载时恢复原 overflow 值", () => { + document.body.style.overflow = "auto"; + const { unmount } = renderHook(() => useBodyScrollLock(true)); + expect(document.body.style.overflow).toBe("hidden"); + unmount(); + expect(document.body.style.overflow).toBe("auto"); + }); + + it("active 由真转假时恢复原值", () => { + document.body.style.overflow = "visible"; + const { rerender } = renderHook( + ({ active }: { active: boolean }) => useBodyScrollLock(active), + { initialProps: { active: true } }, + ); + expect(document.body.style.overflow).toBe("hidden"); + rerender({ active: false }); + expect(document.body.style.overflow).toBe("visible"); + }); +}); diff --git a/apps/web/lib/ui/useBodyScrollLock.ts b/apps/web/lib/ui/useBodyScrollLock.ts new file mode 100644 index 0000000..be7f4ee --- /dev/null +++ b/apps/web/lib/ui/useBodyScrollLock.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +// 弹层(Drawer / CommandPalette / Modal)打开时锁定 body 滚动,关闭后恢复原值。 +// SSR 守卫:服务端无 document,直接跳过。 +export function useBodyScrollLock(active: boolean): void { + useEffect(() => { + if (!active) return; + if (typeof document === "undefined") return; + + const { body } = document; + const previousOverflow = body.style.overflow; + body.style.overflow = "hidden"; + + return () => { + body.style.overflow = previousOverflow; + }; + }, [active]); +} diff --git a/apps/web/lib/ui/variants.test.ts b/apps/web/lib/ui/variants.test.ts index 6025c53..ddfaec7 100644 --- a/apps/web/lib/ui/variants.test.ts +++ b/apps/web/lib/ui/variants.test.ts @@ -4,7 +4,10 @@ import { badgeClass, buttonClass, cn, + focusRing, inputClass, + overlayScrim, + proseBody, segmentedClass, statusNoteClass, } from "./variants"; @@ -49,4 +52,22 @@ describe("ui variants", () => { expect(klass).toContain("inline-flex"); expect(klass).toContain("border-line"); }); + + it("shares the cinnabar focus ring with the button base", () => { + expect(focusRing).toContain("focus-visible:ring-2"); + expect(focusRing).toContain("focus-visible:ring-cinnabar/35"); + expect(buttonClass({ variant: "primary" })).toContain(focusRing); + }); + + it("renders manuscript body as serif 18px with airy leading", () => { + expect(proseBody).toContain("font-serif"); + expect(proseBody).toContain("text-[18px]"); + expect(proseBody).toContain("leading-[1.9]"); + }); + + it("provides a full-screen overlay scrim below dialog chrome", () => { + expect(overlayScrim).toContain("fixed"); + expect(overlayScrim).toContain("inset-0"); + expect(overlayScrim).toContain("z-40"); + }); }); diff --git a/apps/web/lib/ui/variants.ts b/apps/web/lib/ui/variants.ts index a95a992..133d4a6 100644 --- a/apps/web/lib/ui/variants.ts +++ b/apps/web/lib/ui/variants.ts @@ -23,6 +23,16 @@ export function cn(...classes: Array): string return classes.filter(Boolean).join(" "); } +// 统一焦点环(与 buttonBase 现有焦点环一致),供导航/输入等可聚焦元素复用。 +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]"; + +// 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"; diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 250211b..5cc4ceb 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -26,6 +26,9 @@ const config: Config = { sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"], mono: ['"JetBrains Mono"', "ui-monospace"], }, + fontSize: { + "2xs": ["0.6875rem", { lineHeight: "1rem" }], + }, borderRadius: { DEFAULT: "6px" }, boxShadow: { paper: "0 1px 3px var(--shadow-paper)" }, maxWidth: { prose: "720px" },