# Phase 1 — 디자인 시스템 & 앱 셸
> 원본 프로토타입의 디자인 토큰을 픽셀 충실하게 이식하고, MVP 3개 페이지(작업/인박스/대시보드)와 "준비 중" placeholder 페이지가 공유하는 앱 셸(Topbar · 테마 토글 · Icon · 레이아웃 · 라우팅)을 완성하는 단계.
이 문서는 `dev/` 문서 세트의 일부입니다 — 먼저 **overview.md**를 읽으세요.
바로 앞 단계는 **phase-0-foundation.md**(모노레포 스캐폴딩)이며, 다음 단계는 **phase-2-backend.md**(데이터모델/REST API)입니다.
---
## 1. 개요 & 목표
### 1.1 이 phase가 끝나면 무엇이 동작하는가
- `pnpm dev`(또는 `npm run dev`)로 Next.js 앱을 띄우면 **모든 라우트에 동일한 상단바(Topbar)가 붙은 앱 셸**이 렌더된다.
- 상단바에는 **13개 메인 내비 항목**(대시보드·인박스·결재함[badge3]·자동화·여정·일정·작업[badge4]·메일·알림[badge6]·리서치·여행·라이프·하루 마감)이 원본과 동일한 순서·배지로 노출되고, 현재 라우트에 해당하는 항목이 **글래스 pill `active` 상태**로 강조된다.
- 우측 액션 영역에 **검색 / 알림(dot) / 테마 토글(달·해) / 아바타(이니셜 "지")**가 표시된다.
- **테마 토글**을 누르면 ``가 `light`↔`dark`로 바뀌고, 모든 토큰이 즉시 전환되며, 선택은 **localStorage에 영속**되어 새로고침/재방문 후에도 유지된다(SSR FOUC 없음).
- `/`로 접속하면 `/dashboard`로 **리다이렉트**된다.
- MVP 외 10개 페이지(`/inbox-placeholder`가 아니라 결재함·자동화·여정·일정·메일·알림·리서치·여행·라이프·하루 마감)는 **"준비 중" placeholder**로 라우팅되어 내비 일관성을 유지한다.
- **디자인 토큰(tokens.css)**, **글로벌 스타일(globals.css)**, **폰트(Pretendard/Onest/DM Mono)**, **Icon 컴포넌트(MVP 글리프 전체 — 단일 paths 맵)**, **공유 프리미티브(GlassCard/Badge/Chip/Button/SegmentToggle/Avatar)**, **SubRail**이 모두 구현되어 Phase 3~5가 곧바로 소비할 수 있다.
### 1.2 디자인 충실도의 기준
이 단계의 "정답"은 원본 파일의 실제 값이다. 본 문서는 다음 세 파일을 1차 출처로 인용한다.
| 출처 파일 | 역할 |
| --- | --- |
| `design-reference/assets/dash.css` | `:root` / `[data-theme="dark"]` 토큰, `body`, `.topbar`, `.mainnav`, `.t-btn`, `.rail`, `.card`, `.chip` 등 모든 스타일의 원본 |
| `design-reference/assets/shell.jsx` | `P`(아이콘 path 딕셔너리), `MAIN`(13개 내비 배열), `Icon`, `Topbar`, `SubRail` 컴포넌트 원본 |
| `design-reference/대시보드.html` | 폰트 ``, `data-theme` 초기값, 문서 골격 |
---
## 2. 선행 조건 & 산출물
### 2.1 선행 조건 (의존 phase)
- **phase-0-foundation.md** 완료: `frontend/`에 Next.js(App Router) + TypeScript + Vitest + Playwright가 스캐폴딩되어 있고 `pnpm dev`가 빈 페이지라도 뜬다. `next-themes` 설치 가능 상태.
- Node 18+ / pnpm(또는 npm). 백엔드(phase-2)는 이 단계에서 필요 없음(셸은 정적/클라이언트 only).
### 2.2 산출물 (Deliverables)
```
frontend/
├─ app/
│ ├─ layout.tsx # 앱 셸: + ThemeProvider + Topbar
│ ├─ page.tsx # / → /dashboard 리다이렉트
│ ├─ dashboard/page.tsx # (Phase 5 전까지 스텁) "대시보드" 제목만
│ ├─ inbox/page.tsx # (Phase 4 전까지 스텁)
│ ├─ tasks/page.tsx # (Phase 3 전까지 스텁)
│ └─ (placeholder)/
│ ├─ approvals/page.tsx # 결재함 "준비 중"
│ ├─ automation/page.tsx # 자동화
│ ├─ journey/page.tsx # 여정
│ ├─ calendar/page.tsx # 일정
│ ├─ mail/page.tsx # 메일
│ ├─ notifications/page.tsx # 알림
│ ├─ research/page.tsx # 리서치
│ ├─ trip/page.tsx # 여행
│ ├─ life/page.tsx # 라이프
│ └─ wrap/page.tsx # 하루 마감
├─ components/
│ ├─ Icon.tsx #
│ ├─ icons/paths.ts # 글리프 맵(MVP 전 페이지 단일 출처)
│ ├─ Topbar.tsx # 브랜드 + MainNav + 우측 액션
│ ├─ ThemeToggle.tsx # 달/해 토글 버튼
│ ├─ SubRail.tsx # 좌측 아이콘 레일
│ ├─ GlassCard.tsx
│ ├─ Badge.tsx
│ ├─ Chip.tsx
│ ├─ Button.tsx # 라임/필 CTA 변형
│ ├─ SegmentToggle.tsx # 칸반/리스트(캘린더는 post-MVP)
│ ├─ Avatar.tsx
│ └─ Placeholder.tsx # "준비 중" 공용 컴포넌트
├─ lib/
│ ├─ nav.ts # MAIN 배열 + 라우트 매핑
│ └─ types.ts # 공유 타입(Tone, NavItem 등 일부)
├─ styles/
│ ├─ tokens.css # :root + [data-theme=dark]
│ └─ globals.css # body, 폰트, .ic, .mono, 셸 클래스
└─ tests/
├─ Icon.test.tsx
├─ Topbar.test.tsx
├─ Badge.test.tsx
├─ ThemeToggle.test.tsx
└─ a11y.test.tsx
playwright/
└─ shell.spec.ts # 라우팅 스모크 + 테마 토글 영속
```
---
## 3. 상세 구현 (파일별, 단계별)
### 3.1 `styles/tokens.css` — 디자인 토큰 이식
`dash.css`의 `:root`(14~52행)와 `[data-theme="dark"]`(54~77행)를 **값 그대로** 옮긴다. 한 글자도 바꾸지 않는다. Tailwind를 쓰지 않으므로(§10 참조) CSS 변수가 단일 진실 공급원이다.
```css
/* frontend/styles/tokens.css — 원본 dash.css :root 그대로 */
:root {
/* 배경 */
--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);
/* 폰트 (실제 family는 next/font 변수로 바인딩; §3.3) */
--font-disp: "Onest", "Pretendard", system-ui, sans-serif;
--font-ui: "Pretendard", "Onest", system-ui, sans-serif;
--font-mono: "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도 다크에서 액센트/라임은 그대로 둔다. */
}
```
#### 라이트 토큰 값 표 (검증용 — 전수)
| 토큰 | 값(light) | 의미 |
| --- | --- | --- |
| `--bg-top` | `#f6efe7` | 배경 그라데이션 상단(웜 크림) |
| `--bg-mid` | `#eef0f1` | 배경 20% 지점 |
| `--bg-bot` | `#e9ebed` | 배경 하단(쿨 그레이) |
| `--card` | `#ffffff` | 순백 카드 |
| `--card-2` | `#f4f5f6` | 보조 면 |
| `--card-3` | `#eef0f1` | 3차 면 |
| `--ink` | `#211f1c` | 본문 잉크(차콜) |
| `--ink-2` | `#514d47` | 2차 텍스트 |
| `--muted` | `#8d8a85` | 흐린 텍스트 |
| `--faint` | `#b6b3ad` | 가장 흐림 |
| `--line` | `rgba(33,31,28,.07)` | 1차 구분선 |
| `--line-2` | `rgba(33,31,28,.13)` | 2차 구분선 |
| `--fill` | `#29241f` | 검정 채움(선택/CTA 어두운 버튼) |
| `--fill-soft` | `#36302a` | 약한 채움 |
| `--on-fill` | `#f4efe6` | 채움 위 텍스트 |
| `--lime` | `#c2f24a` | 라임 CTA |
| `--lime-hi` | `#cdf85e` | 라임 hover |
| `--lime-ink` | `#233006` | 라임 위 텍스트 |
| `--blue` | `#4f72e0` | 액센트(jiwoo/me) |
| `--coral` | `#df7256` | 액센트(jaeho/긴급/브랜드 dot) |
| `--green` | `#4e9b66` | 액센트(minseo/완료) |
| `--violet` | `#8b6fd4` | 액센트(hyunwoo) |
| `--amber` | `#e0a23c` | 액센트(날씨/보통 우선순위) |
| `--radius` | `22px` | 카드 라운드 |
| `--radius-sm` | `14px` | 작은 라운드 |
| `--shadow` | `0 10px 30px -14px rgba(30,26,22,.22), 0 2px 6px -2px rgba(30,26,22,.06)` | 카드 그림자 |
| `--shadow-sm` | `0 4px 14px -8px rgba(30,26,22,.18)` | 작은 그림자 |
| `--glass` | `rgba(255,255,255,.4)` | 글래스 면 |
| `--glass-2` | `rgba(255,255,255,.26)` | 보조 글래스 |
| `--glass-brd` | `rgba(255,255,255,.7)` | 글래스 테두리 |
| `--glass-ink` | `rgba(26,22,18,.46)` | 글래스 위 잉크 |
| `--blur` | `blur(26px) saturate(190%)` | backdrop blur |
| `--glass-hi` | `inset 0 1px 0 rgba(255,255,255,.75)` | 글래스 상단 하이라이트 |
> **주의:** `sua`의 색 `oklch(0.66 0.13 200)`(CONTRACT 팀원)은 토큰이 아니라 person 시드 데이터(phase-2)에 들어간다. 토큰에는 넣지 않는다.
### 3.2 `styles/globals.css` — 글로벌 스타일 & 셸 클래스
`dash.css` 11~94행(reset, `body`, `button`, `input`, `:focus-visible`, `.ic`, `.mono`)과 셸 관련 클래스(`.topbar`/`.mainnav`/`.t-btn`/`.t-ava`/`.brand` 등 102~204행, `.rail` 264~300행)를 그대로 가져온다. 페이지 컨테이너 `.dash`(96~100행)도 포함한다.
```css
/* frontend/styles/globals.css */
@import "./tokens.css";
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-ui);
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;
}
/* ===== 상단바 — 메인 메뉴 (원본 .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; }
.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); }
/* ===== 반응형 (원본 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%; }
}
```
> **카드 글래스(`.card`)·프리미티브 클래스**(`.chip`, `.badge`, `.glass-card` 등)는 §3.8에서 별도 정의한다. globals.css에는 셸/레이아웃 공통만 둔다.
### 3.3 폰트 적용 (Pretendard / Onest / DM Mono)
원본 `대시보드.html`은 Google Fonts CDN(Onest, DM Mono)과 jsdelivr CDN(Pretendard v1.3.9)을 ``로 로드한다(8~17행). Next.js에서는 **`next/font`(Onest·DM Mono) + Pretendard CDN(@font-face/import)** 조합을 권장한다(Onest/DM Mono는 Google Fonts에 있어 `next/font/google`로 self-host 최적화 가능, Pretendard는 Google Fonts 미제공이므로 CDN 또는 패키지 사용).
#### 방안 A (권장): next/font/google + Pretendard CDN
```ts
// 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",
});
```
Pretendard는 `globals.css` 상단(또는 `tokens.css` 위)에 CDN import:
```css
/* globals.css 최상단, tokens 앞 */
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css");
```
`layout.tsx`에서 ``에 폰트 변수 클래스를 부착하고, `tokens.css`의 폰트 토큰을 **변수 우선 + 패밀리 폴백**으로 정렬한다:
```css
/* tokens.css 폰트 토큰을 next/font 변수와 병행 (변수 미정의 시 패밀리 폴백) */
:root {
--font-disp: var(--f-onest), "Onest", "Pretendard", system-ui, sans-serif;
--font-ui: "Pretendard", var(--f-onest), system-ui, sans-serif;
--font-mono: var(--f-dmmono), "DM Mono", ui-monospace, monospace;
}
```
> 본문 기본 폰트는 **Pretendard(`--font-ui`)**, 디스플레이(큰 제목 `.ph-title` 등)는 **Onest(`--font-disp`)**, 숫자/영문은 **DM Mono(`--font-mono`, `.mono` 클래스)**. 원본 `dash.css` 49~51행과 동일한 우선순위.
#### 방안 B (가장 단순): 전부 CDN `` (원본 동일)
`layout.tsx`의 `
`에 `대시보드.html`과 동일한 ``를 그대로 넣는다. 빌드 의존성 0이지만 self-host 최적화/CLS 이점은 없음. **MVP 초기엔 방안 A를 채택**(폰트 깜빡임 최소화 + 오프라인 일부 대응). DM Mono의 무게가 원본은 400/500만 쓰므로 동일하게 제한한다.
### 3.4 테마 (next-themes로 data-theme 토글 + localStorage 영속)
원본 `shell.jsx`의 `Topbar`는 `useState("light")` + `useEffect`로 `document.documentElement.setAttribute("data-theme", theme)`만 한다(67~70행) — **영속이 없다**. CONTRACT 요구사항(localStorage 영속, FOUC 없음)을 만족하려면 `next-themes`를 쓴다.
```tsx
// 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}
);
}
```
`next-themes`는 ``에 인라인 스크립트를 주입해 **하이드레이션 전에** `data-theme`를 설정하므로 FOUC가 없다. `layout.tsx`의 ``에 `suppressHydrationWarning`을 붙여야 한다(서버는 속성 미정, 클라이언트는 설정 → 경고 방지).
토글 버튼(원본 `t-btn` + 달/해 아이콘 89~91행):
```tsx
// 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);
useEffect(() => setMounted(true), []); // 하이드레이션 불일치 방지: 마운트 후에만 아이콘 결정
const dark = mounted && resolvedTheme === "dark";
return (
);
}
```
> **주의(원본 동작 보존):** 원본은 라이트일 때 **달(moon)** 아이콘을 보여준다("누르면 다크로"). 다크일 때 **해(sun)**. 위 코드가 이를 그대로 따른다. `mounted` 가드 전엔 `moon`을 그려 SSR=초기 light와 일치시킨다.
### 3.5 Icon 컴포넌트 (원본 P 맵 전체 이식)
원본 `shell.jsx`의 `P` 딕셔너리(7~40행)와 `Icon`(42~49행)을 옮긴다. path 문자열은 `"|"`로 split해 다중 ``를 그린다. **글리프 누락 시 렌더가 깨지므로 전부 옮긴다.**
이 `paths.ts`는 **MVP 전 페이지가 쓰는 글리프의 단일 출처**다(다른 페이지에서 별도 path 맵을 만들지 않는다). 원본 `shell.jsx`의 내비/공통 글리프뿐 아니라 `tasks.jsx`·`tasks-risk.jsx`·`dash-v2.jsx`가 쓰는 글리프(아래 "MVP 글리프 전체 목록")를 전부 포함한다. **MVP 페이지에서 사용하는 글리프 전체 목록**은 다음과 같다(전부 `PATHS`에 존재해야 함):
```
// 내비/공통 (shell.jsx 기존)
spark grid route cal check tick mail target heart wallet brain zap more
search bell back send sun moon swap up arrow inbox mic image pen users
clock play plus shield pin
// 작업/인박스/대시보드/리스크 페이지 추가 (tasks.jsx / tasks-risk.jsx / dash-v2.jsx)
chev star flag hash folder branch video list file user msg trash grip x
wand radar scale link bold italic quote
```
> **리스크 아이콘(radar/scale/link)** 도 위 단일 출처(`paths.ts`)에 포함된다. phase-3 §3.9는 "리스크 아이콘은 중앙 paths 맵에 이미 포함"으로 이 맵을 참조한다.
```ts
// frontend/components/icons/paths.ts — MVP 전 페이지가 쓰는 글리프 단일 출처 (위 "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 페이지(작업/인박스/대시보드/리스크)에서 추가로 쓰는 글리프 (원본 shell.jsx/tasks.jsx/tasks-risk.jsx/dash-v2.jsx 근거)
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;
```
```tsx
// 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 (
);
}
```
> **원본과의 차이:** 원본 ``는 prop 이름이 `d`였다. 이식본은 CONTRACT 명세(``)에 맞춰 **`name`** 으로 바꾼다. `aria-hidden`을 추가해 장식 아이콘이 스크린리더에 중복 읽히지 않게 한다(라벨은 부모 요소가 `aria-label`로 제공).
### 3.6 내비 데이터 (`lib/nav.ts`) — MAIN 13항목 + 라우트 매핑
원본 `shell.jsx`의 `MAIN` 배열(51~65행)을 옮기되, `href`를 **한글 .html → 영어 App Router 경로**로 바꾸고 `current` 매칭용 `id`는 보존한다.
```ts
// 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 },
];
```
#### MAIN 라우트 매핑 표 (검증용)
| id | 라벨 | icon | href | badge | MVP | placeholder? |
| --- | --- | --- | --- | --- | --- | --- |
| dash | 대시보드 | grid | `/dashboard` | — | ✅ | — |
| sbox | 인박스 | inbox | `/inbox` | — | ✅ | — |
| appr | 결재함 | spark | `/approvals` | **3** | — | ✅ |
| auto | 자동화 | zap | `/automation` | — | — | ✅ |
| journey | 여정 | route | `/journey` | — | — | ✅ |
| cal | 일정 | cal | `/calendar` | — | — | ✅ |
| task | 작업 | check | `/tasks` | **4** | ✅ | — |
| mail | 메일 | mail | `/mail` | — | — | ✅ |
| noti | 알림 | bell | `/notifications` | **6** | — | ✅ |
| research | 리서치 | brain | `/research` | — | — | ✅ |
| trip | 여행 | send | `/trip` | — | — | ✅ |
| life | 라이프 | heart | `/life` | — | — | ✅ |
| wrap | 하루 마감 | moon | `/wrap` | — | — | ✅ |
> 배지 값(3/4/6)은 Phase 5에서 `GET /api/dashboard`의 `badges:{appr,task,noti}`로 동적 치환될 수 있으나, **이번 phase에서는 원본 정적값을 그대로** 둔다.
### 3.7 Topbar 컴포넌트 + `layout.tsx` 셸 구성
```tsx
// 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
);
}
```
```tsx
// frontend/app/layout.tsx
import type { Metadata } from "next";
import { onest, dmMono } from "./fonts";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Topbar } from "@/components/Topbar";
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "아리 — AI LIFE OS",
description: "적을 때는 분류하지 않는다 — 분류·배치·자동화는 아리가.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// lang=ko + data-theme는 next-themes가 주입 → suppressHydrationWarning 필수
{children}
);
}
```
> `usePathname()`을 쓰므로 `Topbar`는 클라이언트 컴포넌트(`"use client"`)다. `layout.tsx`는 서버 컴포넌트로 유지하고 클라이언트 경계는 `Topbar`/`ThemeProvider`에 둔다. `.shell-page`가 원본 `.dash`의 `max-width:1620px; margin:0 auto; padding:0 26px 40px`를 제공하고, `.topbar`의 `margin:0 -26px`가 컨테이너 패딩을 상쇄해 상단바가 화면 폭까지 퍼지는 원본 레이아웃을 재현한다.
### 3.8 공유 프리미티브 (components/) + 프리미티브 CSS
원본은 BEM-유사 전역 클래스(`.card`, `.chip`, `.badge` 등)를 쓴다. 우리는 §10 결정에 따라 **전역 클래스 + 얇은 React 래퍼**로 간다. 각 프리미티브 CSS는 `globals.css` 하단에 모은다.
#### CSS (globals.css 하단 추가)
```css
/* ===== 공유 프리미티브 ===== */
/* 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); }
```
#### React 래퍼
```tsx
// frontend/components/GlassCard.tsx
export function GlassCard({
className = "", children, ...rest
}: React.HTMLAttributes) {
return {children}
;
}
```
```tsx
// 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};
}
```
```tsx
// 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 (
);
}
```
```tsx
// 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 (
);
}
```
```tsx
// 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) => (
))}
);
}
```
```tsx
// 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}
;
}
```
> Phase 3에서 `SegmentToggle`은 옵션 `[{id:"kanban",label:"칸반",icon:"grid"},{id:"list",label:"리스트",icon:"check"}]`로 소비한다(캘린더는 post-MVP placeholder). `Avatar`의 tone은 person.color(phase-2 시드: jiwoo=blue, hyunwoo=violet, minseo=green, jaeho=coral, sua=oklch)와 매핑된다(sua는 인라인 style로 처리).
### 3.9 SubRail 컴포넌트
원본 `shell.jsx`의 `SubRail`(100~120행)을 TS로 옮긴다. 아이템은 일반 항목 `{id,icon,label,dot?}` 또는 구분자 `{sep:true}`.
```tsx
// 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 (
);
}
```
> SubRail은 MVP에서 **작업 페이지(Phase 3)** 가 선택적으로 사용한다. 이번 phase에서는 컴포넌트만 완성하고 라우트 연결은 하지 않는다.
### 3.10 라우팅: placeholder + 리다이렉트 + MVP 스텁
#### `/` → `/dashboard` 리다이렉트
```tsx
// frontend/app/page.tsx
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}
```
#### "준비 중" 공용 컴포넌트
```tsx
// 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}
준비 중이에요. 이 화면은 곧 아리가 채워둘게요.
);
}
```
> 문구 "준비 중이에요. 이 화면은 곧 아리가 채워둘게요."는 제품 철학("할까요?"가 아니라 "이미 해뒀어요")의 톤을 따른 한국어 마이크로카피다.
#### 10개 placeholder 페이지 (route group `(placeholder)` — URL에 영향 없음)
```tsx
// frontend/app/(placeholder)/approvals/page.tsx
import { Placeholder } from "@/components/Placeholder";
export default function Page() { return ; }
```
나머지 9개도 동일 패턴:
| 파일 | title | icon |
| --- | --- | --- |
| `(placeholder)/automation/page.tsx` | 자동화 | zap |
| `(placeholder)/journey/page.tsx` | 여정 | route |
| `(placeholder)/calendar/page.tsx` | 일정 | cal |
| `(placeholder)/mail/page.tsx` | 메일 | mail |
| `(placeholder)/notifications/page.tsx` | 알림 | bell |
| `(placeholder)/research/page.tsx` | 리서치 | brain |
| `(placeholder)/trip/page.tsx` | 여행 | send |
| `(placeholder)/life/page.tsx` | 라이프 | heart |
| `(placeholder)/wrap/page.tsx` | 하루 마감 | moon |
#### MVP 3개 스텁 (Phase 3~5에서 본 구현으로 교체)
```tsx
// frontend/app/tasks/page.tsx (스텁)
export default function Page() {
return 작업
;
}
// dashboard/page.tsx, inbox/page.tsx 동일 패턴 ("대시보드", "인박스")
```
> 스텁은 셸/내비/테마 검증을 위한 최소 콘텐츠다. **Phase 3는 phase-3-tasks.md, Phase 4는 phase-4-inbox.md, Phase 5는 phase-5-dashboard.md**에서 이 페이지들을 본 구현으로 대체한다.
---
## 4. 데이터/타입/API 계약 (이 phase 관련)
이 phase는 **백엔드 호출이 없다**(셸은 정적/클라이언트 only). 관련 타입은 내비/아이콘/톤뿐이다.
```ts
// frontend/lib/types.ts (이 phase에서 추가되는 부분만)
export type Tone = "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint";
export type { IconName } from "@/components/icons/paths";
export type { NavItem } from "@/lib/nav";
```
> `Tone` 값 집합은 CONTRACT 명세(`blue|violet|coral|green|amber|ink|faint`)와 정확히 일치한다. phase-2의 `backend/app/schemas.py`의 tone 필드와 1:1 대응한다. 배지 정적값(appr=3, task=4, noti=6)은 phase-5에서 `GET /api/dashboard` 응답의 `badges:{appr,task,noti}`로 대체 가능한 지점임을 코드 주석으로 표시한다.
API 응답 형태(참고, phase-5에서 실제 사용):
```json
{
"badges": { "appr": 3, "task": 4, "noti": 6 }
}
```
---
## 5. 디자인 충실도 노트 (정확 값 인용)
모두 `design-reference/assets/dash.css` / `shell.jsx` 라인 기준.
- **배경 그라데이션**: `linear-gradient(178deg, var(--bg-top) 0%, var(--bg-mid) 20%, var(--bg-bot) 100%)` + `background-attachment: fixed` (dash.css 82~83행). 각도 **178deg**(거의 수직, 미세 기울기) 보존.
- **한국어 타이포**: `letter-spacing: -0.011em; word-break: keep-all;` (dash.css 86~87행). 큰 제목은 `.ph-title { letter-spacing:-0.03em; font-family:var(--font-disp); font-size:clamp(30px,3.4vw,46px); }`(232~239행) — Phase 3~5 페이지 헤더에서 사용.
- **상단바**: 높이 **70px**, `gap:24px`, `position:sticky; top:0; z-index:40`, `backdrop-filter: blur(14px) saturate(150%)`(상단바 전용 blur, 카드 `--blur`와 다름). `margin:0 -26px; padding:0 30px`(102~115행).
- **브랜드 마크**: 40×40, `border-radius:12px`, `background:var(--fill)`, 아이콘 색 `var(--coral)`, 우상단 코럴 점 9×9 `border:2px solid var(--bg-top)`(117~133행). 아이콘은 `spark`(별).
- **브랜드 텍스트**: `b` 15px/700, `span` 9.5px/700 uppercase letter-spacing 0.08em "AI LIFE OS"(134~140행).
- **메인 내비 링크**: 14px/500, `padding:9px 16px`, `border-radius:999px`, 아이콘 16×16. **active = 글래스 pill**: `background:var(--glass)` + `backdrop-filter:var(--blur)` + `box-shadow:var(--shadow-sm)` + `border:1px solid var(--glass-brd)` + 600 weight(147~169행).
- **배지**: 11px/700, `background:var(--coral)`, 흰 글자, `min-width:18px`, `padding:0 6px`(170~174행).
- **우측 액션 버튼 `.t-btn`**: 40×40 원형 글래스, 아이콘 18×18, hover `translateY(-1px)`. `.dot::after`는 우상단 7×7 코럴 점(176~196행).
- **아바타 `.t-ava`**: 40×40 원형, `linear-gradient(135deg, var(--coral), var(--violet))`, 흰 글자 15px/700, 내용 "지"(197~204행, data.js `user.initial`).
- **서브 레일 `.rail`**: `position:sticky; top:86px`, 버튼 44×44 `border-radius:13px`, 아이콘 19×19. hover=글래스, active=`var(--fill)` 채움+`var(--on-fill)`. 툴팁(`.tip`)은 위쪽 팝업 `var(--fill)` 배경, 12px/600, 화살표 5px(264~300행).
- **글래스 카드 `.card`**: `var(--glass)` + `--blur` + `border:1px solid var(--glass-brd)` + `border-radius:22px` + `box-shadow:var(--shadow), var(--glass-hi)` + `padding:20px`(309~321행).
- **포커스 링**: `:focus-visible { outline:2px solid var(--blue); outline-offset:2px; border-radius:6px; }`(91행) — 접근성 기준.
- **아이콘 stroke**: `viewBox 0 0 24 24`, `fill:none`, `stroke:currentColor`, `stroke-width:1.9`(기본), `linecap/linejoin:round`(shell.jsx 42~48행). path는 `"|"` 다중 분리.
- **다크 테마**: `[data-theme="dark"]`에서 배경/카드/잉크/라인/fill/그림자/글래스를 오버라이드(54~77행). 라임/액센트(blue·coral·green·violet·amber)는 **다크에서 그대로** — 절대 임의 변경 금지.
### 반응형 충실도
- `@media (max-width:1200px)`: 보드 4열→2열(Phase 5 보드용, 786~790행).
- `@media (max-width:720px)`: `.shell-page` 패딩 축소, 상단바 패딩/gap 축소, **원본은 `.mainnav{display:none}`** 이나 우리는 §6.4 결정에 따라 가로 스크롤 유지, `.rail`은 가로 배치로 전환(791~800행).
---
## 6. 상태 처리 & 엣지 케이스
### 6.1 로딩 / 하이드레이션
- **테마 FOUC**: `next-themes`의 인라인 스크립트가 첫 페인트 전 `data-theme`를 설정 → 깜빡임 없음. `` 필수.
- **테마 아이콘 미스매치**: `ThemeToggle`은 `mounted` 가드로 SSR(=light, moon)와 일치시킨 뒤 클라이언트에서 실제 테마 아이콘으로 전환. 가드 없으면 hydration mismatch 경고.
### 6.2 빈 / 에러
- **알 수 없는 라우트**: `app/not-found.tsx`를 두어 셸 안에서 "페이지를 찾을 수 없어요" 표시(선택). 셸은 layout이라 404에서도 Topbar가 유지됨.
- **아이콘 누락**: `Icon`이 콘솔 경고 + 빈 SVG로 폴백(§3.5) → 레이아웃 무너짐 방지. 테스트로 가드(§7).
- **localStorage 비활성(프라이빗 모드 등)**: `next-themes`가 내부적으로 try/catch → 저장 실패 시 세션 한정으로 동작, 크래시 없음.
### 6.3 오프라인
- 셸은 정적 자산 only이므로 오프라인에서도 렌더된다(폰트 CDN 미도달 시 system-ui 폴백, `--font-ui`/`--font-disp`/`--font-mono`의 폴백 체인이 보장). 방안 A(next/font self-host)면 Onest/DM Mono도 오프라인 동작.
### 6.4 결정 — 모바일 내비
원본은 720px 미만에서 `.mainnav{display:none}`이다. MVP에서는 **13항목 가로 스크롤을 유지**(이미 `overflow-x:auto`)하기로 결정한다. 근거: MVP 평가가 데스크톱 중심이지만, 내비 일관성(모든 페이지에서 13항목 접근)을 깨지 않는 편이 라우팅/테스트 단순화에 유리. 원본 `display:none` 규칙은 globals.css에서 주석 처리해 보존한다.
---
## 7. 테스팅 & 검증 (가장 중요)
### 7.1 실행 명령
```bash
# 프론트 단위/컴포넌트 (Vitest + React Testing Library)
cd frontend && pnpm test # 전체
cd frontend && pnpm test Icon # 특정 파일
cd frontend && pnpm test --coverage # 커버리지
# 접근성 (vitest-axe / jest-axe)
cd frontend && pnpm test a11y
# E2E (Playwright) — dev 서버 자동 기동 설정 시
cd frontend && pnpm playwright test playwright/shell.spec.ts
cd frontend && pnpm playwright test --headed # 시각 확인
# 타입/린트
cd frontend && pnpm tsc --noEmit && pnpm lint
```
> Vitest 환경은 `jsdom`, setup 파일에서 `@testing-library/jest-dom`과 `vitest-axe/extend-expect`를 import. `next/font`는 테스트에서 모킹(빈 객체 반환)하고, `next-themes`는 실제 사용(jsdom localStorage 동작)한다.
### 7.2 컴포넌트 렌더 테스트 (Vitest + RTL)
#### Icon — 누락 path 가드 + 다중 path 렌더
```tsx
// 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(() => {});
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));
});
});
```
#### Topbar — 13항목 렌더 + active 상태 + 배지
```tsx
// 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();
});
});
```
#### Badge — 변형/톤
```tsx
// 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");
});
});
```
### 7.3 테마 토글 테스트 (data-theme & localStorage)
```tsx
// 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();
// 초기 light → moon path 존재(첫 path가 moon의 'M21 12.8...')
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);
});
});
```
### 7.4 라우팅 스모크 (Playwright)
```ts
// 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 영속
});
```
### 7.5 접근성 (axe)
```tsx
// 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();
});
});
```
Playwright + axe(E2E 대비) 추가 검사:
```ts
// playwright/a11y.spec.ts (선택)
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
for (const theme of ["light", "dark"]) {
test(`axe — ${theme} 테마 대시보드`, 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([]);
});
}
```
### 7.6 수동 QA 체크리스트 (라이트/다크 시각 비교)
| # | 점검 항목 | 통과 기준 |
| --- | --- | --- |
| 1 | 배경 그라데이션 | 상단 웜 크림(#f6efe7) → 하단 쿨 그레이(#e9ebed), 스크롤해도 고정(`fixed`) |
| 2 | 상단바 높이/위치 | 70px, sticky 상단 고정, 스크롤 시 blur 배경 유지 |
| 3 | 브랜드 | 차콜 라운드 마크 + 코럴 별 아이콘 + 우상단 코럴 점, "아리 / AI LIFE OS" |
| 4 | 내비 13항목 | 순서·라벨 원본 동일, 결재함3·작업4·알림6 코럴 배지 |
| 5 | active pill | 현재 페이지 항목만 글래스 pill(반투명+blur+shadow+테두리) |
| 6 | 우측 액션 | 검색/알림(점)/테마/아바타("지", 코럴→바이올렛 그라데이션) |
| 7 | 테마 토글 | 클릭 시 라이트↔다크 즉시 전환, 깜빡임 없음 |
| 8 | 테마 영속 | 다크로 두고 새로고침 → 다크 유지 |
| 9 | 테마 아이콘 | 라이트=달, 다크=해 |
| 10 | placeholder | 10개 라우트 모두 "준비 중" 카드 + 해당 제목 |
| 11 | 리다이렉트 | `/` 진입 시 `/dashboard` |
| 12 | 다크 토큰 | 다크에서 카드/잉크/라인/그림자 전환, 액센트(코럴 등) 색 유지 |
| 13 | 폰트 | 본문 Pretendard, 큰 제목 Onest, 숫자 DM Mono(`.mono`) |
| 14 | 한국어 줄바꿈 | 긴 한국어가 어절 단위로 줄바꿈(keep-all), 자간 -0.011em |
| 15 | 포커스 링 | Tab 이동 시 파란 outline(2px, blue) 보임 |
| 16 | 가로 스크롤(좁은 폭) | 1000px 이하에서 내비 가로 스크롤(스크롤바 숨김) |
| 17 | 반응형 | 1200px↓ 보드 2열, 720px↓ 패딩 축소·레일 가로 |
> 라이트/다크 캡처 비교: `pnpm playwright test --headed`로 `/dashboard`를 띄워 각 테마 스크린샷을 찍고 원본 `대시보드.html`(Live Server 등으로 오픈)과 픽셀 대조. 색은 DevTools color picker로 §3.1 HEX와 일치 확인.
### 7.7 통과 기준
- `pnpm test` 전부 green(Icon 글리프 수, Topbar active/배지, Badge, ThemeToggle 영속, a11y).
- `pnpm playwright test playwright/shell.spec.ts` 전부 green(리다이렉트, 내비 일관성, placeholder, active, 테마 영속).
- `pnpm tsc --noEmit` 0 에러, `pnpm lint` 0 에러.
- axe 위반 0건(라이트/다크 모두).
- 수동 QA 체크리스트 17항목 모두 통과.
---
## 8. 완료 기준 (Definition of Done)
- [ ] `styles/tokens.css`가 `dash.css` `:root` + `[data-theme=dark]`를 **값 그대로** 이식(전수 표 일치).
- [ ] `styles/globals.css`에 body 배경 그라데이션(178deg, fixed), `word-break:keep-all`, `letter-spacing:-0.011em`, `.ic`/`.mono`, 셸/레일 클래스 이식.
- [ ] Pretendard/Onest/DM Mono 적용(`--font-ui`/`--font-disp`/`--font-mono` 변수 바인딩, 폴백 체인).
- [ ] `next-themes`로 `data-theme` 토글 + `ari-theme` localStorage 영속 + FOUC 없음(`suppressHydrationWarning`).
- [ ] `ThemeToggle`이 라이트=달 / 다크=해 아이콘(원본 동작), `mounted` 가드.
- [ ] `components/Icon.tsx` + `icons/paths.ts`가 **MVP 페이지에서 사용하는 글리프 전체 목록**(§3.5: 내비/공통 + chev/star/flag/hash/folder/branch/video/list/file/user/msg/trash/grip/x/wand/radar/scale/link/bold/italic/quote)을 단일 출처로 포함, ``, 누락 가드.
- [ ] `Topbar`가 `MAIN` 13항목(배지 3/4/6) + 우측 액션 + 브랜드, 현재 라우트 active pill + `aria-current`.
- [ ] `layout.tsx`가 전 페이지 공통 셸 적용(`.shell-page` 컨테이너 + 상단바 음수 마진).
- [ ] `SubRail` 컴포넌트 완성(작업 페이지 추후 사용).
- [ ] 프리미티브 6종(GlassCard/Badge/Chip/Button[라임·필·글래스]/SegmentToggle/Avatar) 토큰 기반 구현.
- [ ] `/` → `/dashboard` 리다이렉트, MVP 3개 스텁(작업/인박스/대시보드), placeholder 10개 "준비 중".
- [ ] §7 테스트 전부 green + 수동 QA 17항목 통과 + axe 0건.
- [ ] 라이트/다크 캡처가 원본 `대시보드.html`과 색·라운드·그림자·blur 값에서 일치.
---
## 9. 다음 단계
- **phase-2-backend.md** — SQLite 데이터모델(person/folder/project/task/inbox_item/inbox_classification/...), Alembic 마이그레이션, 시드(REF `data.js`/`tasks-data.js`/`sinbox-data.js`/`approve-data.js`), REST API(`/api/tree`, `/api/tasks`, `/api/inbox`, `/api/dashboard` 등), Ollama 추상화(`llm/provider.py`), 분류/리스크 서비스. 이 phase의 `Tone`·필드명과 1:1 정합.
- 이후 페이지 구현은 **phase-3-tasks.md → phase-4-inbox.md → phase-5-dashboard.md** 순(대시보드가 다른 데이터를 집계하므로 마지막). 각 페이지는 본 phase의 셸·프리미티브·Icon·SegmentToggle·SubRail·Avatar를 그대로 소비한다.
- 전체 연합 흐름 통합/E2E/접근성/성능은 **phase-6-integration.md**.