- {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" },