Phase 1: 디자인 시스템 & 앱 셸

- tokens.css: dash.css :root + [data-theme=dark] 전체 무손실 이식
- globals.css: 178deg 그라데이션·keep-all 타이포·.topbar/.mainnav/.rail/프리미티브
- 폰트: Onest/DM Mono(next/font) + Pretendard(CDN), 변수 바인딩
- next-themes: data-theme 토글 + ari-theme localStorage 영속 + FOUC 없음
- Icon(중앙 PATHS 맵 60+ 글리프, 누락 가드), Topbar(MAIN 13항목·배지 3/4/6·active pill)
- 프리미티브: GlassCard/Badge/Chip/Button/SegmentToggle/Avatar/SubRail/Placeholder
- 라우팅: / → /dashboard, MVP 3 스텁, (placeholder) 10페이지 "준비 중"
- 이전 phase-0 산출물에 섞인 .playwright-mcp 캡처 제거 + .gitignore 보강

검증: vitest 16 passed, playwright e2e 8 passed(axe light/dark 위반 0),
tsc/eslint clean, next build(13 routes) OK, 라이트/다크 시각 충실도 확인, 콘솔 에러 0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Claude 2 months ago
parent 9075b7d1ba
commit 59e836bba0

5
.gitignore vendored

@ -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/

@ -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]

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="결재함" icon="spark" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="자동화" icon="zap" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="일정" icon="cal" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="여정" icon="route" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="라이프" icon="heart" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="메일" icon="mail" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="알림" icon="bell" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="리서치" icon="brain" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="여행" icon="send" />;
}

@ -0,0 +1,4 @@
import { Placeholder } from "@/components/Placeholder";
export default function Page() {
return <Placeholder title="하루 마감" icon="moon" />;
}

@ -1,9 +1,4 @@
// frontend/app/dashboard/page.tsx (Phase 0 임시 자리표시자 — Phase 5에서 교체)
export default function DashboardPlaceholder() {
return (
<main style={{ padding: 40 }}>
<h1> ( )</h1>
<p>Phase 0 . phase-5-dashboard.md .</p>
</main>
);
// frontend/app/dashboard/page.tsx (Phase 1 스텁 — Phase 5에서 교체)
export default function Page() {
return <h1 className="ph-title"></h1>;
}

@ -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",
});

@ -0,0 +1,4 @@
// frontend/app/inbox/page.tsx (Phase 1 스텁 — Phase 4에서 교체)
export default function Page() {
return <h1 className="ph-title"></h1>;
}

@ -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 lang="ko" data-theme="light">
<head>
{/* 원본 대시보드.html 의 폰트 로딩을 그대로 옮긴다 */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700;800&family=DM+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css"
/>
</head>
<body>{children}</body>
// lang=ko + data-theme는 next-themes가 주입 → suppressHydrationWarning 필수
<html lang="ko" suppressHydrationWarning className={`${onest.variable} ${dmMono.variable}`}>
<body>
<ThemeProvider>
<div className="shell-page">
<Topbar />
<main>{children}</main>
</div>
</ThemeProvider>
</body>
</html>
);
}

@ -0,0 +1,4 @@
// frontend/app/tasks/page.tsx (Phase 1 스텁 — Phase 3에서 교체)
export default function Page() {
return <h1 className="ph-title"></h1>;
}

@ -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 (
<div className="avatar" data-tone={tone} title={label} aria-label={label}>
{initial}
</div>
);
}

@ -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 <span className="badge-count">{children}</span>;
return (
<span className="badge-pill" data-tone={tone}>
{children}
</span>
);
}

@ -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<HTMLButtonElement>) {
return (
<button className={`btn btn-${variant} ${className}`} {...rest}>
{icon && <Icon name={icon} />}
{children}
</button>
);
}

@ -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 (
<button className="chip" onClick={onClick} type="button">
{icon && <Icon name={icon} />}
{children}
</button>
);
}

@ -0,0 +1,12 @@
// frontend/components/GlassCard.tsx
export function GlassCard({
className = "",
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={`glass-card ${className}`} {...rest}>
{children}
</div>
);
}

@ -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 <svg className={className} viewBox="0 0 24 24" aria-hidden />;
}
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={w}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{d.split("|").map((s, i) => (
<path key={i} d={s} />
))}
</svg>
);
}

@ -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 (
<GlassCard
className="placeholder"
role="status"
style={{ alignItems: "center", textAlign: "center", padding: "56px 24px", marginTop: 24 }}
>
<div className="brand-mark" style={{ width: 56, height: 56, borderRadius: 16 }}>
<Icon name={icon} />
</div>
<h2 style={{ fontFamily: "var(--font-disp)", fontSize: 24, margin: "16px 0 6px" }}>{title}</h2>
<p style={{ color: "var(--muted)", fontSize: 14, maxWidth: "34ch" }}>
. .
</p>
</GlassCard>
);
}

