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(); + } + }; +}