# Phase 5 — 대시보드(Dashboard) 페이지

> 한 줄 요약: 아침 브리핑 · 결재함/인박스 요약 카드 · 오늘 일정/작업/목표 요약 · 자연어 명령 입력을 한 화면에 모은 "요약/알림 허브"를 `GET /api/dashboard` 집계 한 번으로 그려내는 읽기 중심 페이지를 완성한다.
>
> 이 문서는 `dev/` 문서 세트의 일부입니다 — 먼저 `overview.md`를 읽으세요. 선행 문서: `phase-0-foundation.md`, `phase-1-design-system.md`, `phase-2-backend.md`, `phase-3-tasks.md`, `phase-4-inbox.md`.

---

## 1. 개요 & 목표

### 1.1 이 phase가 끝나면 무엇이 동작하는가

`/dashboard` 라우트를 열면 다음이 한 화면(벤토 그리드)에 시드 데이터 기준으로 표시된다.

1. **히어로 브리핑** — "좋은 아침이에요, 지우님" + 날씨/출근/수면 한 줄 + `briefingNote`(HTML, `<b>` 강조 포함) + 추천 칩 3개 + **자연어 명령 입력창**.
2. **결재함 요약 카드** — `risk === "high"` 대기 건 목록 3개(미니 리스트) + "오늘 47분 아껴드렸어요" 서브 + "결재함에서 승인하기" CTA(MVP에서는 `/결재함` placeholder로 라우팅, "준비 중").
3. **인박스 요약 카드** — 최근 캡처 3건 미니 리스트 + "오늘 7건" 서브 + "새로 적기 · 인박스 열기" CTA(→ `/inbox`, 실제 동작).
4. **오늘 일정 요약** — `schedule` 4건(시간·제목·태그·길이·"곧" 배지) + 일정 노트.
5. **할 일 요약** — `task_summary`(남은 개수 + 상위 작업 목록, 첫 항목 "지금" 포커스).
6. **목표 요약** — `goals` 3건(제목·퍼센트·진행 막대·서브).
7. **자연어 명령 입력** — 히어로 하단 `.cmd` 입력창. 입력 후 전송 시 인박스 캡처(`POST /api/inbox/capture`) 호출 → 토스트로 "인박스에 적어뒀어요" 안내 후 `/inbox`로 이동(MVP 동작, 5.5 정의).
8. **라이트/다크 테마** — Topbar 토글에 따라 모든 카드/히어로 색이 토큰 기반으로 전환.
9. **로딩 스켈레톤 / 빈 상태 / 부분 실패(위젯별)** 처리.

> MVP 범위 한정(CONTRACT 준수): 원본 `dash-v2.jsx`에는 13개 위젯(미팅 준비·위임 현황·이어서 하기·집중도 차트·메일·건강·지출·오늘의 집중 등)이 있으나, **이번 MVP 대시보드는 백엔드 `GET /api/dashboard`가 집계하는 6개 블록(브리핑/결재함/인박스/일정/작업/목표)만 실데이터로 렌더**한다. 나머지 위젯은 본 phase에서 구현하지 않는다(원본의 픽셀 보캐뷸러리만 5.8 디자인 충실도 절에서 참고로 인용). 결재함/일정 전용 페이지는 placeholder이므로 카드 CTA는 "준비 중"으로 라우팅하고 카드 본문은 읽기 전용 시드만 표시한다.

### 1.2 핵심 제품 원칙 반영

- "적을 때는 분류하지 않는다 — 분류·배치·자동화는 아리가." → 대시보드의 자연어 명령 입력은 사용자가 "그냥 적기"만 하면 인박스로 흘려보내 아리가 분류하도록 한다.
- "할까요?"가 아니라 "이미 해뒀어요." → 결재함 요약은 "치과 예약을 16:00로 옮겼어요"처럼 **이미 실행된 결과**를 보여주고 승인/되돌리기만 받는다. 일정 노트 역시 "치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요."로 표현한다.

---

## 2. 선행 조건(의존 phase) / 산출물(Deliverables)

### 2.1 선행 조건

| 의존 | 문서 | 이 phase에서 필요한 것 |
|---|---|---|
| 모노레포/개발환경 | `phase-0-foundation.md` | `frontend`(Next.js App Router)·`backend`(FastAPI) 기동, dev 프록시 |
| 디자인 시스템 | `phase-1-design-system.md` | `tokens.css`, `<Icon>`, `<Topbar current="dash">`, `<GlassCard>`, 테마 토글(next-themes), placeholder 라우팅 |
| 백엔드 | `phase-2-backend.md` | `event`/`approval`/`goal`/`briefing` 시드 테이블, `GET /api/dashboard` 라우터, `task_summary` 집계, `POST /api/inbox/capture` |
| 작업 페이지 | `phase-3-tasks.md` | 작업 타입(`Task`, `status` enum), 작업 요약이 참조하는 task 시드 |
| 인박스 페이지 | `phase-4-inbox.md` | `inbox_item`·`inbox_classification` 시드, `POST /api/inbox/capture` 동작(자연어 명령이 호출) |

> 대시보드는 다른 데이터를 **집계**하므로 빌드 순서상 마지막 페이지다(작업 → 인박스 → 대시보드). `phase-6-integration.md`에서 연합 흐름 E2E로 마무리한다.

### 2.2 산출물(Deliverables)

```
backend/app/
└─ (대시보드 백엔드는 phase-2 소유) # schemas.py(DashboardOut 등)·routers/dashboard.py(GET /api/dashboard 인라인 집계)·models.py(event/approval/goal/briefing)·seed.py(_seed_dashboard)는 phase-2-backend.md §3.14가 단일 소유. 본 phase는 그 응답을 소비만 하며 백엔드를 재정의하지 않는다.

frontend/
├─ app/dashboard/page.tsx                 # 서버 컴포넌트 진입(메타데이터) → <DashboardClient/>
├─ components/dashboard/
│  ├─ DashboardClient.tsx                 # 페이지 셸 + 데이터 패칭 + 벤토 그리드
│  ├─ HeroBriefing.tsx                    # 히어로(브리핑 + 칩 + 명령 입력)
│  ├─ CommandInput.tsx                    # 자연어 명령 입력창(.cmd)
│  ├─ ApprovalSummaryCard.tsx             # 결재함 요약(미니 리스트 + CTA)
│  ├─ InboxSummaryCard.tsx                # 인박스 요약(미니 리스트 + CTA)
│  ├─ ScheduleCard.tsx                    # 오늘 일정(.sched)
│  ├─ TaskSummaryCard.tsx                 # 할 일(.tasks)
│  ├─ GoalsCard.tsx                       # 목표(.goals)
│  ├─ MiniRow.tsx                         # 요약 카드 공용 미니 행(approve.css .mini-row)
│  ├─ DashboardSkeleton.tsx              # 로딩 스켈레톤
│  └─ WidgetError.tsx                     # 위젯별 부분 실패 표시
├─ lib/
│  ├─ types.ts                            # Dashboard* 타입(schemas.py와 1:1)
│  ├─ api.ts                              # getDashboard(), captureInbox()
│  └─ hooks/useDashboard.ts               # SWR/React Query 훅
├─ styles/dashboard.css                   # dash.css + approve.css의 대시보드 관련 규칙 이식
└─ tests/                                 # 컴포넌트/E2E/접근성 테스트
   ├─ HeroBriefing.test.tsx
   ├─ ApprovalSummaryCard.test.tsx
   ├─ GoalsCard.test.tsx
   └─ ...
frontend/playwright/dashboard.spec.ts     # E2E
backend/tests/test_dashboard.py           # API 집계 테스트
```

---

## 3. 상세 구현(파일별, 단계별)

### 3.1 백엔드 — 시드 테이블(phase-2 소유, 동일 형태 참조)

대시보드 읽기전용 테이블(`briefing`/`event`/`approval`/`goal`)은 **`phase-2-backend.md`가 유일하게 정의·소유**한다. 이 phase는 모델을 재정의하지 않고 phase-2의 동일 형태를 참조해 집계·소비만 한다(프론트 컴포넌트는 `GET /api/dashboard` 정본 응답을 소비). 아래는 참조 편의를 위한 phase-2 정의 발췌이며, 필드명은 CONTRACT 고정값과 원본 데이터 파일을 그대로 따른다.

```python
# backend/app/models.py (phase-2 소유 — 참조용 발췌, 본 phase에서 재정의하지 않음)
from datetime import datetime
from sqlmodel import SQLModel, Field

class Briefing(SQLModel, table=True):
    """아침 브리핑 — 단일 row(id=1) 또는 settings로 운용."""
    id: int = Field(default=1, primary_key=True)
    today: str            # "6월 7일 일요일"
    weather_temp: int     # 24
    weather_cond: str     # "맑음 · 한낮 28°"
    weather_icon: str     # "cloudSun" 저장 → 응답/표시 시 'sun' 으로 매핑(R11/R12, 디자인 충실도 노트)
    commute: str          # "출근 23분 · 평소보다 4분 빠름"
    sleep: str            # "어젯밤 7시간 12분 · 평소만큼 푹 잤어요"
    note: str             # briefingNote (HTML 허용; <b> 강조)
    saved_today: str      # "47분" (결재함 카드 서브용)
    today_routed: int     # 7 (인박스 카드 서브용)

class Event(SQLModel, table=True):
    """오늘 일정(읽기 전용 시드)."""
    id: str = Field(primary_key=True)   # "e1"
    time: str             # "09:30"
    title: str            # "팀 데일리 스탠드업"
    tag: str              # "프로덕트"
    dur: str              # "15분"
    tone: str             # blue|violet|coral|green
    soon: bool = False
    sort_order: int = 0

class Approval(SQLModel, table=True):
    """결재함(승인 큐) — 대시보드 요약 + (placeholder) 전용 페이지 공용."""
    id: str = Field(primary_key=True)   # "a1"
    icon: str             # "cal" | "mail" | "users" | "wallet"
    tone: str             # coral|blue|violet|green|amber
    risk: str             # "low" | "high"  → high만 요약 카드에 노출
    time: str             # "07:42" | "보내기 대기" 등
    title: str            # "치과 예약을 16:00로 옮겼어요"
    detail: str
    cta: str | None = None        # "보내기" (risk=high)
    alt: str | None = None        # "수정"
    undo_label: str | None = None # "원래 시간으로" (risk=low)
    sort_order: int = 0

class Goal(SQLModel, table=True):
    id: str = Field(primary_key=True)   # "g1"
    title: str            # "분기 OKR — 사용자 리텐션"
    pct: int              # 68
    sub: str              # "12개 중 8개 달성"
    tone: str             # "blue" (tone 키. 프론트에서 var(--blue)로 변환. 'var(--blue)'로 저장하지 않음)
    sort_order: int = 0
```

