diff --git a/.gitignore b/.gitignore
index f7a3f7c..201cb05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,11 @@ frontend/.env.local
frontend/coverage/
frontend/playwright-report/
frontend/test-results/
+frontend/.playwright-mcp/
+.playwright-mcp/
+# 테스트 중 찍은 스크린샷 (루트/프론트 임시 캡처)
+/*.png
+/phase*.png
# ── Python / FastAPI ──
backend/.venv/
diff --git a/.playwright-mcp/page-2026-06-14T01-38-55-253Z.yml b/.playwright-mcp/page-2026-06-14T01-38-55-253Z.yml
deleted file mode 100644
index 9fc8661..0000000
--- a/.playwright-mcp/page-2026-06-14T01-38-55-253Z.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-- generic [active] [ref=e1]:
- - main [ref=e2]:
- - heading "아리 — 대시보드 (준비 중)" [level=1] [ref=e3]
- - paragraph [ref=e4]: Phase 0 스캐폴딩. 실제 대시보드는 phase-5-dashboard.md 에서 구현됩니다.
- - button "Open Next.js Dev Tools" [ref=e10] [cursor=pointer]:
- - img [ref=e11]
- - alert [ref=e14]
\ No newline at end of file
diff --git a/frontend/app/(placeholder)/approvals/page.tsx b/frontend/app/(placeholder)/approvals/page.tsx
new file mode 100644
index 0000000..7cb05cc
--- /dev/null
+++ b/frontend/app/(placeholder)/approvals/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/automation/page.tsx b/frontend/app/(placeholder)/automation/page.tsx
new file mode 100644
index 0000000..5803eb3
--- /dev/null
+++ b/frontend/app/(placeholder)/automation/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/calendar/page.tsx b/frontend/app/(placeholder)/calendar/page.tsx
new file mode 100644
index 0000000..ae64601
--- /dev/null
+++ b/frontend/app/(placeholder)/calendar/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/journey/page.tsx b/frontend/app/(placeholder)/journey/page.tsx
new file mode 100644
index 0000000..9fa342a
--- /dev/null
+++ b/frontend/app/(placeholder)/journey/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/life/page.tsx b/frontend/app/(placeholder)/life/page.tsx
new file mode 100644
index 0000000..20f50d0
--- /dev/null
+++ b/frontend/app/(placeholder)/life/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/mail/page.tsx b/frontend/app/(placeholder)/mail/page.tsx
new file mode 100644
index 0000000..ef3763b
--- /dev/null
+++ b/frontend/app/(placeholder)/mail/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/notifications/page.tsx b/frontend/app/(placeholder)/notifications/page.tsx
new file mode 100644
index 0000000..d0782a8
--- /dev/null
+++ b/frontend/app/(placeholder)/notifications/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/research/page.tsx b/frontend/app/(placeholder)/research/page.tsx
new file mode 100644
index 0000000..5020b21
--- /dev/null
+++ b/frontend/app/(placeholder)/research/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/trip/page.tsx b/frontend/app/(placeholder)/trip/page.tsx
new file mode 100644
index 0000000..86c58d4
--- /dev/null
+++ b/frontend/app/(placeholder)/trip/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/(placeholder)/wrap/page.tsx b/frontend/app/(placeholder)/wrap/page.tsx
new file mode 100644
index 0000000..fcdaec0
--- /dev/null
+++ b/frontend/app/(placeholder)/wrap/page.tsx
@@ -0,0 +1,4 @@
+import { Placeholder } from "@/components/Placeholder";
+export default function Page() {
+ return ;
+}
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
index a0bfed9..abe0dc5 100644
--- a/frontend/app/dashboard/page.tsx
+++ b/frontend/app/dashboard/page.tsx
@@ -1,9 +1,4 @@
-// frontend/app/dashboard/page.tsx (Phase 0 임시 자리표시자 — Phase 5에서 교체)
-export default function DashboardPlaceholder() {
- return (
-
- 아리 — 대시보드 (준비 중)
- Phase 0 스캐폴딩. 실제 대시보드는 phase-5-dashboard.md 에서 구현됩니다.
-
- );
+// frontend/app/dashboard/page.tsx (Phase 1 스텁 — Phase 5에서 교체)
+export default function Page() {
+ return
대시보드
;
}
diff --git a/frontend/app/fonts.ts b/frontend/app/fonts.ts
new file mode 100644
index 0000000..a0e0a3a
--- /dev/null
+++ b/frontend/app/fonts.ts
@@ -0,0 +1,16 @@
+// frontend/app/fonts.ts
+import { Onest, DM_Mono } from "next/font/google";
+
+export const onest = Onest({
+ subsets: ["latin"],
+ weight: ["400", "500", "600", "700", "800"],
+ variable: "--f-onest",
+ display: "swap",
+});
+
+export const dmMono = DM_Mono({
+ subsets: ["latin"],
+ weight: ["400", "500"],
+ variable: "--f-dmmono",
+ display: "swap",
+});
diff --git a/frontend/app/inbox/page.tsx b/frontend/app/inbox/page.tsx
new file mode 100644
index 0000000..c8f5412
--- /dev/null
+++ b/frontend/app/inbox/page.tsx
@@ -0,0 +1,4 @@
+// frontend/app/inbox/page.tsx (Phase 1 스텁 — Phase 4에서 교체)
+export default function Page() {
+ return 인박스
;
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 1410b22..68d3205 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,6 +1,8 @@
// frontend/app/layout.tsx
import type { Metadata } from "next";
-import "@/styles/tokens.css";
+import { onest, dmMono } from "./fonts";
+import { ThemeProvider } from "@/components/ThemeProvider";
+import { Topbar } from "@/components/Topbar";
import "@/styles/globals.css";
export const metadata: Metadata = {
@@ -10,21 +12,16 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
-
- {/* 원본 대시보드.html 의 폰트 로딩을 그대로 옮긴다 */}
-
-
-
-
-
- {children}
+ // lang=ko + data-theme는 next-themes가 주입 → suppressHydrationWarning 필수
+
+
+
+
+
+ {children}
+
+
+
);
}
diff --git a/frontend/app/tasks/page.tsx b/frontend/app/tasks/page.tsx
new file mode 100644
index 0000000..a8025a4
--- /dev/null
+++ b/frontend/app/tasks/page.tsx
@@ -0,0 +1,4 @@
+// frontend/app/tasks/page.tsx (Phase 1 스텁 — Phase 3에서 교체)
+export default function Page() {
+ return 작업
;
+}
diff --git a/frontend/components/Avatar.tsx b/frontend/components/Avatar.tsx
new file mode 100644
index 0000000..05bfcdc
--- /dev/null
+++ b/frontend/components/Avatar.tsx
@@ -0,0 +1,17 @@
+// frontend/components/Avatar.tsx
+type Tone = "blue" | "violet" | "green" | "coral" | "amber";
+export function Avatar({
+ initial,
+ tone = "blue",
+ label,
+}: {
+ initial: string;
+ tone?: Tone;
+ label?: string;
+}) {
+ return (
+
+ {initial}
+
+ );
+}
diff --git a/frontend/components/Badge.tsx b/frontend/components/Badge.tsx
new file mode 100644
index 0000000..a6c96c4
--- /dev/null
+++ b/frontend/components/Badge.tsx
@@ -0,0 +1,18 @@
+// frontend/components/Badge.tsx
+type Tone = "coral" | "blue" | "green" | "violet" | "amber" | "ink";
+export function Badge({
+ children,
+ tone = "coral",
+ variant = "pill",
+}: {
+ children: React.ReactNode;
+ tone?: Tone;
+ variant?: "pill" | "count";
+}) {
+ if (variant === "count") return {children};
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/Button.tsx b/frontend/components/Button.tsx
new file mode 100644
index 0000000..4e1d01f
--- /dev/null
+++ b/frontend/components/Button.tsx
@@ -0,0 +1,18 @@
+// frontend/components/Button.tsx
+import { Icon } from "./Icon";
+import type { IconName } from "./icons/paths";
+type Variant = "fill" | "lime" | "glass";
+export function Button({
+ variant = "fill",
+ icon,
+ children,
+ className = "",
+ ...rest
+}: { variant?: Variant; icon?: IconName } & React.ButtonHTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/frontend/components/Chip.tsx b/frontend/components/Chip.tsx
new file mode 100644
index 0000000..9c98c7c
--- /dev/null
+++ b/frontend/components/Chip.tsx
@@ -0,0 +1,19 @@
+// frontend/components/Chip.tsx
+import { Icon } from "./Icon";
+import type { IconName } from "./icons/paths";
+export function Chip({
+ icon,
+ children,
+ onClick,
+}: {
+ icon?: IconName;
+ children: React.ReactNode;
+ onClick?: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/frontend/components/GlassCard.tsx b/frontend/components/GlassCard.tsx
new file mode 100644
index 0000000..3189508
--- /dev/null
+++ b/frontend/components/GlassCard.tsx
@@ -0,0 +1,12 @@
+// frontend/components/GlassCard.tsx
+export function GlassCard({
+ className = "",
+ children,
+ ...rest
+}: React.HTMLAttributes) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/Icon.tsx b/frontend/components/Icon.tsx
new file mode 100644
index 0000000..1d838ed
--- /dev/null
+++ b/frontend/components/Icon.tsx
@@ -0,0 +1,37 @@
+// frontend/components/Icon.tsx
+import { PATHS, type IconName } from "./icons/paths";
+
+export function Icon({
+ name,
+ w = 1.9, // 원본 strokeWidth 기본 1.9 (shell.jsx 45행)
+ className = "ic",
+}: {
+ name: IconName;
+ w?: number;
+ className?: string;
+}) {
+ const d = PATHS[name];
+ if (!d) {
+ // 누락 가드: 콘솔 경고 + 빈 SVG(레이아웃 보존). 프로덕션 렌더 깨짐 방지.
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(`[Icon] unknown icon name: "${name}"`);
+ }
+ return ;
+ }
+ return (
+
+ );
+}
diff --git a/frontend/components/Placeholder.tsx b/frontend/components/Placeholder.tsx
new file mode 100644
index 0000000..724d082
--- /dev/null
+++ b/frontend/components/Placeholder.tsx
@@ -0,0 +1,22 @@
+// frontend/components/Placeholder.tsx
+import { GlassCard } from "./GlassCard";
+import { Icon } from "./Icon";
+import type { IconName } from "./icons/paths";
+
+export function Placeholder({ title, icon }: { title: string; icon: IconName }) {
+ return (
+
+
+
+
+ {title}
+
+ 준비 중이에요. 이 화면은 곧 아리가 채워둘게요.
+
+
+ );
+}
diff --git a/frontend/components/SegmentToggle.tsx b/frontend/components/SegmentToggle.tsx
new file mode 100644
index 0000000..fcfef83
--- /dev/null
+++ b/frontend/components/SegmentToggle.tsx
@@ -0,0 +1,24 @@
+// frontend/components/SegmentToggle.tsx — 칸반/리스트(캘린더는 post-MVP) (Phase 3 사용)
+import { Icon } from "./Icon";
+import type { IconName } from "./icons/paths";
+export type SegOption = { id: string; label: string; icon?: IconName };
+export function SegmentToggle({
+ options,
+ value,
+ onChange,
+}: {
+ options: SegOption[];
+ value: string;
+ onChange: (id: string) => void;
+}) {
+ return (
+
+ {options.map((o) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/components/SubRail.tsx b/frontend/components/SubRail.tsx
new file mode 100644
index 0000000..5ae7348
--- /dev/null
+++ b/frontend/components/SubRail.tsx
@@ -0,0 +1,40 @@
+// frontend/components/SubRail.tsx
+"use client";
+import { Icon } from "./Icon";
+import type { IconName } from "./icons/paths";
+
+export type RailItem =
+ | { sep: true }
+ | { sep?: false; id: string; icon: IconName; label: string; dot?: boolean };
+
+export function SubRail({
+ items,
+ active,
+ onPick,
+}: {
+ items: RailItem[];
+ active: string;
+ onPick: (id: string) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/frontend/components/ThemeProvider.tsx b/frontend/components/ThemeProvider.tsx
new file mode 100644
index 0000000..c0aa439
--- /dev/null
+++ b/frontend/components/ThemeProvider.tsx
@@ -0,0 +1,17 @@
+// frontend/components/ThemeProvider.tsx
+"use client";
+import { ThemeProvider as NextThemes } from "next-themes";
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ return (
+ ← 토큰 셀렉터와 일치
+ defaultTheme="light" // 원본 대시보드.html data-theme="light"
+ enableSystem={false} // MVP: 시스템 추종 끔(명시 토글만)
+ storageKey="ari-theme" // localStorage 키
+ disableTransitionOnChange // 토글 시 전체 transition 깜빡임 억제
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/components/ThemeToggle.tsx b/frontend/components/ThemeToggle.tsx
new file mode 100644
index 0000000..a23eb4e
--- /dev/null
+++ b/frontend/components/ThemeToggle.tsx
@@ -0,0 +1,26 @@
+// frontend/components/ThemeToggle.tsx
+"use client";
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+import { Icon } from "./Icon";
+
+export function ThemeToggle() {
+ const { resolvedTheme, setTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ // 하이드레이션 불일치 방지: 마운트 후에만 실제 테마 아이콘 결정 (next-themes 표준 패턴)
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ useEffect(() => setMounted(true), []);
+
+ const dark = mounted && resolvedTheme === "dark";
+ return (
+
+ );
+}
diff --git a/frontend/components/Topbar.tsx b/frontend/components/Topbar.tsx
new file mode 100644
index 0000000..01a4412
--- /dev/null
+++ b/frontend/components/Topbar.tsx
@@ -0,0 +1,59 @@
+// frontend/components/Topbar.tsx
+"use client";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { MAIN } from "@/lib/nav";
+import { Icon } from "./Icon";
+import { ThemeToggle } from "./ThemeToggle";
+
+// 사용자 이니셜 (원본 data.js: user.initial === "지")
+const USER_INITIAL = "지";
+
+export function Topbar() {
+ const pathname = usePathname();
+ return (
+
+
+
+
+
+
+ 아리
+ AI LIFE OS
+
+
+
+
+
+
+
+
+
+
+ {USER_INITIAL}
+
+
+
+ );
+}
diff --git a/frontend/components/icons/paths.ts b/frontend/components/icons/paths.ts
new file mode 100644
index 0000000..4cc98fa
--- /dev/null
+++ b/frontend/components/icons/paths.ts
@@ -0,0 +1,67 @@
+// frontend/components/icons/paths.ts — MVP 전 페이지가 쓰는 글리프 단일 출처
+export const PATHS = {
+ spark: "M12 3l1.9 5.2L19 10l-5.1 1.8L12 17l-1.9-5.2L5 10l5.1-1.8z|M19 4v3|M5 17v3",
+ grid: "M3 3h7v7H3z|M14 3h7v7h-7z|M14 14h7v7h-7z|M3 14h7v7H3z",
+ route: "M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M9 16h6a3 3 0 0 0 3-3V11",
+ cal: "M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
+ check: "M9 11l3 3L22 4|M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
+ tick: "M20 6 9 17l-5-5",
+ mail: "M22 7 13.03 12.7a1.94 1.94 0 0 1-2.06 0L2 7|M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
+ target: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z|M12 12h.01",
+ heart: "M19 14c1.5-1.5 3-3.4 3-5.5A4.5 4.5 0 0 0 12 5.5 4.5 4.5 0 0 0 2 8.5c0 2.1 1.5 4 3 5.5l7 7z",
+ wallet: "M19 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0 0 4h15a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5|M16 12h.01",
+ brain:
+ "M12 5a3 3 0 1 0-5.9.8A3 3 0 0 0 4 9.5 3 3 0 0 0 6 14a3 3 0 0 0 6 1zM12 5a3 3 0 1 1 5.9.8A3 3 0 0 1 20 9.5 3 3 0 0 1 18 14a3 3 0 0 1-6 1z|M12 5v14",
+ zap: "M13 2 3 14h9l-1 8 10-12h-9z",
+ more: "M5 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM11 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM17 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0z",
+ search: "M21 21l-4.34-4.34|M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
+ bell: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9|M10.3 21a1.94 1.94 0 0 0 3.4 0",
+ back: "M19 12H5|M12 19l-7-7 7-7",
+ send: "M22 2 11 13|M22 2 15 22l-4-9-9-4z",
+ sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z|M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
+ moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z",
+ swap: "M16 3h5v5|M21 3l-7 7|M8 21H3v-5|M3 21l7-7",
+ up: "M12 19V5|M5 12l7-7 7 7",
+ arrow: "M5 12h14|M13 6l6 6-6 6",
+ inbox:
+ "M22 12h-6l-2 3h-4l-2-3H2|M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",
+ mic: "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z|M19 10v1a7 7 0 0 1-14 0v-1|M12 18v4M8 22h8",
+ image:
+ "M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|M8.5 11a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z|M21 15l-5-5L5 21",
+ pen: "M12 20h9|M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z",
+ users:
+ "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M23 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75",
+ clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|M12 7v5l3 2",
+ play: "M6 3l15 9-15 9z",
+ plus: "M12 5v14M5 12h14",
+ shield: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z",
+ pin: "M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z|M12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
+ // ↓ MVP 페이지(작업/인박스/대시보드/리스크)에서 추가로 쓰는 글리프
+ chev: "M9 6l6 6-6 6",
+ star: "M12 2.5l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.6 9.8l6.5-.9z",
+ flag: "M4 22V4M4 4s1.5-1 5-1 5 2 8 2 4-1 4-1v10s-1.5 1-4 1-5-2-8-2-5 1-5 1",
+ hash: "M4 9h16M4 15h16M10 3L8 21M16 3l-2 18",
+ folder: "M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
+ branch:
+ "M6 3v12|M18 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M15 12H9a3 3 0 0 0-3 3",
+ video: "M23 7l-7 5 7 5zM1 5h15a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H1a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z",
+ list: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
+ file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|M14 2v6h6M9 13h6M9 17h4",
+ user: "M20 21a8 8 0 0 0-16 0|M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z",
+ msg: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",
+ trash: "M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",
+ grip: "M9 5a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM9 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM9 19a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM14 5a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM14 12a1 1 0 1 0 2 0 1 1 0 0 0-2 0zM14 19a1 1 0 1 0 2 0 1 1 0 0 0-2 0z",
+ x: "M18 6 6 18M6 6l12 12",
+ wand: "M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8 19 13M17.8 6.2 19 5M3 21l9-9M12.2 6.2 11 5",
+ radar:
+ "M19.07 4.93A10 10 0 0 0 6.99 3.34|M4 6h.01|M2.29 9.62a10 10 0 1 0 19.02-1.27|M16.24 7.76a6 6 0 1 0 1.07 7.04|M12 18h.01|M17.99 11.66a6 6 0 0 1-2.22 5.01|M12 12a2 2 0 1 0 4 0 2 2 0 0 0-4 0z|M13.41 10.59l5.66-5.66",
+ scale:
+ "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|M22 21v-2a4 4 0 0 0-3-3.87|M16 3.13a4 4 0 0 1 0 7.75",
+ link: "M6 3v12|M18 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|M15 12H9a3 3 0 0 0-3 3",
+ bold: "M6 4h7a4 4 0 0 1 0 8H6z|M6 12h8a4 4 0 0 1 0 8H6z",
+ italic: "M19 4h-9M14 20H5M15 4 9 20",
+ quote:
+ "M6 11H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v8c0 2-1 3.5-3 4|M16 11h-3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v8c0 2-1 3.5-3 4",
+} as const;
+
+export type IconName = keyof typeof PATHS;
diff --git a/frontend/lib/nav.ts b/frontend/lib/nav.ts
new file mode 100644
index 0000000..379a687
--- /dev/null
+++ b/frontend/lib/nav.ts
@@ -0,0 +1,27 @@
+// frontend/lib/nav.ts
+import type { IconName } from "@/components/icons/paths";
+
+export type NavItem = {
+ id: string; // 원본 id 보존 (active 매칭)
+ label: string; // 한국어 표시 라벨 (원본 그대로)
+ icon: IconName;
+ href: string; // 영어 라우트
+ badge?: string; // 배지 숫자 (문자열, 원본과 동일)
+ mvp: boolean; // MVP 3개 페이지 여부 (placeholder 구분)
+};
+
+export const MAIN: NavItem[] = [
+ { id: "dash", label: "대시보드", icon: "grid", href: "/dashboard", mvp: true },
+ { id: "sbox", label: "인박스", icon: "inbox", href: "/inbox", mvp: true },
+ { id: "appr", label: "결재함", icon: "spark", href: "/approvals", badge: "3", mvp: false },
+ { id: "auto", label: "자동화", icon: "zap", href: "/automation", mvp: false },
+ { id: "journey", label: "여정", icon: "route", href: "/journey", mvp: false },
+ { id: "cal", label: "일정", icon: "cal", href: "/calendar", mvp: false },
+ { id: "task", label: "작업", icon: "check", href: "/tasks", badge: "4", mvp: true },
+ { id: "mail", label: "메일", icon: "mail", href: "/mail", mvp: false },
+ { id: "noti", label: "알림", icon: "bell", href: "/notifications", badge: "6", mvp: false },
+ { id: "research", label: "리서치", icon: "brain", href: "/research", mvp: false },
+ { id: "trip", label: "여행", icon: "send", href: "/trip", mvp: false },
+ { id: "life", label: "라이프", icon: "heart", href: "/life", mvp: false },
+ { id: "wrap", label: "하루 마감", icon: "moon", href: "/wrap", mvp: false },
+];
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 2d4db38..3b37335 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -1,4 +1,5 @@
// frontend/lib/types.ts
// Phase 2에서 backend/app/schemas.py 와 1:1 대응하는 타입을 채운다.
-// (person / folder / project / task / inbox_item / classification ...)
-export {};
+export type Tone = "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint";
+export type { IconName } from "@/components/icons/paths";
+export type { NavItem } from "@/lib/nav";
diff --git a/frontend/package.json b/frontend/package.json
index e816481..c67a3ce 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,10 +15,13 @@
},
"dependencies": {
"next": "16.2.9",
+ "next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.11.3",
+ "@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
@@ -30,6 +33,7 @@
"jsdom": "^29.1.1",
"prettier": "^3.8.4",
"typescript": "^5",
- "vitest": "^4.1.8"
+ "vitest": "^4.1.8",
+ "vitest-axe": "^0.1.0"
}
}
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 0000000..b376323
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -0,0 +1,21 @@
+// frontend/playwright.config.ts
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./playwright",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: [["list"]],
+ use: {
+ baseURL: "http://localhost:3000",
+ trace: "on-first-retry",
+ },
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
+ webServer: {
+ command: "pnpm dev",
+ url: "http://localhost:3000",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/frontend/playwright/a11y.spec.ts b/frontend/playwright/a11y.spec.ts
new file mode 100644
index 0000000..da757bd
--- /dev/null
+++ b/frontend/playwright/a11y.spec.ts
@@ -0,0 +1,15 @@
+// frontend/playwright/a11y.spec.ts
+import { test, expect } from "@playwright/test";
+import AxeBuilder from "@axe-core/playwright";
+
+for (const theme of ["light", "dark"] as const) {
+ test(`axe — ${theme} 테마 대시보드 위반 0건`, async ({ page }) => {
+ await page.goto("/dashboard");
+ if (theme === "dark") {
+ await page.getByLabel("테마 전환").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ }
+ const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze();
+ expect(results.violations).toEqual([]);
+ });
+}
diff --git a/frontend/playwright/shell.spec.ts b/frontend/playwright/shell.spec.ts
new file mode 100644
index 0000000..3fd3628
--- /dev/null
+++ b/frontend/playwright/shell.spec.ts
@@ -0,0 +1,55 @@
+// frontend/playwright/shell.spec.ts
+import { test, expect } from "@playwright/test";
+
+test("/ → /dashboard 리다이렉트", async ({ page }) => {
+ await page.goto("/");
+ await expect(page).toHaveURL(/\/dashboard$/);
+});
+
+test("13개 내비 항목이 모든 페이지에서 보인다", async ({ page }) => {
+ await page.goto("/tasks");
+ for (const label of ["대시보드", "인박스", "결재함", "작업", "하루 마감"]) {
+ await expect(page.getByRole("link", { name: new RegExp(label) })).toBeVisible();
+ }
+});
+
+test("placeholder 라우트는 '준비 중'을 보여준다", async ({ page }) => {
+ await page.goto("/research");
+ await expect(page.getByText("준비 중")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "리서치" })).toBeVisible();
+});
+
+test("작업 라우트에서 '작업' 내비가 active", async ({ page }) => {
+ await page.goto("/tasks");
+ await expect(page.getByRole("link", { name: /작업/ })).toHaveClass(/active/);
+});
+
+test("테마 토글이 새로고침 후에도 유지된다", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.getByLabel("테마 전환").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); // localStorage 영속
+});
+
+test("모든 10개 placeholder 라우트가 '준비 중' 카드를 렌더한다", async ({ page }) => {
+ const routes: [string, string][] = [
+ ["/approvals", "결재함"],
+ ["/automation", "자동화"],
+ ["/journey", "여정"],
+ ["/calendar", "일정"],
+ ["/mail", "메일"],
+ ["/notifications", "알림"],
+ ["/research", "리서치"],
+ ["/trip", "여행"],
+ ["/life", "라이프"],
+ ["/wrap", "하루 마감"],
+ ];
+ for (const [href, title] of routes) {
+ await page.goto(href);
+ await expect(page.getByRole("heading", { name: title })).toBeVisible();
+ await expect(page.getByText("준비 중")).toBeVisible();
+ // 셸(Topbar) 유지 확인
+ await expect(page.getByRole("link", { name: "아리 홈" })).toBeVisible();
+ }
+});
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index ae47e8e..e6e0b15 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -10,7 +10,10 @@ importers:
dependencies:
next:
specifier: 16.2.9
- version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ version: 16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
@@ -18,6 +21,12 @@ importers:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
devDependencies:
+ '@axe-core/playwright':
+ specifier: ^4.11.3
+ version: 4.11.3(playwright-core@1.60.0)
+ '@playwright/test':
+ specifier: ^1.60.0
+ version: 1.60.0
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -54,6 +63,9 @@ importers:
vitest:
specifier: ^4.1.8
version: 4.1.8(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.43))
+ vitest-axe:
+ specifier: ^0.1.0
+ version: 0.1.0(vitest@4.1.8(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.43)))
packages:
@@ -75,6 +87,11 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+ '@axe-core/playwright@4.11.3':
+ resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==}
+ peerDependencies:
+ playwright-core: '>= 1.0.0'
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -497,6 +514,11 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
+ '@playwright/test@1.60.0':
+ resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -946,6 +968,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ axe-core@4.11.4:
+ resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
+ engines: {node: '>=4'}
+
axe-core@4.12.1:
resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==}
engines: {node: '>=4'}
@@ -1012,6 +1038,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -1342,6 +1372,11 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1716,6 +1751,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
+ lodash-es@4.18.1:
+ resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -1782,6 +1820,12 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ next-themes@0.4.6:
+ resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
next@16.2.9:
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
engines: {node: '>=20.9.0'}
@@ -1895,6 +1939,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
+ playwright-core@1.60.0:
+ resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.60.0:
+ resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2276,6 +2330,11 @@ packages:
yaml:
optional: true
+ vitest-axe@0.1.0:
+ resolution: {integrity: sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA==}
+ peerDependencies:
+ vitest: '>=0.16.0'
+
vitest@4.1.8:
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -2410,6 +2469,11 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
+ '@axe-core/playwright@4.11.3(playwright-core@1.60.0)':
+ dependencies:
+ axe-core: 4.11.4
+ playwright-core: 1.60.0
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -2794,6 +2858,10 @@ snapshots:
'@oxc-project/types@0.133.0': {}
+ '@playwright/test@1.60.0':
+ dependencies:
+ playwright: 1.60.0
+
'@rolldown/binding-android-arm64@1.0.3':
optional: true
@@ -3228,6 +3296,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ axe-core@4.11.4: {}
+
axe-core@4.12.1: {}
axobject-query@4.1.0: {}
@@ -3291,6 +3361,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ chalk@5.6.2: {}
+
client-only@0.0.1: {}
color-convert@2.0.1:
@@ -3755,6 +3827,9 @@ snapshots:
dependencies:
is-callable: 1.2.7
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -4130,6 +4205,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
+ lodash-es@4.18.1: {}
+
lodash.merge@4.6.2: {}
loose-envify@1.4.0:
@@ -4179,7 +4256,12 @@ snapshots:
natural-compare@1.4.0: {}
- next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ next@16.2.9(@babel/core@7.29.7)(@playwright/test@1.60.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.9
'@swc/helpers': 0.5.15
@@ -4198,6 +4280,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.9
'@next/swc-win32-arm64-msvc': 16.2.9
'@next/swc-win32-x64-msvc': 16.2.9
+ '@playwright/test': 1.60.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -4301,6 +4384,14 @@ snapshots:
picomatch@4.0.4: {}
+ playwright-core@1.60.0: {}
+
+ playwright@1.60.0:
+ dependencies:
+ playwright-core: 1.60.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -4770,6 +4861,16 @@ snapshots:
'@types/node': 20.19.43
fsevents: 2.3.3
+ vitest-axe@0.1.0(vitest@4.1.8(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.43))):
+ dependencies:
+ aria-query: 5.3.2
+ axe-core: 4.12.1
+ chalk: 5.6.2
+ dom-accessibility-api: 0.5.16
+ lodash-es: 4.18.1
+ redent: 3.0.0
+ vitest: 4.1.8(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.43))
+
vitest@4.1.8(@types/node@20.19.43)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.43)):
dependencies:
'@vitest/expect': 4.1.8
diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css
index ecfd890..557773b 100644
--- a/frontend/styles/globals.css
+++ b/frontend/styles/globals.css
@@ -1,4 +1,8 @@
/* frontend/styles/globals.css */
+/* Pretendard CDN (Google Fonts 미제공 — 원본 대시보드.html 8~17행과 동일 소스) */
+@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css");
+@import "./tokens.css";
+
* {
box-sizing: border-box;
}
@@ -10,12 +14,532 @@ body {
body {
font-family: var(--font-ui);
- -webkit-font-smoothing: antialiased;
- letter-spacing: -0.011em; /* 원본 dash.css body 규칙 */
- word-break: keep-all; /* 한국어 가독성 */
+ color: var(--ink);
+ background: linear-gradient(178deg, var(--bg-top) 0%, var(--bg-mid) 20%, var(--bg-bot) 100%);
+ background-attachment: fixed;
min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ letter-spacing: -0.011em; /* 한국어 가독성 */
+ word-break: keep-all; /* 한국어 줄바꿈 */
+}
+button {
+ font-family: inherit;
+ cursor: pointer;
+ border: none;
+ background: none;
+ color: inherit;
+}
+input {
+ font-family: inherit;
+}
+:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: 2px;
+ border-radius: 6px;
+}
+
+.ic {
+ width: 1em;
+ height: 1em;
+ flex-shrink: 0;
+ display: block;
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
+
+/* 페이지 컨테이너 (원본 .dash) */
+.shell-page {
+ max-width: 1620px;
+ margin: 0 auto;
+ padding: 0 26px 40px;
+}
+
+/* 큰 페이지 제목 (원본 .ph-title 232~239행) */
+.ph-title {
+ font-family: var(--font-disp);
+ font-size: clamp(30px, 3.4vw, 46px);
+ letter-spacing: -0.03em;
+ font-weight: 700;
+ margin: 24px 0 0;
+}
+
+/* ===== 상단바 — 메인 메뉴 (원본 .topbar 102~204행 그대로) ===== */
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 40;
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ height: 70px;
+ margin: 0 -26px;
+ padding: 0 30px;
+ -webkit-backdrop-filter: blur(14px) saturate(150%);
+ backdrop-filter: blur(14px) saturate(150%);
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ flex-shrink: 0;
+ text-decoration: none;
+ color: inherit;
+}
+.brand-mark {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ background: var(--fill);
+ color: var(--coral);
+ display: grid;
+ place-items: center;
+ position: relative;
+}
+.brand-mark .ic {
+ width: 21px;
+ height: 21px;
+}
+.brand-mark::after {
+ content: "";
+ position: absolute;
+ top: -2px;
+ right: -2px;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--coral);
+ border: 2px solid var(--bg-top);
+}
+.brand-tag {
+ line-height: 1.15;
+}
+.brand-tag b {
+ display: block;
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ white-space: nowrap;
+}
+.brand-tag span {
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.mainnav {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ min-width: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.mainnav::-webkit-scrollbar {
+ display: none;
+}
+.mainnav a {
+ text-decoration: none;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--ink-2);
+ padding: 9px 16px;
+ border-radius: 999px;
+ white-space: nowrap;
+ transition:
+ background 0.16s,
+ color 0.16s,
+ box-shadow 0.16s;
+}
+.mainnav a .ic {
+ width: 16px;
+ height: 16px;
+ color: var(--muted);
+ transition: color 0.16s;
+}
+.mainnav a:hover {
+ color: var(--ink);
+}
+.mainnav a:hover .ic {
+ color: var(--ink-2);
+}
+.mainnav a.active {
+ background: var(--glass);
+ -webkit-backdrop-filter: var(--blur);
+ backdrop-filter: var(--blur);
+ color: var(--ink);
+ font-weight: 600;
+ box-shadow: var(--shadow-sm);
+ border: 1px solid var(--glass-brd);
+}
+.mainnav a.active .ic {
+ color: var(--ink);
+}
+.mainnav a .badge {
+ font-size: 11px;
+ font-weight: 700;
+ background: var(--coral);
+ color: #fff;
+ border-radius: 999px;
+ padding: 0 6px;
+ min-width: 18px;
+ text-align: center;
+}
+
+.top-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ flex-shrink: 0;
+}
+.t-btn {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ color: var(--ink-2);
+ background: var(--glass);
+ -webkit-backdrop-filter: var(--blur);
+ backdrop-filter: var(--blur);
+ border: 1px solid var(--glass-brd);
+ box-shadow: var(--shadow-sm);
+ position: relative;
+ transition:
+ transform 0.12s,
+ color 0.16s;
+}
+.t-btn .ic {
+ width: 18px;
+ height: 18px;
+}
+.t-btn:hover {
+ color: var(--ink);
+ transform: translateY(-1px);
+}
+.t-btn.dot::after {
+ content: "";
+ position: absolute;
+ top: 9px;
+ right: 10px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--coral);
+ border: 2px solid var(--card);
+}
+.t-ava {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--coral), var(--violet));
+ color: #fff;
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ font-size: 15px;
+ box-shadow: var(--shadow-sm);
+ cursor: pointer;
+}
+
+/* ===== 좌측 서브 레일 (원본 .rail 264~300행) ===== */
+.rail {
+ position: sticky;
+ top: 86px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+.rail button {
+ width: 44px;
+ height: 44px;
+ border-radius: 13px;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ position: relative;
+ transition:
+ background 0.15s,
+ color 0.15s;
+}
+.rail button .ic {
+ width: 19px;
+ height: 19px;
+}
+.rail button:hover {
+ background: var(--glass);
+ -webkit-backdrop-filter: var(--blur);
+ backdrop-filter: var(--blur);
+ color: var(--ink-2);
+ box-shadow: var(--shadow-sm);
+ border: 1px solid var(--glass-brd);
+}
+.rail button.active {
+ background: var(--fill);
+ color: var(--on-fill);
+ box-shadow: var(--shadow);
+}
+.rail button .tip {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 8px);
+ transform: translateX(-50%) translateY(4px) scale(0.96);
+ background: var(--fill);
+ color: var(--on-fill);
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+ padding: 5px 10px;
+ border-radius: 8px;
+ opacity: 0;
+ pointer-events: none;
+ transition:
+ opacity 0.14s,
+ transform 0.14s;
+ z-index: 60;
+ box-shadow: var(--shadow-sm);
+}
+.rail button .tip::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 5px solid transparent;
+ border-top-color: var(--fill);
+}
+.rail button:hover .tip {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+}
+.rail .sp {
+ height: 10px;
+}
+.rail button .rdot {
+ position: absolute;
+ top: 7px;
+ right: 7px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--coral);
+ border: 2px solid var(--bg-top);
+}
+.rail button.active .rdot {
+ border-color: var(--fill);
+}
+
+/* ===== 공유 프리미티브 ===== */
+
+/* GlassCard = 원본 .card (309~321행) */
+.glass-card {
+ background: var(--glass);
+ -webkit-backdrop-filter: var(--blur);
+ backdrop-filter: var(--blur);
+ border: 1px solid var(--glass-brd);
+ border-radius: var(--radius);
+ box-shadow:
+ var(--shadow),
+ var(--glass-hi);
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+/* Badge = 원본 .mainnav .badge 색을 재사용한 독립 토큰 */
+.badge-pill {
+ font-size: 11px;
+ font-weight: 700;
+ color: #fff;
+ background: var(--coral);
+ border-radius: 999px;
+ padding: 0 6px;
+ min-width: 18px;
+ text-align: center;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.badge-pill[data-tone="blue"] {
+ background: var(--blue);
+}
+.badge-pill[data-tone="green"] {
+ background: var(--green);
+}
+.badge-pill[data-tone="violet"] {
+ background: var(--violet);
+}
+.badge-pill[data-tone="amber"] {
+ background: var(--amber);
+}
+.badge-pill[data-tone="ink"] {
+ background: var(--fill);
+ color: var(--on-fill);
+}
+/* 차분한 카운트 배지 = 원본 .ch .count (344~349행) */
+.badge-count {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--ink-2);
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ border-radius: 999px;
+ padding: 3px 10px;
+}
+
+/* Chip = 원본 .chip (413~423행) */
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12.5px;
+ font-weight: 500;
+ color: var(--ink-2);
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ padding: 7px 12px;
+ border-radius: 999px;
+ transition: background 0.14s;
+}
+.chip .ic {
+ width: 14px;
+ height: 14px;
+ color: var(--muted);
+}
+.chip:hover {
+ background: var(--card-2);
+}
+
+/* Button — 라임 CTA / 필(검정) CTA / 글래스 */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-size: 13.5px;
+ font-weight: 600;
+ padding: 10px 16px;
+ border-radius: 13px;
+ white-space: nowrap;
+ transition:
+ filter 0.14s,
+ background 0.14s,
+ transform 0.12s;
+}
+.btn .ic {
+ width: 16px;
+ height: 16px;
+}
+.btn-fill {
+ background: var(--fill);
+ color: var(--on-fill);
+} /* 원본 .mtg-cta/.send/.rs-cta */
+.btn-fill:hover {
+ filter: brightness(1.1);
+}
+.btn-lime {
+ background: var(--lime);
+ color: var(--lime-ink);
+} /* 라임 CTA (--lime/--lime-ink) */
+.btn-lime:hover {
+ background: var(--lime-hi);
+}
+.btn-glass {
+ background: var(--glass-2);
+ color: var(--ink);
+ border: 1px solid var(--glass-brd);
+}
+.btn-glass:hover {
+ background: var(--card-2);
+}
+
+/* SegmentToggle (칸반/리스트(캘린더는 post-MVP)) — 글래스 트랙 + 필 선택 */
+.seg {
+ display: inline-flex;
+ gap: 2px;
+ padding: 3px;
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ border-radius: 999px;
+}
+.seg button {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ink-2);
+ padding: 7px 14px;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ transition:
+ background 0.14s,
+ color 0.14s;
+}
+.seg button .ic {
+ width: 15px;
+ height: 15px;
+}
+.seg button[aria-selected="true"] {
+ background: var(--fill);
+ color: var(--on-fill);
+}
+
+/* Avatar (원본 .mtg-people .av / .dg-av 톤 컬러) */
+.avatar {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ color: #fff;
+ flex-shrink: 0;
+}
+.avatar[data-tone="blue"] {
+ background: var(--blue);
+}
+.avatar[data-tone="violet"] {
+ background: var(--violet);
+}
+.avatar[data-tone="green"] {
+ background: var(--green);
+}
+.avatar[data-tone="coral"] {
+ background: var(--coral);
+}
+.avatar[data-tone="amber"] {
+ background: var(--amber);
+}
+
+/* ===== 반응형 (원본 786~800행) ===== */
+@media (max-width: 720px) {
+ .shell-page {
+ padding: 0 14px 30px;
+ }
+ .topbar {
+ margin: 0 -14px;
+ padding: 0 16px;
+ gap: 14px;
+ }
+ /* 원본은 모바일에서 .mainnav { display:none } 이지만,
+ MVP에선 가로 스크롤 유지가 더 유용하므로 §6.4 결정에 따라 유지한다. */
+ .rail {
+ flex-direction: row;
+ position: static;
+ overflow-x: auto;
+ width: 100%;
+ }
+}
diff --git a/frontend/styles/tokens.css b/frontend/styles/tokens.css
index aa36d3f..fc16944 100644
--- a/frontend/styles/tokens.css
+++ b/frontend/styles/tokens.css
@@ -1,7 +1,76 @@
-/* frontend/styles/tokens.css — Phase 1에서 design-reference/assets/dash.css :root 전체 이식 */
+/* frontend/styles/tokens.css — 원본 dash.css :root 그대로 */
:root {
- --font-disp: "Onest", "Pretendard", system-ui, sans-serif;
- --font-ui: "Pretendard", "Onest", system-ui, sans-serif;
- --font-mono: "DM Mono", ui-monospace, monospace;
- /* 색/그림자/글래스 토큰은 phase-1-design-system.md 에서 채운다 */
+ /* 배경 */
+ --bg-top: #f6efe7;
+ --bg-mid: #eef0f1;
+ --bg-bot: #e9ebed;
+ /* 카드 */
+ --card: #ffffff;
+ --card-2: #f4f5f6;
+ --card-3: #eef0f1;
+ /* 잉크/텍스트 */
+ --ink: #211f1c;
+ --ink-2: #514d47;
+ --muted: #8d8a85;
+ --faint: #b6b3ad;
+ /* 라인 */
+ --line: rgba(33, 31, 28, 0.07);
+ --line-2: rgba(33, 31, 28, 0.13);
+ /* 검정 채움(선택 상태) */
+ --fill: #29241f;
+ --fill-soft: #36302a;
+ --on-fill: #f4efe6;
+ /* CTA 라임 */
+ --lime: #c2f24a;
+ --lime-hi: #cdf85e;
+ --lime-ink: #233006;
+ /* 액센트 */
+ --blue: #4f72e0;
+ --coral: #df7256;
+ --green: #4e9b66;
+ --violet: #8b6fd4;
+ --amber: #e0a23c;
+ /* 라운드/그림자 */
+ --radius: 22px;
+ --radius-sm: 14px;
+ --shadow: 0 10px 30px -14px rgba(30, 26, 22, 0.22), 0 2px 6px -2px rgba(30, 26, 22, 0.06);
+ --shadow-sm: 0 4px 14px -8px rgba(30, 26, 22, 0.18);
+ /* 글래스 */
+ --glass: rgba(255, 255, 255, 0.4);
+ --glass-2: rgba(255, 255, 255, 0.26);
+ --glass-brd: rgba(255, 255, 255, 0.7);
+ --glass-ink: rgba(26, 22, 18, 0.46);
+ --blur: blur(26px) saturate(190%);
+ --glass-hi: inset 0 1px 0 rgba(255, 255, 255, 0.75);
+ /* 폰트 (next/font 변수 우선 + 패밀리 폴백; §3.3) */
+ --font-disp: var(--f-onest), "Onest", "Pretendard", system-ui, sans-serif;
+ --font-ui: "Pretendard", var(--f-onest), "Onest", system-ui, sans-serif;
+ --font-mono: var(--f-dmmono), "DM Mono", ui-monospace, monospace;
+}
+
+[data-theme="dark"] {
+ --bg-top: #201d1a;
+ --bg-mid: #1a1816;
+ --bg-bot: #161413;
+ --card: #2c2925;
+ --card-2: #353029;
+ --card-3: #3b352d;
+ --ink: #f3eee6;
+ --ink-2: #c3bdb4;
+ --muted: #948e85;
+ --faint: #6f6a62;
+ --line: rgba(244, 239, 230, 0.09);
+ --line-2: rgba(244, 239, 230, 0.16);
+ --fill: #f4efe6;
+ --fill-soft: #e7e0d4;
+ --on-fill: #211f1c;
+ --shadow: 0 14px 36px -16px rgba(0, 0, 0, 0.7), 0 2px 8px -3px rgba(0, 0, 0, 0.4);
+ --shadow-sm: 0 6px 16px -10px rgba(0, 0, 0, 0.6);
+ --glass: rgba(48, 43, 38, 0.4);
+ --glass-2: rgba(70, 63, 55, 0.28);
+ --glass-brd: rgba(255, 255, 255, 0.1);
+ --glass-ink: rgba(16, 13, 11, 0.42);
+ --glass-hi: inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ /* --lime/--blue/--coral/--green/--violet/--amber/--fill-soft 미오버라이드:
+ 원본 dash.css도 다크에서 액센트/라임은 그대로 둔다. */
}
diff --git a/frontend/tests/Badge.test.tsx b/frontend/tests/Badge.test.tsx
new file mode 100644
index 0000000..e84b63f
--- /dev/null
+++ b/frontend/tests/Badge.test.tsx
@@ -0,0 +1,21 @@
+// frontend/tests/Badge.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { Badge } from "@/components/Badge";
+
+describe("Badge", () => {
+ it("기본 pill은 coral 톤", () => {
+ render(3);
+ const el = screen.getByText("3");
+ expect(el).toHaveClass("badge-pill");
+ expect(el).toHaveAttribute("data-tone", "coral");
+ });
+ it("count 변형은 차분한 카운트 스타일", () => {
+ render(8개);
+ expect(screen.getByText("8개")).toHaveClass("badge-count");
+ });
+ it("톤 지정", () => {
+ render(1);
+ expect(screen.getByText("1")).toHaveAttribute("data-tone", "green");
+ });
+});
diff --git a/frontend/tests/Icon.test.tsx b/frontend/tests/Icon.test.tsx
new file mode 100644
index 0000000..534f743
--- /dev/null
+++ b/frontend/tests/Icon.test.tsx
@@ -0,0 +1,51 @@
+// frontend/tests/Icon.test.tsx
+import { render } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { Icon } from "@/components/Icon";
+import { PATHS } from "@/components/icons/paths";
+
+describe("Icon", () => {
+ it("모든 글리프가 split된 path 개수만큼 를 그린다", () => {
+ for (const [name, d] of Object.entries(PATHS)) {
+ const { container, unmount } = render();
+ const expected = d.split("|").length;
+ expect(container.querySelectorAll("path").length).toBe(expected);
+ unmount();
+ }
+ });
+
+ it("stroke-width 기본값 1.9, viewBox 0 0 24 24", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg")!;
+ expect(svg.getAttribute("stroke-width")).toBe("1.9");
+ expect(svg.getAttribute("viewBox")).toBe("0 0 24 24");
+ });
+
+ it("알 수 없는 이름은 경고 + 빈 svg 폴백(렌더 깨짐 방지)", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ // @ts-expect-error 의도적으로 잘못된 이름 전달
+ const { container } = render();
+ expect(container.querySelectorAll("path").length).toBe(0);
+ expect(container.querySelector("svg")).toBeTruthy();
+ expect(warn).toHaveBeenCalled();
+ warn.mockRestore();
+ });
+
+ it("핵심 글리프 13개(MAIN icon)가 PATHS에 존재한다", () => {
+ [
+ "grid",
+ "inbox",
+ "spark",
+ "zap",
+ "route",
+ "cal",
+ "check",
+ "mail",
+ "bell",
+ "brain",
+ "send",
+ "heart",
+ "moon",
+ ].forEach((n) => expect(PATHS).toHaveProperty(n));
+ });
+});
diff --git a/frontend/tests/ThemeToggle.test.tsx b/frontend/tests/ThemeToggle.test.tsx
new file mode 100644
index 0000000..581d9cf
--- /dev/null
+++ b/frontend/tests/ThemeToggle.test.tsx
@@ -0,0 +1,50 @@
+// frontend/tests/ThemeToggle.test.tsx
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, beforeEach } from "vitest";
+import { ThemeProvider } from "@/components/ThemeProvider";
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+function setup() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ThemeToggle", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute("data-theme");
+ });
+
+ it("클릭 시 data-theme가 light↔dark로 토글", async () => {
+ setup();
+ const btn = screen.getByLabelText("테마 전환");
+ fireEvent.click(btn);
+ await waitFor(() =>
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark"),
+ );
+ fireEvent.click(btn);
+ await waitFor(() =>
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light"),
+ );
+ });
+
+ it("선택이 localStorage(ari-theme)에 영속", async () => {
+ setup();
+ fireEvent.click(screen.getByLabelText("테마 전환"));
+ await waitFor(() => expect(localStorage.getItem("ari-theme")).toBe("dark"));
+ });
+
+ it("다크에서 sun, 라이트에서 moon 아이콘", async () => {
+ const { container } = setup();
+ await waitFor(() => expect(container.querySelector("svg")).toBeTruthy());
+ fireEvent.click(screen.getByLabelText("테마 전환"));
+ await waitFor(() =>
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark"),
+ );
+ // sun은 다중 path(8개), moon은 단일 path → path 수로 구분
+ expect(container.querySelectorAll("path").length).toBeGreaterThan(1);
+ });
+});
diff --git a/frontend/tests/Topbar.test.tsx b/frontend/tests/Topbar.test.tsx
new file mode 100644
index 0000000..b11e5f4
--- /dev/null
+++ b/frontend/tests/Topbar.test.tsx
@@ -0,0 +1,51 @@
+// frontend/tests/Topbar.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { Topbar } from "@/components/Topbar";
+
+vi.mock("next/navigation", () => ({ usePathname: () => "/tasks" }));
+
+describe("Topbar", () => {
+ it("13개 메인 내비 항목을 렌더한다", () => {
+ render();
+ [
+ "대시보드",
+ "인박스",
+ "결재함",
+ "자동화",
+ "여정",
+ "일정",
+ "작업",
+ "메일",
+ "알림",
+ "리서치",
+ "여행",
+ "라이프",
+ "하루 마감",
+ ].forEach((label) => expect(screen.getByText(label)).toBeInTheDocument());
+ });
+
+ it("현재 경로(/tasks=작업)가 active 클래스 + aria-current=page", () => {
+ render();
+ const active = screen.getByRole("link", { name: /작업/ });
+ expect(active).toHaveClass("active");
+ expect(active).toHaveAttribute("aria-current", "page");
+ });
+
+ it("결재함=3, 작업=4, 알림=6 배지가 표시된다", () => {
+ render();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(screen.getByText("4")).toBeInTheDocument();
+ expect(screen.getByText("6")).toBeInTheDocument();
+ });
+
+ it("브랜드/아바타 텍스트와 우측 액션 aria-label", () => {
+ render();
+ expect(screen.getByText("아리")).toBeInTheDocument();
+ expect(screen.getByText("AI LIFE OS")).toBeInTheDocument();
+ expect(screen.getByText("지")).toBeInTheDocument(); // user.initial
+ expect(screen.getByLabelText("검색")).toBeInTheDocument();
+ expect(screen.getByLabelText("알림")).toBeInTheDocument();
+ expect(screen.getByLabelText("테마 전환")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/tests/a11y.test.tsx b/frontend/tests/a11y.test.tsx
new file mode 100644
index 0000000..17816ea
--- /dev/null
+++ b/frontend/tests/a11y.test.tsx
@@ -0,0 +1,14 @@
+// frontend/tests/a11y.test.tsx
+import { render } from "@testing-library/react";
+import { axe } from "vitest-axe";
+import { describe, it, expect, vi } from "vitest";
+import { Topbar } from "@/components/Topbar";
+
+vi.mock("next/navigation", () => ({ usePathname: () => "/dashboard" }));
+
+describe("a11y", () => {
+ it("Topbar에 axe 위반 없음", async () => {
+ const { container } = render();
+ expect(await axe(container)).toHaveNoViolations();
+ });
+});
diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts
new file mode 100644
index 0000000..b14efb8
--- /dev/null
+++ b/frontend/tests/setup.ts
@@ -0,0 +1,20 @@
+// frontend/tests/setup.ts — vitest 전역 setup
+import "@testing-library/jest-dom/vitest";
+import { expect, vi } from "vitest";
+import { toHaveNoViolations } from "vitest-axe/dist/matchers";
+
+expect.extend({ toHaveNoViolations });
+
+// jsdom 미구현 API 폴리필 (next-themes 가 window.matchMedia 사용)
+if (!window.matchMedia) {
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(), // deprecated
+ removeListener: vi.fn(), // deprecated
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+}
diff --git a/frontend/tests/vitest-axe.d.ts b/frontend/tests/vitest-axe.d.ts
new file mode 100644
index 0000000..9a35e45
--- /dev/null
+++ b/frontend/tests/vitest-axe.d.ts
@@ -0,0 +1,10 @@
+// frontend/tests/vitest-axe.d.ts — vitest Assertion 에 axe 매처 타입 보강
+import "vitest";
+import type { AxeMatchers } from "vitest-axe/dist/matchers";
+
+declare module "vitest" {
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface Assertion extends AxeMatchers {}
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface AsymmetricMatchersContaining extends AxeMatchers {}
+}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 185ca39..d851b7e 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -8,6 +8,7 @@ export default defineConfig({
test: {
environment: "jsdom",
globals: true,
+ setupFiles: ["tests/setup.ts"],
include: ["tests/**/*.test.{ts,tsx}"],
},
resolve: {