@ -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 (
<div className="seg" role="tablist">
{options.map((o) => (
<button key={o.id} role="tab" aria-selected={value === o.id} onClick={() => onChange(o.id)}>
{o.icon && <Icon name={o.icon} />}
{o.label}
</button>
))}
</div>
);
}

@ -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 (
<aside className="rail">
{items.map((r, i) =>
"sep" in r && r.sep ? (
<span className="sp" key={`s${i}`} />
) : (
<button
key={r.id}
className={active === r.id ? "active" : ""}
onClick={() => onPick(r.id)}
aria-label={r.label}
aria-current={active === r.id ? "page" : undefined}
>
<Icon name={r.icon} />
{r.dot && <span className="rdot" />}
<span className="tip">{r.label}</span>
</button>
),
)}
</aside>
);
}

@ -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 (
<NextThemes
attribute="data-theme" // <html data-theme="light|dark"> ← 토큰 셀렉터와 일치
defaultTheme="light" // 원본 대시보드.html data-theme="light"
enableSystem={false} // MVP: 시스템 추종 끔(명시 토글만)
storageKey="ari-theme" // localStorage 키
disableTransitionOnChange // 토글 시 전체 transition 깜빡임 억제
>
{children}
</NextThemes>
);
}

@ -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 (
<button
className="t-btn"
aria-label="테마 전환"
aria-pressed={dark}
onClick={() => setTheme(dark ? "light" : "dark")}
>
{/* 원본: light면 moon(다크로 가는 버튼), dark면 sun. mounted 전엔 moon 고정으로 SSR 안정화 */}
<Icon name={dark ? "sun" : "moon"} />
</button>
);
}

@ -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 (
<header className="topbar">
<Link href="/dashboard" className="brand" aria-label="아리 홈">
<div className="brand-mark">
<Icon name="spark" />
</div>
<div className="brand-tag">
<b></b>
<span>AI LIFE OS</span>
</div>
</Link>
<nav className="mainnav" aria-label="메인 메뉴">
{MAIN.map((m) => {
// active: 정확히 일치하거나 하위 경로(예: /tasks/123)도 활성
const active = pathname === m.href || pathname.startsWith(m.href + "/");
return (
<Link
key={m.id}
href={m.href}
className={active ? "active" : ""}
aria-current={active ? "page" : undefined}
>
<Icon name={m.icon} />
{m.label}
{m.badge && <span className="badge">{m.badge}</span>}
</Link>
);
})}
</nav>
<div className="top-actions">
<button className="t-btn" aria-label="검색">
<Icon name="search" />
</button>
<button className="t-btn dot" aria-label="알림">
<Icon name="bell" />
</button>
<ThemeToggle />
<div className="t-ava" aria-hidden>
{USER_INITIAL}
</div>
</div>
</header>
);
}

@ -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;

@ -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 },
];

@ -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";

@ -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"
}
}

@ -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,
},
});

@ -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([]);
});
}

@ -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();
}
});

@ -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

@ -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%;
}
}

@ -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 / . */
}

@ -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(<Badge>3</Badge>);
const el = screen.getByText("3");
expect(el).toHaveClass("badge-pill");
expect(el).toHaveAttribute("data-tone", "coral");
});
it("count 변형은 차분한 카운트 스타일", () => {
render(<Badge variant="count">8</Badge>);
expect(screen.getByText("8개")).toHaveClass("badge-count");
});
it("톤 지정", () => {
render(<Badge tone="green">1</Badge>);
expect(screen.getByText("1")).toHaveAttribute("data-tone", "green");
});
});

@ -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 개수만큼 <path>를 그린다", () => {
for (const [name, d] of Object.entries(PATHS)) {
const { container, unmount } = render(<Icon name={name as keyof typeof PATHS} />);
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(<Icon name="grid" />);
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(<Icon name={"nope"} />);
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));
});
});

@ -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(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
}
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);
});
});

@ -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(<Topbar />);
[
"대시보드",
"인박스",
"결재함",
"자동화",
"여정",
"일정",
"작업",
"메일",
"알림",
"리서치",
"여행",
"라이프",
"하루 마감",
].forEach((label) => expect(screen.getByText(label)).toBeInTheDocument());
});
it("현재 경로(/tasks=작업)가 active 클래스 + aria-current=page", () => {
render(<Topbar />);
const active = screen.getByRole("link", { name: /작업/ });
expect(active).toHaveClass("active");
expect(active).toHaveAttribute("aria-current", "page");
});
it("결재함=3, 작업=4, 알림=6 배지가 표시된다", () => {
render(<Topbar />);
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("6")).toBeInTheDocument();
});
it("브랜드/아바타 텍스트와 우측 액션 aria-label", () => {
render(<Topbar />);
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();
});
});

@ -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(<Topbar />);
expect(await axe(container)).toHaveNoViolations();
});
});

@ -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(),
}));
}

@ -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 {}
}

@ -8,6 +8,7 @@ export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["tests/setup.ts"],
include: ["tests/**/*.test.{ts,tsx}"],
},
resolve: {

Loading…
Cancel
Save