|
|
# 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) / 테마 토글(달·해) / 아바타(이니셜 "지")**가 표시된다.
|
|
|
- **테마 토글**을 누르면 `<html data-theme="...">`가 `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` | 폰트 `<link>`, `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 # 앱 셸: <html> + 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 # <Icon name="grid" />
|
|
|
│ ├─ 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)을 `<link>`로 로드한다(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`에서 `<html>`에 폰트 변수 클래스를 부착하고, `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 `<link>` (원본 동일)
|
|
|
|
|
|
`layout.tsx`의 `<head>`에 `대시보드.html`과 동일한 `<link>`를 그대로 넣는다. 빌드 의존성 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 (
|
|
|
<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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`next-themes`는 `<head>`에 인라인 스크립트를 주입해 **하이드레이션 전에** `data-theme`를 설정하므로 FOUC가 없다. `layout.tsx`의 `<html>`에 `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 (
|
|
|
<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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> **주의(원본 동작 보존):** 원본은 라이트일 때 **달(moon)** 아이콘을 보여준다("누르면 다크로"). 다크일 때 **해(sun)**. 위 코드가 이를 그대로 따른다. `mounted` 가드 전엔 `moon`을 그려 SSR=초기 light와 일치시킨다.
|
|
|
|
|
|
### 3.5 Icon 컴포넌트 (원본 P 맵 전체 이식)
|
|
|
|
|
|
원본 `shell.jsx`의 `P` 딕셔너리(7~40행)와 `Icon`(42~49행)을 옮긴다. path 문자열은 `"|"`로 split해 다중 `<path>`를 그린다. **글리프 누락 시 렌더가 깨지므로 전부 옮긴다.**
|
|
|
|
|
|
이 `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 <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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> **원본과의 차이:** 원본 `<Icon d="grid"/>`는 prop 이름이 `d`였다. 이식본은 CONTRACT 명세(`<Icon name="grid"/>`)에 맞춰 **`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 (
|
|
|
<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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```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 필수
|
|
|
<html lang="ko" suppressHydrationWarning className={`${onest.variable} ${dmMono.variable}`}>
|
|
|
<body>
|
|
|
<ThemeProvider>
|
|
|
<div className="shell-page">
|
|
|
<Topbar />
|
|
|
<main>{children}</main>
|
|
|
</div>
|
|
|
</ThemeProvider>
|
|
|
</body>
|
|
|
</html>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> `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<HTMLDivElement>) {
|
|
|
return <div className={`glass-card ${className}`} {...rest}>{children}</div>;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```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 <span className="badge-count">{children}</span>;
|
|
|
return <span className="badge-pill" data-tone={tone}>{children}</span>;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```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 (
|
|
|
<button className="chip" onClick={onClick} type="button">
|
|
|
{icon && <Icon name={icon} />}
|
|
|
{children}
|
|
|
</button>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```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<HTMLButtonElement>) {
|
|
|
return (
|
|
|
<button className={`btn btn-${variant} ${className}`} {...rest}>
|
|
|
{icon && <Icon name={icon} />}
|
|
|
{children}
|
|
|
</button>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```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 (
|
|
|
<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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```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 <div className="avatar" data-tone={tone} title={label} aria-label={label}>{initial}</div>;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 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 (
|
|
|
<aside className="rail">
|
|
|
{items.map((r, i) =>
|
|
|
"sep" in r && r.sep ? (
|
|
|
<span className="sp" key={`s${i}`} />
|
|
|
) : (
|
|
|
<button
|
|
|
key={(r as Exclude<RailItem, { sep: true }>).id}
|
|
|
className={active === (r as any).id ? "active" : ""}
|
|
|
onClick={() => onPick((r as any).id)}
|
|
|
aria-label={(r as any).label}
|
|
|
aria-current={active === (r as any).id ? "page" : undefined}
|
|
|
>
|
|
|
<Icon name={(r as any).icon} />
|
|
|
{(r as any).dot && <span className="rdot" />}
|
|
|
<span className="tip">{(r as any).label}</span>
|
|
|
</button>
|
|
|
)
|
|
|
)}
|
|
|
</aside>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 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 (
|
|
|
<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>
|
|
|
);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
> 문구 "준비 중이에요. 이 화면은 곧 아리가 채워둘게요."는 제품 철학("할까요?"가 아니라 "이미 해뒀어요")의 톤을 따른 한국어 마이크로카피다.
|
|
|
|
|
|
#### 10개 placeholder 페이지 (route group `(placeholder)` — URL에 영향 없음)
|
|
|
|
|
|
```tsx
|
|
|
// frontend/app/(placeholder)/approvals/page.tsx
|
|
|
import { Placeholder } from "@/components/Placeholder";
|
|
|
export default function Page() { return <Placeholder title="결재함" icon="spark" />; }
|
|
|
```
|
|
|
|
|
|
나머지 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 <h1 style={{ fontFamily: "var(--font-disp)", fontSize: 40, marginTop: 24 }}>작업</h1>;
|
|
|
}
|
|
|
// 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`를 설정 → 깜빡임 없음. `<html suppressHydrationWarning>` 필수.
|
|
|
- **테마 아이콘 미스매치**: `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 개수만큼 <path>를 그린다", () => {
|
|
|
for (const [name, d] of Object.entries(PATHS)) {
|
|
|
const { container, unmount } = render(<Icon name={name as any} />);
|
|
|
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(() => {});
|
|
|
const { container } = render(<Icon name={"nope" as any} />);
|
|
|
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(<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();
|
|
|
});
|
|
|
});
|
|
|
```
|
|
|
|
|
|
#### 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(<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");
|
|
|
});
|
|
|
});
|
|
|
```
|
|
|
|
|
|
### 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(<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();
|
|
|
// 초기 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(<Topbar />);
|
|
|
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)을 단일 출처로 포함, `<Icon name=.../>`, 누락 가드.
|
|
|
- [ ] `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**.
|