diff --git a/frontend/app/inbox/page.tsx b/frontend/app/inbox/page.tsx
index c8f5412..06a4f67 100644
--- a/frontend/app/inbox/page.tsx
+++ b/frontend/app/inbox/page.tsx
@@ -1,4 +1,18 @@
-// frontend/app/inbox/page.tsx (Phase 1 스텁 — Phase 4에서 교체)
-export default function Page() {
- return
인박스 ;
+// frontend/app/inbox/page.tsx
+import "@/styles/inbox.css";
+import InboxView from "@/components/inbox/InboxView";
+import { getInbox } from "@/lib/inbox/api";
+import type { InboxItem } from "@/lib/types";
+
+export const dynamic = "force-dynamic"; // 시드 데이터 항상 최신
+
+export default async function InboxPage() {
+ let items: InboxItem[] = [];
+ let loadError = false;
+ try {
+ items = await getInbox();
+ } catch {
+ loadError = true;
+ }
+ return ;
}
diff --git a/frontend/components/inbox/CaptureComposer.tsx b/frontend/components/inbox/CaptureComposer.tsx
new file mode 100644
index 0000000..06122e1
--- /dev/null
+++ b/frontend/components/inbox/CaptureComposer.tsx
@@ -0,0 +1,55 @@
+// frontend/components/inbox/CaptureComposer.tsx
+"use client";
+import { useState } from "react";
+import { Icon } from "@/components/Icon";
+
+// MVP 스텁: 음성/이미지는 더미 transcript/caption 주입
+const VOICE_STUB = "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기";
+const IMAGE_STUB = "이미지 캡처 — 영수증/스크린샷 (자동 인식 결과)";
+
+export default function CaptureComposer({
+ onSubmit,
+}: {
+ onSubmit: (raw: string, kind: "text" | "voice" | "image") => void;
+}) {
+ const [input, setInput] = useState("");
+
+ const submitText = () => {
+ const t = input.trim();
+ if (!t) return;
+ onSubmit(t, "text");
+ setInput("");
+ };
+
+ return (
+
+
+ setInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && submitText()}
+ placeholder="갑자기 생각난 것 아무거나… 예) 다음 주 한국 가는 비행기 티켓 사기"
+ aria-label="인박스에 빠르게 캡처"
+ />
+ onSubmit(VOICE_STUB, "voice")}
+ >
+
+
+ onSubmit(IMAGE_STUB, "image")}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/inbox/CaptureRow.tsx b/frontend/components/inbox/CaptureRow.tsx
new file mode 100644
index 0000000..d7c820e
--- /dev/null
+++ b/frontend/components/inbox/CaptureRow.tsx
@@ -0,0 +1,73 @@
+// frontend/components/inbox/CaptureRow.tsx
+"use client";
+import { Icon } from "@/components/Icon";
+import type { IconName } from "@/components/icons/paths";
+import type { UiInboxItem } from "@/lib/types";
+import ReasonLine from "./ReasonLine";
+import RouteChips from "./RouteChips";
+
+export default function CaptureRow({
+ item: c,
+ onConfirm,
+ onReType,
+}: {
+ item: UiInboxItem;
+ onConfirm: (id: string) => void;
+ onReType: (id: string) => void;
+}) {
+ const kindIcon: IconName = c.kind === "voice" ? "mic" : c.kind === "image" ? "image" : "pen";
+ const canAct = c.status === "classified"; // 원본의 new(분류 완료, 액션 가능)
+
+ return (
+
+
+
+
+
+
{c.raw}
+
+ {c.status === "thinking" ? (
+
+
+
+
+ 아리가 분류하고 있어요
+
+ ) : c.status === "error" ? (
+
+ 분류에 실패했어요.{" "}
+ onReType(c.id)}>
+ 다시 시도
+
+
+ ) : c.classification ? (
+ <>
+
onReType(c.id)} />
+
+ {canAct && (
+
+ onConfirm(c.id)}>
+
+ 좋아요, 그렇게 해줘
+
+ onReType(c.id)}>
+
+ 다르게 분류
+
+
+ )}
+ >
+ ) : null}
+
+
+ {c.status === "confirmed" ? (
+
+
+
+ ) : (
+ c.time
+ )}
+
+
+ );
+}
diff --git a/frontend/components/inbox/ClassifyPrinciples.tsx b/frontend/components/inbox/ClassifyPrinciples.tsx
new file mode 100644
index 0000000..e79006c
--- /dev/null
+++ b/frontend/components/inbox/ClassifyPrinciples.tsx
@@ -0,0 +1,60 @@
+// frontend/components/inbox/ClassifyPrinciples.tsx
+import type { CSSProperties } from "react";
+import { Icon } from "@/components/Icon";
+import type { IconName } from "@/components/icons/paths";
+
+const RULES: { icon: IconName; tone: string; title: string; desc: string }[] = [
+ {
+ icon: "check",
+ tone: "green",
+ title: "행동이 있으면 → 작업",
+ desc: "‘비행기 티켓 사기’도 작업이에요. 작업 트리의 ‘개인 › 여행’ 프로젝트로 들어가 다른 작업과 똑같이 보여요.",
+ },
+ {
+ icon: "cal",
+ tone: "blue",
+ title: "시간이 정해지면 → 일정",
+ desc: "‘수요일 11시 자전거 수리’는 작업이 아니라 캘린더에 바로 등록돼요.",
+ },
+ {
+ icon: "brain",
+ tone: "violet",
+ title: "막연하면 → 아이디어",
+ desc: "행동이 정해지지 않은 생각은 보드에 보관하고, 관련 프로젝트에 연결해둬요.",
+ },
+ {
+ icon: "clock",
+ tone: "coral",
+ title: "개인은 섹션이 아니라 프로젝트",
+ desc: "따로 숨기거나 분리하지 않아요. 같은 작업 트리에서 필터로만 구분하고, 배치만 저녁·주말 빈 시간으로 추천해요.",
+ },
+];
+
+export default function ClassifyPrinciples() {
+ return (
+
+
+
+
+
+
+
아리의 분류 원칙
+
적을 때 고민하지 마세요
+
+
+
+ {RULES.map((r, i) => (
+
+
+
+
+
+ {r.title}
+ {r.desc}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/components/inbox/InboxView.tsx b/frontend/components/inbox/InboxView.tsx
new file mode 100644
index 0000000..5a79081
--- /dev/null
+++ b/frontend/components/inbox/InboxView.tsx
@@ -0,0 +1,184 @@
+// frontend/components/inbox/InboxView.tsx — 인박스 클라이언트 루트
+"use client";
+import { useRef, useState } from "react";
+import { Icon } from "@/components/Icon";
+import { captureInbox, confirmInbox, reclassifyInbox } from "@/lib/inbox/api";
+import { nextType } from "@/lib/inbox/presentation";
+import type { InboxItem, RouteType, Sphere, UiInboxItem } from "@/lib/types";
+import ClassifyPrinciples from "./ClassifyPrinciples";
+import SmartInbox from "./SmartInbox";
+import TodayRouted from "./TodayRouted";
+
+function relTime(iso: string): string {
+ if (!iso) return "방금";
+ const diff = Date.now() - new Date(iso).getTime();
+ if (Number.isNaN(diff)) return "방금";
+ const m = Math.floor(diff / 60000);
+ if (m < 1) return "방금";
+ if (m < 60) return `${m}분 전`;
+ const h = Math.floor(m / 60);
+ if (h < 24) return `${h}시간 전`;
+ return `${Math.floor(h / 24)}일 전`;
+}
+
+function toUi(it: InboxItem): UiInboxItem {
+ return { ...it, status: it.status, time: relTime(it.created_at) };
+}
+
+export default function InboxView({
+ initialItems,
+ initialLoadError,
+}: {
+ initialItems: InboxItem[];
+ initialLoadError: boolean;
+}) {
+ const [items, setItems] = useState(() => initialItems.map(toUi));
+ const [filter, setFilter] = useState<"all" | Sphere>("all");
+ const tmpSeq = useRef(0);
+
+ const clearFresh = (xs: UiInboxItem[]) => xs.map((x) => (x.fresh ? { ...x, fresh: false } : x));
+
+ /* ---- 캡처 제출 ---- */
+ const submit = async (raw: string, kind: "text" | "voice" | "image") => {
+ const text = raw.trim();
+ if (!text) return;
+ const tmpId = "tmp-" + ++tmpSeq.current;
+ setItems((xs) => [
+ {
+ id: tmpId,
+ kind,
+ raw: text,
+ status: "thinking",
+ time: "방금",
+ created_at: new Date(0).toISOString(),
+ materialized_task_id: null,
+ classification: null,
+ fresh: true,
+ } as UiInboxItem,
+ ...clearFresh(xs),
+ ]);
+ try {
+ const { item, classification } = await captureInbox({ kind, raw: text });
+ setItems((xs) =>
+ xs.map((x) =>
+ x.id === tmpId
+ ? {
+ ...item,
+ classification,
+ status: "classified",
+ time: "방금",
+ fresh: true,
+ fallbackUsed: classification.model === "heuristic",
+ }
+ : x,
+ ),
+ );
+ } catch {
+ setItems((xs) => xs.map((x) => (x.id === tmpId ? { ...x, status: "error" } : x)));
+ }
+ };
+
+ /* ---- 다르게 분류 (타입 순환) ---- */
+ const reType = async (id: string) => {
+ const cur = items.find((x) => x.id === id);
+ if (!cur?.classification) return;
+ const original = cur.classification.type;
+ const wanted = nextType(original);
+ // 낙관적 미리보기
+ setItems((xs) =>
+ xs.map((x) =>
+ x.id === id && x.classification
+ ? { ...x, classification: { ...x.classification, type: wanted } }
+ : x,
+ ),
+ );
+ try {
+ const { item, classification } = await reclassifyInbox(id, wanted as RouteType);
+ setItems((xs) =>
+ xs.map((x) =>
+ x.id === id
+ ? {
+ ...item,
+ classification,
+ status: "classified",
+ time: x.time,
+ fresh: x.fresh,
+ fallbackUsed: classification.model === "heuristic",
+ }
+ : x,
+ ),
+ );
+ } catch {
+ // 롤백: 원래 타입 복구
+ setItems((xs) =>
+ xs.map((x) =>
+ x.id === id && x.classification
+ ? { ...x, classification: { ...x.classification, type: original } }
+ : x,
+ ),
+ );
+ }
+ };
+
+ /* ---- 좋아요(confirm) → 실체화 ---- */
+ const confirm = async (id: string) => {
+ setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "confirmed" } : x)));
+ try {
+ const res = await confirmInbox(id);
+ setItems((xs) =>
+ xs.map((x) =>
+ x.id === id
+ ? { ...res.item, classification: x.classification, status: "confirmed", time: x.time }
+ : x,
+ ),
+ );
+ } catch {
+ setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "classified" } : x)));
+ }
+ };
+
+ const todayRouted = items.filter(
+ (x) => x.classification && x.status !== "thinking" && x.status !== "error",
+ ).length;
+ const shown = items.filter(
+ (x) => filter === "all" || (x.classification && x.classification.sphere === filter),
+ );
+
+ return (
+ <>
+
+
+
+ 오늘 {todayRouted}건 정리
+
+ 분류를 고민한 시간 0초
+
+
+ 스마트 인박스 일단 적으세요
+
+
+
+
+
+
+ {initialLoadError && (
+
+ 인박스를 불러오지 못했어요. 새로고침하거나 백엔드 연결을 확인하세요.
+
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/components/inbox/ReasonLine.tsx b/frontend/components/inbox/ReasonLine.tsx
new file mode 100644
index 0000000..46073cf
--- /dev/null
+++ b/frontend/components/inbox/ReasonLine.tsx
@@ -0,0 +1,22 @@
+// frontend/components/inbox/ReasonLine.tsx
+import { Icon } from "@/components/Icon";
+
+export default function ReasonLine({
+ reason,
+ fallbackUsed,
+}: {
+ reason: string;
+ fallbackUsed?: boolean;
+}) {
+ return (
+
+ {fallbackUsed && (
+
+
+ 규칙 기반(오프라인)
+
+ )}
+ {reason}
+
+ );
+}
diff --git a/frontend/components/inbox/RouteChips.tsx b/frontend/components/inbox/RouteChips.tsx
new file mode 100644
index 0000000..c7d2cd0
--- /dev/null
+++ b/frontend/components/inbox/RouteChips.tsx
@@ -0,0 +1,65 @@
+// frontend/components/inbox/RouteChips.tsx
+"use client";
+import { Icon } from "@/components/Icon";
+import { SPHERE, TYPE_ICON, TYPE_LABEL } from "@/lib/inbox/presentation";
+import type { Classification } from "@/lib/types";
+
+export default function RouteChips({
+ r,
+ editable,
+ onReType,
+}: {
+ r: Classification;
+ editable: boolean;
+ onReType: () => void;
+}) {
+ return (
+
+
+
+
+
+ {/* 타입 칩: 클릭 시 다르게 분류(편집 가능할 때만) */}
+ editable && onReType()}
+ disabled={!editable}
+ aria-label={`타입: ${TYPE_LABEL[r.type]}${editable ? " (눌러서 변경)" : ""}`}
+ >
+
+ {TYPE_LABEL[r.type]}
+
+
+ {/* sphere 칩 (고정색) */}
+
+
+ {SPHERE[r.sphere].label}
+
+
+ {/* proj 칩 (동적 tone) */}
+
+
+ {r.proj_label}
+
+
+ {r.due_text && (
+
+
+ {r.due_text}
+
+ )}
+ {r.when_text && (
+
+
+ {r.when_text}
+
+ )}
+ {r.extra && (
+
+
+ {r.extra}
+
+ )}
+
+ );
+}
diff --git a/frontend/components/inbox/SmartInbox.tsx b/frontend/components/inbox/SmartInbox.tsx
new file mode 100644
index 0000000..2ef70e2
--- /dev/null
+++ b/frontend/components/inbox/SmartInbox.tsx
@@ -0,0 +1,55 @@
+// frontend/components/inbox/SmartInbox.tsx
+"use client";
+import { Icon } from "@/components/Icon";
+import type { Sphere, UiInboxItem } from "@/lib/types";
+import CaptureComposer from "./CaptureComposer";
+import CaptureRow from "./CaptureRow";
+import SphereFilters from "./SphereFilters";
+
+export default function SmartInbox({
+ items,
+ todayRouted,
+ filter,
+ onFilter,
+ onSubmit,
+ onConfirm,
+ onReType,
+}: {
+ items: UiInboxItem[];
+ todayRouted: number;
+ filter: "all" | Sphere;
+ onFilter: (f: "all" | Sphere) => void;
+ onSubmit: (raw: string, kind: "text" | "voice" | "image") => void;
+ onConfirm: (id: string) => void;
+ onReType: (id: string) => void;
+}) {
+ return (
+
+
+
+
+
+
+
스마트 인박스
+
업무든 일상이든 일단 적기 — 분류는 아리가 해요
+
+
오늘 {todayRouted}건 정리
+
+
+
+
+
+
+ {items.length === 0 ? (
+
+ 아직 비어 있어요. 위에 아무거나 적어보세요 — 분류는 아리가 할게요.
+
+ ) : (
+ items.map((c) => (
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/frontend/components/inbox/SphereFilters.tsx b/frontend/components/inbox/SphereFilters.tsx
new file mode 100644
index 0000000..3ac531a
--- /dev/null
+++ b/frontend/components/inbox/SphereFilters.tsx
@@ -0,0 +1,37 @@
+// frontend/components/inbox/SphereFilters.tsx
+"use client";
+import { SPHERE } from "@/lib/inbox/presentation";
+import type { Sphere } from "@/lib/types";
+
+const TABS: ["all" | Sphere, string][] = [
+ ["all", "전체"],
+ ["work", "업무"],
+ ["life", "개인"],
+];
+
+export default function SphereFilters({
+ filter,
+ onFilter,
+}: {
+ filter: "all" | Sphere;
+ onFilter: (f: "all" | Sphere) => void;
+}) {
+ return (
+
+ {TABS.map(([k, l]) => (
+ onFilter(k)}
+ aria-pressed={filter === k}
+ >
+ {k !== "all" && (
+
+ )}
+ {l}
+
+ ))}
+ 업무/개인은 필터일 뿐 — 작업은 한 곳에서 다 보여요
+
+ );
+}
diff --git a/frontend/components/inbox/TodayRouted.tsx b/frontend/components/inbox/TodayRouted.tsx
new file mode 100644
index 0000000..f83174e
--- /dev/null
+++ b/frontend/components/inbox/TodayRouted.tsx
@@ -0,0 +1,83 @@
+// frontend/components/inbox/TodayRouted.tsx
+"use client";
+import Link from "next/link";
+import { Icon } from "@/components/Icon";
+import { TYPE_LABEL } from "@/lib/inbox/presentation";
+import type { RouteType, UiInboxItem } from "@/lib/types";
+
+const DEST_META: Record = {
+ task: { tone: "green", href: "/tasks" },
+ event: { tone: "blue", href: "/dashboard" },
+ idea: { tone: "violet", href: null },
+};
+const ORDER: RouteType[] = ["task", "event", "idea"];
+
+export default function TodayRouted({
+ items,
+ todayRouted,
+}: {
+ items: UiInboxItem[];
+ todayRouted: number;
+}) {
+ const routed = items.filter(
+ (x) => x.classification && (x.status === "confirmed" || x.status === "classified"),
+ );
+ const byType = (t: RouteType) => routed.filter((x) => x.classification!.type === t);
+
+ const subFor = (t: RouteType) => {
+ const g = byType(t);
+ const life = g.filter((x) => x.classification!.sphere === "life").length;
+ const work = g.filter((x) => x.classification!.sphere === "work").length;
+ if (t === "event") return "캘린더 등록";
+ if (t === "idea") return "보드 보관";
+ return `개인 ${life} · 업무 ${work}`;
+ };
+
+ return (
+
+
+
+
+
+
+
오늘 어디로 갔나
+
{todayRouted}건의 행선지
+
+
+
+
+ {ORDER.map((t) => {
+ const meta = DEST_META[t];
+ const n = byType(t).length;
+ const inner = (
+ <>
+
+
{TYPE_LABEL[t]}
+
{subFor(t)}
+
{n}
+ {meta.href && (
+
+
+
+ )}
+ >
+ );
+ return meta.href ? (
+
+ {inner}
+
+ ) : (
+
+ {inner}
+
+ );
+ })}
+
+
+
+
+ 분류가 마음에 안 들면 칩을 눌러 바꾸세요 — 아리가 다음부터 기억해요.
+
+
+ );
+}
diff --git a/frontend/lib/inbox/api.ts b/frontend/lib/inbox/api.ts
new file mode 100644
index 0000000..bca88fa
--- /dev/null
+++ b/frontend/lib/inbox/api.ts
@@ -0,0 +1,53 @@
+// frontend/lib/inbox/api.ts
+import type { CaptureResult, ConfirmResult, InboxItem, RouteType } from "@/lib/types";
+
+const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ public body: string,
+ ) {
+ super(`API ${status}`);
+ }
+}
+
+async function j(res: Response): Promise {
+ if (!res.ok) throw new ApiError(res.status, await res.text());
+ return res.json() as Promise;
+}
+
+export async function getInbox(): Promise {
+ return j(await fetch(`${BASE}/api/inbox`, { cache: "no-store" }));
+}
+
+export async function captureInbox(input: {
+ kind: "text" | "voice" | "image";
+ raw: string;
+}): Promise {
+ return j(
+ await fetch(`${BASE}/api/inbox/capture`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input),
+ }),
+ );
+}
+
+export async function reclassifyInbox(id: string, type?: RouteType): Promise {
+ return j(
+ await fetch(`${BASE}/api/inbox/${id}/reclassify`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(type ? { type } : {}),
+ }),
+ );
+}
+
+export async function confirmInbox(id: string): Promise {
+ return j(await fetch(`${BASE}/api/inbox/${id}/confirm`, { method: "POST" }));
+}
+
+export async function dismissInbox(id: string): Promise {
+ return j(await fetch(`${BASE}/api/inbox/${id}/dismiss`, { method: "POST" }));
+}
diff --git a/frontend/lib/inbox/presentation.ts b/frontend/lib/inbox/presentation.ts
new file mode 100644
index 0000000..af2f6a4
--- /dev/null
+++ b/frontend/lib/inbox/presentation.ts
@@ -0,0 +1,27 @@
+// frontend/lib/inbox/presentation.ts — 분류 결과 → 화면 표현 매핑
+import type { IconName } from "@/components/icons/paths";
+import type { RouteType, Sphere } from "@/lib/types";
+
+/** 원본 sinbox.jsx SPHERE */
+export const SPHERE: Record = {
+ work: { label: "업무", tone: "var(--blue)" },
+ life: { label: "개인", tone: "var(--green)" },
+};
+
+/** 원본 sinbox.jsx TYPE_ICON */
+export const TYPE_ICON: Record = {
+ task: "check",
+ event: "cal",
+ idea: "brain",
+};
+
+export const TYPE_LABEL: Record = {
+ task: "작업",
+ event: "일정",
+ idea: "아이디어",
+};
+
+/** 원본 cycleType — 다르게 분류 시 다음 타입 */
+export function nextType(t: RouteType): RouteType {
+ return ({ task: "event", event: "idea", idea: "task" } as const)[t];
+}
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 758f459..bbd7b7f 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -80,3 +80,57 @@ export interface ScaffoldResult {
created: boolean;
created_task_ids: string[];
}
+
+// ---------- 인박스 (Phase 4) ----------
+export type InboxKind = "text" | "voice" | "image";
+export type InboxStatus = "new" | "classified" | "confirmed" | "dismissed";
+export type RouteType = "task" | "event" | "idea";
+export type Sphere = "work" | "life";
+
+/** 백엔드 inbox_classification 1:1 */
+export interface Classification {
+ id: string;
+ inbox_item_id: string;
+ type: RouteType;
+ sphere: Sphere;
+ project_id: string | null;
+ proj_label: string;
+ tone: Tone;
+ due_text: string | null;
+ when_text: string | null;
+ extra: string | null;
+ reason: string;
+ confidence: number;
+ model: string;
+ created_at: string;
+}
+
+/** 백엔드 inbox_item (+ 최신 classification) */
+export interface InboxItem {
+ id: string;
+ kind: InboxKind;
+ raw: string;
+ status: InboxStatus;
+ created_at: string;
+ materialized_task_id: string | null;
+ classification: Classification | null;
+}
+
+export interface CaptureResult {
+ item: InboxItem;
+ classification: Classification;
+}
+
+export interface ConfirmResult {
+ item: InboxItem;
+ task?: Task;
+}
+
+/** UI 전용: thinking/error 추가 */
+export type UiInboxStatus = InboxStatus | "thinking" | "error";
+export interface UiInboxItem extends Omit {
+ status: UiInboxStatus;
+ time: string;
+ fresh?: boolean;
+ fallbackUsed?: boolean;
+}
diff --git a/frontend/playwright/inbox.spec.ts b/frontend/playwright/inbox.spec.ts
new file mode 100644
index 0000000..24f0e4a
--- /dev/null
+++ b/frontend/playwright/inbox.spec.ts
@@ -0,0 +1,82 @@
+// frontend/playwright/inbox.spec.ts
+// 백엔드를 LLM_PROVIDER=heuristic 로 기동한 상태를 가정(결정성·속도·폴백 배지).
+// 캡처는 인박스 시드를 변경하므로 단일 워커 직렬.
+import AxeBuilder from "@axe-core/playwright";
+import { expect, test } from "@playwright/test";
+
+test.describe.configure({ mode: "serial" });
+
+test("캡처 → 분류 → 좋아요 → 작업 페이지에 등장(연합)", async ({ page }) => {
+ await page.goto("/inbox");
+ const input = page.getByLabel("인박스에 빠르게 캡처");
+ await input.fill("다음 주에 한국 놀러가는 비행기 티켓 사기");
+ await input.press("Enter");
+ const fresh = page.locator(".sb-cap.fresh").first();
+ await expect(fresh.getByText("작업", { exact: true })).toBeVisible();
+ await expect(fresh.locator(".r-chip.proj")).toContainText("개인 › 여행 — 한국");
+ await expect(fresh.locator(".r-chip.auto")).toContainText("가격 추적 알림 켜둠");
+ await fresh.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click();
+ await expect(fresh.locator(".sb-done")).toBeVisible();
+
+ // 작업 페이지(개인 스코프)에서 확인 — 연합
+ await page.goto("/tasks");
+ await page.locator(".tree-row.folder", { hasText: "개인" }).click();
+ await expect(
+ page.locator(".kcard-title", { hasText: "다음 주에 한국 놀러가는 비행기 티켓 사기" }),
+ ).toBeVisible();
+});
+
+test("다르게 분류 — 타입 순환 task→event", async ({ page }) => {
+ await page.goto("/inbox");
+ const input = page.getByLabel("인박스에 빠르게 캡처");
+ await input.fill("리포트 회신 보내기");
+ await input.press("Enter");
+ const fresh = page.locator(".sb-cap.fresh").first();
+ await expect(fresh.getByText("작업", { exact: true })).toBeVisible();
+ await fresh.getByRole("button", { name: /다르게 분류/ }).click();
+ await expect(fresh.getByText("일정", { exact: true })).toBeVisible();
+});
+
+const GOLDEN = [
+ { raw: "다음 주에 한국 놀러가는 비행기 티켓 사기", type: "작업", proj: "개인 › 여행 — 한국" },
+ { raw: "수요일 11시 자전거 수리 맡기기", type: "일정", proj: "개인 캘린더" },
+ { raw: "엄마 생신 선물 미리 알아보기", type: "작업", proj: "가족" },
+ { raw: "온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까", type: "아이디어", proj: "아이디어 보드" },
+];
+for (const g of GOLDEN) {
+ test(`골든: "${g.raw}" → ${g.type}`, async ({ page }) => {
+ await page.goto("/inbox");
+ const input = page.getByLabel("인박스에 빠르게 캡처");
+ await input.fill(g.raw);
+ await input.press("Enter");
+ const fresh = page.locator(".sb-cap.fresh").first();
+ await expect(fresh.getByText(g.type, { exact: true })).toBeVisible();
+ await expect(fresh.locator(".r-chip.proj")).toContainText(g.proj);
+ });
+}
+
+test("LLM 폴백 — 규칙 기반(오프라인) 배지", async ({ page }) => {
+ await page.goto("/inbox");
+ const input = page.getByLabel("인박스에 빠르게 캡처");
+ await input.fill("엄마 생신 선물 미리 알아보기");
+ await input.press("Enter");
+ const fresh = page.locator(".sb-cap.fresh").first();
+ await expect(fresh.getByText("작업", { exact: true })).toBeVisible();
+ await expect(fresh.getByText("규칙 기반(오프라인)")).toBeVisible();
+});
+
+test("음성 스텁 — voice 아이콘 행", async ({ page }) => {
+ await page.goto("/inbox");
+ await page.getByLabel(/음성으로 캡처/).click();
+ await expect(page.locator(".cap-k.voice").first()).toBeVisible();
+});
+
+test("a11y — axe 위반 없음(color-contrast 제외)", async ({ page }) => {
+ await page.goto("/inbox");
+ await expect(page.getByRole("heading", { name: "스마트 인박스", exact: true })).toBeVisible();
+ const r = await new AxeBuilder({ page })
+ .withTags(["wcag2a", "wcag2aa"])
+ .disableRules(["color-contrast"])
+ .analyze();
+ expect(r.violations).toEqual([]);
+});
diff --git a/frontend/styles/dash-base.css b/frontend/styles/dash-base.css
new file mode 100644
index 0000000..9400531
--- /dev/null
+++ b/frontend/styles/dash-base.css
@@ -0,0 +1,301 @@
+/* frontend/styles/dash-base.css — 원본 dash.css 의 공유 레이아웃/카드 기본 클래스
+ (인박스·대시보드 공통). 전역/토큰/상단바는 tokens.css·globals.css 가 제공.
+ .ph-title 은 globals.css 에 정의됨(항상 로드). */
+
+/* 페이지 헤더 */
+.pagehead {
+ display: flex;
+ align-items: flex-end;
+ gap: 18px;
+ padding: 14px 4px 22px;
+}
+.ph-back {
+ width: 46px;
+ height: 46px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background: var(--glass);
+ border: 1px solid var(--glass-brd);
+ -webkit-backdrop-filter: var(--blur);
+ backdrop-filter: var(--blur);
+ color: var(--ink);
+ box-shadow: var(--shadow-sm);
+ margin-bottom: 4px;
+ transition: transform 0.12s;
+}
+.ph-back .ic {
+ width: 19px;
+ height: 19px;
+}
+.ph-back:hover {
+ transform: translateX(-2px);
+}
+.ph-eyebrow {
+ font-size: 13.5px;
+ color: var(--muted);
+ font-weight: 500;
+ margin-bottom: 7px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+.ph-eyebrow .wx {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--ink-2);
+}
+.ph-eyebrow .wx .ic {
+ width: 15px;
+ height: 15px;
+ color: var(--amber);
+}
+.ph-eyebrow .sep {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--faint);
+}
+.ph-title em {
+ font-style: normal;
+ color: var(--muted);
+}
+.ph-search {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 250px;
+ padding: 12px 18px;
+ border-radius: 999px;
+ background: var(--glass);
+ border: 1px solid var(--glass-brd);
+ -webkit-backdrop-filter: var(--blur);
+ backdrop-filter: var(--blur);
+ box-shadow: var(--shadow-sm);
+ margin-bottom: 4px;
+ transition: box-shadow 0.18s, border-color 0.18s;
+}
+.ph-search:focus-within {
+ border-color: var(--line-2);
+ box-shadow: 0 0 0 4px rgba(79, 114, 224, 0.12);
+}
+.ph-search .ic {
+ width: 17px;
+ height: 17px;
+ color: var(--muted);
+}
+.ph-search input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: none;
+ outline: none;
+ font-size: 14px;
+ color: var(--ink);
+}
+.ph-search input::placeholder {
+ color: var(--faint);
+}
+
+/* 작업 영역 + 보드 */
+.work {
+ display: flex;
+ gap: 18px;
+ align-items: flex-start;
+}
+.board {
+ flex: 1;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+.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;
+}
+.card.sp2 {
+ grid-column: span 2;
+}
+.card.r2 {
+ grid-row: span 2;
+}
+
+/* 카드 헤더 */
+.ch {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+.ch .ico {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ background: var(--glass-2);
+ color: var(--ink);
+ border: 1px solid var(--glass-brd);
+ flex-shrink: 0;
+}
+.ch .ico .ic {
+ width: 17px;
+ height: 17px;
+}
+.ch .htext {
+ min-width: 0;
+}
+.ch h3 {
+ font-size: 15.5px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin: 0;
+ white-space: nowrap;
+}
+.ch .sub {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 1px;
+}
+.ch .tool {
+ margin-left: auto;
+ width: 30px;
+ height: 30px;
+ border-radius: 9px;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ transition: background 0.15s, color 0.15s;
+}
+.ch .tool .ic {
+ width: 16px;
+ height: 16px;
+}
+.ch .tool:hover {
+ background: var(--card-2);
+ color: var(--ink);
+}
+.ch .count {
+ margin-left: auto;
+ 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;
+}
+
+/* 캡처 종류 아이콘 / 분류 칩 / 시간 (인박스·대시보드 공유) */
+.cap-k {
+ width: 32px;
+ height: 32px;
+ border-radius: 9px;
+ flex-shrink: 0;
+ display: grid;
+ place-items: center;
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ color: var(--ink-2);
+}
+.cap-k .ic {
+ width: 15px;
+ height: 15px;
+}
+.cap-k.voice {
+ color: var(--violet);
+}
+.cap-k.image {
+ color: var(--blue);
+}
+.cap-k.text {
+ color: var(--ink-2);
+}
+.r-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--ink-2);
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ padding: 3px 9px;
+ border-radius: 999px;
+}
+.r-chip .ic {
+ width: 12px;
+ height: 12px;
+ color: var(--muted);
+}
+.r-chip .pdot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.r-chip.proj {
+ color: var(--ink);
+ font-weight: 700;
+}
+.cap-time {
+ font-size: 11px;
+ color: var(--faint);
+ white-space: nowrap;
+ flex-shrink: 0;
+ padding-top: 2px;
+}
+
+/* 자율성 노트 (원본 approve.css) */
+.auton-note {
+ margin-top: 13px;
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ font-size: 11.5px;
+ color: var(--ink-2);
+ line-height: 1.5;
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ border-radius: var(--radius-sm);
+ padding: 10px 12px;
+}
+.auton-note .ic {
+ width: 13px;
+ height: 13px;
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--coral);
+}
+.auton-note.dim {
+ color: var(--muted);
+}
+.auton-note.dim .ic {
+ color: var(--muted);
+}
+
+/* 보드 반응형 (원본 786~795) */
+@media (max-width: 1200px) {
+ .board {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+@media (max-width: 700px) {
+ .board {
+ grid-template-columns: 1fr;
+ }
+ .card.sp2 {
+ grid-column: span 1;
+ }
+}
diff --git a/frontend/styles/inbox.css b/frontend/styles/inbox.css
new file mode 100644
index 0000000..615e251
--- /dev/null
+++ b/frontend/styles/inbox.css
@@ -0,0 +1,474 @@
+/* frontend/styles/inbox.css — 원본 sinbox.css 이식 + 신설 클래스
+ (공유 dash 기본 클래스는 dash-base.css) */
+@import "./dash-base.css";
+
+/* ---------- 입력 ---------- */
+.sb-cmd {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ border-radius: 15px;
+ padding: 6px 6px 6px 15px;
+ margin-bottom: 11px;
+}
+.sb-cmd > svg:first-child {
+ width: 17px;
+ height: 17px;
+ color: var(--coral);
+ flex-shrink: 0;
+}
+.sb-cmd input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: none;
+ outline: none;
+ color: var(--ink);
+ font-size: 14px;
+}
+.sb-cmd input::placeholder {
+ color: var(--faint);
+}
+.sb-mode {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ flex-shrink: 0;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ transition: background 0.13s, color 0.13s;
+}
+.sb-mode .ic {
+ width: 16px;
+ height: 16px;
+}
+.sb-mode:hover {
+ background: var(--card-2);
+ color: var(--ink);
+}
+.sb-send {
+ width: 36px;
+ height: 36px;
+ border-radius: 11px;
+ flex-shrink: 0;
+ background: var(--fill);
+ color: var(--on-fill);
+ display: grid;
+ place-items: center;
+ transition: filter 0.14s;
+}
+.sb-send .ic {
+ width: 16px;
+ height: 16px;
+}
+.sb-send:hover {
+ filter: brightness(1.1);
+}
+
+/* ---------- 컨텍스트 필터 ---------- */
+.sb-filters {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+.sb-f {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--glass-brd);
+ background: var(--glass-2);
+ transition: background 0.13s, color 0.13s;
+}
+.sb-f .fdot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+}
+.sb-f:hover {
+ color: var(--ink);
+}
+.sb-f.on {
+ background: var(--fill);
+ color: var(--on-fill);
+ border-color: transparent;
+}
+.sb-hint {
+ font-size: 11px;
+ color: var(--faint);
+ margin-left: auto;
+ text-align: right;
+ min-width: 140px;
+ flex: 1;
+}
+
+/* ---------- 목록 ---------- */
+.sb-list {
+ display: flex;
+ flex-direction: column;
+}
+.sb-cap {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ padding: 13px 0;
+ border-top: 1px solid var(--line);
+}
+.sb-cap:first-child {
+ border-top: none;
+ padding-top: 2px;
+}
+.sb-cap.fresh {
+ margin: 0 -10px;
+ padding: 13px 10px;
+ border-radius: var(--radius-sm);
+ border-top: none;
+ background: linear-gradient(
+ 100deg,
+ color-mix(in oklab, var(--coral) 9%, transparent),
+ transparent 65%
+ ),
+ var(--glass-2);
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--coral) 26%, var(--glass-brd));
+}
+.sb-cap.fresh + .sb-cap {
+ border-top: none;
+}
+.sb-body {
+ flex: 1;
+ min-width: 0;
+}
+.sb-raw {
+ font-size: 13.5px;
+ font-weight: 600;
+ line-height: 1.4;
+ letter-spacing: -0.01em;
+}
+
+.sb-route {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ margin-top: 8px;
+}
+.sb-route .r-arrow {
+ color: var(--coral);
+ display: inline-flex;
+}
+.sb-route .r-arrow .ic {
+ width: 14px;
+ height: 14px;
+}
+.r-chip.type {
+ font-weight: 700;
+ color: var(--ink);
+ cursor: pointer;
+}
+.r-chip.type .ic {
+ color: var(--ink-2);
+}
+.r-chip.type.event {
+ color: var(--blue);
+ border-color: color-mix(in oklab, var(--blue) 30%, var(--glass-brd));
+}
+.r-chip.type.event .ic {
+ color: var(--blue);
+}
+.r-chip.type.idea {
+ color: var(--violet);
+ border-color: color-mix(in oklab, var(--violet) 30%, var(--glass-brd));
+}
+.r-chip.type.idea .ic {
+ color: var(--violet);
+}
+.r-chip.type:disabled {
+ cursor: default;
+}
+.r-chip.sphere {
+ font-weight: 700;
+}
+.r-chip.auto {
+ color: var(--amber);
+ font-weight: 700;
+ border-color: color-mix(in oklab, var(--amber) 36%, var(--glass-brd));
+ background: color-mix(in oklab, var(--amber) 9%, transparent);
+}
+.r-chip.auto .ic {
+ color: var(--amber);
+}
+
+.sb-reason {
+ font-size: 11.5px;
+ color: var(--muted);
+ line-height: 1.5;
+ margin-top: 7px;
+ max-width: 62ch;
+}
+.sb-fallback {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 10.5px;
+ font-weight: 700;
+ color: var(--amber);
+ margin-right: 6px;
+ padding: 1px 7px;
+ border-radius: 999px;
+ border: 1px solid color-mix(in oklab, var(--amber) 36%, var(--glass-brd));
+ background: color-mix(in oklab, var(--amber) 9%, transparent);
+}
+.sb-fallback .ic {
+ width: 10px;
+ height: 10px;
+}
+
+/* ---------- 확인 액션 ---------- */
+.sb-acts {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin-top: 10px;
+}
+.sb-ok {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--lime-ink);
+ background: var(--lime);
+ padding: 7px 13px;
+ border-radius: 999px;
+ box-shadow: 0 4px 12px -6px color-mix(in oklab, var(--lime-ink) 50%, transparent);
+ transition: background 0.13s, transform 0.12s;
+}
+.sb-ok .ic {
+ width: 12px;
+ height: 12px;
+}
+.sb-ok:hover {
+ background: var(--lime-hi);
+ transform: translateY(-1px);
+}
+.sb-alt {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+ padding: 7px 11px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ transition: color 0.13s, border-color 0.13s;
+}
+.sb-alt .ic {
+ width: 12px;
+ height: 12px;
+}
+.sb-alt:hover {
+ color: var(--ink);
+ border-color: var(--line-2);
+}
+
+/* ---------- 생각 중 ---------- */
+.sb-think {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 9px;
+}
+.sb-think .tdot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--coral);
+ animation: sb-pulse 1s ease-in-out infinite;
+}
+.sb-think .tdot:nth-child(2) {
+ animation-delay: 0.15s;
+}
+.sb-think .tdot:nth-child(3) {
+ animation-delay: 0.3s;
+ margin-right: 4px;
+}
+@keyframes sb-pulse {
+ 0%,
+ 100% {
+ opacity: 0.25;
+ transform: scale(0.8);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+.sb-done {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background: color-mix(in oklab, var(--green) 16%, transparent);
+ color: var(--green);
+}
+.sb-done .ic {
+ width: 11px;
+ height: 11px;
+}
+
+/* 빈 상태 / 에러 (신설) */
+.sb-empty {
+ font-size: 12.5px;
+ color: var(--muted);
+ padding: 16px 2px;
+}
+.sb-err {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--coral);
+ margin-top: 9px;
+}
+.sb-retry {
+ font-weight: 700;
+ color: var(--ink);
+ text-decoration: underline;
+}
+.inbox-loaderr {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 16px;
+ border-radius: 14px;
+ background: color-mix(in oklab, var(--coral) 8%, var(--card));
+ border: 1px solid color-mix(in oklab, var(--coral) 26%, transparent);
+ color: var(--ink-2);
+ font-size: 13px;
+ margin-bottom: 14px;
+}
+
+/* ===== 분류 원칙 + 행선지 ===== */
+.prin-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.prin {
+ display: flex;
+ gap: 11px;
+ align-items: flex-start;
+ padding: 11px 12px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--glass-brd);
+ background: var(--glass-2);
+}
+.prin-ic {
+ width: 28px;
+ height: 28px;
+ border-radius: 9px;
+ flex-shrink: 0;
+ display: grid;
+ place-items: center;
+ background: color-mix(in oklab, var(--tone) 14%, transparent);
+ color: var(--tone);
+}
+.prin-ic .ic {
+ width: 14px;
+ height: 14px;
+}
+.prin-body {
+ min-width: 0;
+}
+.prin-title {
+ display: block;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+.prin-desc {
+ display: block;
+ font-size: 11.5px;
+ color: var(--muted);
+ margin-top: 3px;
+ line-height: 1.5;
+}
+
+.dest-list {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 4px;
+}
+.dest {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 11px 2px;
+ border-top: 1px solid var(--line);
+ text-decoration: none;
+ color: inherit;
+}
+.dest:first-child {
+ border-top: none;
+ padding-top: 2px;
+}
+.dest-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.dest-label {
+ font-size: 13.5px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+.dest-sub {
+ font-size: 11.5px;
+ color: var(--muted);
+ flex: 1;
+ min-width: 0;
+}
+.dest-n {
+ font-family: var(--font-mono);
+ font-size: 15px;
+ font-weight: 500;
+}
+.dest-go {
+ width: 24px;
+ height: 24px;
+ border-radius: 8px;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ transition: background 0.13s, color 0.13s, transform 0.13s;
+}
+.dest-go .ic {
+ width: 13px;
+ height: 13px;
+}
+.dest.go:hover .dest-go {
+ background: var(--card-2);
+ color: var(--ink);
+ transform: translateX(2px);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sb-think .tdot {
+ animation: none !important;
+ }
+}
diff --git a/frontend/tests/inbox/CaptureComposer.test.tsx b/frontend/tests/inbox/CaptureComposer.test.tsx
new file mode 100644
index 0000000..87bba54
--- /dev/null
+++ b/frontend/tests/inbox/CaptureComposer.test.tsx
@@ -0,0 +1,37 @@
+// frontend/tests/inbox/CaptureComposer.test.tsx
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import CaptureComposer from "@/components/inbox/CaptureComposer";
+
+describe("CaptureComposer", () => {
+ it("Enter로 text 제출 후 입력 비움", () => {
+ const onSubmit = vi.fn();
+ render( );
+ const input = screen.getByLabelText("인박스에 빠르게 캡처") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "엄마 생신 선물 알아보기" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onSubmit).toHaveBeenCalledWith("엄마 생신 선물 알아보기", "text");
+ expect(input.value).toBe("");
+ });
+
+ it("mic 버튼: voice kind + 더미 transcript", () => {
+ const onSubmit = vi.fn();
+ render( );
+ fireEvent.click(screen.getByLabelText(/음성으로 캡처/));
+ expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining("음성 메모"), "voice");
+ });
+
+ it("image 버튼: image kind", () => {
+ const onSubmit = vi.fn();
+ render( );
+ fireEvent.click(screen.getByLabelText(/이미지로 캡처/));
+ expect(onSubmit).toHaveBeenCalledWith(expect.any(String), "image");
+ });
+
+ it("빈 입력은 제출 무시", () => {
+ const onSubmit = vi.fn();
+ render( );
+ fireEvent.keyDown(screen.getByLabelText("인박스에 빠르게 캡처"), { key: "Enter" });
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/tests/inbox/RouteChips.test.tsx b/frontend/tests/inbox/RouteChips.test.tsx
new file mode 100644
index 0000000..213c607
--- /dev/null
+++ b/frontend/tests/inbox/RouteChips.test.tsx
@@ -0,0 +1,57 @@
+// frontend/tests/inbox/RouteChips.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import RouteChips from "@/components/inbox/RouteChips";
+import type { Classification } from "@/lib/types";
+
+const base: Classification = {
+ id: "c1",
+ inbox_item_id: "s1",
+ type: "task",
+ sphere: "life",
+ project_id: "p",
+ proj_label: "개인 › 여행 — 한국",
+ tone: "coral",
+ due_text: "출발 전",
+ when_text: "오늘 21:00",
+ extra: "가격 추적 알림 켜둠",
+ reason: "r",
+ confidence: 0.9,
+ model: "heuristic",
+ created_at: "",
+};
+
+describe("RouteChips", () => {
+ it("task 칩: 작업 + sphere(개인) + proj + extra", () => {
+ render( {}} />);
+ expect(screen.getByText("작업")).toBeInTheDocument();
+ expect(screen.getByText("개인")).toBeInTheDocument();
+ expect(screen.getByText("개인 › 여행 — 한국")).toBeInTheDocument();
+ expect(screen.getByText("가격 추적 알림 켜둠")).toBeInTheDocument();
+ });
+
+ it("event 칩: type 클래스에 event 포함", () => {
+ const { container } = render(
+ {}} />,
+ );
+ expect(container.querySelector(".r-chip.type.event")).toBeTruthy();
+ expect(screen.getByText("일정")).toBeInTheDocument();
+ });
+
+ it("due/when/extra 없으면 칩 미표시", () => {
+ render(
+ {}}
+ />,
+ );
+ expect(screen.queryByText("출발 전")).toBeNull();
+ expect(screen.getByText("아이디어")).toBeInTheDocument();
+ });
+
+ it("editable=false면 타입 칩 disabled", () => {
+ render( {}} />);
+ expect(screen.getByRole("button", { name: /타입: 작업/ })).toBeDisabled();
+ });
+});
diff --git a/frontend/tests/inbox/inbox-presentation.test.ts b/frontend/tests/inbox/inbox-presentation.test.ts
new file mode 100644
index 0000000..e1f3efc
--- /dev/null
+++ b/frontend/tests/inbox/inbox-presentation.test.ts
@@ -0,0 +1,20 @@
+// frontend/tests/inbox/inbox-presentation.test.ts
+import { describe, expect, it } from "vitest";
+import { SPHERE, TYPE_ICON, TYPE_LABEL, nextType } from "@/lib/inbox/presentation";
+
+describe("inbox-presentation", () => {
+ it("nextType 순환 task→event→idea→task", () => {
+ expect(nextType("task")).toBe("event");
+ expect(nextType("event")).toBe("idea");
+ expect(nextType("idea")).toBe("task");
+ });
+ it("라벨/아이콘/sphere 맵", () => {
+ expect(TYPE_LABEL.task).toBe("작업");
+ expect(TYPE_LABEL.event).toBe("일정");
+ expect(TYPE_LABEL.idea).toBe("아이디어");
+ expect(TYPE_ICON.idea).toBe("brain");
+ expect(TYPE_ICON.task).toBe("check");
+ expect(SPHERE.life.label).toBe("개인");
+ expect(SPHERE.work.label).toBe("업무");
+ });
+});