> 참고: 원본 `data.js`는 `goals[].c: "var(--blue)"`처럼 CSS 변수 문자열을 그대로 담지만, 백엔드 스키마는 **tone 키(`blue`)** 만 저장하고 프론트에서 `var(--${tone})`로 변환한다(CONTRACT tone 값 집합 준수: `blue|violet|coral|green|amber|ink|faint`).

### 3.2 백엔드 — 시드 데이터(`seed.py`)

원본 파일의 값을 그대로 옮긴다. `briefingNote`/`note`의 HTML은 보존한다.

> 시드 진입점은 `phase-2`의 `run_seed(session=None, reset=True)` 단일 정본이며, 대시보드 시드는 그 내부에서 호출되는 비공개 헬퍼 `_seed_dashboard(session)`로 둔다(공개 진입점으로 노출하지 않음). `run_seed`가 자체/주입 세션을 만들고 `reset` 후 `_seed_dashboard(session)`를 호출한다.

```python
# backend/app/seed.py (대시보드 시드 발췌 — run_seed 내부에서 호출되는 비공개 헬퍼)
def _seed_dashboard(session):
    session.add(Briefing(
        id=1,
        today="6월 7일 일요일",
        weather_temp=24, weather_cond="맑음 · 한낮 28°", weather_icon="cloudSun",  # 표시 시 'sun' 매핑
        commute="출근 23분 · 평소보다 4분 빠름",
        sleep="어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
        note=("오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 "
              "집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요."),
        saved_today="47분",
        today_routed=7,
    ))

    events = [
        Event(id="e1", time="09:30", title="팀 데일리 스탠드업", tag="프로덕트", dur="15분", tone="blue",   sort_order=0),
        Event(id="e2", time="11:00", title="디자인 리뷰 — 온보딩 플로우", tag="디자인", dur="45분", tone="violet", sort_order=1),
        Event(id="e3", time="14:00", title="분기 전략 미팅", tag="경영진", dur="60분", tone="coral", soon=True, sort_order=2),
        Event(id="e4", time="16:30", title="1:1 — 민서님", tag="팀", dur="30분", tone="green",  sort_order=3),
    ]
    session.add_all(events)

    # approve-data.js 그대로
    approvals = [
        Approval(id="a1", icon="cal",    tone="coral",  risk="low",  time="07:42",
                 title="치과 예약을 16:00로 옮겼어요",
                 detail="14시 분기 전략 미팅과 겹침 · 병원 예약 시스템에서 빈 슬롯 확인 후 변경",
                 undo_label="원래 시간으로", sort_order=0),
        Approval(id="a2", icon="mail",   tone="blue",   risk="low",  time="06:10",
                 title="영수증·뉴스레터 7통을 정리했어요",
                 detail="영수증 3통 → 금융 폴더 · 뉴스레터 4통 → 읽을거리, 받은편지함은 중요한 것만 남김",
                 undo_label="되돌리기", sort_order=1),
        Approval(id="a3", icon="cal",    tone="violet", risk="low",  time="07:40",
                 title="내일 오전 딥 워크 2시간을 예약했어요",
                 detail="분기 리포트 마감(내일 18시) 대비 · 9:00–11:00, 방해 금지로 설정",
                 undo_label="블록 해제", sort_order=2),
        Approval(id="a4", icon="mail",   tone="violet", risk="high", time="보내기 대기",
                 title="현우님께 회신 초안이 준비됐어요",
                 detail="“잘 받았어요! 금요일 오전까지 화면별 코멘트 정리해서 드릴게요.”",
                 cta="보내기", alt="수정", sort_order=3),
        Approval(id="a5", icon="users",  tone="green",  risk="high", time="전달 대기",
                 title="민서님께 ‘데이터 전처리’ 위임 요청",
                 detail="오늘 일정 과부하 감지 · 맥락 요약과 마감(목)을 담은 요청 메시지 작성 완료",
                 cta="전달", alt="내가 할게", sort_order=4),
        Approval(id="a6", icon="wallet", tone="amber",  risk="high", time="확인 필요",
                 title="Netflix 일시정지를 추천해요",
                 detail="최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요",
                 cta="일시정지", alt="유지", sort_order=5),
    ]
    session.add_all(approvals)

    # data.js goals (var(--x) → tone 키)
    goals = [
        Goal(id="g1", title="분기 OKR — 사용자 리텐션", pct=68, sub="12개 중 8개 달성", tone="blue",   sort_order=0),
        Goal(id="g2", title="주 4회 운동",            pct=75, sub="이번 주 3/4회",   tone="coral",  sort_order=1),
        Goal(id="g3", title="‘딥 워크’ 책 완독",       pct=40, sub="320쪽 중 128쪽",  tone="violet", sort_order=2),
    ]
    session.add_all(goals)
    session.commit()
```

> 인박스 최근 캡처는 별도 시드하지 않는다 — `phase-4-inbox.md`에서 시드한 `inbox_item`(sinbox-data.js의 s1~s4)을 그대로 집계해 최신 3건을 사용한다.

### 3.3 백엔드 — 응답 스키마(`schemas.py`)

`frontend/lib/types.ts`와 **필드명 1:1 대응**. CONTRACT의 `GET /api/dashboard → {user, briefing, saved_today, today_routed, schedule[], task_summary, goals[], approvals_summary[], inbox_recent[], badges:{appr,task,noti}}` 형태를 그대로 따른다. `briefing`은 `{weather, commute, sleep, note}`만 담고, `saved_today`·`today_routed`는 **최상위 필드**다. `goals[]`의 색상 키는 `tone`(예 `blue`), `inbox_recent[]`는 `{id,kind,raw,type,proj_label,tone}`이다.

```python
# backend/app/schemas.py (대시보드 출력)
from pydantic import BaseModel

class UserOut(BaseModel):
    name: str              # "지우"
    initial: str           # "지"

class WeatherOut(BaseModel):  # phase-2 §3.2 소유 — 동일. { temp:int, cond:str, icon:str }
    temp: int              # 24
    cond: str              # "맑음 · 한낮 28°"
    icon: str              # "sun" (cloudSun→sun 매핑 후)

class BriefingOut(BaseModel):
    today: str             # "6월 7일 일요일" (phase-2 Briefing 소유 컬럼)
    weather: WeatherOut    # 중첩 타입(dict 아님): {temp, cond, icon}
    commute: str
    sleep: str
    note: str              # HTML 허용
    # 주의: saved_today / today_routed 는 응답 최상위 필드(briefing 안이 아님)

class ScheduleItemOut(BaseModel):
    id: str
    time: str
    title: str
    tag: str
    dur: str
    tone: str
    soon: bool

class ApprovalSummaryOut(BaseModel):
    id: str
    icon: str
    tone: str
    title: str
    time: str              # "보내기 대기" 등 (risk=high만)

class InboxRecentOut(BaseModel):
    id: str
    kind: str              # text|voice|image
    raw: str
    type: str              # 분류 type: "task"|"event"|"idea" (없으면 "" 또는 미분류)
    proj_label: str        # 프로젝트 표시 라벨 "개인 › 여행 — 한국" (없으면 "")
    tone: str              # 분류 tone (없으면 "faint")

class TaskSummaryItem(BaseModel):  # phase-2 §3.2 소유 — 동일 이름·필드
    id: str
    title: str
    project: str           # 프로젝트 표시명
    prio: str              # 높음|보통|낮음

class TaskSummaryOut(BaseModel):
    open_count: int        # 미완료 작업 수
    items: list[TaskSummaryItem]  # 미완료(open) 작업만, 상위 N건(기본 5)

class GoalOut(BaseModel):
    id: str
    title: str
    pct: int
    sub: str
    tone: str              # tone 키(예 "blue"). 프론트가 var(--tone)로 변환

class BadgesOut(BaseModel):
    appr: int              # 결재 대기(=high risk 개수) → Topbar 결재함 badge
    task: int              # 작업 미완료 수 → Topbar 작업 badge
    noti: int              # 알림 수(시드 상수 6)

class DashboardOut(BaseModel):
    user: UserOut
    briefing: BriefingOut
    saved_today: str       # "47분" (결재함 카드 서브용, 최상위)
    today_routed: int      # 7 (인박스 카드 서브용, 최상위)
    schedule: list[ScheduleItemOut]
    task_summary: TaskSummaryOut
    goals: list[GoalOut]
    approvals_summary: list[ApprovalSummaryOut]
    inbox_recent: list[InboxRecentOut]
    badges: BadgesOut
```

### 3.4 백엔드 — 집계·라우터(phase-2 §3.14 단일 소유)

> 대시보드 백엔드 집계·스키마·라우터는 **`phase-2-backend.md` §3.14가 단일 소유**한다. 별도 `services/dashboard.py`는 만들지 않으며, 집계는 `backend/app/routers/dashboard.py` 안에서 **인라인**으로 수행한다(라우터는 `APIRouter()`로 내부 prefix 없이 정의하고, `main.py`에서 `prefix="/api"`로 등록 — R13). 본 phase는 그 응답을 **소비만** 하며 백엔드 집계·스키마·라우터를 재정의하지 않는다.
>
> 집계 규칙 요약(정본 참조용): 결재함 요약은 `risk=="high"`만 정렬 순서대로 최대 3건, `task_summary.items`는 **미완료(open) 작업만** 상위 N건(done 제외), 인박스 최근 3건 + 최신 분류, `weather_icon`은 `cloudSun→sun` 매핑 후 `briefing.weather`(중첩 타입)로 응답, `user`는 people 시드 `is_me`에서 채움. 구현 코드 정본은 phase-2 §3.14를 참조한다.

