Phase 4: 인박스(Smart Inbox) 페이지

- 멀티모달 캡처 컴포저(텍스트 Enter/보내기 + mic·image 스텁)
- 실시간 AI 분류: POST capture(동기) → 생각중 펄스 → 결과 칩(type/sphere/proj/due/when/extra)
  + 이유 한 줄 + heuristic 폴백 배지("규칙 기반(오프라인)")
- 확인(confirm)=실체화(federation): task → 실제 작업 생성 + 작업 페이지 등장
- 다르게 분류(reclassify) 타입 순환 task→event→idea, 낙관적 미리보기
- 분류 원칙 카드 4건(원문 그대로) + "오늘 어디로 갔나" 행선지 카드(동적 집계)
- sinbox.css 픽셀 이식 + dash-base.css(공유 레이아웃/카드, 대시보드 재사용)
- 서버 컴포넌트 초기 fetch + 클라 낙관적 업데이트/롤백, 전체/업무/개인 필터

검증: 백엔드 pytest 46, 프론트 vitest 42, playwright 28(shell+tasks+inbox+a11y),
tsc/eslint clean, build OK. 인박스→작업 연합 e2e 통과, 4 골든 + 폴백 배지 + voice 스텁 검증.
라이브 시각 확인(컴포저/칩/이유/원칙/행선지).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Claude 2 months ago
parent d2d7c17ad3
commit cb028df08a

@ -1,4 +1,18 @@
// frontend/app/inbox/page.tsx (Phase 1 스텁 — Phase 4에서 교체)
export default function Page() {
return <h1 className="ph-title"></h1>;
// 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 <InboxView initialItems={items} initialLoadError={loadError} />;
}

@ -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 (
<div className="sb-cmd">
<Icon name="spark" />
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitText()}
placeholder="갑자기 생각난 것 아무거나… 예) 다음 주 한국 가는 비행기 티켓 사기"
aria-label="인박스에 빠르게 캡처"
/>
<button
className="sb-mode"
aria-label="음성으로 캡처(MVP 스텁)"
title="음성 입력은 MVP에서 더미 메모로 동작합니다"
onClick={() => onSubmit(VOICE_STUB, "voice")}
>
<Icon name="mic" />
</button>
<button
className="sb-mode"
aria-label="이미지로 캡처(MVP 스텁)"
title="이미지 입력은 MVP에서 더미 캡션으로 동작합니다"
onClick={() => onSubmit(IMAGE_STUB, "image")}
>
<Icon name="image" />
</button>
<button className="sb-send" onClick={submitText} aria-label="보내기">
<Icon name="arrow" />
</button>
</div>
);
}

@ -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 (
<div className={"sb-cap" + (c.fresh ? " fresh" : "")}>
<div className={"cap-k " + c.kind}>
<Icon name={kindIcon} />
</div>
<div className="sb-body">
<div className="sb-raw">{c.raw}</div>
{c.status === "thinking" ? (
<div className="sb-think" role="status" aria-live="polite">
<span className="tdot" />
<span className="tdot" />
<span className="tdot" />
</div>
) : c.status === "error" ? (
<div className="sb-err" role="alert">
.{" "}
<button className="sb-retry" onClick={() => onReType(c.id)}>
</button>
</div>
) : c.classification ? (
<>
<RouteChips r={c.classification} editable={canAct} onReType={() => onReType(c.id)} />
<ReasonLine reason={c.classification.reason} fallbackUsed={c.fallbackUsed} />
{canAct && (
<div className="sb-acts">
<button className="sb-ok" onClick={() => onConfirm(c.id)}>
<Icon name="tick" w={3} />
,
</button>
<button className="sb-alt" onClick={() => onReType(c.id)}>
<Icon name="swap" />
</button>
</div>
)}
</>
) : null}
</div>
<span className="cap-time">
{c.status === "confirmed" ? (
<span className="sb-done">
<Icon name="tick" w={3} />
</span>
) : (
c.time
)}
</span>
</div>
);
}

