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
parent
9075b7d1ba
commit
59e836bba0
@ -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>;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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 {}
|
||||
}
|
||||
Loading…
Reference in New Issue