#### 예시 응답(`GET /api/dashboard`, 시드 기준 — phase-2 정본과 동일)

```json
{
  "user": { "name": "지우", "initial": "지" },
  "briefing": {
    "today": "6월 7일 일요일",
    "weather": { "temp": 24, "cond": "맑음 · 한낮 28°", "icon": "sun" },
    "commute": "출근 23분 · 평소보다 4분 빠름",
    "sleep": "어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
    "note": "오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요."
  },
  "saved_today": "47분",
  "today_routed": 7,
  "schedule": [
    { "id": "e1", "time": "09:30", "title": "팀 데일리 스탠드업", "tag": "프로덕트", "dur": "15분", "tone": "blue",   "soon": false },
    { "id": "e2", "time": "11:00", "title": "디자인 리뷰 — 온보딩 플로우", "tag": "디자인", "dur": "45분", "tone": "violet", "soon": false },
    { "id": "e3", "time": "14:00", "title": "분기 전략 미팅", "tag": "경영진", "dur": "60분", "tone": "coral",  "soon": true },
    { "id": "e4", "time": "16:30", "title": "1:1 — 민서님", "tag": "팀", "dur": "30분", "tone": "green",  "soon": false }
  ],
  "task_summary": {
    "open_count": 4,
    "items": [
      { "id": "k1", "title": "분기 리포트 초안 마무리", "prio": "높음", "project": "분기 리포트" },
      { "id": "k2", "title": "온보딩 와이어프레임 피드백 정리", "prio": "높음", "project": "와이어프레임" },
      { "id": "k5", "title": "사용자 인터뷰 5건 정리", "prio": "보통", "project": "사용자 리서치" },
      { "id": "k4", "title": "구독 결제 카드 갱신", "prio": "보통", "project": "일상" }
    ]
  },
  "goals": [
    { "id": "g1", "title": "분기 OKR — 사용자 리텐션", "pct": 68, "sub": "12개 중 8개 달성", "tone": "blue" },
    { "id": "g2", "title": "주 4회 운동", "pct": 75, "sub": "이번 주 3/4회", "tone": "coral" },
    { "id": "g3", "title": "‘딥 워크’ 책 완독", "pct": 40, "sub": "320쪽 중 128쪽", "tone": "violet" }
  ],
  "approvals_summary": [
    { "id": "a4", "icon": "mail",   "tone": "violet", "title": "현우님께 회신 초안이 준비됐어요", "time": "보내기 대기" },
    { "id": "a5", "icon": "users",  "tone": "green",  "title": "민서님께 ‘데이터 전처리’ 위임 요청", "time": "전달 대기" },
    { "id": "a6", "icon": "wallet", "tone": "amber",  "title": "Netflix 일시정지를 추천해요", "time": "확인 필요" }
  ],
  "inbox_recent": [
    { "id": "s1", "kind": "text",  "raw": "다음 주에 한국 놀러가는 비행기 티켓 사기", "type": "task",  "proj_label": "개인 › 여행 — 한국", "tone": "coral" },
    { "id": "s2", "kind": "text",  "raw": "수요일 11시 자전거 수리 맡기기", "type": "event", "proj_label": "개인", "tone": "blue" },
    { "id": "s3", "kind": "voice", "raw": "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기", "type": "task",  "proj_label": "개인", "tone": "green" }
  ],
  "badges": { "appr": 3, "task": 4, "noti": 6 }
}
```

> `approvals_summary`는 `risk === "high"`인 a4·a5·a6만 노출된다(원본 `apPending = AP.items.filter(i => i.risk === "high")`와 동일). `badges.appr=3`은 Topbar 결재함 배지("3")와 일치, `badges.task`는 미완료 작업 수(시드상 4)와 일치.

---

### 3.6 프론트 — 타입(`lib/types.ts`)

```ts
// frontend/lib/types.ts (대시보드)
export interface User { name: string; initial: string }

export interface Weather { temp: number; cond: string; icon: string }

export interface Briefing {
  today: string;
  weather: Weather;
  commute: string;
  sleep: string;
  note: string;          // HTML (dangerouslySetInnerHTML)
}

export interface ScheduleItem {
  id: string; time: string; title: string; tag: string; dur: string;
  tone: Tone; soon: boolean;
}

export interface ApprovalSummary {
  id: string; icon: string; tone: Tone; title: string; time: string;
}

export interface InboxRecent {
  id: string; kind: "text" | "voice" | "image"; raw: string;
  type: string; proj_label: string; tone: Tone;
}

export interface TaskSummaryItem {
  id: string; title: string; project: string; prio: "높음" | "보통" | "낮음";
}
export interface TaskSummary { open_count: number; items: TaskSummaryItem[] }

export interface Goal { id: string; title: string; pct: number; sub: string; tone: Tone }

export interface Badges { appr: number; task: number; noti: number }

export interface Dashboard {
  user: User;
  briefing: Briefing;
  saved_today: string;   // "47분" (최상위)
  today_routed: number;  // 7 (최상위)
  schedule: ScheduleItem[];
  task_summary: TaskSummary;
  goals: Goal[];
  approvals_summary: ApprovalSummary[];
  inbox_recent: InboxRecent[];
  badges: Badges;
}

export type Tone = "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint";
```

### 3.7 프론트 — API 클라이언트(`lib/api.ts`)

```ts
// frontend/lib/api.ts (발췌)
import type { Dashboard } from "./types";

const BASE = process.env.NEXT_PUBLIC_API_BASE ?? ""; // dev 프록시 사용 시 ""

export async function getDashboard(signal?: AbortSignal): Promise<Dashboard> {
  const res = await fetch(`${BASE}/api/dashboard`, { signal, cache: "no-store" });
  if (!res.ok) throw new Error(`dashboard ${res.status}`);
  return res.json();
}

// 자연어 명령 → 인박스 캡처 (phase-4와 동일 엔드포인트)
export async function captureInbox(raw: string) {
  const res = await fetch(`${BASE}/api/inbox/capture`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ kind: "text", raw }),
  });
  if (!res.ok) throw new Error(`capture ${res.status}`);
  return res.json(); // { item, classification }
}
```

### 3.8 프론트 — 데이터 훅(`lib/hooks/useDashboard.ts`)

React Query(또는 SWR) 기준. 위젯별 부분 실패는 단일 호출이라 "전체 실패 vs 성공"이지만, **위젯 렌더 시 각 섹션이 null/빈 배열일 때를 방어**해 부분 표시(5.9)한다.

```ts
// frontend/lib/hooks/useDashboard.ts
"use client";
import { useQuery } from "@tanstack/react-query";
import { getDashboard } from "@/lib/api";

export function useDashboard() {
  return useQuery({
    queryKey: ["dashboard"],
    queryFn: ({ signal }) => getDashboard(signal),
    staleTime: 30_000,
    retry: 1,
  });
}
```

### 3.9 프론트 — 라우트 진입(`app/dashboard/page.tsx`)

```tsx
// frontend/app/dashboard/page.tsx
import type { Metadata } from "next";
import { DashboardClient } from "@/components/dashboard/DashboardClient";

export const metadata: Metadata = { title: "대시보드 · 아리" };

export default function DashboardPage() {
  return <DashboardClient />;
}
```

> `app/page.tsx`(`/`)는 `phase-1-design-system.md`에서 `redirect("/dashboard")`로 구성됨.

### 3.10 프론트 — 페이지 셸 & 벤토 그리드(`DashboardClient.tsx`)

원본 `dash-v2.jsx`의 구조(상단 페이지 헤더 + `.work > .board` 벤토 그리드 + 진입 애니메이션 `entered`)를 재현한다. Topbar/테마는 `phase-1`의 앱 셸(`app/layout.tsx`)이 제공하므로 여기서는 페이지 본문만 그린다.

```tsx
// frontend/components/dashboard/DashboardClient.tsx
"use client";
import { useEffect, useRef } from "react";
import { useDashboard } from "@/lib/hooks/useDashboard";
import { HeroBriefing } from "./HeroBriefing";
import { ApprovalSummaryCard } from "./ApprovalSummaryCard";
import { InboxSummaryCard } from "./InboxSummaryCard";
import { ScheduleCard } from "./ScheduleCard";
import { TaskSummaryCard } from "./TaskSummaryCard";
import { GoalsCard } from "./GoalsCard";
import { DashboardSkeleton } from "./DashboardSkeleton";
import { Icon } from "@/components/Icon";

export function DashboardClient() {
  const { data, isLoading, isError, refetch } = useDashboard();
  const shellRef = useRef<HTMLDivElement>(null);

  // 원본 진입 애니메이션: requestAnimationFrame → .entered 부여
  useEffect(() => {
    const el = shellRef.current;
    if (!el) return;
    const id = requestAnimationFrame(() => el.classList.add("entered"));
    return () => cancelAnimationFrame(id);
  }, [data]);

  if (isLoading) return <DashboardSkeleton />;
  if (isError || !data) {
    return (
      <div className="dash-error" role="alert">
        <p>대시보드를 불러오지 못했어요.</p>
        <button className="mini-cta" onClick={() => refetch()}>
          <Icon name="swap" /> 다시 시도
        </button>
      </div>
    );
  }

  const { user, briefing, saved_today, today_routed, schedule, task_summary, goals, approvals_summary, inbox_recent } = data;

  return (
    <div className="dash-page" ref={shellRef}>
      {/* 페이지 헤더 (원본 .pagehead) */}
      <div className="pagehead">
        <div>
          <div className="ph-eyebrow">
            <span>{briefing.today}</span>
            <span className="sep" />
            <span className="wx"><Icon name={briefing.weather.icon} />{briefing.weather.cond}</span>
          </div>
          <h1 className="ph-title">좋은 아침이에요, {user.name}님 <em>☀</em></h1>
        </div>
        <div className="ph-search">
          <Icon name="search" />
          <input placeholder="아리에게 무엇이든…" aria-label="검색" />
        </div>
      </div>

      <div className="work">
        <div className="board">
          {/* 히어로 — sp2(2칸) */}
          <HeroBriefing briefing={briefing} />

          {/* 요약 카드 2개 */}
          <ApprovalSummaryCard items={approvals_summary} savedToday={saved_today} pending={data.badges.appr} />
          <InboxSummaryCard items={inbox_recent} todayRouted={today_routed} />

          {/* 오늘 요약 */}
          <ScheduleCard items={schedule} />
          <TaskSummaryCard summary={task_summary} />
          <GoalsCard goals={goals} />
        </div>
      </div>
    </div>
  );
}
```