@ -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 (
<section className="card">
<div className="ch">
<div className="ico">
<Icon name="route" />
</div>
<div className="htext">
<h3> </h3>
<div className="sub"> </div>
</div>
</div>
<div className="prin-list">
{RULES.map((r, i) => (
<div className="prin" key={i} style={{ "--tone": `var(--${r.tone})` } as CSSProperties}>
<span className="prin-ic">
<Icon name={r.icon} />
</span>
<span className="prin-body">
<span className="prin-title">{r.title}</span>
<span className="prin-desc">{r.desc}</span>
</span>
</div>
))}
</div>
</section>
);
}

@ -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<UiInboxItem[]>(() => 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 (
<>
<div className="pagehead">
<div>
<div className="ph-eyebrow">
<span> {todayRouted} </span>
<span className="sep" />
<span> 0</span>
</div>
<h1 className="ph-title">
<em> </em>
</h1>
</div>
</div>
<div className="work">
<div className="board">
{initialLoadError && (
<div className="inbox-loaderr card sp2" style={{ gridColumn: "span 4" }}>
<Icon name="x" /> . .
</div>
)}
<SmartInbox
items={shown}
todayRouted={todayRouted}
filter={filter}
onFilter={setFilter}
onSubmit={submit}
onConfirm={confirm}
onReType={reType}
/>
<ClassifyPrinciples />
<TodayRouted items={items} todayRouted={todayRouted} />
</div>
</div>
</>
);
}

@ -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 (
<div className="sb-reason">
{fallbackUsed && (
<span className="sb-fallback" title="LLM에 연결할 수 없어 규칙 기반으로 분류했어요">
<Icon name="zap" />
()
</span>
)}
{reason}
</div>
);
}

@ -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 (
<div className="sb-route">
<span className="r-arrow">
<Icon name="arrow" />
</span>
{/* 타입 칩: 클릭 시 다르게 분류(편집 가능할 때만) */}
<button
className={"r-chip type " + r.type}
onClick={() => editable && onReType()}
disabled={!editable}
aria-label={`타입: ${TYPE_LABEL[r.type]}${editable ? " (눌러서 변경)" : ""}`}
>
<Icon name={TYPE_ICON[r.type]} />
{TYPE_LABEL[r.type]}
</button>
{/* sphere 칩 (고정색) */}
<span className="r-chip sphere">
<span className="pdot" style={{ background: SPHERE[r.sphere].tone }} />
{SPHERE[r.sphere].label}
</span>
{/* proj 칩 (동적 tone) */}
<span className="r-chip proj">
<span className="pdot" style={{ background: `var(--${r.tone})` }} />
{r.proj_label}
</span>
{r.due_text && (
<span className="r-chip">
<Icon name="cal" />
{r.due_text}
</span>
)}
{r.when_text && (
<span className="r-chip">
<Icon name="clock" />
{r.when_text}
</span>
)}
{r.extra && (
<span className="r-chip auto">
<Icon name="zap" />
{r.extra}
</span>
)}
</div>
);
}

@ -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 (
<section className="card sbox sp2">
<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>
<CaptureComposer onSubmit={onSubmit} />
<SphereFilters filter={filter} onFilter={onFilter} />
<div className="sb-list">
{items.length === 0 ? (
<div className="sb-empty">
. .
</div>
) : (
items.map((c) => (
<CaptureRow key={c.id} item={c} onConfirm={onConfirm} onReType={onReType} />
))
)}
</div>
</section>
);
}

@ -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 (
<div className="sb-filters">
{TABS.map(([k, l]) => (
<button
key={k}
className={"sb-f" + (filter === k ? " on" : "")}
onClick={() => onFilter(k)}
aria-pressed={filter === k}
>
{k !== "all" && (
<span className="fdot" style={{ background: SPHERE[k as Sphere].tone }} />
)}
{l}
</button>
))}
<span className="sb-hint">/ </span>
</div>
);
}

@ -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<RouteType, { tone: string; href: string | null }> = {
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 (
<section className="card">
<div className="ch">
<div className="ico">
<Icon name="arrow" />
</div>
<div className="htext">
<h3> </h3>
<div className="sub">{todayRouted} </div>
</div>
</div>
<div className="dest-list">
{ORDER.map((t) => {
const meta = DEST_META[t];
const n = byType(t).length;
const inner = (
<>
<span className="dest-dot" style={{ background: `var(--${meta.tone})` }} />
<span className="dest-label">{TYPE_LABEL[t]}</span>
<span className="dest-sub">{subFor(t)}</span>
<span className="dest-n">{n}</span>
{meta.href && (
<span className="dest-go">
<Icon name="arrow" />
</span>
)}
</>
);
return meta.href ? (
<Link className="dest go" key={t} href={meta.href}>
{inner}
</Link>
) : (
<div className="dest" key={t}>
{inner}
</div>
);
})}
</div>
<div className="auton-note dim">
<Icon name="swap" />
.
</div>
</section>
);
}

