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 ( + + {d.split("|").map((s, i) => ( + + ))} + + ); +} 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: {