> 사용자명(인사 "좋은 아침이에요, {name}님")은 **`GET /api/dashboard` 응답 최상위 `user.name`**을 사용한다(R12). 하드코딩하지 않는다 — 백엔드가 phase-2 people 시드의 `is_me` 인물에서 `{name, initial}`을 채워 내려준다.

### 3.11 프론트 — 히어로 브리핑(`HeroBriefing.tsx`)

원본 `.card.hero.sp2` 구조 그대로: `hero-spark`(코럴 pill "아리 브리핑") + `p.brief`(HTML) + `.chips`(추천 칩 3개) + `.cmd`(자연어 명령 입력).

```tsx
// frontend/components/dashboard/HeroBriefing.tsx
"use client";
import { Icon } from "@/components/Icon";
import { CommandInput } from "./CommandInput";
import type { Briefing } from "@/lib/types";

// 원본 CHIPS (dash-v2.jsx)
const CHIPS = [
  { icon: "cal",   text: "내일 오후 비워줘" },
  { icon: "mail",  text: "중요 메일만 요약" },
  { icon: "video", text: "14시 미팅 준비" },
];

export function HeroBriefing({ briefing }: { briefing: Briefing }) {
  return (
    <section className="card hero sp2">
      <span className="hero-spark"><Icon name="spark" />아리 브리핑</span>

      {/* briefingNote — HTML(<b> 강조) */}
      <p className="brief" dangerouslySetInnerHTML={{ __html: briefing.note }} />

      <div className="chips">
        {CHIPS.map((c, i) => (
          <button className="chip" key={i} type="button">
            <Icon name={c.icon} />{c.text}
          </button>
        ))}
      </div>

      <CommandInput />
    </section>
  );
}
```

> 보안 메모: `briefing.note`는 시드 출처의 HTML이며 `<b>`만 포함한다. 사용자 입력이 아니므로 `dangerouslySetInnerHTML` 사용이 안전하다. 향후 사용자 생성 텍스트를 넣게 되면 `DOMPurify` 같은 sanitizer를 거쳐야 한다(주석으로 명시).

### 3.12 프론트 — 자연어 명령 입력(`CommandInput.tsx`)

원본 `.cmd`(좌측 spark 아이콘 + input + 우측 검정 채움 send 버튼). MVP 동작은 5.5에서 정의: **전송 시 인박스 캡처 → 토스트 → `/inbox` 이동**.

```tsx
// frontend/components/dashboard/CommandInput.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Icon } from "@/components/Icon";
import { captureInbox } from "@/lib/api";

export function CommandInput() {
  const [value, setValue] = useState("");
  const [busy, setBusy] = useState(false);
  const router = useRouter();

  async function submit() {
    const raw = value.trim();
    if (!raw || busy) return;
    setBusy(true);
    try {
      await captureInbox(raw);          // POST /api/inbox/capture {kind:"text", raw}
      // 토스트(phase-1 디자인 시스템 Toast): "인박스에 적어뒀어요 — 아리가 분류할게요"
      window.dispatchEvent(new CustomEvent("ari:toast", {
        detail: { text: "인박스에 적어뒀어요 — 아리가 분류할게요" },
      }));
      router.push("/inbox");            // 분류 결과 확인은 인박스에서
    } catch {
      window.dispatchEvent(new CustomEvent("ari:toast", {
        detail: { text: "지금은 적어두지 못했어요. 잠시 후 다시 시도해 주세요.", tone: "coral" },
      }));
    } finally {
      setBusy(false);
      setValue("");
    }
  }

  return (
    <form
      className="cmd"
      onSubmit={(e) => { e.preventDefault(); submit(); }}
    >
      <Icon name="spark" />
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="오늘 하루, 무엇이든 맡겨보세요…"
        aria-label="아리에게 명령 입력"
        disabled={busy}
      />
      <button className="send" type="submit" aria-label="보내기" disabled={busy || !value.trim()}>
        <Icon name="send" />
      </button>
    </form>
  );
}
```

> 원본 placeholder 문구 "오늘 하루, 무엇이든 맡겨보세요…" 그대로 사용. Enter 키 전송은 `<form onSubmit>`으로 자연스럽게 지원.

### 3.13 프론트 — 결재함 요약 카드(`ApprovalSummaryCard.tsx`)

원본 구조: `.ch`(라임 아이콘 `ico lime` + 제목 "아리 결재함" + 서브 "오늘 47분 아껴드렸어요" + `count warm` "대기 N건") + `.mini-list`(3행) + `.mini-cta`(검정 채움 "결재함에서 승인하기"). `appr` 클래스로 `approve.css`의 `.ico.lime`/`.count.warm` 적용.

```tsx
// frontend/components/dashboard/ApprovalSummaryCard.tsx
"use client";
import Link from "next/link";
import { Icon } from "@/components/Icon";
import { MiniRow } from "./MiniRow";
import type { ApprovalSummary } from "@/lib/types";

export function ApprovalSummaryCard({
  items, savedToday, pending,
}: { items: ApprovalSummary[]; savedToday: string; pending: number }) {
  return (
    <section className="card appr">
      <div className="ch">
        <div className="ico lime"><Icon name="spark" /></div>
        <div className="htext">
          <h3>아리 결재함</h3>
          <div className="sub">오늘 {savedToday} 아껴드렸어요</div>
        </div>
        <span className="count warm">대기 {pending}건</span>
      </div>

      {items.length === 0 ? (
        <div className="appr-empty">
          <span className="ae-tick"><Icon name="tick" w={3} /></span>
          지금은 확인할 게 없어요 — 다 처리해뒀어요.
        </div>
      ) : (
        <div className="mini-list">
          {items.map((it) => (
            <MiniRow key={it.id} tone={it.tone} icon={it.icon} text={it.title} sub={it.time} />
          ))}
        </div>
      )}

      {/* MVP: 결재함 전용 페이지는 placeholder("준비 중") */}
      <Link className="mini-cta" href="/결재함" aria-label="결재함에서 승인하기 (준비 중)">
        <Icon name="tick" w={3} />결재함에서 승인하기
      </Link>
    </section>
  );
}
```

> 미니 행은 원본 `<span className="mini-text">{it.title}</span><span className="mini-sub">{it.time}</span>`와 동일. CTA `href`는 placeholder 라우트(예: `/결재함` 또는 `/(placeholder)/approvals`) — 클릭 시 phase-1의 "준비 중" 플레이스홀더 페이지를 보여준다.

### 3.14 프론트 — 인박스 요약 카드(`InboxSummaryCard.tsx`)

원본: `.ico`(inbox) + "스마트 인박스" + 서브 "적으면 아리가 제자리로" + `count` "오늘 7건" + `.mini-list`(3행: kind에 따라 아이콘 pen/mic/image) + `.mini-cta`(→ 인박스, "새로 적기 · 인박스 열기").

```tsx
// frontend/components/dashboard/InboxSummaryCard.tsx
"use client";
import Link from "next/link";
import { Icon } from "@/components/Icon";
import { MiniRow } from "./MiniRow";
import type { InboxRecent } from "@/lib/types";

const kindIcon = (k: InboxRecent["kind"]) =>
  k === "voice" ? "mic" : k === "image" ? "image" : "pen";

// 분류 type → 한글 라벨(없으면 "분류 중")
const typeLabel = (t: string) =>
  ({ task: "작업", event: "일정", idea: "아이디어" } as Record<string, string>)[t] ?? "분류 중";

export function InboxSummaryCard({
  items, todayRouted,
}: { items: InboxRecent[]; todayRouted: number }) {
  return (
    <section className="card">
      <div className="ch">
        <div className="ico"><Icon name="inbox" /></div>
        <div className="htext">
          <h3>스마트 인박스</h3>
          <div className="sub">적으면 아리가 제자리로</div>
        </div>
        <span className="count">오늘 {todayRouted}건</span>
      </div>

      {items.length === 0 ? (
        <div className="appr-empty">
          <span className="ae-tick"><Icon name="tick" w={3} /></span>
          인박스가 비었어요 — 떠오르면 바로 적어두세요.
        </div>
      ) : (
        <div className="mini-list">
          {items.map((c) => (
            <MiniRow key={c.id} tone={c.tone} icon={kindIcon(c.kind)} text={c.raw} sub={typeLabel(c.type)} />
          ))}
        </div>
      )}

      <Link className="mini-cta" href="/inbox">
        <Icon name="plus" />새로 적기 · 인박스 열기
      </Link>
    </section>
  );
}
```

### 3.15 프론트 — 공용 미니 행(`MiniRow.tsx`)

`approve.css`의 `.mini-row`/`.mini-ic`/`.mini-text`/`.mini-sub`를 그대로 사용. `--tone` 인라인 변수로 아이콘 칩 색을 입힌다.

```tsx
// frontend/components/dashboard/MiniRow.tsx
import { Icon } from "@/components/Icon";
import type { Tone } from "@/lib/types";

export function MiniRow({
  tone, icon, text, sub,
}: { tone: Tone; icon: string; text: string; sub: string }) {
  return (
    <div className="mini-row">
      <span className="mini-ic" style={{ "--tone": `var(--${tone})` } as React.CSSProperties}>
        <Icon name={icon} />
      </span>
      <span className="mini-text">{text}</span>
      <span className="mini-sub">{sub}</span>
    </div>
  );
}
```

