From 7fa7dc1510713086bfe1e78812c70c4b96fb1c92 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 7 Apr 2026 08:26:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B8=B0=EB=B0=98=20=E2=80=94=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=97=AC=ED=8D=BC=20+=20CI=20rule=20+=20=EC=B2=AB?= =?UTF-8?q?=206=EA=B0=9C=20=ED=94=84=EB=A6=AC=EB=AF=B8=ED=8B=B0=EB=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX/UI 개편 Phase A-3 / A-4 / A-5. 후속 phase가 곧바로 소비할 수 있도록 디자인 시스템의 코어 자산을 한꺼번에 도입한다. A-3 — 유틸 헬퍼 (lib/utils/) - pLimit.ts: 동시 실행 N개 제한 (5대 원칙 #4 — 일괄 PATCH/DELETE에서 GPU 서버/SSE 부하 방지). 외부 의존성 없음. - mergeDoc.ts: PATCH/SSE 응답을 로컬 cache에 머지할 때 updated_at으로 stale 갱신 차단 (5대 원칙 #6 — optimistic update conflict resolution). dropDoc 헬퍼 포함. A-4 — CI 토큰 차단 (5대 원칙 #1) - scripts/check-tokens.sh: bg-[var(--*)] 등 임의값 토큰 우회 grep 차단. - npm run lint:tokens 등록. - 현재 baseline 421건 — A-8 토큰 swap에서 0으로 떨어진 후 pre-commit 강제화. A-5 — 첫 6개 프리미티브 (lib/components/ui/) - Button.svelte: variant(primary/secondary/ghost/danger) × size(sm/md), loading/disabled, icon 슬롯, href 자동 a 변환, focus-visible ring. - IconButton.svelte: 정사각형, aria-label 필수, Button과 동일 variant 체계. - Card.svelte: bg-surface + rounded-card + border-default 패턴 1군데화. padded/interactive 옵션, interactive면 button 시맨틱. - Badge.svelte: 의미적 tone(neutral/success/warning/error/accent) 표시. TagPill과 별개 (TagPill은 도메인 prefix 코드 전용). - Skeleton.svelte: ad-hoc animate-pulse div 통합. w/h/rounded prop. - EmptyState.svelte: icon + title + description + action slot. 모든 프리미티브는 Svelte 5 runes mode strict (\$props/\$derived/\$bindable), @theme 토큰만 사용 (bg-surface, text-dim, border-default 등 — bg-[var(--*)] 미사용), focus-visible ring 통일, slot은 {@render children?.()}로 작성. svelte-check: 0 errors, 8 warnings (모두 기존 latent 이슈, 새 코드 무관). build: 1.95s 무경고. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package.json | 3 +- frontend/scripts/check-tokens.sh | 66 ++++++++++ frontend/src/lib/components/ui/Badge.svelte | 49 ++++++++ frontend/src/lib/components/ui/Button.svelte | 113 ++++++++++++++++++ frontend/src/lib/components/ui/Card.svelte | 45 +++++++ .../src/lib/components/ui/EmptyState.svelte | 36 ++++++ .../src/lib/components/ui/IconButton.svelte | 100 ++++++++++++++++ .../src/lib/components/ui/Skeleton.svelte | 29 +++++ frontend/src/lib/utils/mergeDoc.ts | 35 ++++++ frontend/src/lib/utils/pLimit.ts | 33 +++++ 10 files changed, 508 insertions(+), 1 deletion(-) create mode 100755 frontend/scripts/check-tokens.sh create mode 100644 frontend/src/lib/components/ui/Badge.svelte create mode 100644 frontend/src/lib/components/ui/Button.svelte create mode 100644 frontend/src/lib/components/ui/Card.svelte create mode 100644 frontend/src/lib/components/ui/EmptyState.svelte create mode 100644 frontend/src/lib/components/ui/IconButton.svelte create mode 100644 frontend/src/lib/components/ui/Skeleton.svelte create mode 100644 frontend/src/lib/utils/mergeDoc.ts create mode 100644 frontend/src/lib/utils/pLimit.ts 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(); + } + }; +}