diff --git a/frontend/package.json b/frontend/package.json
index 9d4934e..9738654 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "lint:tokens": "bash ./scripts/check-tokens.sh"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.0",
diff --git a/frontend/scripts/check-tokens.sh b/frontend/scripts/check-tokens.sh
new file mode 100755
index 0000000..d869944
--- /dev/null
+++ b/frontend/scripts/check-tokens.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Tailwind 임의값 토큰 우회 차단 — plan 5대 원칙 #1.
+#
+# 새 코드에서 bg-[var(--*)] / text-[var(--*)] / border-[var(--*)] 등을 grep으로 차단.
+# @theme 토큰을 우회해 임의값을 작성하면 이 스크립트가 fail.
+#
+# 예외:
+# - frontend/src/lib/components/ui/ (프리미티브 정의 자체는 토큰 매핑이 필요할 수 있음)
+# - frontend/src/app.css (@theme/:root 선언이므로 grep 대상 아님)
+#
+# 사용:
+# npm run -C frontend lint:tokens
+#
+# 향후 pre-commit hook에 포함:
+# .git/hooks/pre-commit → npm run -C frontend lint:tokens
+
+set -e
+
+# 스크립트 위치 기준으로 frontend 루트로 이동
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+FRONTEND_DIR="$(dirname "$SCRIPT_DIR")"
+cd "$FRONTEND_DIR"
+
+PATTERNS=(
+ 'bg-\[var\(--'
+ 'text-\[var\(--'
+ 'border-\[var\(--'
+ 'ring-\[var\(--'
+ 'fill-\[var\(--'
+ 'stroke-\[var\(--'
+)
+
+EXIT=0
+TOTAL=0
+
+for p in "${PATTERNS[@]}"; do
+ HITS=$(grep -rEn \
+ --include='*.svelte' \
+ --include='*.ts' \
+ --include='*.js' \
+ --exclude-dir=node_modules \
+ --exclude-dir=.svelte-kit \
+ --exclude-dir=ui \
+ "$p" src 2>/dev/null || true)
+ if [ -n "$HITS" ]; then
+ COUNT=$(echo "$HITS" | wc -l | tr -d ' ')
+ TOTAL=$((TOTAL + COUNT))
+ echo ""
+ echo "❌ 금지 패턴 발견: $p ($COUNT 건)"
+ echo "$HITS" | head -20
+ if [ "$COUNT" -gt 20 ]; then
+ echo "... (이하 $((COUNT - 20)) 건 생략)"
+ fi
+ EXIT=1
+ fi
+done
+
+if [ $EXIT -eq 0 ]; then
+ echo "✅ 토큰 우회 패턴 없음."
+else
+ echo ""
+ echo "총 $TOTAL 건의 토큰 우회 패턴이 발견되었습니다."
+ echo "→ @theme 유틸리티 (bg-surface, text-dim, border-default 등)로 교체하세요."
+fi
+
+exit $EXIT
diff --git a/frontend/src/lib/components/ui/Badge.svelte b/frontend/src/lib/components/ui/Badge.svelte
new file mode 100644
index 0000000..3086e39
--- /dev/null
+++ b/frontend/src/lib/components/ui/Badge.svelte
@@ -0,0 +1,49 @@
+
+
+
+ {@render children?.()}
+
diff --git a/frontend/src/lib/components/ui/Button.svelte b/frontend/src/lib/components/ui/Button.svelte
new file mode 100644
index 0000000..9f7d461
--- /dev/null
+++ b/frontend/src/lib/components/ui/Button.svelte
@@ -0,0 +1,113 @@
+
+
+{#if href}
+
+ {#if loading}
+
+ {:else if Icon && iconPosition === 'left'}
+
+ {/if}
+ {@render children?.()}
+ {#if !loading && Icon && iconPosition === 'right'}
+
+ {/if}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/lib/components/ui/Card.svelte b/frontend/src/lib/components/ui/Card.svelte
new file mode 100644
index 0000000..94ff0d6
--- /dev/null
+++ b/frontend/src/lib/components/ui/Card.svelte
@@ -0,0 +1,45 @@
+
+
+{#if interactive}
+
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/frontend/src/lib/components/ui/EmptyState.svelte b/frontend/src/lib/components/ui/EmptyState.svelte
new file mode 100644
index 0000000..45a2dbd
--- /dev/null
+++ b/frontend/src/lib/components/ui/EmptyState.svelte
@@ -0,0 +1,36 @@
+
+
+
+ {#if Icon}
+
+
+
+ {/if}
+
{title}
+ {#if description}
+
{description}
+ {/if}
+ {#if children}
+
+ {@render children()}
+
+ {/if}
+
diff --git a/frontend/src/lib/components/ui/IconButton.svelte b/frontend/src/lib/components/ui/IconButton.svelte
new file mode 100644
index 0000000..d9121b3
--- /dev/null
+++ b/frontend/src/lib/components/ui/IconButton.svelte
@@ -0,0 +1,100 @@
+
+
+{#if href}
+
+ {#if loading}
+
+ {:else}
+
+ {/if}
+
+{:else}
+
+{/if}
diff --git a/frontend/src/lib/components/ui/Skeleton.svelte b/frontend/src/lib/components/ui/Skeleton.svelte
new file mode 100644
index 0000000..779ab1a
--- /dev/null
+++ b/frontend/src/lib/components/ui/Skeleton.svelte
@@ -0,0 +1,29 @@
+
+
+
diff --git a/frontend/src/lib/utils/mergeDoc.ts b/frontend/src/lib/utils/mergeDoc.ts
new file mode 100644
index 0000000..b88e1e1
--- /dev/null
+++ b/frontend/src/lib/utils/mergeDoc.ts
@@ -0,0 +1,35 @@
+// PATCH 응답 또는 (향후 도입 시) SSE 푸시로 들어온 doc을 로컬 cache의 doc과
+// 합칠 때 사용. updated_at 비교로 stale 갱신을 무시 → optimistic update가
+// 더 fresh한 데이터 위에 덮어쓰는 race를 차단. plan 5대 원칙 #6.
+//
+// 더 강력한 보호는 backend ETag/If-Match 도입이 필요(plan 부록 #8).
+// 이 헬퍼는 그 사이의 가장 흔한 race(stale PATCH가 fresh push 위에 덮어쓰기)를 막는다.
+//
+// 사용 예:
+// documents = mergeDoc(documents, response); // PATCH 응답
+// documents = dropDoc(documents, id); // 삭제 / Inbox 승인 후 제거
+
+export interface DocLike {
+ id: number;
+ updated_at: string;
+}
+
+export function mergeDoc(list: T[], incoming: T): T[] {
+ const idx = list.findIndex((d) => d.id === incoming.id);
+ if (idx === -1) {
+ // 새 doc — 맨 앞에 삽입
+ return [incoming, ...list];
+ }
+ const current = list[idx];
+ if (new Date(incoming.updated_at) < new Date(current.updated_at)) {
+ // stale — 무시
+ return list;
+ }
+ const next = [...list];
+ next[idx] = incoming;
+ return next;
+}
+
+export function dropDoc(list: T[], id: number): T[] {
+ return list.filter((d) => d.id !== id);
+}
diff --git a/frontend/src/lib/utils/pLimit.ts b/frontend/src/lib/utils/pLimit.ts
new file mode 100644
index 0000000..562d125
--- /dev/null
+++ b/frontend/src/lib/utils/pLimit.ts
@@ -0,0 +1,33 @@
+// 동시 실행 N개로 제한하는 작은 헬퍼 (외부 의존성 추가 없음).
+// 일괄 PATCH/DELETE 같은 batch 작업에서 GPU 서버 / API / SSE에 부하 폭탄을
+// 던지지 않도록 사용. plan 5대 원칙 #4.
+//
+// 사용 예:
+// const limit = pLimit(5);
+// const results = await Promise.allSettled(
+// ids.map((id) => limit(() => api(`/documents/${id}`, { method: 'PATCH', body })))
+// );
+
+export function pLimit(concurrency: number) {
+ let active = 0;
+ const queue: Array<() => void> = [];
+
+ const next = () => {
+ active--;
+ if (queue.length > 0) {
+ queue.shift()!();
+ }
+ };
+
+ return async function run(fn: () => Promise): Promise {
+ if (active >= concurrency) {
+ await new Promise((resolve) => queue.push(resolve));
+ }
+ active++;
+ try {
+ return await fn();
+ } finally {
+ next();
+ }
+ };
+}