@ -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<T>(res: Response): Promise<T> {
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json() as Promise<T>;
}
export async function getInbox(): Promise<InboxItem[]> {
return j(await fetch(`${BASE}/api/inbox`, { cache: "no-store" }));
}
export async function captureInbox(input: {
kind: "text" | "voice" | "image";
raw: string;
}): Promise<CaptureResult> {
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<CaptureResult> {
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<ConfirmResult> {
return j(await fetch(`${BASE}/api/inbox/${id}/confirm`, { method: "POST" }));
}
export async function dismissInbox(id: string): Promise<InboxItem> {
return j(await fetch(`${BASE}/api/inbox/${id}/dismiss`, { method: "POST" }));
}

@ -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<Sphere, { label: string; tone: string }> = {
work: { label: "업무", tone: "var(--blue)" },
life: { label: "개인", tone: "var(--green)" },
};
/** 원본 sinbox.jsx TYPE_ICON */
export const TYPE_ICON: Record<RouteType, IconName> = {
task: "check",
event: "cal",
idea: "brain",
};
export const TYPE_LABEL: Record<RouteType, string> = {
task: "작업",
event: "일정",
idea: "아이디어",
};
/** 원본 cycleType — 다르게 분류 시 다음 타입 */
export function nextType(t: RouteType): RouteType {
return ({ task: "event", event: "idea", idea: "task" } as const)[t];
}

@ -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<InboxItem, "status"> {
status: UiInboxStatus;
time: string;
fresh?: boolean;
fallbackUsed?: boolean;
}

@ -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([]);
});

@ -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;
}
}

@ -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;
}
}

@ -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(<CaptureComposer onSubmit={onSubmit} />);
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(<CaptureComposer onSubmit={onSubmit} />);
fireEvent.click(screen.getByLabelText(/음성으로 캡처/));
expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining("음성 메모"), "voice");
});
it("image 버튼: image kind", () => {
const onSubmit = vi.fn();
render(<CaptureComposer onSubmit={onSubmit} />);
fireEvent.click(screen.getByLabelText(/이미지로 캡처/));
expect(onSubmit).toHaveBeenCalledWith(expect.any(String), "image");
});
it("빈 입력은 제출 무시", () => {
const onSubmit = vi.fn();
render(<CaptureComposer onSubmit={onSubmit} />);
fireEvent.keyDown(screen.getByLabelText("인박스에 빠르게 캡처"), { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
});
});

@ -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(<RouteChips r={{ ...base, type: "task" }} editable onReType={() => {}} />);
expect(screen.getByText("작업")).toBeInTheDocument();
expect(screen.getByText("개인")).toBeInTheDocument();
expect(screen.getByText("개인 여행 — 한국")).toBeInTheDocument();
expect(screen.getByText("가격 추적 알림 켜둠")).toBeInTheDocument();
});
it("event 칩: type 클래스에 event 포함", () => {
const { container } = render(
<RouteChips r={{ ...base, type: "event" }} editable onReType={() => {}} />,
);
expect(container.querySelector(".r-chip.type.event")).toBeTruthy();
expect(screen.getByText("일정")).toBeInTheDocument();
});
it("due/when/extra 없으면 칩 미표시", () => {
render(
<RouteChips
r={{ ...base, type: "idea", due_text: null, when_text: null, extra: null }}
editable
onReType={() => {}}
/>,
);
expect(screen.queryByText("출발 전")).toBeNull();
expect(screen.getByText("아이디어")).toBeInTheDocument();
});
it("editable=false면 타입 칩 disabled", () => {
render(<RouteChips r={{ ...base, type: "task" }} editable={false} onReType={() => {}} />);
expect(screen.getByRole("button", { name: /타입: 작업/ })).toBeDisabled();
});
});

@ -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("업무");
});
});
Loading…
Cancel
Save