### 3.16 프론트 — 오늘 일정 카드(`ScheduleCard.tsx`)

원본 `.sched > .ev` 구조: 좌측 mono `ev-time`, `ev-body`(accent 좌측 바 `--accent`), `ev-title`, `ev-meta`(tag + `dur`), `soon` 배지("곧"), 하단 `sched-note`.

```tsx
// frontend/components/dashboard/ScheduleCard.tsx
"use client";
import { Icon } from "@/components/Icon";
import type { ScheduleItem } from "@/lib/types";

const TONE: Record<string, string> = {
  blue: "var(--blue)", violet: "var(--violet)", coral: "var(--coral)", green: "var(--green)",
};

export function ScheduleCard({ items }: { items: ScheduleItem[] }) {
  return (
    <section className="card">
      <div className="ch">
        <div className="ico"><Icon name="cal" /></div>
        <div className="htext">
          <h3>오늘 일정</h3>
          <div className="sub">{items.length}개 · 다음까지 2시간</div>
        </div>
        <button className="tool" aria-label="더보기"><Icon name="more" /></button>
      </div>
      <div className="sched">
        {items.map((e) => (
          <div className="ev" key={e.id}>
            <div className="ev-time mono">{e.time}</div>
            <div className="ev-body" style={{ "--accent": TONE[e.tone] } as React.CSSProperties}>
              <div className="ev-title">{e.title}</div>
              <div className="ev-meta"><span>{e.tag}</span><span className="dur">{e.dur}</span></div>
            </div>
            {e.soon && <span className="soon">곧</span>}
          </div>
        ))}
        <div className="sched-note">
          <Icon name="bell" />치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요.
        </div>
      </div>
    </section>
  );
}
```

> `sched-note` 문구는 원본 그대로. "이미 해뒀어요" 원칙을 보여주는 핵심 카피이므로 변경하지 않는다.

### 3.17 프론트 — 할 일 카드(`TaskSummaryCard.tsx`)

원본 `.tasks > .task`(체크박스 `box` + `task-body`(title/meta) + 우측 `prio` 또는 첫 항목 `focus-tag` "지금"). MVP에서는 **읽기 전용 요약**이므로 체크 토글은 비활성(클릭 시 `/tasks`로 이동하거나 no-op). 원본 PRIO 매핑(`높음→high, 보통→mid, 낮음→low`) 유지.

```tsx
// frontend/components/dashboard/TaskSummaryCard.tsx
"use client";
import Link from "next/link";
import { Icon } from "@/components/Icon";
import type { TaskSummary } from "@/lib/types";

const PRIO: Record<string, string> = { 높음: "high", 보통: "mid", 낮음: "low" };

export function TaskSummaryCard({ summary }: { summary: TaskSummary }) {
  return (
    <section className="card">
      <div className="ch">
        <div className="ico"><Icon name="check" /></div>
        <div className="htext"><h3>할 일</h3></div>
        <span className="count">{summary.open_count}개 남음</span>
      </div>
      <div className="tasks">
        {summary.items.map((t, idx) => {
          // task_summary.items 는 미완료(open) 작업만 담긴다(백엔드가 done 제외) → 첫 항목을 focus 강조
          const focus = idx === 0;
          return (
            <Link
              key={t.id}
              href={`/tasks?task=${t.id}`}
              className={"task" + (focus ? " focus" : "")}
            >
              <div className="box"><Icon name="tick" w={3} /></div>
              <div className="task-body">
                <div className="task-title">{t.title}</div>
                <div className="task-meta">{t.project}</div>
              </div>
              {focus
                ? <span className="focus-tag">지금</span>
                : <span className={"prio " + PRIO[t.prio]}>{t.prio}</span>}
            </Link>
          );
        })}
      </div>
    </section>
  );
}
```

> 원본 `dash-v2`는 첫 미완료 항목을 `focus`로 강조하고 "지금" 배지를 단다. `task_summary.items`는 백엔드(phase-2 §3.14)가 **미완료(open) 작업만** 내려주므로(`status` 필드 없음, done 제외) 프론트는 첫 항목(`idx === 0`)을 그대로 `focus`로 강조한다. MVP 대시보드 작업 카드는 토글이 아니라 **작업 페이지로의 진입점**(`/tasks?task={id}`, phase-3의 상세 드로어 딥링크)으로 동작시킨다.

### 3.18 프론트 — 목표 카드(`GoalsCard.tsx`)

원본 `.goals > div`(goal-top: title + pct / goal-bar i(width%, background) / goal-sub).

```tsx
// frontend/components/dashboard/GoalsCard.tsx
import { Icon } from "@/components/Icon";
import type { Goal } from "@/lib/types";

export function GoalsCard({ goals }: { goals: Goal[] }) {
  return (
    <section className="card">
      <div className="ch">
        <div className="ico"><Icon name="target" /></div>
        <div className="htext"><h3>목표</h3></div>
      </div>
      <div className="goals">
        {goals.map((g) => (
          <div key={g.id}>
            <div className="goal-top">
              <span className="goal-title">{g.title}</span>
              <span className="goal-pct mono">{g.pct}%</span>
            </div>
            <div
              className="goal-bar"
              role="progressbar"
              aria-valuenow={g.pct}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-label={g.title}
            >
              <i style={{ width: `${g.pct}%`, background: `var(--${g.tone})` }} />
            </div>
            <div className="goal-sub">{g.sub}</div>
          </div>
        ))}
      </div>
    </section>
  );
}
```

> 원본은 `g.c`에 `var(--blue)` 문자열을 넣지만, 백엔드가 tone 키(`blue`)를 주므로 `var(--${g.tone})`로 변환. `goal-bar`에 `role="progressbar"` + aria 속성을 더해 접근성을 보강(원본에는 없으나 axe 통과용).

---

## 4. 데이터/타입/API 계약(이 phase 관련)

### 4.1 엔드포인트

| 메서드 | 경로 | 용도 | 응답 |
|---|---|---|---|
| GET | `/api/dashboard` | 대시보드 집계(사용자·브리핑·일정·작업요약·목표·결재요약·인박스최근·배지) | `DashboardOut` |
| POST | `/api/inbox/capture` | 자연어 명령 입력 → 인박스 캡처 + 분류(phase-4 재사용) | `{ item, classification }` |

> 사용자명은 `GET /api/dashboard` 응답 최상위 `user{name,initial}`로 내려온다(R12) — 별도 `/api/people` 호출 불필요.

### 4.2 필드 매핑(원본 → 백엔드 → 프론트)

| 원본(data.js / approve-data.js / sinbox-data.js) | 백엔드 필드 | 프론트 타입 |
|---|---|---|
| people `is_me` | `user{name,initial}` (최상위) | `User` |
| `today` | `briefing.today` | `Briefing.today` |
| `weather.cond` | `briefing.weather.cond` | `Briefing.weather.cond` |
| `commute` / `sleep` | `briefing.commute` / `briefing.sleep` | 동일 |
| `briefingNote` | `briefing.note` (HTML) | `Briefing.note` |
| `AriApprovals.savedToday` | `saved_today` (최상위) | `Dashboard.saved_today` |
| `AriSmartInbox.todayRouted` | `today_routed` (최상위) | `Dashboard.today_routed` |
| `schedule[]` | `schedule[]` (id 부여) | `ScheduleItem[]` |
| `AriApprovals.items[risk=="high"]` | `approvals_summary[]` (title/time/icon/tone) | `ApprovalSummary[]` |
| `AriSmartInbox.items[:3]` + classification | `inbox_recent[]` (`type`/`proj_label`/`tone`) | `InboxRecent[]` |
| `tasks[]` (작업 시드) | `task_summary` | `TaskSummary` |
| `goals[]` (`c`→tone) | `goals[]` (`tone`) | `Goal[]` |
| MAIN badge `appr:3 / task:4 / noti:6` | `badges` | `Badges` |

### 4.3 tone → CSS 변수

`tone` 값 집합 `blue|violet|coral|green|amber|ink|faint`. 프론트는 항상 `var(--${tone})`로 변환. 분류 미완료 인박스 행은 `tone="faint"`로 폴백.

---

## 5. 디자인 충실도 노트(원본 토큰/레이아웃/인터랙션 재현)

이식 대상 CSS는 `frontend/styles/dashboard.css`에 모으되, **공통 토큰은 `tokens.css`(phase-1)** 에 이미 있으므로 여기서는 대시보드/요약 카드 전용 규칙만 옮긴다.

### 5.1 벤토 그리드 레이아웃(`dash.css` `.board`)

```css
.board {
  flex: 1; min-width: 0;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 16px;
}
.card.sp2 { grid-column: span 2; }   /* 히어로 */
.card.r2  { grid-row: span 2; }       /* (MVP 미사용 위젯용) */
```

- 데스크톱: 4열. 히어로 `.sp2`가 2열 차지 → 첫 줄에 [히어로(2)] [결재함(1)] [인박스(1)].
- 둘째 줄: [일정] [할 일] [목표] + 빈 1칸(또는 MVP에서 3개 카드를 1칸씩 배치).
- 반응형: `@media (max-width:1200px) → repeat(2, 1fr)`, `@media (max-width:720px) → 1fr`(`.sp2`도 1칸으로). 720px 이하에서는 `.mainnav` 숨김(phase-1 Topbar에서 처리).

### 5.2 카드 글래스(`dash.css` `.card`)

```css
.card {
  background: var(--glass);              /* rgba(255,255,255,.4) */
  -webkit-backdrop-filter: var(--blur);  /* blur(26px) saturate(190%) */
  backdrop-filter: var(--blur);
  border: 1px solid var(--glass-brd);    /* rgba(255,255,255,.7) */
  border-radius: var(--radius);          /* 22px */
  box-shadow: var(--shadow), var(--glass-hi);
  padding: 20px;
  display: flex; flex-direction: column;
  min-width: 0;
}
```

