diff --git a/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx
new file mode 100644
index 000000000..f590964d9
--- /dev/null
+++ b/frontend/src/components/WorkspaceHome.empty-live-region.test.tsx
@@ -0,0 +1,173 @@
+/* @vitest-environment jsdom */
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/components/EmailList", () => ({
+ EmailList: () => ,
+}));
+
+vi.mock("@/components/EmailDetail", () => ({
+ EmailDetail: () => ,
+}));
+
+vi.mock("@/components/ui/resizable", () => ({
+ ResizablePanelGroup: ({ children }: { children: React.ReactNode }) =>
{children}
,
+ ResizablePanel: ({ children }: { children: React.ReactNode }) => {children}
,
+ ResizableHandle: () => ,
+}));
+
+vi.mock("@/components/mobile-workspace-panels", () => ({
+ MobileCalendarPanel: () => ,
+ MobileSearchPanel: () => ,
+}));
+
+vi.mock("next/dynamic", () => ({
+ default: () => function MockDynamic() {
+ return mock graph
;
+ },
+}));
+
+vi.mock("lucide-react", () => ({
+ CalendarDays: () => ,
+ CheckCircle2: () => ,
+ Inbox: () => ,
+ Network: () => ,
+ Send: () => ,
+ Settings: () => ,
+ Sparkles: () => ,
+}));
+
+import { WorkspaceHome } from "./WorkspaceHome";
+
+type DeferredResponse = {
+ promise: Promise<{ ok: true; json: () => Promise }>;
+ bodyPromise: Promise;
+ resolveResponse: () => void;
+ resolveBody: (body: unknown) => void;
+};
+
+function deferredResponse(): DeferredResponse {
+ let resolveResponsePromise!: (response: { ok: true; json: () => Promise }) => void;
+ let resolveBodyPromise!: (body: unknown) => void;
+ const bodyPromise = new Promise((resolve) => {
+ resolveBodyPromise = resolve;
+ });
+ const promise = new Promise<{ ok: true; json: () => Promise }>((resolve) => {
+ resolveResponsePromise = resolve;
+ });
+ const response = { ok: true as const, json: () => bodyPromise };
+
+ return {
+ promise,
+ bodyPromise,
+ resolveResponse: () => resolveResponsePromise(response),
+ resolveBody: resolveBodyPromise,
+ };
+}
+
+function statusWithText(container: HTMLElement, text: string) {
+ return Array.from(container.querySelectorAll('[role="status"]')).find(
+ (element) => element.textContent?.includes(text),
+ ) ?? null;
+}
+
+describe("WorkspaceHome empty-state live regions", () => {
+ let root: Root | null = null;
+ let container: HTMLDivElement | null = null;
+
+ afterEach(() => {
+ if (root) act(() => root?.unmount());
+ root = null;
+ container?.remove();
+ container = null;
+ localStorage.clear();
+ vi.unstubAllGlobals();
+ });
+
+ it("announces primary dashboard empty states only after asynchronous bodies leave loading", async () => {
+ vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
+ matches: false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })));
+
+ const emailsResponse = deferredResponse();
+ const pendingRepliesResponse = deferredResponse();
+ const tasksResponse = deferredResponse();
+
+ vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/emails/pending-replies?limit=3")) return pendingRepliesResponse.promise;
+ if (url.endsWith("/api/emails")) return emailsResponse.promise;
+ if (url.endsWith("/api/tasks")) return tasksResponse.promise;
+ if (url.endsWith("/api/calendar/writeback-sources") || url.endsWith("/api/webdav/folders")) {
+ return Promise.resolve({ ok: true, json: async () => [] });
+ }
+ if (url.endsWith("/api/search")) {
+ return Promise.resolve({ ok: true, json: async () => ({ results: [] }) });
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ }));
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+
+ expect(container.textContent).toContain("답변 대기 메일을 불러오는 중...");
+ expect(container.textContent).toContain("작업을 불러오는 중...");
+ expect(container.textContent).toContain("메일을 불러오는 중...");
+
+ await act(async () => {
+ emailsResponse.resolveResponse();
+ pendingRepliesResponse.resolveResponse();
+ tasksResponse.resolveResponse();
+ await Promise.all([
+ emailsResponse.promise,
+ pendingRepliesResponse.promise,
+ tasksResponse.promise,
+ ]);
+ });
+
+ expect(container.textContent).toContain("답변 대기 메일을 불러오는 중...");
+ expect(container.textContent).toContain("작업을 불러오는 중...");
+ expect(container.textContent).toContain("메일을 불러오는 중...");
+ for (const copy of [
+ "답변 대기 중인 보낸 메일이 없습니다.",
+ "대기 작업이 없습니다.",
+ "수신된 메일이 없습니다.",
+ ]) {
+ expect(statusWithText(container, copy), copy).toBeNull();
+ }
+
+ await act(async () => {
+ emailsResponse.resolveBody({ emails: [] });
+ pendingRepliesResponse.resolveBody({ emails: [] });
+ tasksResponse.resolveBody([]);
+ await Promise.all([
+ emailsResponse.bodyPromise,
+ pendingRepliesResponse.bodyPromise,
+ tasksResponse.bodyPromise,
+ ]);
+ });
+
+ for (const copy of [
+ "답변 대기 중인 보낸 메일이 없습니다.",
+ "대기 작업이 없습니다.",
+ "수신된 메일이 없습니다.",
+ ]) {
+ const emptyStatus = statusWithText(container, copy);
+ expect(emptyStatus, copy).not.toBeNull();
+ expect(emptyStatus?.getAttribute("aria-live"), copy).toBe("polite");
+ }
+
+ expect(container.textContent).not.toContain("답변 대기 메일을 불러오는 중...");
+ expect(container.textContent).not.toContain("작업을 불러오는 중...");
+ expect(container.textContent).not.toContain("메일을 불러오는 중...");
+ });
+});
diff --git a/frontend/src/components/WorkspaceHome.tsx b/frontend/src/components/WorkspaceHome.tsx
index e0c97d49e..9ca5619a0 100644
--- a/frontend/src/components/WorkspaceHome.tsx
+++ b/frontend/src/components/WorkspaceHome.tsx
@@ -610,7 +610,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
) : pendingReplyUnavailable ? (
답변 대기 메일을 확인하지 못했습니다.
) : pendingReplies.length === 0 ? (
- 답변 대기 중인 보낸 메일이 없습니다.
+ 답변 대기 중인 보낸 메일이 없습니다.
) : pendingReplies.map((reply) => {
const safeSubject = toSafeReactText(reply.subject?.trim() || null, '(제목 없음)');
const safeSnippet = toSafeReactText(reply.snippet);
@@ -641,7 +641,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
) : taskUnavailable ? (
작업 현황을 확인하지 못했습니다.
) : pendingTasks.length === 0 ? (
- 대기 작업이 없습니다.
+ 대기 작업이 없습니다.
) : pendingTasks.slice(0, 3).map((task) => {
const pKor = mapPriorityToKorean(task.priority);
const pClass = pKor === '긴급' || pKor === '높음' ? 'text-red-500' : pKor === '보통' ? 'text-green-500' : 'text-muted-foreground';
@@ -744,7 +744,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
) : emailUnavailable ? (
최근 메일을 확인하지 못했습니다.
) : emails.length === 0 ? (
- 수신된 메일이 없습니다.
+ 수신된 메일이 없습니다.
) : emails.slice(0, 5).map((mail) => (