### 5.3 히어로(`dash.css` `.hero`)

- 배경: 좌상단 코럴 글로우 + 우상단 화이트 글로우 + 글래스.
  ```css
  .hero {
    background:
      radial-gradient(95% 105% at 0% 0%, rgba(231,128,82,.22), transparent 56%),
      radial-gradient(130% 130% at 100% 0%, rgba(255,255,255,.42), transparent 50%),
      var(--glass);
    backdrop-filter: var(--blur);
    border: 1px solid var(--glass-brd);
    box-shadow: var(--shadow), var(--glass-hi);
  }
  ```
- `hero-spark`(코럴 pill): `background: var(--coral)`, `color:#fff`, `padding:5px 12px`, `border-radius:999px`, `box-shadow:0 5px 14px -5px var(--coral)`.
- `p.brief`: `font-size: clamp(18px,1.7vw,22px)`, `line-height:1.5`, `letter-spacing:-0.02em`, `max-width:40ch`. `b` 강조는 코럴 underline(`border-bottom:2px solid color-mix(in oklab, var(--coral) 60%, transparent)`).
- `.cmd`(명령 입력): `background:var(--glass-2)`, `border:1px solid var(--glass-brd)`, `border-radius:15px`, `padding:7px 7px 7px 16px`, `margin-top:auto`. send 버튼 `38×38`, `border-radius:11px`, `background:var(--fill)`, `color:var(--on-fill)`.
- `.chips .chip`: `font-size:12.5px`, glass-2 배경, pill.

### 5.4 요약 카드(`approve.css` `.mini-*`)

```css
.mini-list { display:flex; flex-direction:column; margin-bottom:12px; }
.mini-row  { display:flex; align-items:center; gap:9px; padding:9px 0; border-top:1px solid var(--line); }
.mini-row:first-child { border-top:none; padding-top:2px; }
.mini-ic {
  width:26px; height:26px; border-radius:8px; flex-shrink:0;
  display:grid; place-items:center;
  background: color-mix(in oklab, var(--tone, var(--blue)) 13%, transparent);
  color: var(--tone, var(--blue));
}
.mini-ic .ic { width:13px; height:13px; }
.mini-text { flex:1; min-width:0; font-size:12.5px; font-weight:600; letter-spacing:-0.01em;
             white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mini-sub  { font-size:10.5px; color:var(--faint); flex-shrink:0; white-space:nowrap; }
.mini-cta {
  margin-top:auto; display:flex; align-items:center; justify-content:center; gap:7px;
  padding:11px; border-radius:13px;
  background:var(--fill); color:var(--on-fill);
  font-size:13px; font-weight:600; text-decoration:none;
  transition:filter .14s;
}
.mini-cta:hover { filter:brightness(1.12); }
```

- 결재함 카드 전용: `.appr .ico.lime { background:var(--lime); color:var(--lime-ink); }`, `.appr .count.warm { color:#fff; background:var(--coral); }`.
- 빈 상태: `.appr-empty`(녹색 글래스 + `ae-tick`).

### 5.5 자연어 명령 입력 — MVP 동작 정의(명세)

| 상태 | 동작 |
|---|---|
| 입력 후 Enter 또는 send 클릭 | `POST /api/inbox/capture {kind:"text", raw}` 호출(분류 동기 실행) |
| 성공 | 토스트 "인박스에 적어뒀어요 — 아리가 분류할게요" → `/inbox`로 라우팅(`router.push`). 분류 결과/이유는 인박스 페이지에서 확인(phase-4) |
| 실패 | 토스트(coral) "지금은 적어두지 못했어요. 잠시 후 다시 시도해 주세요." → 입력값 유지하지 않고 비움(또는 유지: 구현 선택, 본 문서는 비움) |
| 빈 입력 | send 버튼 비활성(`disabled`), no-op |
| 전송 중 | input/버튼 `disabled`(중복 전송 방지) |

> 제품 철학 반영: 사용자는 "그냥 적기"만 한다. 대시보드 명령 입력은 분류 UI를 띄우지 않고 인박스로 흘려보내 아리가 분류·배치하게 한다("적을 때는 분류하지 않는다").
> 히어로 상단 `.ph-search`("아리에게 무엇이든…")는 MVP에서 **장식/검색 placeholder**로 남기고 동작은 명령 입력(`.cmd`)에 집중한다(중복 입력 UI 혼선 방지).

### 5.6 일정/할 일/목표 픽셀(요약)

| 요소 | 원본 값 |
|---|---|
| `.ev-time` | mono, `13px`, `width:42px`, `color:var(--ink-2)` |
| `.ev-body::before` | 좌측 `3px` accent 바, `var(--accent, var(--blue))` |
| `.ev .soon` | `10.5px`, `#fff`/`var(--coral)`, pill "곧" |
| `.task` | glass-2, `border-radius:14px`, `padding:12px 13px` |
| `.task .box` | `21×21`, `border-radius:7px`, 체크 시 `var(--green)` 채움 |
| `.task.focus` | 코럴 그라데이션 + `inset 3px 0 0 0 var(--coral)`, "지금" 배지(`focus-tag`) |
| `.prio.high` | coral / `.mid` amber / `.low` muted |
| `.goal-bar` | `height:7px`, `var(--card-2)` 트랙, `i`에 width%+색 |
| `.goal-pct` | mono, `13px`, `font-weight:700` |

### 5.7 진입 애니메이션(`dash.css`)

```css
.entered .card { animation: cardin .5s cubic-bezier(.2,.7,.2,1) both; }
@keyframes cardin { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:none; } }
```

`DashboardClient`가 마운트 후 `requestAnimationFrame`으로 `.entered`를 부여(원본 `shellRef` 로직 동일). `prefers-reduced-motion: reduce`에서는 애니메이션 비활성:

```css
@media (prefers-reduced-motion: reduce) {
  .entered .card { animation: none; }
}
```

### 5.8 라이트/다크

`tokens.css`의 `[data-theme="dark"]` 오버라이드(phase-1)를 그대로 활용. 대시보드 전용 추가 다크 규칙은 없음(모든 색이 토큰 참조). next-themes의 `data-theme`이 `<html>`에 적용되면 카드·히어로 글래스·잉크가 자동 전환. 검증은 7장 QA에서 두 테마 모두 스크린샷.

> 날씨 아이콘 충실도(R11/R12): 백엔드는 `weather_icon`을 원본 `data.js` 값인 `'cloudSun'`으로 저장하고, 집계 시 중앙 paths 맵에 존재하는 글리프 `'sun'`으로 매핑해 응답(`briefing.weather.icon`)에 담는다. 프론트는 `<Icon name={briefing.weather.icon} />`로 그대로 그린다(하드코딩 금지).

### 5.9 MVP에서 구현하지 않는 원본 위젯(참고)

원본 `dash-v2.jsx`의 다음 위젯은 **MVP 대시보드 범위 밖**(집계 API에 없음). 본 phase에서는 렌더하지 않으며, 향후 확장 시 동일 글래스 보캐뷸러리(`.card`, `.ch`)로 추가한다: 오늘의 집중(`.foc-*`), 미팅 준비(`.mtg-*`), 위임 현황(`.dg-*`), 이어서 하기/환경 우선순위(`.rs-*`/`.env-nudge`), 이번 주 집중도 차트(`.chart`), 메일 요약(`.mails`), 건강(`.rings`), 지출(`.fin-*`).

---

## 6. 상태 처리(로딩/빈/에러/오프라인) & 엣지 케이스

### 6.1 로딩 — 스켈레톤(`DashboardSkeleton.tsx`)

`isLoading` 동안 동일 그리드 레이아웃의 스켈레톤 카드를 그린다(레이아웃 점프 방지).

```tsx
// frontend/components/dashboard/DashboardSkeleton.tsx
export function DashboardSkeleton() {
  return (
    <div className="dash-page" aria-busy="true" aria-live="polite">
      <div className="pagehead">
        <div>
          <div className="skel skel-eyebrow" />
          <div className="skel skel-title" />
        </div>
      </div>
      <div className="work">
        <div className="board">
          <section className="card hero sp2"><div className="skel skel-block" /></section>
          {Array.from({ length: 5 }).map((_, i) => (
            <section className="card" key={i}>
              <div className="skel skel-head" />
              <div className="skel skel-line" />
              <div className="skel skel-line" />
              <div className="skel skel-line short" />
            </section>
          ))}
        </div>
      </div>
    </div>
  );
}
```

```css
/* dashboard.css */
.skel { background: var(--card-2); border-radius: 8px; position: relative; overflow: hidden; }
.skel::after {
  content:""; position:absolute; inset:0;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
  transform: translateX(-100%); animation: shimmer 1.4s infinite;
}
@keyframes shimmer { to { transform: translateX(100%); } }
.skel-title { height:40px; width:60%; margin-top:8px; }
.skel-eyebrow { height:14px; width:180px; }
.skel-head { height:20px; width:50%; margin-bottom:16px; }
.skel-line { height:14px; margin:8px 0; } .skel-line.short { width:60%; }
.skel-block { height:160px; }
@media (prefers-reduced-motion: reduce) { .skel::after { animation: none; } }
```

### 6.2 빈 상태(위젯별)

| 위젯 | 빈 조건 | 표시 |
|---|---|---|
| 결재함 요약 | `approvals_summary.length === 0` | `.appr-empty` "지금은 확인할 게 없어요 — 다 처리해뒀어요." |
| 인박스 요약 | `inbox_recent.length === 0` | `.appr-empty` "인박스가 비었어요 — 떠오르면 바로 적어두세요." (CTA "새로 적기"는 유지) |
| 오늘 일정 | `schedule.length === 0` | "오늘은 일정이 없어요. 여유로운 하루예요." (sched-note 대신 빈 안내) |
| 할 일 | `task_summary.items.length === 0` | "할 일이 모두 끝났어요 🎉" / `count`는 "0개 남음" |
| 목표 | `goals.length === 0` | "추적 중인 목표가 없어요." |

### 6.3 에러 — 전체 실패 vs 부분 방어

- `GET /api/dashboard`는 단일 호출이므로 네트워크/5xx 실패 시 **전체 에러 화면**(`role="alert"` + "다시 시도" 버튼, `refetch()`).
- 단, 응답 JSON에서 특정 섹션이 누락/null/빈 배열일 수 있으므로 **각 카드 컴포넌트는 옵셔널 체이닝/기본값으로 방어**(부분 표시). 예: `briefing.note ?? ""`, `schedule ?? []`. 한 위젯이 깨져도 나머지는 렌더되도록 각 카드를 ErrorBoundary(`WidgetError`)로 감쌀 수 있다:

```tsx
// frontend/components/dashboard/WidgetError.tsx (ErrorBoundary fallback)
export function WidgetErrorFallback({ name }: { name: string }) {
  return (
    <section className="card" role="alert">
      <div className="ch"><div className="htext"><h3>{name}</h3></div></div>
      <div className="appr-empty">이 카드를 불러오지 못했어요.</div>
    </section>
  );
}
```

> 사용처(선택): `<ErrorBoundary fallback={<WidgetErrorFallback name="오늘 일정" />}><ScheduleCard .../></ErrorBoundary>`. React Query 단일 쿼리 구조에서는 "부분 실패"가 데이터 형태 결손으로만 발생하므로, ErrorBoundary는 렌더 예외(예상치 못한 데이터) 안전망 역할.

### 6.4 오프라인

- `navigator.onLine === false` 또는 fetch 실패 → 전체 에러 화면에 "오프라인 상태예요 — 연결되면 자동으로 불러올게요." 문구. `online` 이벤트 리스너로 자동 `refetch`.
- 자연어 명령 입력은 오프라인 시 전송 비활성 + 토스트 "오프라인이라 지금은 적어둘 수 없어요."

### 6.5 엣지 케이스

| 케이스 | 처리 |
|---|---|
| `briefing.note`에 `<b>` 외 태그 | 시드 출처라 안전. 사용자 입력 경로 없음(명령 입력은 인박스로만 감) |
| `approvals_summary`가 3건 초과 | 백엔드에서 이미 `[:3]` 슬라이스 |
| 인박스 최근 항목 분류 미완료 | `type=""`(프론트 라벨 "분류 중"), `proj_label=""`, `tone="faint"` 폴백 |
| 작업 요약 항목 0개지만 `open_count>0` | 데이터 불일치 — `open_count` 우선 표시, 목록 빈 안내 |
| 매우 긴 제목 | `.mini-text`/`.task-title` ellipsis(원본 CSS) |
| 자연어 명령 연속 전송 | `busy` 플래그로 중복 차단 |

---

## 7. 테스팅 & 검증

### 7.1 실행 명령

```bash
# 백엔드 (repo root에서)
cd backend && uv run pytest tests/test_dashboard.py -v
# 또는: cd backend && pytest tests/test_dashboard.py -v

# 프론트 컴포넌트 (Vitest + RTL)
cd frontend && pnpm test                       # 전체
cd frontend && pnpm test dashboard             # 대시보드 관련만

# E2E (Playwright) — 백엔드 + 프론트 dev 서버 기동 후
cd frontend && pnpm playwright test playwright/dashboard.spec.ts

# 접근성 (axe, Playwright 통합)
cd frontend && pnpm playwright test playwright/dashboard.a11y.spec.ts
```

### 7.2 백엔드 테스트(`backend/tests/test_dashboard.py`)

```python
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_dashboard_shape():
    r = client.get("/api/dashboard")
    assert r.status_code == 200
    d = r.json()
    for k in ("user", "briefing", "saved_today", "today_routed", "schedule",
              "task_summary", "goals", "approvals_summary", "inbox_recent", "badges"):
        assert k in d

def test_user_top_level():
    d = client.get("/api/dashboard").json()
    assert d["user"]["name"] == "지우"
    assert d["user"]["initial"] == "지"

def test_briefing_seed_values():
    d = client.get("/api/dashboard").json()
    b = d["briefing"]
    assert b["today"] == "6월 7일 일요일"
    assert b["weather"]["cond"] == "맑음 · 한낮 28°"
    assert d["saved_today"] == "47분"     # 최상위
    assert d["today_routed"] == 7         # 최상위
    assert "<b>분기 리포트</b>" in b["note"]  # HTML 강조 보존

def test_schedule_four_items_and_soon():
    d = client.get("/api/dashboard").json()
    sch = d["schedule"]
    assert len(sch) == 4
    assert sch[0]["time"] == "09:30"
    soon = [e for e in sch if e["soon"]]
    assert len(soon) == 1 and soon[0]["title"] == "분기 전략 미팅"

def test_approvals_summary_only_high_risk_max3():
    d = client.get("/api/dashboard").json()
    ap = d["approvals_summary"]
    assert len(ap) == 3
    titles = [a["title"] for a in ap]
    assert "현우님께 회신 초안이 준비됐어요" in titles      # a4
    assert "Netflix 일시정지를 추천해요" in titles           # a6
    # low risk(a1 치과 예약)는 요약에 없어야 함
    assert all("치과 예약" not in t for t in titles)

def test_inbox_recent_three_with_type_and_proj():
    d = client.get("/api/dashboard").json()
    inb = d["inbox_recent"]
    assert len(inb) == 3
    assert inb[0]["type"] in ("task", "event", "idea", "")
    assert all("proj_label" in x for x in inb)
    assert all("tone" in x for x in inb)

def test_goals_three_with_tone_key():
    d = client.get("/api/dashboard").json()
    goals = d["goals"]
    assert len(goals) == 3
    assert goals[0]["pct"] == 68
    assert goals[0]["tone"] == "blue"   # var(--blue) 아님(tone 키)

def test_badges_match_counts():
    d = client.get("/api/dashboard").json()
    b = d["badges"]
    assert b["appr"] == 3   # high risk 개수
    assert b["task"] == d["task_summary"]["open_count"]
    assert b["noti"] == 6
```

### 7.3 프론트 컴포넌트 테스트(Vitest + RTL)

`HeroBriefing.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react";
import { HeroBriefing } from "@/components/dashboard/HeroBriefing";

const briefing = {
  today: "6월 7일 일요일",
  weather: { temp: 24, cond: "맑음 · 한낮 28°", icon: "sun" },
  commute: "출근 23분", sleep: "7시간 12분",
  note: "오늘은 오후 미팅이 핵심이에요. <b>분기 리포트</b>",
};

test("브리핑 노트의 HTML 강조를 렌더한다", () => {
  render(<HeroBriefing briefing={briefing as any} />);
  expect(screen.getByText("아리 브리핑")).toBeInTheDocument();
  const b = document.querySelector(".brief b");
  expect(b?.textContent).toBe("분기 리포트");
});

test("추천 칩 3개와 명령 입력창을 렌더한다", () => {
  render(<HeroBriefing briefing={briefing as any} />);
  expect(screen.getByText("내일 오후 비워줘")).toBeInTheDocument();
  expect(screen.getByPlaceholderText("오늘 하루, 무엇이든 맡겨보세요…")).toBeInTheDocument();
});
```

`ApprovalSummaryCard.test.tsx`:
```tsx
test("대기 N건 배지와 미니 행, 결재함 CTA를 렌더한다", () => {
  const items = [
    { id: "a4", icon: "mail", tone: "violet", title: "현우님께 회신 초안이 준비됐어요", time: "보내기 대기" },
  ];
  render(<ApprovalSummaryCard items={items as any} savedToday="47분" pending={3} />);
  expect(screen.getByText("아리 결재함")).toBeInTheDocument();
  expect(screen.getByText("오늘 47분 아껴드렸어요")).toBeInTheDocument();
  expect(screen.getByText("대기 3건")).toBeInTheDocument();
  expect(screen.getByText("현우님께 회신 초안이 준비됐어요")).toBeInTheDocument();
  const cta = screen.getByRole("link", { name: /결재함에서 승인하기/ });
  expect(cta).toHaveAttribute("href", expect.stringContaining("결재함"));
});

test("대기 0건이면 빈 상태를 보여준다", () => {
  render(<ApprovalSummaryCard items={[]} savedToday="0분" pending={0} />);
  expect(screen.getByText(/다 처리해뒀어요/)).toBeInTheDocument();
});
```

`GoalsCard.test.tsx`:
```tsx
test("목표 진행 막대 width와 progressbar aria를 렌더한다", () => {
  const goals = [{ id:"g1", title:"분기 OKR", pct:68, sub:"12개 중 8개", tone:"blue" }];
  render(<GoalsCard goals={goals as any} />);
  const bar = screen.getByRole("progressbar", { name: "분기 OKR" });
  expect(bar).toHaveAttribute("aria-valuenow", "68");
  const fill = bar.querySelector("i");
  expect(fill).toHaveStyle({ width: "68%" });
});
```

`InboxSummaryCard.test.tsx`:
```tsx
test("kind별 아이콘과 새로 적기 CTA(/inbox)를 렌더한다", () => {
  const items = [{ id:"s3", kind:"voice", raw:"엄마 생신 선물", type:"task", proj_label:"개인", tone:"green" }];
  render(<InboxSummaryCard items={items as any} todayRouted={7} />);
  expect(screen.getByText("오늘 7건")).toBeInTheDocument();
  expect(screen.getByText("엄마 생신 선물")).toBeInTheDocument();
  expect(screen.getByRole("link", { name: /새로 적기/ })).toHaveAttribute("href", "/inbox");
});
```

`CommandInput.test.tsx`(captureInbox·router 모킹):
```tsx
import { vi } from "vitest";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
const capture = vi.fn().mockResolvedValue({ item: {}, classification: {} });
vi.mock("@/lib/api", () => ({ captureInbox: (r: string) => capture(r) }));

test("전송 시 캡처 호출 후 /inbox로 이동", async () => {
  const { default: userEvent } = await import("@testing-library/user-event");
  render(<CommandInput />);
  const input = screen.getByLabelText("아리에게 명령 입력");
  await userEvent.type(input, "다음 주 한국 비행기 티켓 사기");
  await userEvent.click(screen.getByLabelText("보내기"));
  expect(capture).toHaveBeenCalledWith("다음 주 한국 비행기 티켓 사기");
  expect(push).toHaveBeenCalledWith("/inbox");
});

test("빈 입력이면 send 비활성", () => {
  render(<CommandInput />);
  expect(screen.getByLabelText("보내기")).toBeDisabled();
});
```

### 7.4 E2E 테스트(`playwright/dashboard.spec.ts`)

```ts
import { test, expect } from "@playwright/test";

test.describe("대시보드", () => {
  test("로드 → 집계가 시드 기준으로 표시된다", async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page.getByText("아리 브리핑")).toBeVisible();
    await expect(page.locator(".brief b")).toHaveText("분기 리포트");
    // 결재함 요약: high risk 3건
    await expect(page.getByText("아리 결재함")).toBeVisible();
    await expect(page.getByText("대기 3건")).toBeVisible();
    await expect(page.getByText("현우님께 회신 초안이 준비됐어요")).toBeVisible();
    // 일정 4건 + "곧"
    await expect(page.locator(".ev")).toHaveCount(4);
    await expect(page.getByText("곧")).toBeVisible();
    // 목표 3건
    await expect(page.getByRole("progressbar")).toHaveCount(3);
  });

  test("인박스 카드 '새로 적기' → /inbox", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByRole("link", { name: /새로 적기/ }).click();
    await expect(page).toHaveURL(/\/inbox$/);
  });

  test("결재함 카드 CTA → placeholder(준비 중)", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByRole("link", { name: /결재함에서 승인하기/ }).click();
    await expect(page.getByText(/준비 중/)).toBeVisible();
  });

  test("자연어 명령 입력 → 캡처 후 /inbox 이동", async ({ page }) => {
    await page.goto("/dashboard");
    const input = page.getByLabel("아리에게 명령 입력");
    await input.fill("수요일 11시 자전거 수리 맡기기");
    await page.getByLabel("보내기").click();
    await expect(page).toHaveURL(/\/inbox$/);
    // 인박스에 방금 캡처 항목이 보인다
    await expect(page.getByText("수요일 11시 자전거 수리 맡기기")).toBeVisible();
  });

  test("작업 요약 항목 클릭 → /tasks 딥링크", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByText("분기 리포트 초안 마무리").click();
    await expect(page).toHaveURL(/\/tasks\?task=/);
  });

  test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => {
    await page.goto("/dashboard");
    await page.getByRole("button", { name: "테마" }).click();
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
    await expect(page.getByText("아리 브리핑")).toBeVisible();
  });
});
```

### 7.5 접근성 테스트(`playwright/dashboard.a11y.spec.ts`)

```ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("대시보드 a11y — 위반 없음(라이트)", async ({ page }) => {
  await page.goto("/dashboard");
  await page.getByText("아리 브리핑").waitFor();
  const res = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa"]).analyze();
  expect(res.violations).toEqual([]);
});

test("대시보드 a11y — 다크 테마", async ({ page }) => {
  await page.goto("/dashboard");
  await page.getByRole("button", { name: "테마" }).click();
  const res = await new AxeBuilder({ page }).withTags(["wcag2a","wcag2aa"]).analyze();
  expect(res.violations).toEqual([]);
});
```

접근성 체크 포인트:
- 모든 아이콘 전용 버튼에 `aria-label`(검색/메일/테마/보내기/더보기).
- `goal-bar`에 `role="progressbar"` + `aria-valuenow/min/max`.
- 로딩 영역 `aria-busy="true"` + `aria-live="polite"`, 에러 `role="alert"`.
- 색 대비: 잉크(`#211f1c`)/배경(글래스) 4.5:1 이상, 다크 테마(`#f3eee6`/`#2c2925`)도 확인.
- 키보드: 명령 입력 Enter 전송, 카드 CTA/링크 Tab 포커스 가능, `:focus-visible` 아웃라인(blue) 보임.

### 7.6 수동 QA 체크리스트

- [ ] `/dashboard` 진입 시 카드 진입 애니메이션(`cardin`)이 한 번 재생된다.
- [ ] 히어로 코럴 pill "아리 브리핑", 브리핑 본문에 "분기 리포트"가 코럴 underline `<b>`로 강조된다.
- [ ] 추천 칩 3개("내일 오후 비워줘", "중요 메일만 요약", "14시 미팅 준비")가 표시된다.
- [ ] 명령 입력 placeholder "오늘 하루, 무엇이든 맡겨보세요…"가 보이고, 텍스트 입력 후 send 버튼이 활성화된다.
- [ ] 명령 전송 시 토스트가 뜨고 `/inbox`로 이동, 인박스에 방금 적은 항목이 보인다.
- [ ] 결재함 카드: 라임 spark 아이콘, "오늘 47분 아껴드렸어요", "대기 3건"(코럴 배지), 미니 행 3개(현우 회신/민서 위임/Netflix), CTA 클릭 시 "준비 중".
- [ ] 인박스 카드: inbox 아이콘, "오늘 7건", 미니 행 3개(kind별 pen/mic/image 아이콘 + tone 색), "새로 적기 · 인박스 열기" → `/inbox`.
- [ ] 일정 카드: 4건, 14:00 "분기 전략 미팅"에 "곧" 배지, accent 좌측 바 색(blue/violet/coral/green), 하단 sched-note 문구.
- [ ] 할 일 카드: "N개 남음" 배지, 첫 미완료 항목에 "지금" focus 강조(코럴), 나머지에 우선순위 배지(높음=coral/보통=amber/낮음=muted), 항목 클릭 시 `/tasks?task=...`.
- [ ] 목표 카드: 3건, 진행 막대 width(68/75/40%)와 색(blue/coral/violet), mono 퍼센트.
- [ ] 다크 토글: 모든 카드/히어로/배지가 다크 토큰으로 전환, 텍스트 가독성 유지.
- [ ] 반응형: 1200px↓ 2열, 720px↓ 1열 + Topbar 메인 nav 숨김 + 히어로 1칸.
- [ ] 로딩 중 스켈레톤이 동일 레이아웃으로 보이고 점프 없음.
- [ ] 백엔드 중단 상태에서 새로고침 → 전체 에러 화면 + "다시 시도" 동작.
- [ ] `prefers-reduced-motion` 켠 상태에서 진입/쉬머 애니메이션 비활성.

### 7.7 통과 기준

- 백엔드 `test_dashboard.py` 전 케이스 통과(시드 값 정확성 포함).
- 컴포넌트 테스트(Hero/Approval/Inbox/Goals/Command) 전 통과.
- E2E 6개 시나리오 전 통과(로드·인박스 링크·결재함 placeholder·명령 입력·작업 딥링크·다크).
- axe 위반 0건(라이트/다크).
- 수동 QA 체크리스트 100% 충족.

---

## 8. 완료 기준(Definition of Done)

- [ ] `GET /api/dashboard`가 `DashboardOut` 형태로 시드 기준 집계를 반환(사용자/브리핑/saved_today·today_routed 최상위/일정/작업요약/목표/결재요약/인박스최근/배지).
- [ ] 인사 "좋은 아침이에요, {name}님"이 응답 최상위 `user.name`을 사용(하드코딩 제거, R12).
- [ ] `briefing.note`의 HTML(`<b>분기 리포트</b>`)이 보존되어 프론트에서 강조 렌더.
- [ ] 결재함 요약이 `risk==="high"` 3건만(현우/민서/Netflix), "대기 3건" 배지가 Topbar 결재함 배지(3)와 일치.
- [ ] 인박스 요약이 최근 3건 + 분류 `type`(프론트 한글 라벨)/`proj_label`/`tone`을 표시, kind별 아이콘 정확.
- [ ] 일정 4건 + "곧" 배지(분기 전략 미팅), 할 일 요약(open_count + 포커스/우선순위), 목표 3건(진행 막대) 정상.
- [ ] 인박스 카드 "새로 적기" → `/inbox`, 작업 항목 → `/tasks?task=`, 결재함 CTA → placeholder("준비 중").
- [ ] 자연어 명령 입력: 전송 → `POST /api/inbox/capture` → 토스트 → `/inbox` 이동(MVP 동작).
- [ ] 라이트/다크 테마 모두 정상 렌더(토큰 기반), 진입 애니메이션 동작, reduced-motion 대응.
- [ ] 로딩 스켈레톤/빈 상태(위젯별)/전체 에러/오프라인 처리 구현.
- [ ] 디자인 충실도: `.board` 4열 벤토, `.card` 글래스(22px radius, blur(26px) saturate(190%)), 히어로 코럴/화이트 글로우, `.mini-*`/`.appr .ico.lime`/`.count.warm` 픽셀·색 일치.
- [ ] 7장의 모든 테스트/QA 통과 기준 충족.

---

## 9. 다음 단계

대시보드까지 3개 MVP 페이지(작업·인박스·대시보드)가 완성되었다. 다음은 **`phase-6-integration.md`** — 연합(federation) 흐름 통합 검증과 마무리:

- 대시보드 명령 입력 → 인박스 캡처/분류 → 확인(실체화) → 작업 생성 → **대시보드 작업 요약/배지에 반영**되는 end-to-end 흐름 E2E.
- 인박스 `confirm`으로 만든 작업이 `task_summary`/`badges.task`에 합산되는지, 결재함 처리 후 `badges.appr` 감소(추후 확장) 등 집계 정합성.
- 전체 접근성(axe) 스윕, 성능(LCP/번들), 실행법(README), MVP 수용 기준 최종 점검.

참조: `overview.md`(전체 아키텍처), `phase-2-backend.md`(집계/시드 원천), `phase-4-inbox.md`(명령 입력이 호출하는 캡처/분류), `phase-3-tasks.md`(작업 요약/딥링크 대상).
